2026-09-0313 min readRishi Choudhary

MCP Protocol in Practice: What We Learned Implementing Model Context Protocol for AI Commerce

Share

Part of An Airline in the Chat Window: Building an Agentic Commerce Accelerator on ACP and MCP

1.What We Learned Implementing Model Context Protocol for AI Commerce

Every tool in our MCP server passed the JSON-RPC booking-flow test. Search returned flights, checkout sessions advanced state by state, test-mode payments cleared. Then a real assistant drove it, assembled a booking, and never told the customer where to pay. The protocol was fine. The tool description was the bug.

This post is a deep dive from our agentic commerce accelerator: what the Model Context Protocol specification covers, and the parts you only learn by putting a model on the other end of the wire.

MCP at the Wire Level: JSON-RPC, Two Transports, One Tools List

MCP is a small JSON-RPC 2.0 protocol between a client (the assistant's host application) and a server (your code). The client opens with initialize, both sides declare capabilities, and from then on the client mostly asks two questions. tools/list: what can you do? tools/call: do this one. Resources and prompt templates exist in the spec as well. For a commerce surface, tools are the whole game.

A tool is a name, a description, and a JSON Schema for its input. Our server derives that schema from zod, so the shape the model is shown and the shape we validate against are the same object. Here's the outline of one tool, trimmed to the parts that matter (illustrative, not the source file):

{
  "name": "check_flight_availability",
  "description": "Check current seat availability and fare for one flight on one date. Call this before create_booking_checkout: search results can be stale.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "flightId": { "type": "string" },
      "date": { "type": "string", "format": "date" },
      "passengers": {
        "type": "object",
        "properties": {
          "adult": { "type": "integer" },
          "child": { "type": "integer" },
          "infant": { "type": "integer" }
        }
      }
    },
    "required": ["flightId", "date", "passengers"]
  }
}

A tools/call result comes back as a content array (text items, in our case) plus an optional isError flag. That flag carries more weight than it looks, and the error section below is mostly about it.

Transports come in two flavours. Over stdio, a local host spawns your server as a child process and exchanges newline-delimited JSON on stdin and stdout. Over Streamable HTTP there's a single endpoint: the client POSTs JSON-RPC messages, and the server answers with a JSON body or opens a Server-Sent Events stream when it has more than one message to send back. The official @modelcontextprotocol/sdk implements both, which is why our server speaks both without two codebases.

That is close to everything the protocol asks. Answer initialize, tools/list and tools/call correctly over one transport and an MCP client will drive you. Ours was driven exactly that way, by real MCP clients and by a JSON-RPC test script that walks the funnel from search to ticket, before any model touched it.

One thing to say plainly first. The accelerator is protocol-complete and demo-tested, not live. Flight inventory is seeded, Stripe runs in test mode, there is no ChatGPT merchant listing and no carrier connected behind it. Everything below comes from building the surface, driving it with real MCP clients, and running Vitest unit, integration and end-to-end suites against it. Where a production number would normally sit, you'll get the mechanism instead. The pillar's Honest Status section has the full ledger.


Tool Descriptions Are User-Interface Copy

Twelve tools cover the funnel: search_flights, search_multi_city_flights, get_flight_details, check_flight_availability, get_available_seats, get_addons, create_booking_checkout, select_seats, get_checkout_session, complete_booking, check_payment_status, get_order_details. Read them in order and you can watch a booking happen.

Granular tools, on purpose

In Agentic Architecture Patterns we made the case that fewer, broader tools help a model choose correctly. Commerce pulled us the other way. Each step in a booking has its own failure mode and its own thing to show the customer. A seat map is not a fare. An add-on list is not a payment status. A single book_flight tool that did all of it would hide those seams, and when a step in the middle went sideways the model would have nothing specific to tell the human.

The price of granularity is that the model has to sequence twelve calls correctly. That cost gets paid in the descriptions, where prerequisites belong ("call check_flight_availability before create_booking_checkout") rather than trusting the model to infer ordering from names. More small decisions, each easy to check.

The humbling commit

With the test script green end to end, we put a model in the loop and asked it to book a flight. It searched. It checked availability. It created the checkout session, selected seats, completed the booking. It reported back to the customer. And it stopped.

No payment link. The session carried a payment URL, the tool had returned it, and it was sitting in the model's context. From where the model stood the job was finished: the booking was assembled, so why mention a URL?

