Skip to content
← Public packages

@kody/doordash

Order food on DoorDash as a customer through the consumer MCP used by dd-cli.

src/mcp.ts

307 lines · 8.5 KB · TypeScript
/**
 * DoorDash consumer MCP transport (same gateway `dd-cli` uses).
 *
 * Endpoint: `https://openapi.doordash.com/mcp/consumerc`
 * Auth: Bearer access token from `dd-cli export-token` / `DD_CLI_ACCESS_TOKEN`
 * saved as Kody secret `doorDashCliAccessToken` (approve host openapi.doordash.com).
 */

export const CONSUMER_MCP_URL = 'https://openapi.doordash.com/mcp/consumerc'
export const CONSUMER_MCP_HOST = 'openapi.doordash.com'
export const DEFAULT_ACCESS_TOKEN_SECRET = 'doorDashCliAccessToken'
export const WAITLIST_URL = 'https://forms.gle/gvCQZvu9C1EKA6aM6'
export const CLI_RELEASES_URL = 'https://github.com/doordash-oss/doordash-cli/releases'

const ACCESS_TOKEN_PATTERN =
	/^doorDashCliAccessToken(?:-[A-Za-z0-9][A-Za-z0-9_-]{0,47})?$/
const ACCOUNT_LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/

export type DoorDashMcpAuthInput = {
	account?: string
	accessTokenSecret?: string
	/** Pre-resolved Bearer token (local tooling only — never paste in chat). */
	accessToken?: string
	mcpUrl?: string
}

export class DoorDashMcpError extends Error {
	status: number | null
	code: string | number | null
	details: unknown
	setup: {
		accessTokenUrl: string
		hosts: string[]
		waitlist: string
		cliReleases: string
	}

	constructor(
		message: string,
		input: {
			status?: number | null
			code?: string | number | null
			details?: unknown
			auth?: DoorDashMcpAuthInput
		} = {},
	) {
		super(message)
		this.name = 'DoorDashMcpError'
		this.status = input.status ?? null
		this.code = input.code ?? null
		this.details = input.details ?? null
		this.setup = {
			accessTokenUrl: accessTokenSetupUrl(resolveAccessTokenSecretName(input.auth)),
			hosts: [CONSUMER_MCP_HOST],
			waitlist: WAITLIST_URL,
			cliReleases: CLI_RELEASES_URL,
		}
	}
}

function accountLabel(value: string | undefined): string | null {
	const trimmed = (value ?? '').trim()
	if (!trimmed || trimmed === 'default') return null
	if (!ACCOUNT_LABEL_PATTERN.test(trimmed)) {
		throw new Error('account must be a short label such as "work" (letters, numbers, _ or -).')
	}
	return trimmed
}

export function resolveAccessTokenSecretName(input?: DoorDashMcpAuthInput): string {
	if (input?.accessTokenSecret != null && String(input.accessTokenSecret).trim() !== '') {
		const name = String(input.accessTokenSecret).trim()
		if (!ACCESS_TOKEN_PATTERN.test(name)) {
			throw new Error(
				'Secret name must match doorDashCliAccessToken (optional -label). Got: ' +
					JSON.stringify(name),
			)
		}
		return name
	}
	const label = accountLabel(input?.account)
	return label ? DEFAULT_ACCESS_TOKEN_SECRET + '-' + label : DEFAULT_ACCESS_TOKEN_SECRET
}

export function accessTokenSetupUrl(name = DEFAULT_ACCESS_TOKEN_SECRET): string {
	const q =
		'name=' +
		encodeURIComponent(name) +
		'&description=' +
		encodeURIComponent(
			'DoorDash dd-cli access token from `dd-cli export-token` (waitlist account). Same as DD_CLI_ACCESS_TOKEN.',
		) +
		'&allowedHosts=' +
		encodeURIComponent(CONSUMER_MCP_HOST) +
		'&scope=user'
	return 'https://kody.codes/account/secrets/new?' + q
}

export function setupUrls(auth?: DoorDashMcpAuthInput) {
	return {
		accessTokenUrl: accessTokenSetupUrl(resolveAccessTokenSecretName(auth)),
		hosts: [CONSUMER_MCP_HOST] as string[],
		waitlist: WAITLIST_URL,
		cliReleases: CLI_RELEASES_URL,
		mcpUrl: CONSUMER_MCP_URL,
	}
}

function secretPlaceholder(name: string): string {
	return '{{secret:' + name + '}}'
}

function parseSseOrJson(text: string): unknown {
	const trimmed = text.trim()
	if (!trimmed) return null
	if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
		return JSON.parse(trimmed)
	}
	const dataLines: string[] = []
	for (const line of trimmed.split(/\r?\n/)) {
		if (line.startsWith('data:')) dataLines.push(line.slice(5).trim())
	}
	if (dataLines.length) {
		const joined = dataLines.join('\n')
		return JSON.parse(joined)
	}
	return JSON.parse(trimmed)
}

