Skip to main content
Glama
francisschmaltz

DoorDash CLI MCP Server

README.md
# DoorDash CLI MCP server

Streamable HTTP MCP wrapper for DoorDash CLI. It runs on macOS Apple Silicon
or Linux amd64, with PostgreSQL storing MCP tokens, submission protection, and
the shared DoorDash login credential.

The server exposes the authenticated ordering services in DoorDash CLI v0.2.5.
`login`, `export-token`, help, and version remain local CLI operations; the
`doordash_auth` MCP tool accepts exported credentials without a restart.

The Linux image and [Nomad deployment instructions](docs/nomad/README.md) run
the service without macOS or a keyring.

## Setup

Requirements:

- macOS on Apple Silicon or Linux amd64
- Node.js 24 or newer
- PostgreSQL (17 is used in CI)
- DoorDash CLI v0.2.5 for the current platform

Install dependencies and the checksum-verified CLI release. The installer
preserves the full distribution, including Linux's `_internal` directory,
under the ignored `./doordash-cli/` directory and updates `./dd-cli`:

```bash
npm ci
npm run install:cli
./dd-cli --version
cp .env.example .env
```

Generate a secret with `openssl rand -hex 32`, paste it into
`ADMIN_ACCESS_TOKEN` in `.env`, and set `DATABASE_URL` for the dedicated
`doordash` database. See the [database setup and SQLite import](docs/nomad/README.md)
before upgrading an existing installation. Start the server; migrations finish
before it accepts traffic:

```bash
npm start
```

Open `http://127.0.0.1:8787/` and enter the admin secret. Generate an MCP
bearer token, copy it immediately, and put it in the MCP client's
Authorization/Bearer-token setting. Only the MCP token's SHA-256 hash is
stored.

The MCP endpoint is:

```text
http://127.0.0.1:8787/mcp
```

All MCP requests require a valid bearer token. Then run `./dd-cli export-token`
on a machine with browser login and provide the exported token through
`doordash_auth({"access_token":"EXPORTED_TOKEN"})`. Alternatively, set
`DD_CLI_ACCESS_TOKEN` in `.env` before first boot to seed an empty credential
store. Later replacements through MCP are saved in PostgreSQL and survive
restarts.

### Open WebUI in Docker

Listen on the Mac's network interface:

```dotenv
HOST=0.0.0.0
```

In Open WebUI configure:

- Type: `MCP (Streamable HTTP)`
- URL: `http://host.docker.internal:8787/mcp`
- Authentication: Bearer token generated by the local UI

Open WebUI caches tool schemas. After upgrading this service, disconnect and
reconnect the MCP integration before starting a new chat; otherwise the model
may keep sending an obsolete input shape.

The admin UI, token APIs, and raw activity log require the admin secret. A
successful web login receives an HttpOnly session cookie. Scripts can instead
send `Authorization: Bearer <ADMIN_ACCESS_TOKEN>`. The `/mcp` endpoint keeps
its separate MCP bearer tokens.

In the admin's **Order preferences**, enable **Prefer priority / express
delivery** once to use express automatically for all MCP clients. The setting
is saved in PostgreSQL and survives restarts. Eligible ASAP delivery previews
include the express fee; orders without express, pickup, and scheduled orders
use standard delivery. Order confirmation is still required. Omit `priority`
in `preview_order` to follow this preference, or pass `priority: false` for an
explicit standard-delivery request. Submission always uses the confirmed
`submit_context.priority`, even if the preference changes afterward.

## Token permissions

Each token has its own **Checkout & card details** checkbox.

Unchecked tokens receive the complete non-purchase tool set. Checked tokens
also receive:

- `list_payment_methods`
- `order_submit`

Changing the checkbox changes that token immediately. There is no global
purchase switch. Revoking a token invalidates it immediately.

Payment output contains only card brand, last four digits, expiry, and default
status. DoorDash CLI cannot return a full card number or CVC, and the wrapper
does not expose provider/payment IDs.

`order_submit` is deliberately obnoxious:

- It requires the exact `submit_context` returned by `preview_order`.
- It requires an explicit tip in dollars, including an explicit zero.
- It requires the confirmed default card, account default, or work budget.
- It requires explicit PIN-handoff acknowledgement when the preview says a
  delivery PIN is required.
- It re-previews immediately before submission and stops on drift.
- It records the cart before spawning the purchase command.
- It never retries a cart with any recorded submission attempt.
- It polls `order_status` after DoorDash accepts the submission.

A bearer token authenticates a caller. It is not purchase consent; the tool's
confirmation contract is still mandatory.

## MCP tools

Every tool advertises an MCP `outputSchema` with a success/error union. Calls
return a readable result plus the same machine payload through both
client-compatible channels:

- `content[0].text` is a short readable summary.
- `content[1].text` is minified JSON, exactly equal to
  `JSON.stringify(structuredContent)`.
- `structuredContent` is the stable, versioned machine result.

The advertised output schemas are intentionally shallow: they name the fields
needed for the next tool without repeating the full response grammar for every tool.
Every actual response is still checked against the strict internal contract
before it is returned.

Typed errors also return the JSON text copy and `isError: true`.
`structuredContent.error` contains a stable code, `retryable: false`, and,
when recovery is possible, the exact `recovery_tool` and
`recovery_arguments`. Do not repeat the failed call; perform the one stated
recovery action instead.

State-changing tools are serialized inside the service. A cart, address,
preview, reorder, or submission cannot slip between purchase revalidation and
the purchase command. If a non-idempotent command loses its upstream result,
the error names one inspection tool; it never tells the caller to repeat the
write.

Cart preflight failures are the one deliberate exception: they return the
`cart` shape with `items: []`, complete `item_errors`, and `isError: true`.
That means no cart write occurred. A DoorDash partial-add response instead has
both successful `items` and `item_errors`; those successful lines are already
in the cart and must never be sent again.

The public `tools/list` schemas use strict snake_case fields. Copy response
fields such as `address_id`, `store_id`, `menu_id`, `item_id`, `cart_uuid`,
`cart_item_id`, `order_uuid`, and `budget_id` directly into the next tool.
Unknown fields are rejected. Older camelCase and cents-based inputs remain
runtime compatibility aliases, but new callers should not use them.
Restaurant stores, items, and cart lines retain upstream `menu_id` values. A
cart also exposes top-level `menu_id` when every line agrees, so a failed menu
fetch does not erase a working restaurant-item handoff.

The contract stays compact. Money is a floating-point dollar number rounded to
two decimals. An ETA is one `delivery_time` string, including a range such as
`"25-35 min"`. Distance is one display string, preferring miles when DoorDash
provides them. Unavailable optional fields are omitted. Checkout, tracking, and
group-cart links appear only when DoorDash supplied them.

Typed tools discard unknown CLI response fields instead of leaking the upstream
payload. See [MCP response examples](docs/mcp-response-examples.md) for complete
wire examples.

### Authentication

- `doordash_auth`

Call without arguments for login status and renewal instructions. Provide
`access_token` to validate a replacement through a read-only CLI command, save
it, and use it immediately. An invalid replacement leaves the stored credential
intact. Any active MCP bearer can renew the shared account credential; checkout
permission remains separately gated.

The exported token expires and contains no refresh credential. Missing or
expired login produces an MCP error directing the assistant to request a new
exported token and call `doordash_auth`. Recovery does not need a Nomad Variable
change, an admin UI visit, or a restart. Follow the returned inspection action
for any earlier cart/order mutation; renewing login does not authorize a retry.

### Addresses

- `list_addresses`
- `set_default_address`

Address changes are account-wide. DoorDash CLI has no per-cart address
override, and checkout URLs do not pin an address. Delivery carts, previews,
and submissions use the account-wide default address.

### Discovery and catalogs

- `search_restaurants`
- `find_nearby_stores`
- `get_store_details`
- `get_menu`
- `find_items`
- `get_item_details`
- `build_grocery_list`

`find_items` searches grocery and retail catalogs only. A known restaurant
`store_id` is rejected before any retail catalog call and directs the caller to
`get_menu`; never retry `find_items` for that store.