Nothing in the protocol had failed. The tool result was correct. What was missing was an instruction, and the only channel through which a tool can instruct a model is its description. The fix (and the literal last commit on the repository) was adding explicit payment-URL handling to the booking and checkout tool descriptions. In substance: this call returns a payment URL; present it to the customer verbatim; the booking is not paid until they open it.

A tool description is not documentation. It's user-interface copy aimed at a model. Documentation is read by a person who already wants to understand. A description is read by a model deciding what to do next and what to say, under a system prompt you don't control and can't see. Anything the assistant must surface to the human (a link, a warning, a deadline, a next step) has to live in the tool contract, or it's optional. And optional means sometimes.

A few habits followed. Descriptions should cover what to tell the customer, beyond what the tool returns. The 15-minute session clock belongs in them where it bites, since a model can't warn about an expiry it was never told about. And the zod schema stopped being the contract. The schema is what the model must send. The description is what the model must do with the answer.


Two Doors, One Core: ChatGPT Through ACP, Claude Through MCP

ChatGPT reaches the accelerator through the Agentic Commerce Protocol, which is a REST surface: checkout-session endpoints for create, retrieve, update, complete and cancel; a discovery document at /.well-known/acp.json; a delegate_payment endpoint minting single-use vault tokens; Stripe webhooks with signature verification; and a required header set (API-Version, Idempotency-Key, Request-Id, an RFC 3339 Timestamp) on every call, echoed back on every response. Claude, or any MCP host, reaches it through the tool surface described above.

What makes that tractable is a decision that looks almost too simple. The MCP server contains no business logic. It is a thin client over the same REST API that ACP calls, so no seat lock, no state transition and no idempotency check lives in the MCP layer at all. You cannot bypass the state machine by choosing a different door, and there is exactly one implementation of each guarantee to test.

That state machine is the checkout session, enforced with an explicit transition map. The shape, not the source:

const transitions: Record<SessionState, SessionState[]> = {
  not_ready_for_payment: ['ready_for_payment', 'canceled'],
  ready_for_payment: ['in_progress', 'canceled'],
  in_progress: ['completed', 'canceled'],
  completed: [],
  canceled: [],
}

Terminal states have empty lists. A model can ask for anything; the map only permits legal moves. Idempotency keys (UUIDs of at least 16 characters, held 24 hours) replay to the existing session instead of booking a second seat. Sessions expire after 15 minutes. Amounts are stored in minor units, never floats, and totals travel as an array with display text. Every one of those rules applies identically whether the request arrived as an ACP complete call or an MCP complete_booking tool call.

What differs, and what we don't know yet

The differences between the two doors are architectural, and we can list them: a REST session surface with a discovery document and delegated payment tokens on one side, a JSON-RPC tool surface with an initialize handshake and per-tool schemas on the other; a required header set on each ACP request versus zod-validated inputs on each MCP call; HTTPS from OpenAI's platform versus stdio from a local host or Streamable HTTP from a remote one.

The differences the pillar teased, in how ChatGPT and Claude actually select tools, format parameters and plan multi-step flows, are a different kind of question. Answering it needs a live ChatGPT merchant listing with conformance testing behind it, driving real traffic against the same core Claude drives through MCP. We don't have that listing yet, and we won't guess. What we can say is that the architecture was built so the answer doesn't matter for correctness. If one assistant sequences calls oddly, it meets the same transition map as the other.


Errors a Model Can Read

MCP gives you two ways to say no, and choosing the right one is most of error design.

The first is a JSON-RPC protocol error: unknown method, malformed request, invalid params. The host deals with those, and by the time anything reaches the model there's rarely a useful sentence left. The second is a tool result with isError: true and text content. The model reads that text as it reads any other result, which means it can reason about it, retry, or relay it to the customer in plain language.

Our rule: anything the model could act on or explain goes back as a readable result, never as a protocol error. Malformed tool input fails zod validation before touching the REST core, and the message names the field. An expired session comes back from the core as 410 Gone, and the thin client turns that status into words:

{
  "isError": true,
  "content": [
    {
      "type": "text",
      "text": "Checkout session expired: sessions last 15 minutes. Start again with create_booking_checkout. Do not retry complete_booking on this session."
    }
  ]
}

