← 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,
}