Skip to main content
Glama

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.

Related MCP server: Online Boutique AI Assistant MCP Server

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

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 — the old server can't even finish saying hello; the new one is unbothered. Start here.

2

npm run exp:02

Restart mid-conversation — where the cart actually lived, and why "just add Redis" only half-works.

3

npm run exp:03

Confirm before checkout — 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 — 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:

Driving it by hand

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

# 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:

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:

# 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.tsserver-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, 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.

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 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() 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.


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.

Maintenance

ActivityMaintained
ResponsivenessSyncing

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Connectors

Related MCP Servers

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ritik913553/mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server