Notice what that message does. It states the fact, names the tool to call next, and forbids the retry the model would otherwise attempt. An illegal transition gets the same treatment: the current state and the moves the map allows, rather than a bare rejection.

The most important error is the one you don't raise. A replayed idempotency key returns the existing session rather than an error, because from the model's side a network hiccup and a genuine second attempt look the same, and either way the right answer is the session that already exists. Raise an error there and a helpful assistant will try again with a fresh key.

When conversation state drifts

The awkward case is when the model's picture of the booking and the server's picture diverge. The model believes seats are selected; the server has a session that timed out two turns ago. A model that infers state from its own conversation history will confidently act on the wrong picture. We covered why in the architecture patterns post: the system should know where it is, and the model should read that rather than remember it.

That is what get_checkout_session and check_payment_status are for: read tools that let the model re-anchor on server truth before it does anything with money. And money itself never asks the model to do arithmetic: totals arrive as an array with display text like "Tax (18% GST)", ready to be read aloud, because a model summing minor units is a rounding error waiting to be spoken.


The Caller Is Never the User

Who is on the other end of an MCP request? Not the customer: a platform, acting on the customer's behalf, and the trust model has to say so.

Each platform gets its own bearer key. OpenAI, Claude and internal callers hold separate credentials, so a leaked or rotated key affects one door. An optional CIDR allowlist restricts the ACP surface to OpenAI's ranges. Rate limiting, helmet and CORS hardening sit in front of Fastify as they would on any API. None of that is exotic. What's different is what the credential means.

A platform key tells you which platform is calling. It tells you nothing about which human is behind the conversation. Don't let one stand in for the other. The handles the human actually holds are the ones the flow produced: a checkout session, a confirmation code. Treat those as the authorization boundary for reads and post-booking operations, and the platform key as what lets a caller into the building at all.

Transport shifts where authentication happens. Over stdio there is no bearer token: the trust is that whoever launched the process was allowed to. Over Streamable HTTP the key is the whole story, which is where the allowlist and rate limits earn their keep.

One more caller that isn't a user: Stripe. Webhooks need signature verification, and on Fastify that means preserving the raw request body before any parser touches it, because a re-serialised body won't match the signature. It costs an afternoon the first time. Then it's a line of config.


Latency Budgets When a Model Sits in the Middle

We haven't measured this under real customers, so what follows is the budget as designed, not as observed.

A tool call from an assistant is never one round trip. The model decides to call, the host calls you, your result lands back in the model's context, the model reasons over it and possibly speaks, then decides on the next call. Your latency is one term in that sum, and the model's thinking time before and after is the rest. A booking is a chain of those sums.

Two design consequences fall out. Fewer round trips beat faster individual calls, which is why results carry enough (fare, display text, the payment URL) that the model doesn't need a follow-up call to explain what it just did. And the budget that actually constrains the funnel is the 15-minute session clock, which the model's reasoning, the human's decision-making and your tool latency all draw from together. A customer who spends four minutes reading seat options has spent four minutes of your session.

Seeded inventory makes search fast, so the accelerator says little about the slow path; the integration design against a real PSS does. Availability calls against Navitaire New Skies are expensive and carriers run caching layers you have to respect rather than hammer, so the design treats availability as the cached call and the price re-validation at session creation as the one call that must never be cached. Whatever doesn't need to finish before the tool returns is what BullMQ is in the stack for, with e-ticket delivery through SES the obvious candidate.

Seat selection is where latency and correctness collide. Redlock holds distributed seat locks so two conversations cannot claim the same seat, and reconciling that lock's lifetime with a carrier's own hold expiry is the whole integration problem. The pillar walks through it.


Three Questions Before You Expose a Tool Surface

  1. For each tool, what must the assistant say to the human after calling it, and is that sentence written into the description or left to chance?
  2. If a second protocol arrived tomorrow, which of your guarantees would have to be reimplemented, and which live in a core beneath the protocol layer?
  3. When the model's picture of the transaction and the server's diverge, which tool lets the model re-read the truth, and does the model know to call it?

The spec tells you how to answer tools/call: the description tells the model what to do with your answer. We got the first right in a test script and the second right one commit later.

Share

Content on this page may not be reproduced, distributed, or republished without prior written permission. Sharing links is encouraged. See our Terms of Use for details.