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:
_metafields →-32602, HTTP 400- Headers mirror the body →
-32020, HTTP 400 - 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 /message → 405.
Portability note. FastAPI returns the
405for free, because only a POST route is declared. Other routers do not: chi, Express and ASP.NET each need this asserted explicitly, and several return404by 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_weather → get_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-serverused to be that second server, exposingget_news,get_headlinesandlist_sourceson: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_headlinesandlist_sourcesstay on the conformance suite’s rejected list: nothing serves them now, and a reappearance means the old server crept back.
get_newsis 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:
- A wrong answer is not
isError. The tool ran and the answer was wrong, which is a result, not a failure.isError: truehere would tell a model to retry with different arguments, and the different argument it would invent is a different guess. - An unknown
quest_idis-32602, exactly as an unknown tool is — the client asked for something absent.
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
-
_metarequired on every request; emptyclientCapabilitiesobject accepted - All three mirrored headers checked against the body
- Base64 header values (
=?base64?…?=) decoded before comparison - Validation order:
_meta→ headers → version - Every error code paired with its HTTP status per §4
-
server/discoverimplemented, withsupportedVersions -
resultTypeon every result -
ttlMs+cacheScopeontools/listandserver/discover, absent ontools/call -
_meta.serverInfoon cacheable results -
Originvalidated; absent origin allowed - GET/DELETE on
/message→ 405 - Unknown method →
-32601+ 404; unknown tool →-32602+ 400 - No
initialize, no session ID, no SSE resumption anywhere - Ships
get_weather_forecastonly; anything added uses a §8 name -
symbol_codetranslated to Norwegian, same 29 lookups as the reference - No
NEWS_SERVER_URL, no server-side federation
Client / agent
- Sends
_metaon every request - Sends all applicable mirrored headers, base64-wrapping non-ASCII values
- Sends
Accept: application/json, text/event-stream - No
initializecall at startup —tools/listis the first call - LLM provider bound in exactly one place (
.env), Gemini by default - Reads
resultType(open item P-8 in the jz26 backlog — we should not inherit it)
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 |