The protocol #

This server implements Model Context Protocol revision 2026-07-28. That revision is a redesign rather than an increment, and this page is the mapping from what it requires to where that requirement lives in the code.

What changed, and why it matters here #

Up to 2025-11-25 2026-07-28
Negotiation An initialize handshake, once per connection _meta on every request
State Per-connection session None; servers must not rely on prior requests
Discovery Implied by the handshake result server/discover, mandatory
Result shape Method-specific Every result carries resultType
Errors -32002, -32042 in use Those withdrawn; -32020-32099 reserved

The statelessness requirement is the one that shapes the code. Server holds configuration and a tool registry — things fixed at start-up — and nothing derived from a request. Every per-request value lives in a RequestContext built inside the handler and discarded when it returns. There is no session table to consult, and therefore none to forget to clear.

Every request #

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "read_file",
    "arguments": { "path": "src/main.py" },
    "_meta": {
      "io.modelcontextprotocol/protocolVersion": "2026-07-28",   // required
      "io.modelcontextprotocol/clientCapabilities": {},          // required, may be empty
      "io.modelcontextprotocol/clientInfo": {                    // optional, advisory
        "name": "my-editor", "version": "2.1"
      }
    }
  }
}

clientCapabilities is required and must be an object. A client with no capabilities sends {}, not nothing. Omitting either required field is -32602, and over HTTP that is a 400.

clientInfo is optional and grants nothing. It is parsed leniently — a non-string member is ignored rather than failing the request — because failing a request over a field that authorises nothing would be failing it for no benefit.

_meta key rules #

Keys are [prefix/]name. A prefix whose second label is modelcontextprotocol or mcp is reserved to the specification: io.modelcontextprotocol/x and x.mcp/y are both reserved, com.example/z is not. A client inventing a key under a reserved prefix is refused.

W3C trace context is the explicit exception. traceparent, tracestate and baggage are bare keys, accepted, and echoed back in the result's _meta so a client correlating a response to a span does not have to hold the request open.

Methods #

server/discover #

Mandatory. Replaces the handshake's role.

{
  "resultType": "complete",
  "supportedVersions": ["2026-07-28"],
  "capabilities": { "tools": { "listChanged": false, "count": 7 } },
  "instructions": "Read-only tools over a single contained workspace...",
  "ttlMs": 3600000,
  "cacheScope": "server",
  "_meta": {
    "io.modelcontextprotocol/serverInfo": {
      "name": "mcp-devserver", "version": "0.1.0", "title": "MCP Developer Server"
    }
  }
}

cacheScope: "server" means the answer is identical for every client — true here, because there is no per-caller state for it to vary by. ttlMs is an hour because the tool set is fixed at start-up.

The instructions field is where the meaning of the <untrusted-...> fences is explained. A server that fenced content but never told anyone what the fence meant would be marking for nobody.

tools/list #

Paginated at five per page, ordered by name. Determinism is a SHOULD with a stated reason: it lets clients cache the listing, and it keeps a prompt containing the tool set stable across calls, which is the difference between a warm prompt cache and a cold one.

Cursors are opaque — base64 of an offset — because a cursor a client can construct is a cursor a client will construct, and then the paging strategy is part of the contract. A cursor this server did not issue is -32602, not an ignored parameter.

tools/call #

Arguments are validated against the tool's published inputSchema before the handler runs. additionalProperties: false on every schema means a typo in an argument name is refused rather than silently defaulted.

The two error mechanisms #

This is the distinction most often got wrong, and this server's position on it is ADR-004.

A protocol error — a JSON-RPC error object — means the request itself was wrong, in a way no argument change would fix:

Code Meaning When
-32700 Parse error The body was not JSON
-32600 Invalid request JSON, but not a JSON-RPC request
-32601 Method not found An unknown method
-32602 Invalid params Missing _meta fields, bad arguments, unknown tool, an unissued cursor
-32603 Internal error An unexpected failure, with no detail — a traceback names paths the sandbox exists to withhold
-32020 Header mismatch An HTTP protocol-version header disagreeing with _meta
-32021 Missing client capability Reserved; no tool here requires one, so this server never emits it
-32022 Unsupported version With data.supported and data.requested

-32002 and -32042 were withdrawn by this revision. A test asserts neither ever leaves the process, and another asserts no code inside -32099..-32020 is emitted beyond the three defined above.

A tool error — a result with isError: true — means the call was well formed and the answer is "no":

{
  "resultType": "complete",
  "isError": true,
  "content": [{ "type": "text", "text": "'..' resolves outside the workspace and cannot be read. Paths must stay inside myproject/." }],
  "structuredContent": {
    "error": "outside_workspace",
    "message": "'..' resolves outside the workspace and cannot be read.",
    "remedy": "Paths must stay inside myproject/."
  }
}

The model reads content and adapts; a program branches on error. A denial delivered as a transport failure would be invisible to the model that needs to learn from it.

Notifications #

A request without an id is a notification and must not be answered — not even with an error. Over HTTP that is 202 with an empty body; over stdio, nothing is written. A server that answers a notification desynchronises a client that is not reading for a response.

Legacy clients #

A client sending initialize gets:

{
  "jsonrpc": "2.0", "id": 1,
  "error": {
    "code": -32022,
    "message": "unsupported protocol version '2025-11-25'",
    "data": { "supported": ["2026-07-28"], "requested": "2025-11-25" }
  }
}

One round trip, and the client knows exactly what to speak. ADR-002 explains why there is no second protocol era here.

Transports #

stdio — newline-delimited JSON, one request per line. What an editor spawns. Reads happen on a worker thread rather than through loop.connect_read_pipe, which does not work on Windows; an MCP server has to run on the platform the developer is using. Requests may overlap and are handled concurrently, bounded at eight; because the server is stateless, that cannot change any answer.

Streamable HTTPPOST /mcp, plus /healthz and /readyz. Adds a bearer token, an Origin check (DNS-rebinding defence, checked before authentication so the endpoint is not an oracle for token validity), a body limit enforced on the stream rather than only on the header, and the protocol-version header cross-check. JSON-RPC batching is refused: it is not part of this revision, and accepting it would let one HTTP request start an unbounded number of tool runs.

Both are thin wrappers around the same dispatcher, which is what lets the conformance suite run identically over either.

Verifying all of this #

Do not take this page's word for it:

uv run mcp-devserver conform --strict
uv run mcp-devserver conform --stdio "uv run mcp-devserver serve --transport stdio" --strict
uv run mcp-devserver conform --http http://localhost:8080/mcp --strict

See conformance.md.