You deploy your MCP server as a serverless function. Each request may wake up a brand-new instance with empty memory.
Under the legacy protocol that's a contradiction. Instance #1 handles initialize and then disappears; instance #2 gets tools/call and has no idea which version or capabilities were agreed. You end up bolting on a key-value store just to remember a handshake.
Now the first request is a complete request. Instance #2 reads the version and capabilities straight from _meta and answers. On a mobile agent with 300 ms of latency, dropping the initialize round trip also saves about a third of a second before the first useful answer.
io.modelcontextprotocol/protocolVersion: required on every request.
io.modelcontextprotocol/clientCapabilities: required. It covers only the capabilities relevant to this request.
io.modelcontextprotocol/clientInfo: clients SHOULD send it. It's for display and logs only; never use it for security decisions.
Servers SHOULD put io.modelcontextprotocol/serverInfo in every result's _meta.
There are three errors to know. A request missing a required field gets -32602 (Invalid params). An unsupported version gets -32022UnsupportedProtocolVersion, with a supported list. A missing capability the server needs gets -32021MissingRequiredClientCapability. Over HTTP, all three return status 400.
The handshake pinned negotiated facts (version, capabilities) to a connection. Any instance that later handled a request needed those facts, so you needed sticky routing or a shared store. Putting them in each request removes that dependency, and it lets capabilities differ from one request to the next.
What it used to look like (legacy, for comparison only)
Legacybefore
// ① client → server: open the session{"jsonrpc":"2.0", "id":1, "method":"initialize","params": {"protocolVersion":"2025-11-25","capabilities": { "elicitation": {} },"clientInfo": { "name":"ExampleClient", "version":"1.0.0" } }}// ② client → server: "I'm ready"{"jsonrpc":"2.0", "method":"notifications/initialized"}// ③ only now can real work start{"jsonrpc":"2.0", "id":2, "method":"tools/call","params": { "name":"get_weather","arguments": { "location":"Seattle, WA" } }}
Note that the request ID no longer has to be 2: there is no earlier request on this "connection" to count from.
A server that stores the negotiated version and capabilities at connect time has nothing to read: a modern client never sends initialize.
Middleware that rejects requests "before init" rejects every modern request.
A legacy client that sends initialize to a modern-only server fails. Over HTTP, its request lacks the required headers and gets 400. Legacy clients have no way to move forward on their own.
The fix: read version and capabilities from each request's _meta. To keep old clients working, go dual-era: answer initialize with legacy semantics, and serve requests carrying _meta statelessly. A modern-only server SHOULD name its supported versions in the error it returns to initialize; it's the only diagnostic an old client can show.
# 1. In a SECOND terminal, start the reference server (Node 18+, no dependencies)curl -sO https://www.diegozuluaga.dev/mcpa/reference-server.mjsnode reference-server.mjs # http://localhost:3000/mcp, logs appear here# 2. In THIS terminal, define the helper every test uses (bash or zsh)export MCP=http://localhost:3000/mcpMETA='"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}'mcp() { # usage: mcp <method> '<json body>' [extra curl args...] curl -sS -N "$MCP" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -H 'MCP-Protocol-Version: 2026-07-28' \ -H "Authorization: Bearer ${MCP_USER:-alice}" \ -H "Mcp-Method: $1" "${@:3}" -d "$2" \ -w '\nHTTP %{http_code}\n'}# Demo auth: the reference server treats the bearer token as the user's name.# Prefix a command with MCP_USER=bob to act as someone else.
Run these against the reference server from the setup block, or point MCP at your own server. The helper adds the transport headers, which are explained in lesson 03.
ShellTests
# 1. A modern request with no initialize firstmcp tools/list '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{'"$META"'}}'# expect: HTTP 200 and "resultType":"complete"# 2. Drop the required capabilities fieldmcp tools/list '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'# expect: HTTP 400 and error code -32602# 3. Ask for a version nobody speaks (header and body must agree)curl -sS "$MCP" -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -H 'MCP-Protocol-Version: 1900-01-01' -H 'Mcp-Method: tools/list' -d '{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"1900-01-01","io.modelcontextprotocol/clientCapabilities":{}}}}' -w '\nHTTP %{http_code}\n'# expect: HTTP 400, code -32022, data.supported lists real versions