`search_restaurants` and `find_nearby_stores` always call `list_addresses` and
use the coordinates of the account-wide default address. They do not accept a
location override. The wrapper returns an error instead of guessing when
DoorDash has no marked default or the default has no coordinates.

`get_menu` requires `store_id`. It returns the complete restaurant menu and its
authoritative `menu_id` for later item-detail and cart calls. The wrapper makes
exactly one read-only `dd-cli menu --store-id` call and never reads or changes
cart state.
If DoorDash cannot return that menu, the operation fails instead of fabricating
a partial menu from order history.
`get_item_details` routes `i_`-prefixed IDs, plus bare historical item IDs
paired with a restaurant `menu_id`, through the restaurant modifier endpoint.
For an `i_` ID without `menu_id`, the wrapper follows the CLI's documented
chain: call `menu --store-id`, copy its authoritative `menu_id`, then call
`restaurant-item-details`. It never substitutes `store_id` for `menu_id`.
Other item IDs use grocery or retail details. On a large modifier tree, pass
`option_queries` such as `["Ranch"]` to receive root choices plus compact
matching paths instead of dumping the entire tree.
When the full-menu endpoint succeeds, `get_menu` returns every valid item and
category supplied by DoorDash, without modifier trees.

### Carts

- `list_carts`
- `show_cart`
- `add_cart_items`
- `remove_cart_item`
- `delete_cart`

The add operation is additive and non-idempotent. Send the complete requested
batch once. Every line requires the exact menu `item_id` and exact menu `name`;
names are labels, not customization. Before one DoorDash cart write, the
wrapper fetches current details for every item and validates its ID, name,
availability, and required choices. Bare historical restaurant IDs are routed
with the supplied restaurant `menu_id`. A required group with one possible
choice is selected automatically. A preference with multiple choices is never
guessed.

Put requested choices per line in structured `requested_options` entries:
`{"name":"Ranch","quantity":2,"option_id":"o_ranch_sauce"}`. `name` is
required; omit `quantity` for one (maximum 100), and include `option_id` when
the same name appears in more than one branch. If DoorDash reuses one
`option_id` across groups, use the qualified name returned by the error, such
as `Sauce Ranch`. A group name can select its unambiguous Yes option. Omit an
optional add-on to decline it; never invent a `"No ..."`
option. An unmatched or unreachable choice blocks the full batch. An ambiguous
choice such as Ranch sauce versus Ranch dressing returns both candidates; ask
the user, then resend the chosen `option_id`. Resolution stays within the
selected parent branch.

Use `get_item_details` when the choices are not already known. Menu results
omit modifier trees. Item details keeps large responses bounded with
`option_queries`, while cart preflight resolves the full tree internally.
Malformed trees or selectable options without IDs still fail closed before any
cart write.

Exact choices may instead be copied into `nested_options` as
`{"option_id":"o_...","name":"Chosen option"}`. Do not pass modifier group IDs
such as `e_...`.
Ordinary selections stay flat. If a selected option exposes another modifier
group, put the child selections in that option's `options` array.
The wrapper uses `default_handling: "exact"` so CLI defaults do not add
modifiers the caller did not select.

If preflight returns `items: []` plus `item_errors`, no cart change occurred.
Resolve every reported line from the user's stated choices, or ask the user.
Then retry the complete batch once. Never repeat unchanged input.
An unavailable item explicitly says not to retry; choose another item or use
DoorDash checkout.
Cart errors return only relevant choices or ambiguity candidates. Call
`get_item_details` with that `item_id`, `menu_id`, and focused
`option_queries` when more context is needed.

If DoorDash instead returns successful `items` plus `item_errors`, the update
was partial. Call `show_cart`, then add only the failed lines using the returned
`cart_uuid`. Resending an added line or the full original batch duplicates it.
When DoorDash reports an error for repeated copies of the same item but omits
the modifier variant, the error has `ambiguous: true` and lists every candidate
request line with its selected options. Inspect the cart and add only a variant
confirmed missing; never guess which candidate failed.

After a successful add, `add_cart_items` automatically creates and returns a
browser `checkout_url`. If DoorDash adds the items but link creation fails, the
tool preserves the cart result and tells the caller to use
`create_checkout_link`.

