Handle
Connecting…
Workshop

MCP 2026-07-28 — the wire contract

The single source of truth for this workshop. When an implementation and this document disagree, one of them is a bug.

The single source of truth for what every implementation in this repo must do, and what every code block on the workshop site must show.

Pinned to: zral/mcp-lab-jz26 @ 90eaf5a — specifically services/mcp-server/mcp_protocol.py, which is the reference implementation of everything below. When this document and that file disagree, that file wins; fix this document.

Spec: https://modelcontextprotocol.io/specification/2026-07-28

Why a contract file? Six language implementations and ~91 code blocks on the website have to agree on the same wire format. Without one written source they will agree on six slightly different ones, and the disagreements will surface in the room on 1 September.


1. The one idea

2026-07-28 made MCP explicitly stateless:

“The Model Context Protocol (MCP) is a stateless protocol: all the information needed to process a request is contained in the request itself.”

Everything that used to live in a handshake and a session now rides along on every request.

Gone

Removed Consequence for us
initialize / notifications/initialized No handshake. Delete the startup call.
Protocol sessions, Mcp-Session-Id No state between requests.
The GET endpoint (standalone SSE stream) GET and DELETE on /message must answer 405.
SSE resumability (Last-Event-ID) Streams cannot be resumed.
resources/subscribe Replaced by subscriptions/listen — out of scope for the lab.
ping, logging/setLevel Retired.

Added

Added Level
params._meta with protocol version + client capabilities on every request MUST
HTTP headers mirroring the body MUST
server/discover MUST
resultType on every result MUST
ttlMs + cacheScope on cacheable results MUST
Origin validation (DNS rebinding) MUST
JSON-RPC error codes paired with HTTP status codes MUST
MRTR (server asks for input inside a result) out of scope for the lab

2. The request

POST /message
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather_forecast

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_weather_forecast",
    "arguments": { "location": "Oslo" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",
      "io.modelcontextprotocol/clientCapabilities": {},
      "io.modelcontextprotocol/clientInfo": {
        "name": "travel-agent",
        "version": "1.0.0"
      }
    }
  }
}

_meta keys

The io.modelcontextprotocol/ prefix is reserved for the spec. Your own fields go under your own reverse-DNS prefix (no.javazone/).

Key Type Required Note
io.modelcontextprotocol/protocolVersion string yes Non-empty.
io.modelcontextprotocol/clientCapabilities object yes An empty object is valid — it means “no capabilities”. Only the absent key is an error.
io.modelcontextprotocol/clientInfo object no {name, version}.

Headers that mirror the body

Header Mirrors Required on
MCP-Protocol-Version _meta[…/protocolVersion] every request
Mcp-Method method every request
Mcp-Name params.name / params.uri tools/call, resources/read, prompts/get

tools/list and server/discover have no name parameter, so they carry no Mcp-Name.

The body is the truth. The headers are mirrors. They exist so intermediaries — load balancers, gateways, observability tooling — can route and inspect without parsing JSON. The server MUST verify that the two agree: a gateway waving through Mcp-Name: read_document while the body says delete_everything is an attack surface, not a mismatch to be tolerated.

Non-ASCII header values

HTTP headers carry visible ASCII only. Bergen, Vestland is fine; Tromsø is not. A client wraps such values:

Mcp-Name: =?base64?VHJvbXPDuA==?=

The markers are case-sensitive and must be exactly =?base64??=. The server MUST decode before comparing against the body. Malformed base64 is a header mismatch (-32020).

Any implementation that skips this rejects every tool name or resource URI containing æ, ø or å as a header mismatch. In a Norwegian workshop that is not a hypothetical.


3. Validation order

Non-negotiable, and the reference implementation comments on why:

  1. _meta fields-32602, HTTP 400
  2. Headers mirror the body-32020, HTTP 400
  3. Version is supported-32022, HTTP 400

Swap 1 and 2 and the errors become useless: a client that forgot _meta entirely gets told “header mismatch” against a body value that does not exist, instead of “you are missing _meta”.

Origin is validated before all of it, since a rejected origin never gets to argue about protocol details.


4. Error codes ↔ HTTP status

Both halves are a MUST. Getting the code right and the status wrong still breaks clients, and nothing in ordinary use will tell you.

