Skip to main content
Glama
README.md
# Two MCP servers, one product, two protocol revisions

**The same shopping cart, implemented twice: once on the old stateful MCP spec
(`2025-11-25`), once on the new stateless one (`2026-07-28`). Run them side by
side and watch one of them fall over.**

The goal is not working code — it's understanding *why* the spec changed. Every
experiment here is designed so the failure is loud and the reason is visible on
the wire.

## What is MCP? (three sentences)

MCP — the Model Context Protocol — is a standard way for an AI application to
call tools that someone else wrote. It replaces N applications × M integrations
with N + M, the same way the Language Server Protocol replaced every editor
writing its own TypeScript support. Concretely it's JSON-RPC 2.0 messages with
agreed method names, sent over stdio or HTTP.

Longer version, if that went past too fast:
[docs/01 — why MCP exists](docs/01-why-mcp-exists.md).

## What this repo demonstrates

Five tools — `catalog_list`, `cart_create`, `cart_add_item`, `cart_view`,
`cart_checkout` — with **identical names in both servers** and identical business
logic in a shared `cart-core` package that knows nothing about MCP. The only
difference between the two servers is the protocol layer, which is exactly the
thing under study.

Four things you can watch happen:

1. `server-old` can't complete its own handshake behind a plain round-robin load
   balancer. `server-new` doesn't notice the load balancer exists.
2. Restart `server-old` mid-conversation and the cart is gone, permanently, with
   no request the client can send to recover it.
3. Asking "confirm this total?" costs `server-old` a held-open socket for the
   whole of human thinking time (**1522ms** measured). `server-new` does it in
   two independent requests, **4ms + 15ms**, and can finish on a different
   machine than it started on.
4. Stable list ordering and cache hints — and the arithmetic showing why a
   missing `.sort()` is worth about **$4,200/year**.

The servers are deliberately **not** refactored to share protocol code. There is
duplication between them on purpose, so you can read each one straight through
and diff them.

### Make sure you see the wire

Both servers print every request at the HTTP level: method, path, all MCP
headers, the JSON-RPC method and params, which instance handled it, and the
response including `resultType`. Nothing is hidden behind SDK abstractions. If
you only read one thing while an experiment runs, read the coloured log lines.

## Prerequisites

- **Node.js 20 or newer** (developed on 25.5). `node -v` to check.
- A terminal that renders ANSI colour — the logs lean on it heavily.
- Ports **3000–3002**, **3011**, **3012** free.
- No database, no Docker, no cloud account. Shared state is a JSON file.

Zero Python anywhere in this repo.

## Install

```bash
git clone <this repo>
cd mcp-server
npm install
npm run typecheck    # should print nothing and exit 0
```

`npm install` sets up an npm workspace containing **two generations of the MCP
SDK at once**. They have different package names, so they coexist with no
aliasing tricks:

| Package | Version | Used by |
|---|---|---|
| `@modelcontextprotocol/sdk` | 1.30.0 | `server-old`, the old client |
| `@modelcontextprotocol/{core,server,client,node}` | 2.0.0 | `server-new`, the new client |

## The four experiments, in order

Each is one command. Each starts and stops its own servers — no second terminal
needed. Read the linked write-up **after** running it; each explains what you
just saw and why.

| Order | Command | What it teaches |
|---|---|---|
| 1 | `npm run exp:01` | [Two instances behind a load balancer](experiments/01-load-balancer.md) — the old server can't even finish saying hello; the new one is unbothered. Start here. |
| 2 | `npm run exp:02` | [Restart mid-conversation](experiments/02-restart-mid-conversation.md) — where the cart actually lived, and why "just add Redis" only half-works. |
| 3 | `npm run exp:03` | [Confirm before checkout](experiments/03-confirm-before-checkout.md) — 1522ms of held socket vs two 4ms requests, and why the old way can never run on serverless. |
| 4 | `npm run exp:04` | [Cache hints and stable ordering](experiments/04-list-caching.md) — proving cache hits by counter rather than by stopwatch, and the money argument for `.sort()`. |

