Letting an AI agent dispatch a sidewalk robot: building an MCP server for Serve Robotics

·

14 min read

Cover Image for Letting an AI agent dispatch a sidewalk robot: building an MCP server for Serve Robotics

Getting an AI agent to call a delivery API takes an afternoon. Getting it to call that API safely, when each call sends a real robot down a real sidewalk and costs real money, is most of the work. This post walks through serve-delivery-mcp, an open-source MCP server for the Serve Robotics delivery API, and the design choices that keep the model from doing anything the human didn't approve.

On November 11, 2026, Serve Robotics opened its robot delivery network to outside developers. The announcement said anyone could dispatch robots with a few lines of code, "even through an AI assistant." It didn't mention the Model Context Protocol, the open standard that lets assistants like Claude call outside tools. So I built the bridge.

One catch: I'm on the waitlist for sandbox credentials. Everything here was built against Serve's public API reference and a local mock of that contract. It hasn't yet touched Serve's real sandbox. That constraint shaped the project in useful ways, and I'll come back to it.

Here's the experience the server is built for:

You:     Send two coffees from Corner Cafe to Alex at 200 Spring St.
Claude:  A robot can do that for $4.07, arriving in about 13 minutes. Book it?
You:     Yes.
Claude:  Dispatched. The robot is heading to the cafe now.