Code Name HTTP When
-32700 Parse error 400 invalid JSON
-32600 Invalid Request 400 not valid JSON-RPC 2.0
-32601 Method not found 404 unknown method
-32602 Invalid params 400 missing _meta, missing parameter, unknown tool
-32603 Internal error 500 unexpected server-side failure
-32020 HeaderMismatch 400 a header disagrees with the body
-32021 MissingRequiredClientCapability 400 client lacks a capability the method needs
-32022 UnsupportedProtocolVersion 400 we do not speak that version; data.supported lists what we do
1001 Origin not allowed 403 untrusted Origin

The 404 is deliberate. It lets a client tell a modern server that does not know the method from a legacy server that does not host the endpoint at all.

On the range: JSON-RPC reserves -32000..-32099 for implementation-defined errors. MCP 2026-07-28 partitions it — -32000..-32019 is legacy with no defined meaning, -32020..-32099 belongs to the spec. Errors the spec does not define MUST sit outside -32768..-32000. That is why ORIGIN_NOT_ALLOWED is a positive 1001 and not something in the reserved block.

Unknown tool is -32602, not isError: true. The distinction matters: isError: true means “the tool ran and it went wrong” — that is for the model, which can retry with different arguments. A tool that does not exist is the client asking for something absent, which surfaces as a JSON-RPC error so the client can re-fetch tools/list.


5. Results

Every result

{ "resultType": "complete" }

"complete" or "input_required" (the latter is MRTR — out of scope here).

Cacheable results — tools/list, server/discover

{
  "resultType": "complete",
  "tools": [  ],
  "ttlMs": 300000,
  "cacheScope": "public",
  "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "…", "version": "…" } }
}
Field Meaning
ttlMs How long a client MAY treat the answer as fresh. >= 0.
cacheScope "public" when the answer is not user-specific.

Reference values: tools/list uses 300000 (5 min), server/discover uses 3600000 (1 hour). Both "public".

tools/call is NOT cacheable

resultType only — no ttlMs, no cacheScope.

{
  "resultType": "complete",
  "content": [{ "type": "text", "text": "…" }],
  "structuredContent": {  },
  "isError": false
}

server/discover

New, and servers MUST implement it. It replaces part of what initialize did — in one call the client learns which versions we speak, what we can do, and who we are — except that it establishes no session, the answer is just information, and a client MAY skip the call entirely.

{
  "resultType": "complete",
  "supportedVersions": ["2026-07-28"],
  "capabilities": { "tools": { "listChanged": false } },
  "instructions": "…",
  "ttlMs": 3600000,
  "cacheScope": "public",
  "_meta": { "io.modelcontextprotocol/serverInfo": {  } }
}

supportedVersions is how a client recovers from -32022: it sees what the server actually speaks and retries with a version from the list.


6. Origin validation

“Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks.”

The requirement applies when the header is present. Server-to-server callers — the agent, curl — send no Origin and MUST NOT be rejected for it. The browser sets Origin, and the browser is what the attack travels through.

Input Result
absent allowed
in MCP_ALLOWED_ORIGINS (comma-separated) allowed
MCP_ALLOWED_ORIGINS contains * all allowed — debugging only, the protection is gone
host is localhost, 127.0.0.1, ::1 allowed
host ends in .app.github.dev / .github.dev allowed (Codespaces forwarded ports)
anything else 403 + code 1001

7. Method surface

server/discover · tools/list · tools/call

Anything else → -32601 + HTTP 404.

GET /message and DELETE /message405.

Portability note. FastAPI returns the 405 for free, because only a POST route is declared. Other routers do not: chi, Express and ASP.NET each need this asserted explicitly, and several return 404 by default. This is a conformance-suite item precisely because it is the kind of thing that passes review and fails the test.


8. Tool surface

The wire format is only half the contract. Today the five implementations expose five different tool sets — get_weather vs get_weather_forecast, a stray get_time — so a client written against one of them fails against the next. This section fixes the surface.

The canonical set

Three tools, under exactly these names, and no others:

Tool Introduced Source Arguments
get_weather_forecast Module 4 MET/yr.no + Nominatim, branded User-Agent location (required)
get_random_fact Module 7 local, no I/O none
get_news Module 8 Google News RSS, no key topic (optional)

Renames the ports must make: get_weatherget_weather_forecast (go, java, typescript). Drop get_time (typescript) — no module teaches it.