function unwrapMcpResult(envelope: any): unknown {
	if (!envelope || typeof envelope !== 'object') return envelope
	if (envelope.error) {
		const err = envelope.error
		const message =
			typeof err === 'object' && err && err.message
				? String(err.message)
				: 'DoorDash MCP error'
		throw new DoorDashMcpError(message, {
			code: typeof err === 'object' && err ? (err.code ?? null) : null,
			details: err,
		})
	}
	const result = envelope.result ?? envelope
	if (result && typeof result === 'object' && Array.isArray(result.content)) {
		const texts = result.content
			.filter((block: any) => block && block.type === 'text' && typeof block.text === 'string')
			.map((block: any) => block.text as string)
		if (texts.length === 1) {
			const only = texts[0]!.trim()
			if (only.startsWith('{') || only.startsWith('[')) {
				try {
					return JSON.parse(only)
				} catch {
					return only
				}
			}
			return only
		}
		if (texts.length > 1) return texts
	}
	return result
}

let rpcId = 0

export type McpCallInput = DoorDashMcpAuthInput & {
	method: 'tools/list' | 'tools/call' | string
	params?: Record<string, unknown>
	dryRun?: boolean
}

export async function mcpRequest(input: McpCallInput): Promise<unknown> {
	const url = String(input.mcpUrl ?? CONSUMER_MCP_URL).trim()
	const method = String(input.method ?? '').trim()
	if (!method) throw new Error('method is required.')

	if (input.dryRun === true) {
		return {
			ok: true,
			dryRun: true,
			preview: {
				url,
				method,
				params: input.params ?? {},
				auth: 'Bearer {{secret:' + resolveAccessTokenSecretName(input) + '}}',
				setup: setupUrls(input),
			},
		}
	}

	const token =
		String(input.accessToken ?? '').trim() ||
		secretPlaceholder(resolveAccessTokenSecretName(input))
	const id = ++rpcId
	const payload = {
		jsonrpc: '2.0',
		id,
		method,
		params: input.params ?? {},
	}

	const response = await fetch(url, {
		method: 'POST',
		headers: {
			Authorization: 'Bearer ' + token,
			'Content-Type': 'application/json',
			Accept: 'application/json, text/event-stream',
		},
		body: JSON.stringify(payload),
	})
	const text = await response.text()
	let parsed: unknown = null
	try {
		parsed = parseSseOrJson(text)
	} catch {
		parsed = { raw: text }
	}

	if (!response.ok) {
		const message =
			parsed && typeof parsed === 'object' && (parsed as any).error
				? String((parsed as any).error)
				: 'DoorDash MCP HTTP ' + response.status
		throw new DoorDashMcpError(message, {
			status: response.status,
			details: parsed,
			auth: input,
		})
	}

	try {
		return unwrapMcpResult(parsed)
	} catch (error) {
		if (error instanceof DoorDashMcpError) {
			error.setup = {
				accessTokenUrl: accessTokenSetupUrl(resolveAccessTokenSecretName(input)),
				hosts: [CONSUMER_MCP_HOST],
				waitlist: WAITLIST_URL,
				cliReleases: CLI_RELEASES_URL,
			}
			throw error
		}
		throw error
	}
}

/** JSON-RPC `tools/call`. */
export async function callMcpTool(
	input: DoorDashMcpAuthInput & {
		name: string
		arguments?: Record<string, unknown>
		dryRun?: boolean
	},
): Promise<unknown> {
	const name = String(input.name ?? '').trim()
	if (!name) throw new Error('MCP tool name is required.')
	return mcpRequest({
		...input,
		method: 'tools/call',
		params: {
			name,
			arguments: input.arguments ?? {},
		},
	})
}

/** Official consumer MCP tool names used by dd-cli. */
export const TOOLS = {
	findRestaurants: 'doordash_find_restaurants',
	restaurantMenu: 'doordash_get_restaurant_menu',
	foodItem: 'doordash_get_food_item',
	storeInfo: 'internal_get_store_info',
	findNearbyStores: 'internal_find_nearby_stores',
	findItemsInStore: 'internal_find_items_in_store',
	itemDetails: 'internal_get_item_details',
	listActiveCarts: 'doordash_list_active_carts',
	getCart: 'doordash_get_cart',
	addToCart: 'doordash_add_to_cart',
	removeCartItem: 'doordash_remove_cart_item',
	clearCart: 'doordash_clear_cart',
	orderHistory: 'internal_get_order_history',
	previewOrder: 'internal_preview_order',
	submitOrder: 'internal_submit_order',
	reorder: 'internal_reorder',
	orderReceipt: 'internal_get_order_receipt',
	orderStatus: 'internal_get_order_status',
	checkoutUrl: 'doordash_get_checkout_url',
	listAddresses: 'doordash_list_delivery_addresses',
	findAddress: 'doordash_find_address',
	saveAddress: 'doordash_save_address',
	setDeliveryAddress: 'doordash_set_delivery_address',
	paymentInfo: 'doordash_get_payment_info',
	listPromos: 'internal_list_eligible_cart_promotions',
	applyPromo: 'internal_apply_cart_promotion',
	removePromo: 'internal_remove_cart_promotion',
	createProductList: 'doordash_create_product_list',
} as const

export type ToolName = (typeof TOOLS)[keyof typeof TOOLS]

export default {
	mcpRequest,
	callMcpTool,
	TOOLS,
	setupUrls,
	CONSUMER_MCP_URL,
}