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.

What the client does, in order

client.mjsprints every line server.mjschild process stdin: requests stdout: responses stderr → your terminal ([server] …) spawn: the same thing a host does with "command": "node" modern: discover → deploy → input_required → retry → bad version stdin closed → server exits a fresh server, legacy: early call → initialize → initialized → list → call
  1. The client spawns the server as a child process with pipes, exactly as a host does for a local server in its config.
  2. Modern scenario: server/discover, a tools/call that returns input_required, the retry with a new id, then a request with a bogus version.
  3. The client closes stdin. That is the stdio shutdown signal; the server logs it on stderr and exits.
  4. 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.

    PieceWhat it isWhat it demonstrates
    greet toolSays hello to a nameThe smallest possible tool and its zod schema
    whoami toolReturns era, principal, scopes and replicaIdentity arrives with every request, never from server memory
    deploy toolAsks the user to confirm, then "deploys"MRTR with inputRequired() and a signed, user-bound requestState
    app-config resourceconfig://app, a small JSON documentA resource the application can attach
    review promptA code review prompt templateA prompt the user picks, often as a slash command
    src/stdio.tsserveStdio(createServer)The local entry: npm run stdio
    src/http.tscreateMcpHandler behind Node's http, stub bearer tokens alice-token and bob-tokenThe 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?

    • It sends a shutdown request
    • It closes the server's stdin
    • It sends SIGKILL
    On stdio, closing stdin is the polite signal. The server logs "stdin closed" on stderr and exits; signals are the fallback.

    In the stateless demo, why does bob's retry succeed on replica B?

    • The load balancer pinned bob to B
    • A and B share a session store
    • The signed requestState carries the context, and both replicas have the same key
    The replicas share nothing but configuration. The ticket is the state; the key lets any replica trust it.

    You send notifications/initialized and see no response. What is wrong?

    • Nothing: notifications have no id and get no reply
    • The server crashed
    • You must wait for the server's own initialized
    Only requests (with an id) get responses. The server's stderr line "legacy: initialized" is your evidence it arrived.

    Recap