Four lines of chat. Behind them sit nine tools, a quote cache, a spending cap, a retry policy that treats one endpoint differently from all the others, and about 3,000 lines of TypeScript including tests. (The $4.07 is invented by the mock; Serve hasn't published pricing.)

The API, and the tools on top of it

Serve's delivery API follows the quote-then-job pattern familiar from courier APIs. POST /quotes returns a fee, pickup and dropoff ETAs, and an expiry. Quotes reserve nothing, last about five minutes, and can be used once. POST /jobs turns a quote into a delivery. Everything after that (tracking, cancelling, unlocking the cargo bin at the dropoff, fetching the planned route) hangs off the job. Auth is an X-API-Key header plus a partner ID.

Serve also offers a one-step endpoint that quotes and dispatches in a single call. The server deliberately doesn't expose it. More on why below.

The server maps the API onto nine tools:

ToolWhat it doesActs?
check_robot_availabilityCounts robots on duty near a locationRead-only
get_delivery_quotePrice and ETA for pickup to dropoff; reserves nothingRead-only
dispatch_robotTurns a confirmed quote into a deliveryActs, costs money
track_deliveryStatus, robot position, ETAsRead-only
list_deliveriesActive or recent deliveriesRead-only
cancel_deliveryCancels an unfinished deliveryActs
unlock_robot_binOpens the cargo bin at the dropoffActs
get_delivery_routePlanned route summary, GeoJSON on requestRead-only
simulate_delivery_progressMoves a delivery through its stages (mock and sandbox only)Test only

Each tool declares MCP tool annotations such as readOnlyHint, destructiveHint and openWorldHint. Clients use them to decide which calls can run without asking and which need a human click. Annotations are hints, not enforcement, so they're the first layer, not the only one.

The simulator tool is registered only when SERVE_ENV isn't production. A tool that doesn't exist can't be called by mistake, so it's safer to leave it out than to register it and refuse at runtime. The same goes for the simulate_rejection parameter on quotes, which forces errors like couriers_busy during testing.

Guardrails in code

The main design rule: anything that matters is enforced by the server, not requested of the model. Tool descriptions do tell the model to show the fee and get a yes before dispatching. But a description is a request. Models skip steps, misread context, and get prompt-injected by text in a web page or email they were asked to summarize. So every rule that protects money or a physical outcome also exists as a check the model can't talk its way past.

Dispatch checks diagram

The model supplies a quote ID, a yes and the recipient details. The addresses and fee come from the server's own cache.

Two steps, with the human in the middle

Dispatching is always two calls: get_delivery_quote, then dispatch_robot. The second one requires a quote_id from the first and confirmed: true. That's why the one-step instant-dispatch endpoint stays unexposed. It removes the pause where a person sees the price.

To be honest about it, confirmed is a boolean the model fills in. It's a speed bump, not proof of consent. It makes skipping the confirmation an explicit act instead of an accident, and it gives the model a clear refusal message to relay. The real human gate is the MCP client's own approval prompt for a tool that isn't read-only. The guarantees come from the next three checks.

The quote is bound on the server

When a quote comes back, the server stores it in a session cache keyed by quote_id: the exact pickup and dropoff it priced, the fee Serve returned, and the expiry. dispatch_robot takes no addresses and no fee. It takes the ID, and the server rebuilds the job request from what it cached.

const lookup = ctx.quotes.lookup(args.quote_id);
switch (lookup.status) {
  case "unknown":
    return fail("Unknown quote_id. Get a quote with get_delivery_quote first.");
  case "expired":
    return fail("That quote has expired. Get a new quote and confirm it with the user.");
  case "used":
    return fail("That quote was already dispatched. Use list_deliveries to find it.");
  case "ok":
    break;
}

The model can't swap in a different dropoff after the user approved one. It can't claim a lower fee. It can't reuse a spent quote. The only inputs it adds at dispatch are the items, the recipient's name and phone, and handoff notes, and all of them are validated with zod. (Phone numbers, for example, must match a simple digits-and-punctuation pattern.)

A spending cap checked against Serve's number

SERVE_MAX_FEE_CENTS (default 1500, so $15) is checked twice: when the quote comes back, and again at dispatch. Both checks compare against fee as Serve returned it, never against anything in the model's arguments. A quote over the cap returns an error saying it can't be dispatched, so the model doesn't offer the user something it can't deliver.

Production takes two switches

The default environment is mock. Pointing at production takes both SERVE_ENV=production and SERVE_ALLOW_PRODUCTION=true. Config loading fails fast with a plain explanation if the second is missing. Outside mock mode, the base URL must be HTTPS, and a partner ID and API key are required. A config typo can't quietly send real robots out.

Smaller rules

  • track_delivery never returns the bin's unlock code. Opening the bin is its own explicit tool call.
  • Every job is created with undeliverable_action: "return", so a failed handoff brings the goods back instead of leaving them on a sidewalk.

Never dispatch twice

The worst bug a delivery agent can have is two robots for one order. The classic cause is an automatic retry: the request reaches the server, the response is lost to a timeout, and the client sends it again.

The HTTP client sorts requests by whether they're safe to repeat:

RequestAuto-retried?Why
GET (tracking, lists, routes)Yes, up to 3 attemptsReads have no side effects
POST /quotesYes, up to 3 attemptsQuotes reserve nothing
POST /jobsNeverA repeat could dispatch a second robot

Retries happen only on 429, 502, 503 and 504, with exponential backoff from 500 ms, capped at 10 s, and they honour retry-after on 429s.

That leaves the hard case: POST /jobs times out, and you don't know whether the robot is coming. The server handles this without an idempotency key. Every job carries a fresh external_order_id the server generates. When a transport error might have reached Serve, the server looks for that ID among active jobs before reporting anything:

async function createJobSafely(ctx: ToolContext, request: CreateJobRequest): Promise<JobResponse> {
  try {
    return await ctx.client.createJob(request);
  } catch (error) {
    if (error instanceof ServeApiError && error.code === "used_quote") {
      ctx.quotes.markUsed(request.quote_id);
    }
    if (!(error instanceof ServeTransportError) || !error.mayHaveReachedServer) throw error;

    const existing = await findJobByOrderId(ctx, request.external_order_id);
    if (existing) return existing;
    throw new ServeTransportError(
      "The delivery request didn't get a response and no matching delivery was found. " +
        "Check list_deliveries before trying again.",
      false,
      { cause: error },
    );
  }
}

There are three outcomes. The job exists, so the dispatch succeeded and the tool says so. The job doesn't exist, so it's safe to say it failed. Or the lookup itself fails, and the model is told to check list_deliveries before trying again rather than being invited to retry. The mayHaveReachedServer flag is set conservatively. A timeout, a network error, even a response that fails schema validation all count as "may have reached Serve", because from the client's side you can't tell them apart from a lost success.

If Serve adds an Idempotency-Key header later, that becomes the first line of defence. The lookup stays as the second.

The plumbing was most of the work

Geocoding, with the match shown back. Serve needs both a street address and coordinates for each stop. Users say "the cafe on Main", not a latitude. If the assistant doesn't supply coordinates, the server geocodes with OpenStreetMap's Nominatim. It's rate-limited to one request per second and sends an identifying User-Agent, as Nominatim's usage policy requires. The quote then returns the address it actually matched, plus whether the location came from the caller or the geocoder. A wrong pin is the most likely real-world failure, and the cheapest place to catch it is the moment a human looks at the price.

stdout belongs to the protocol. Over the stdio transport, MCP's JSON-RPC messages travel on stdout. One stray console.log corrupts the stream, and the client usually reports it as a vague connection failure. All logging goes through a small structured logger that writes JSON lines to stderr.

Secrets get redacted at the sink. Credentials come only from environment variables. The logger is given the secret values at startup and scrubs them from every line before writing. That covers the case nobody plans for: an upstream error message that echoes back a header.

Every response is validated. Each Serve response goes through a zod schema before any tool sees it. If the API drifts, you get a clear "unexpected format" error at the boundary instead of an undefined three layers down.

Errors are written for the model. Each handler is wrapped so that any exception becomes a tool result with guidance. Serve's error codes map to messages that say what to do next. used_quote becomes "That quote has already been used. Get a new quote." couriers_busy says no robot is free and to try again in a few minutes. not_arrived_at_dropoff explains why the bin can't open yet. Retryable failures get a note that a later retry may work. The model acts on whatever text you return, so error messages are effectively part of the prompt.

Building before access: mock-first

Being waitlisted turned out to be a useful constraint. With no sandbox, the only spec was Serve's public API reference, so I wrote that contract down as code. mock/server.ts is an in-memory implementation of the API built on nothing but node:http. It uses the same paths, payloads and error format. Quotes are single-use and expire after five minutes. Jobs move through the documented lifecycle. It returns the documented error codes (used_quote, couriers_busy, not_arrived_at_dropoff and others) under the same conditions.

That mock is what the server defaults to, so anyone can clone the repo and drive a fake robot from Claude Desktop without credentials. It's also what the tests run against. The suite has 41 test cases across eight files: unit tests for config, the HTTP client, error mapping, the logger, the geocoder and the quote cache, plus 13 end-to-end tests. Those connect a real MCP client to the real server, which talks to the mock over HTTP. Among them:

  • The full lifecycle: quote, dispatch, track, unlock, complete
  • Refusing to dispatch without explicit confirmation
  • Refusing unknown and reused quotes
  • Enforcing the fee cap
  • Recovering from a lost dispatch response without creating a duplicate
  • Explaining why the bin can't be unlocked yet
  • Cancelling an active delivery, but not a finished one

The end-to-end layer is the one that earns its keep. Unit tests prove a function works. The protocol-level tests prove the model sees the right tools, the right schemas and the right error text, which is everything it has to go on.

CI runs typecheck, ESLint, Prettier, the tests, a build and npm audit on every push and pull request. Dependabot keeps dependencies current.

The obvious limit: a mock encodes my reading of the docs. When sandbox access arrives, the first job is to run the same end-to-end suite against Serve's sandbox and fix wherever my reading was wrong. That's also when the sandbox-only fleet controls (powering simulated robots up and down) get added.

What carries over to any agent-facing API

This started as a robot project. The lessons apply to any MCP server whose tools spend money or change something outside the chat.

  1. Make the model pass IDs. If a later step depends on an earlier one, have the model carry a server-issued ID and look the facts up on the server. Whatever the model can restate, it can misstate.
  2. Enforce limits against upstream numbers. Check caps against what the API returned, never against the arguments. The arguments are the model's opinion.
  3. Leave out the shortcut endpoint. If the API has a one-step "just do it" call, think hard before exposing it. The two-step version is where the human gets to look.
  4. Sort every call by whether it's safe to repeat. Retry reads freely. Never blindly retry a write with side effects, and design a way to reconcile when its response goes missing.
  5. Ship only the tools that make sense in this environment. Register tools conditionally. A test tool that isn't in production can't be misused there.
  6. Treat error text as prompt text. The model will do what your error message implies. Write it as an instruction.
  7. Make production opt-in, twice. Safe by default, loud failure on a half-configured switch.

None of these depend on the model getting smarter. They're ordinary distributed-systems discipline, aimed at a caller that's fluent, fast and sometimes wrong.

Try it

The code is MIT-licensed at github.com/edtechniti/serve-mcp. It needs Node.js 22.12 or later and no credentials:

git clone https://github.com/edtechniti/serve-mcp.git
cd serve-mcp
npm install
npm run build
npm run mock      # terminal 1: mock Serve API on http://127.0.0.1:4010
npm run inspect   # terminal 2: MCP Inspector (optional)

To use it from Claude Desktop, add this to claude_desktop_config.json and keep the mock running:

{
  "mcpServers": {
    "serve-delivery": {
      "command": "node",
      "args": ["/absolute/path/to/serve-mcp/dist/src/index.js"],
      "env": {
        "SERVE_ENV": "mock",
        "GEOCODER_USER_AGENT": "serve-delivery-mcp ([email protected])"
      }
    }
  }
}

Then ask for something like "Quote a robot delivery from 100 Main St to 200 Spring St, Los Angeles CA 90012." Try saying no to the price, letting a quote expire, or asking it to dispatch to a different address than the one quoted.

When sandbox access comes through, I'll run the suite against Serve's real sandbox and write up what the mock got wrong. If you're building agent tools that act in the physical world, or have Serve sandbox access yourself, I'd like to compare notes.

serve-delivery-mcp is an independent project. It isn't affiliated with, endorsed by, or sponsored by Serve Robotics. "Serve Robotics" is a trademark of its owner and is used here only to describe compatibility.

Sources