One `add_cart_items` call accepts at most 20 complete request lines and checks
at most four distinct item-detail records concurrently.

When `cart_uuid` is omitted, the wrapper checks for an active cart at that store
before adding. An empty cart left by an earlier failed add is reused
automatically. A nonempty cart returns `ACTIVE_CART_EXISTS` with
`recovery_tool: show_cart` and its exact `cart_uuid` instead of silently
duplicating items. Inspect it, then return its checkout link when it already
matches, explicitly extend it using its `cart_uuid`, or ask whether to replace
it. `delete_cart` requires the exact confirmation `"DELETE CART"` after the
user chooses replacement.

`remove_cart_item` also fails closed. For a replacement, first add and verify
the new line, then pass its `cart_item_id` as `replacement_cart_item_id`; the
tool verifies that both the old and replacement lines exist before removing
anything. For a true deletion with no replacement, pass
`confirm_delete_without_replacement: true` instead. Provide exactly one of
those proofs. On success, the tool returns a freshly hydrated cart confirming
that the old line is gone and, for a replacement, the new line remains. If the
mutation or hydration outcome is unknown, follow the returned one-time
`show_cart` action and do not retry the removal.

When extending a `cart_uuid`, omit `fulfillment` to preserve its current mode,
or copy `show_cart.fulfillment` when the user explicitly chose delivery or
pickup.

`list_carts` returns at most 25 carts and 10 lines per cart; call `show_cart`
for one cart's detail. Cart detail and add results return at most 100 lines.
`items_truncation` states exactly how many lines were omitted.

Group-cart `spend_limit` is a dollar amount with at most two decimal places. It
requires `group_cart: true` and cannot be used while extending `cart_uuid`.

### Orders

- `list_orders`
- `reorder`
- `preview_order`
- `create_checkout_link`
- `get_receipt`
- `order_status`
- `order_submit` — permission-gated

`list_orders` returns at most 25 orders with 10 item lines each and marks
omitted lines with `items_truncation`; call `get_receipt` for one order's
itemized detail.
`reorder` first inspects the source order and active same-store carts. It
refuses to merge into a nonempty cart, performs the upstream reorder exactly
once, then hydrates the result with `show_cart` and reports item, quantity, and
modifier differences from the source. Success always contains the verified
hydrated cart. If hydration or the mutation outcome is unknown, follow the one
returned inspection action; never issue a second reorder blindly.

Preview supports scheduled orders, delivery/pickup, Priority delivery, credit
opt-out, and work-benefit budgets. It returns an exact `submit_context`:

Omit `fulfillment` to preserve the cart's current delivery/pickup mode. Passing
`delivery` or `pickup` explicitly changes that mode.

```json
{
  "cart_uuid": "cart-123",
  "preview_token": "bHy-Nx9_OVxS4mVXGF8TF08f-HmEiEvPl-9mM9pHUaQ.sxoJtkJslOz0zqrsIs4uuss1vX8cPgTWuvHCAScEilk",
  "expected_total_before_tip": 25.01,
  "expected_delivery_address": "123 Main St, Oakland, CA 94611",
  "fulfillment": "delivery",
  "priority": false,
  "apply_credits": true,
  "pin_handoff_required": false
}
```

`preview_token` binds the confirmed cart contents, every setting returned in
`submit_context`, and the selected work budget's identity, rules, and remaining
balance. Changing any of that requires a new preview; tip, payment confirmation,
and expense details are added afterward.

Copy `cart_uuid`, `preview_token`, `expected_total_before_tip`,
`expected_delivery_address`, `fulfillment`, `priority`, `apply_credits`, and
`pin_handoff_required` from `submit_context`; also copy `scheduled_time` when
present. Add the user's confirmed `tip` in dollars, `tip_confirmed: true`,
payment confirmation, and `confirmation: "PLACE ORDER"`. For pickup, copy
`expected_delivery_address: null` when the preview returns no delivery address.