Then read the architecture docs, which tie the four together:

- [01 — why MCP exists](docs/01-why-mcp-exists.md) — the N×M problem, and what
  MCP is and isn't. Read first if you're new to MCP.
- [02 — the old architecture](docs/02-old-architecture.md) — the handshake, the
  session id, and every operational pain point traced to its cause.
- [03 — the new architecture](docs/03-new-architecture.md) — handles, MRTR, cache
  hints, and what you give up.
- [04 — side by side](docs/04-side-by-side.md) — every change in the release,
  where to find it here, and an honest list of what this repo doesn't cover.

## Driving it by hand

Worth doing at least once, because you choose the pace and can read each log line
as it appears.

```bash
# Old server (port 3001)
npm run old:server
npm run client -- --target old --scenario basic
npm run client -- --target old --scenario checkout
npm run client -- --target old --scenario checkout --decline

# New server (port 3002)
npm run new:server
npm run client -- --target new --scenario basic
npm run client -- --target new --scenario checkout
npm run client -- --target new --scenario discover    # server/discover — new spec only
```

Two instances plus a load balancer, manually:

```bash
PORT=3002 INSTANCE_ID=A npm run new:server
PORT=3012 INSTANCE_ID=B npm run new:server
PORT=3000 TARGETS=http://localhost:3002,http://localhost:3012 npm run lb
npm run client -- --target new --url http://localhost:3000/mcp --scenario basic
```

Watch both server terminals: the same cart id appears in requests handled by
each, and neither cares.

### Poking at it with curl

The most direct way to feel how self-describing a new-spec request is. Start
`npm run new:server`, then:

```bash
# A complete, valid request — note how much has to be in it
curl -s -X POST http://localhost:3002/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -H 'mcp-protocol-version: 2026-07-28' \
  -H 'mcp-method: tools/call' \
  -H 'mcp-name: catalog_list' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
        "name":"catalog_list","arguments":{},
        "_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
                 "io.modelcontextprotocol/clientInfo":{"name":"curl","version":"1"},
                 "io.modelcontextprotocol/clientCapabilities":{}}}}'
```

Now break it one piece at a time and watch the error change:

| Change | Expected |
|---|---|
| `-H 'mcp-method: tools/list'` (body still `tools/call`) | `-32020` HeaderMismatch |
| `-H 'mcp-name: cart_view'` | `-32020`, naming the disagreement |
| drop the `mcp-method` header | `-32020`, *"the required Mcp-Method header is absent"* |
| drop the `_meta` block | `-32602`, listing the missing envelope keys |
| `mcp-protocol-version: 2099-01-01` in both header and `_meta` | `-32022` Unsupported protocol version |
| `curl http://localhost:3002/mcp` (a GET) | **405** — the GET endpoint is gone |

Do the same against the old server and you'll be told to `initialize` first.

## Repo layout

```
packages/
  cart-core/     the actual product. zero MCP knowledge. shared by both servers.
  server-old/    MCP 2025-11-25. sessions, handshake, held-open streams.
  server-new/    MCP 2026-07-28. stateless, handles, MRTR, cache hints.
  client-demo/   both clients — one per SDK generation.
  round-robin/   ~50-line load balancer. no stickiness, on purpose.
experiments/     four runnable scripts + a write-up each.
docs/            the four architecture notes.
.cart-store/     server-new's shared state. a JSON file. delete it freely.
```

Reading order for the code: `cart-core/src/cart.ts` (what the product does) →
`server-old/src/index.ts` → `server-new/src/index.ts`. The protocol-level code in
both servers is commented line by line; the plumbing is not.

`npm run clean` removes build output and `.cart-store`.

---

## Glossary

Terms used throughout, in the order they'll bite you.