symbol_code from MET is translated in all five implementations, with the same 29 lookups (SYMBOL_CODE_MAP in the Python reference). That table is part of the surface: the same forecast must read partly cloudy, never partlycloudy_day, whichever language is running. The lookup strips the _day / _night / _polartwilight suffix and falls back to the raw code, so an untranslated code is visible as exactly that suffix leaking into the response.

The descriptions are English — the whole application went English when jz26 became the base. Norwegian place names still go in and come back out unchanged, which is what the base64 header rule in §2 is there for.

What ships in the repo

workshop-code/<language>/ is the participant’s starting point, not the answer key — docs/architecture/multi-language.md says so, and modules 7 and 8 are the exercises that add get_random_fact and get_news.

So every language ships with get_weather_forecast only. A checkout that already contains the other two leaves a Go or Java participant with nothing to do in two of the ten modules, which is exactly where three of the five implementations are today. The code the participant writes is on the website, in all six language tabs of modules 7 and 8.

The conformance suite follows from this: get_weather_forecast is always required, get_random_fact and get_news are checked only when present, and the superseded and federated names are rejected always.

No server-side federation

The bonus module Connect Multiple MCP Servers teaches the agent to discover and route across two servers — that is the lesson, and a server that proxied the second one’s tools would hand out the answer before the exercise starts.

So: no merging of remote tools into tools/list, no proxying in tools/call, no second server’s URL in any workshop-code/*/services/mcp-server. The agent holds the second connection.

A services/news-server used to be that second server, exposing get_news, get_headlines and list_sources on :8002. It was removed on 2026-09-01 — it was no longer in use, and it was the only thing that needed Redis and an LLM key of its own. The quest server took over the role. get_headlines and list_sources stay on the conformance suite’s rejected list: nothing serves them now, and a reappearance means the old server crept back.

get_news is unaffected. It is module 8’s exercise on the participant’s own server, reading Google News RSS directly, and was never the news server’s to own.

The quest server is the second server

mcp-lab03/services/quest-server runs on :8004 and hands out the workshop’s learning quests, grades submitted answers, and serves the live score card. It is the second server the bonus module connects to: its own tool surface, no federation into anything, and nothing here appears on the weather server.

Tool Arguments Note
list_quests difficulty?, category? The board. Never carries an answer.
get_quest quest_id, handle? Adds the assignment text.
submit_answer quest_id, username, answer, claim_token? Grades and awards.
get_scoreboard limit? Ranked handles.
verify_my_server username, server_url, claim_token? Probes the participant’s own server.

Two rules that are easy to get backwards, and both follow from §4:

The quest server implements this contract in full. That is not decoration: half the quests grade a participant on these rules, and a quest server that bent them while teaching them would be the worst artifact in the repository.

The conformance suite is pointed at it with docker compose --profile conformance run --rm -e MCP_URL=http://quest-server:8004 conformance. The protocol half passes — envelope, _meta, mirrored headers, error pairs, 405, origin, cache fields. Seven tests fail by construction, because they are weather-surface tests: they call get_weather_forecast and reject any tool outside §8’s canonical three. Splitting the suite along that seam is worth doing when a fourth server appears; until then the quest server’s own suite covers the same protocol ground.

Beyond MCP it exposes GET /scoreboard (the same payload as get_scoreboard) and a GET /ws/scoreboard WebSocket that pushes a snapshot on connect and a delta per solve. Neither is MCP and neither is in the conformance suite; the website reads them, and the WebSocket falls back to polling the former.

GET /health

Not MCP, but every compose healthcheck reads it, so it is part of the surface: 200 with {"status": "healthy", "service": "…"}. Python said healthy and Go said ok until this line existed.

Not in scope

resources/* and prompts/* are not implemented. Mcp-Name still has to mirror params.uri per §2 if they ever are, but no conformance test covers them today.


9. Per-implementation conformance checklist

Every language in workshop-code/ must satisfy all of it. Done means the conformance suite is green — not that the code looks right.

Server

Client / agent


10. What cites this document

Keep this list current — it is the blast radius of any change here.

Surface Where
Reference implementation workshop-code/python/ (from jz26)
Ports workshop-code/{go,typescript,java,csharp}/
Conformance suite tests/conformance/
Quest server mcp-lab03/services/quest-server/ (quest text quotes §2–§7)
Website, protocol blocks website/src/pages/workshop/03…08, website/src/pages/bonus/
Architecture docs docs/architecture/mcp-protocol.md, docs/reference/api-endpoints.md
Consistency guard scripts/check-site-consistency