Run, break, explain: the labs
Everything in this course was built and run while writing it. Here is how to run it yourself, what you will see, and how to break it on purpose. This is where Phase 1 of the roadmap happens.
By the end of this lesson you can
- run the raw dual-era demo and read every line it prints against the spec;
- run the SDK server over stdio and HTTP, and the two-replica stateless demo;
- predict, then observe, what six deliberate faults do;
- explain the lifecycle, MRTR and the two transports in your own words.
Before you start
You need Node 22 and a clone of the course repository. No global installs: the first lab has no dependencies at all, and the second installs its own.
# Lab 1: the wire, raw. A dual-era server and a client that prints every line node mcp/01-lifecycle-demo/client.mjs # Lab 2: the SDK v2 server cd mcp/02-server && npm install npm run stdio # local: speaks JSON-RPC on stdin/stdout, logs on stderr npm run http # remote: POST http://127.0.0.1:3000/mcp with a bearer token ./stateless-demo.sh # two replicas, no shared memory, one MRTR across both
Lab 1: the raw dual-era demo
mcp/01-lifecycle-demo/ holds two small files and no node_modules.
server.mjs: a zero-dependency, dual-era stdio server of about 120 lines. It answers modern requests (with_meta) and the legacyinitializehandshake; itsdeploytool uses MRTR with an HMAC-signedrequestState; unknown versions get-32022; legacy requests beforeinitializeget-32600; it logs only to stderr and exits when stdin closes.client.mjs: spawns the server, prints every line it sends (-->) and receives (<--), runs the modern scenario and then the legacy one, and closes stdin to end each.
What the client does, in order
- The client spawns the server as a child process with pipes, exactly as a host does for a local server in its config.
- Modern scenario:
server/discover, atools/callthat returnsinput_required, the retry with a new id, then a request with a bogus version. - The client closes stdin. That is the stdio shutdown signal; the server logs it on stderr and exits.
- A fresh server for the legacy scenario: a call before
initialize(rejected), the handshake, then list and call.
The real output, trimmed
=== MODERN (2026-07-28): probe -> call -> input_required -> retry ===
--> {"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{…}}}
<-- {"jsonrpc":"2.0","id":1,"result":{"resultType":"complete","supportedVersions":["2026-07-28"],"capabilities":{"tools":{}},…,"ttlMs":60000,"cacheScope":"public"}}
--> {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"deploy","arguments":{"env":"prod"},"_meta":{…}}}
<-- {"jsonrpc":"2.0","id":2,"result":{"resultType":"input_required","inputRequests":{"confirm":{"method":"elicitation/create","params":{"mode":"form","message":"Deploy to prod?",…}}},"requestState":"eyJlbnYiOiJwcm9kIiwi…HXNy-gUM"}}
(client: asking user... user says yes)
--> {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"deploy","arguments":{"env":"prod"},"_meta":{…},"requestState":"eyJlbnYiOiJwcm9kIiwi…HXNy-gUM","inputResponses":{"confirm":{"action":"accept","content":{"confirm":true}}}}}
<-- {"jsonrpc":"2.0","id":3,"result":{"resultType":"complete","content":[{"type":"text","text":"deployed to prod"}]}}
-- bad version:
--> {"jsonrpc":"2.0","id":4,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"1900-01-01",…}}}
<-- {"jsonrpc":"2.0","id":4,"error":{"code":-32022,"message":"Unsupported protocol version","data":{"supported":["2026-07-28","2025-11-25"],"requested":"1900-01-01"}}}
[server] stdin closed -> exit (graceful shutdown)
=== LEGACY (<=2025-11-25): initialize -> initialized -> ops -> close stdin ===
--> {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
<-- {"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Not initialized and no per-request _meta"}}
--> {"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{…}}}
<-- {"jsonrpc":"2.0","id":2,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"demo","version":"0.1.0"}}}
--> {"jsonrpc":"2.0","method":"notifications/initialized"}
--> {"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}
[server] legacy: initialized
<-- {"jsonrpc":"2.0","id":3,"result":{"tools":[{"name":"deploy",…}]}}
--> {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"deploy","arguments":{"env":"prod"}}}
<-- {"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"deployed to prod (legacy, no confirm)"}]}}
[server] stdin closed -> exit (graceful shutdown)
{…} stands for the three _meta keys (protocolVersion, clientInfo, clientCapabilities) every modern request carries; the requestState is shortened.
Read the wire, line by line
The first Phase 1 exercise is to map every line to the spec. Pick a line to see what it is and which rule it follows. Try to say it yourself before you click.
Lines
What this line is
Pick a line.
Lab 2: the SDK server
mcp/02-server/ is the same ideas built on the TypeScript SDK v2 (why an SDK). One factory, src/createServer.ts, is shared by two entry points.
| Piece | What it is | What it demonstrates |
|---|---|---|
greet tool | Says hello to a name | The smallest possible tool and its zod schema |
whoami tool | Returns era, principal, scopes and replica | Identity arrives with every request, never from server memory |
deploy tool | Asks the user to confirm, then "deploys" | MRTR with inputRequired() and a signed, user-bound requestState |
app-config resource | config://app, a small JSON document | A resource the application can attach |
review prompt | A code review prompt template | A prompt the user picks, often as a slash command |
src/stdio.ts | serveStdio(createServer) | The local entry: npm run stdio |
src/http.ts | createMcpHandler behind Node's http, stub bearer tokens alice-token and bob-token | The remote, stateless entry: npm run http |
The real output of ./stateless-demo.sh, trimmed
1) no token ->
HTTP 401
2) alice on replica A:
{"result":{"content":[{"type":"text","text":"{\"era\":\"modern\",\"principal\":\"alice\",\"scopes\":[\"deploy:read\"],\"replica\":\"A\"}"}],"resultType":"complete",…}
3) bob on replica B:
{"result":{"content":[{"type":"text","text":"{\"era\":\"modern\",\"principal\":\"bob\",\"scopes\":[\"deploy:read\",\"deploy:write\"],\"replica\":\"B\"}"}],…}
4) bob deploy on A (round 1) ->
{"result":{"resultType":"input_required","inputRequests":{"confirm":{"method":"elicitation/create","params":{"message":"Deploy to prod?",…}}},"requestState":"…"}}
5) bob retry on B (different replica, no shared memory) ->
{"result":{"content":[{"type":"text","text":"deployed to prod"}],"resultType":"complete",…},"jsonrpc":"2.0","id":4}
6) alice replays bob's requestState on B ->
{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid or expired requestState","data":{"reason":"invalid_request_state"}}}
Lab
Lines 4 and 5 are the heart of the stateless lesson: round 1 on replica A, the retry on replica B, and B finishes because the ticket carries the context and both share the signing key. Line 6 fails because the ticket is bound to bob.
Break it on purpose
The Phase 1 break-it list. For each one, write down what you expect first, then make the change in client.mjs (or pipe a line into node mcp/01-lifecycle-demo/server.mjs by hand) and compare. Ticks are saved in this browser.
Explain it
The Phase 1 checkpoint: one paragraph each, in your own words, without looking. Saved in this browser only.
When all three read well to you, Phase 1 is done: tick it on the course roadmap.
Quick check
In Lab 1, how does the client tell the server to shut down?
In the stateless demo, why does bob's retry succeed on replica B?
You send notifications/initialized and see no response. What is wrong?
Recap
- Lab 1 (
node mcp/01-lifecycle-demo/client.mjs): a zero-dependency dual-era server, every line printed, modern then legacy. - Lab 2 (
mcp/02-server): one SDK factory, stdio and HTTP entries, MRTR across two replicas. - Break it: tamper, reuse an id, drop
_meta, call beforeinitialize, wait on a notification, pollute stdout. - Explain it: three paragraphs, then Phase 1 is done.