**Load balancer** — a box in front of several identical copies of your server that
spreads incoming requests across them. The default policy is *round-robin*: send
each request to the next copy in the list. It assumes any copy can answer any
request, which is exactly the assumption the old MCP spec broke. Here it's
[`packages/round-robin`](packages/round-robin/src/index.ts), about 50 lines.

**Session** — a server-side memory of a client, spanning multiple requests. On
`2025-11-25` the server minted an `Mcp-Session-Id` during the handshake, the
client echoed it on every request, and the server used it as a key into an
in-memory map. The session id is a *pointer into one process's heap*, which is
where all the trouble comes from.

**Stateless** — the server keeps nothing between requests. Every request carries
everything needed to serve it. Note what this does *not* mean: there's still a
cart, and it's still stored. What's gone is state held *in a particular process*,
implicitly, keyed by connection. Application state in a shared database is
perfectly compatible with a stateless protocol.

**Sticky session** (session affinity) — configuring the load balancer so all
requests from one client return to the same server copy, usually by hashing a
cookie or a header. The standard workaround for a stateful protocol. It works, and
it costs you even load distribution, painless deploys, useful autoscaling, and a
load balancer that doesn't need to understand your application protocol. Cost list
in [docs/02](docs/02-old-architecture.md#attempt-2-sticky-sessions).

**Elicitation** — a server asking the end user a question mid-operation ("total is
$180.36, confirm?"). On the old spec the server sent its *own* request to the
client over a held-open stream and blocked inside the tool handler while a human
thought about it. That single feature required a live process, an open socket, and
guaranteed routing back to the same box.

**MRTR** (Multi Round-Trip Requests) — how `2026-07-28` does elicitation instead.
The server returns a normal `200` with `resultType: "input_required"`, the
questions in `inputRequests`, and an opaque signed `requestState`. That request is
over — nothing is held. The client gathers the answers and sends a **new** request
(new JSON-RPC id) carrying `inputResponses` and the same `requestState`. The
in-flight state travelled through the client instead of sitting in a process,
which is why round 2 can be served by a completely different machine.

**Handle** — a server-minted identifier returned as ordinary tool output, then
passed back as an ordinary argument. `cart_create` returns `cartId`;
`cart_add_item` takes it. This is how `2026-07-28` replaces session state, and the
difference from the old design is *who holds the key*: the transport, invisibly,
versus the client, in a value the model can read and pass along. Caveat: a handle
alone is a bearer token — it needs to be scoped to an authenticated user, which
[docs/04](docs/04-side-by-side.md#two-places-where-the-missing-auth-actually-bites-this-code)
covers honestly.

**Prompt caching** — LLM providers cache the *prefix* of a prompt: send the same
opening bytes again and the provider reuses its computed state instead of
reprocessing those tokens, at roughly **a tenth** of the input price. Two
properties make it fragile: the match is on **exact bytes**, and it's
**positional from the front**. So if your tool list or catalog sits in the prefix
and two entries swap places, you lose the discount on *every token after the
swap*. That's why `2026-07-28` says servers SHOULD return lists in a
deterministic order, and why
[`listProducts()`](packages/cart-core/src/products.ts) sorts by a unique `id`
rather than by name or price — a unique key gives a *total* order with no ties for
a sort implementation to resolve differently. Worked example, with prices:
[experiment 04](experiments/04-list-caching.md#why-stable-ordering-is-worth-real-money).

---

## If you only remember three things

1. **"Stateless" doesn't mean "no state" — it means no state pinned to a
   process.** The cart still exists. It moved somewhere any instance can reach.
2. **Sticky sessions were a real fix with real costs**, and one of those costs was
   making your infrastructure parse your application protocol.
3. **MRTR, not statelessness, is what unlocked serverless.** Statelessness got MCP
   behind a load balancer. Elicitation still needed a process to stay alive while a
   human read a dialog — and that's precisely what serverless removed.