When `pin_handoff_required` is true, ask the user to accept handing the
delivery PIN to the Dasher, then add `pin_handoff_acknowledged: true`. For a
work budget, call `preview_order` again with the selected `budget_id`, then
copy `work_benefits.team_id` and that budget's `budget_id`, `name`, and
optional `team_account_id`; use
`payment_confirmation: {"type":"work_budget","name":"..."}` and include any
required expense code or notes. For a personal card, call
`list_payment_methods` and copy `brand` and `last4` from the `is_default` card
after the user confirms it.
Call `list_payment_methods` before asking for final approval so the user can
confirm the order, tip, and default card together. `payment_confirmation` is an
object identifying that payment; `"PLACE ORDER"` belongs in `confirmation`.
Input validation errors submit nothing and leave the cart's submission record
untouched. Fix every reported field together before another call; do not repeat
unchanged arguments.
Use `account_default` only when that call cannot identify the default, browser
checkout was offered, and the user explicitly accepts the unseen account
default.

### Payments

- `list_payment_methods` — permission-gated

Every CLI invocation automatically uses root `--json-output` and appends:

```text
--intent cli-usage
```

## CLI limitations

The current MCP tool set does not support:

- Adding or changing saved payment methods
- Per-cart delivery addresses
- Creating or deleting saved addresses
- Setting an existing cart line to an absolute quantity
- Merchant tips for pickup
- Agent checkout for restricted or age-gated items

Browser checkout is the fallback for those cases.

## Storage and activity

PostgreSQL stores bearer-token hashes, per-token purchase permissions, the
duplicate-submission ledger, and the shared DoorDash access token. The
environment bootstrap credential never overwrites a credential already stored
there. DoorDash owns carts and orders. See the [one-time SQLite import](docs/nomad/README.md)
to preserve existing bearer tokens and submission history.

The dashboard keeps the last 100 MCP-routed CLI calls in memory.
Commands and CLI results include coordinates, addresses, URLs, payment
metadata, and error details; login credentials are excluded. Anyone with
admin dashboard or raw activity-log access can read them. The log resets on
restart. Direct terminal calls do not appear.

Authenticated raw activity JSON:

```text
http://127.0.0.1:8787/activity
```

## Configuration

| Variable | Default | Purpose |
| --- | --- | --- |
| `HOST` | `127.0.0.1` | Listen address |
| `PORT` | `8787` | Listen port |
| `ADMIN_ACCESS_TOKEN` | required | Admin UI and API secret; minimum 16 characters |
| `DD_CLI_PATH` | `./dd-cli` | DoorDash executable |
| `DD_CLI_TIMEOUT_MS` | `120000` | Per-command timeout |
| `DATABASE_URL` | required | PostgreSQL connection URL |
| `DATABASE_SSL` | `false` | Enable TLS for PostgreSQL |
| `DATABASE_SSL_REJECT_UNAUTHORIZED` | `true` | Validate PostgreSQL certificates; `false` allows a private self-signed certificate |
| `DD_CLI_ACCESS_TOKEN` | unset | Bootstrap an empty credential store; existing stored credentials take precedence |

`/health/live` reports process liveness. `/health/ready` verifies PostgreSQL
and returns 503 during database outages. DoorDash login expiry does not change
health checks, leaving MCP authentication recovery reachable.

## Container and verification

```bash
npm run check
npm test
# Alternatively, include integration tests against a dedicated test database:
TEST_DATABASE_URL=postgres://doordash:TEST_PASSWORD@localhost:5432/doordash_test npm test
# Optional local container smoke check:
docker build --platform linux/amd64 --tag doordash-cli-mcp:test .
npm run test:container
```

The optional container smoke test creates its own temporary PostgreSQL container and
network, verifies the bundled CLI and HTTP/MCP server, and removes those test
resources afterward. It does not connect to DoorDash or a live database.
GitHub uses one workflow and one syntax/test job, including PostgreSQL integration,
for pull requests and pushes to `main`. Docs-only changes skip CI. After checks
pass on `main`, it builds one cached Linux amd64 image and publishes `latest`
and `sha-FULL_COMMIT_SHA` tags. Container smoke checks are manual. Deployment
commands remain manual in the
[Nomad guide](docs/nomad/README.md).