Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Annotated message trace

Audience:engineerarchitectMCP spec 2026-07-28

TL;DR: The internals overview describes the stateless per-request model in prose; this page shows the actual JSON-RPC frames, in order, for one complete exchange over the Streamable HTTP transport under the 2026-07-28 revision: optional discovery, a list, a tool call, and the input_required retry loop, each annotated field by field and header by header. There is no initialize frame, no session id, and no shutdown: every request stands alone.

A normative trace, checked against the deployable stack. The frames below follow the published 2026-07-28 spec pages (Streamable HTTP, Versioning, the base-protocol overview, and MRTR) and are the shape a client sending modern headers gets back from a deployed example. Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path. Which of the two shapes you capture therefore depends on the client, not the server; What the deployed stack emits shows both, and the cache-hint values in frames 2 and 4 are illustrative (see the note there).

The subject is the repo’s examples/minimal-server (in the repository) (one echo tool) deployed as a Vercel Function, so every client frame below is the body of an HTTP POST to the MCP endpoint at /api/mcp, and every server frame comes back in an HTTP response. Unlike a stdio trace, the HTTP layer is load-bearing here: three required headers mirror the body so the edge can route without parsing it, and status codes carry half the error semantics.

How to read a frame

Every MCP message is JSON-RPC 2.0 and is one of three shapes:

  • Request: has an id and a method (and params, which always includes _meta). Expects a matching response.
  • Response: has the same id and either a result or an error. No method. Every result carries a required resultType: "complete" or "input_required".
  • Notification: has a method but no id, so there is no response. Fire-and-forget.

is client to server; is server to client. Correlate a request with its response by id, never by arrival order.

On Streamable HTTP, one more rule matters: every client frame is its own HTTP POST to the single MCP endpoint. There is no persistent pipe and no session stitching the POSTs together; what makes them one “conversation” is only that the same client sent them.

The HTTP layer

The headers that carry the protocol (per the 2026-07-28 Streamable HTTP spec):

HeaderDirectionRule
Accept: application/json, text/event-streamclient → serverThe client MUST list both content types on every POST.
Content-Type: application/jsonclient → serverThe POST body is a single JSON-RPC request or notification (never a response).
MCP-Protocol-Version: 2026-07-28client → serverRequired on every POST, and it MUST match io.modelcontextprotocol/protocolVersion in the body’s _meta. Mismatch or absence gets 400 with HeaderMismatch (-32020).
Mcp-Methodclient → serverRequired on all requests. Mirrors the JSON-RPC method so intermediaries can route without body inspection.
Mcp-Nameclient → serverRequired on tools/call, resources/read, prompts/get. Mirrors params.name or params.uri; non-ASCII values use the =?base64?...?= sentinel encoding.
Mcp-Param-{Name}client → serverPresent when a tool schema property carries an x-mcp-header annotation; mirrors that argument value.
Originclient → serverThe server MUST validate it; if present and invalid, it MUST respond 403 Forbidden (the DNS-rebinding defense).

There is deliberately no session header in this table. Mcp-Session-Id was removed in 2026-07-28; a modern server ignores it if a legacy client sends one.

Status codes to expect: 200 (a response body follows, as one JSON object or an SSE stream scoped to this request), 202 (notification accepted, empty body), 400 (header validation failed -32020, required _meta fields missing -32602, unsupported protocol version -32022, or missing client capability -32021; the JSON-RPC error body tells you which), 403 (Origin rejected), 404 with a -32601 error body (method not implemented; a bare 404 with no modern error body means you are probably talking to a legacy server or the wrong URL), 405 (GET or DELETE on the MCP endpoint; both are correct behavior now that the listening stream and session termination are gone).

Header names are case-insensitive in HTTP. The spec prints MCP-Protocol-Version and Mcp-Method; wire captures may show any casing. Do not write a case-sensitive matcher. Header values (method names, tool names) are case-sensitive.

Flow at a glance

MCP endpoint on VercelClientMCP endpoint on VercelClientno initialize, no session id, nothing to shut downPOST server/discover (optional)200 DiscoverResultPOST tools/list200 tool catalog with ttlMs and cacheScopePOST tools/call echo200 result (resultType complete)
MCP endpoint on VercelClientMCP endpoint on VercelClientno initialize, no session id, nothing to shut downPOST server/discover (optional)200 DiscoverResultPOST tools/list200 tool catalog with ttlMs and cacheScopePOST tools/call echo200 result (resultType complete)
Mermaid sequence diagramOpen in Mermaid Live Editor
Diagram source (Mermaid)
sequenceDiagram
    participant C as Client
    participant S as MCP endpoint on Vercel
    C->>S: POST server/discover (optional)
    S-->>C: 200 DiscoverResult
    C->>S: POST tools/list
    S-->>C: 200 tool catalog with ttlMs and cacheScope
    C->>S: POST tools/call echo
    S-->>C: 200 result (resultType complete)
    note over C,S: no initialize, no session id, nothing to shut down

The trace

1 → server/discover (request, optional)

POST /api/mcp HTTP/1.1
Host: minimal-server-demo.vercel.app
Content-Type: application/json
Accept: application/json, text/event-stream
Origin: https://inspector.example.com
MCP-Protocol-Version: 2026-07-28
Mcp-Method: server/discover
{
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleHost", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}

The client’s first frame, and already a template for every frame after it. _meta carries the two required keys (protocolVersion, matching the header exactly, and clientCapabilities, empty here: this host offers no elicitation, sampling, or roots) plus the recommended clientInfo. Servers MUST implement server/discover; calling it is the client’s choice. A client that skips it just sends its real first request and handles UnsupportedProtocolVersionError if the version does not line up.

2 ← server/discover result (response)

HTTP/1.1 200 OK
Content-Type: application/json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": { "tools": { "listChanged": true } },
"ttlMs": 3600000,
"cacheScope": "public",
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "minimal-server", "version": "0.1.0" }
}
}
}

Illustrative cache hints. The ttlMs and cacheScope values in this frame and in frame 4 show what a server that configures cache hints advertises. examples/minimal-server/ sets none, and the installed SDK then emits ttlMs: 0 and cacheScope: "private" (the safe default: never cache, never share). To advertise the values shown, set cacheHints in the createMcpHandler server options, or cacheHint on an individual registerResource call.

Same id: 1: this is the response to frame 1, delivered as a plain JSON body (a server answers each POSTed request with either Content-Type: application/json or an SSE stream; the client MUST support both). Note the fields that used to live in the initialize result: supported versions, capabilities (this server offers tools and nothing else), and the server’s identity, now riding _meta as serverInfo, which servers SHOULD attach to every result. resultType: "complete" is the required result tag, and ttlMs/cacheScope make the freshness contract explicit: cache this for an hour, and a shared cache may hold it (illustrative values; see the note above). serverInfo is self-reported and unverified; display it, log it, never authorize on it.

3 → tools/list (request)

POST /api/mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/list
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleHost", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}

Discovery proper. The same _meta block rides along, because the server is entitled to forget frame 1 ever happened: on Vercel this POST may land on a different function instance, and the protocol now guarantees that is fine. Mcp-Method mirrors the method for the edge; no Mcp-Name here because tools/list is not one of the three name-carrying methods.

4 ← tools/list result (response)

{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"tools": [
{
"name": "echo",
"description": "Echo a message back to the caller.",
"inputSchema": {
"type": "object",
"properties": { "message": { "type": "string" } },
"required": ["message"]
}
}
],
"ttlMs": 60000,
"cacheScope": "public",
"_meta": {
"io.modelcontextprotocol/serverInfo": { "name": "minimal-server", "version": "0.1.0" }
}
}
}

The server’s current catalog, in the deterministic order the spec now asks for (one tool makes that easy). Each tool carries an inputSchema (JSON Schema 2020-12 by default) that the client uses to validate arguments and render UI; this one round-trips from the zod schema in examples/minimal-server (in the repository) (inputSchema: z.object({ message: z.string() })). Expect serializer noise in real captures: SDKs may add keys such as additionalProperties or $schema around the shape shown here. The ttlMs/cacheScope pair is required on all five list-and-read methods (the values shown are illustrative; an unconfigured server emits 0 and "private"); a client honoring it will not re-fetch this catalog for a minute unless a notifications/tools/list_changed arrives on a listen stream. A large catalog would paginate with nextCursor.

5 → tools/call (request)

POST /api/mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: echo
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "echo",
"arguments": { "message": "hello" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": { "name": "ExampleHost", "version": "1.0.0" },
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}

Invocation. name selects the tool; arguments must satisfy its inputSchema. This is the first frame where Mcp-Name is required, and it MUST equal params.name; a WAF can now rate-limit or block calls to a specific tool without reading a byte of body, and the server MUST reject any header/body disagreement with -32020. On Vercel this POST is one function invocation with a maxDuration budget; see Serverless sessions for what that means for slow tools.

6 ← tools/call result (response)

event: message
data: {"jsonrpc":"2.0","id":3,"result":{"resultType":"complete","content":[{"type":"text","text":"hello"}],"isError":false,"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"minimal-server","version":"0.1.0"}}}}

The result, this time as an SSE event: the server chose a stream for this request (both framings remain legal per request). Before the final response event, the server MAY interleave request-scoped notifications (progress, and log messages if this request had set io.modelcontextprotocol/logLevel in _meta) on this same stream; the final response SHOULD terminate it. What it must not do anymore is send its own JSON-RPC requests here: server-initiated requests left the protocol with 2026-07-28. content is the unstructured array (text here, but also image, audio, resource_link, embedded resource); a tool with an outputSchema would also return structuredContent. isError is optional, default false: a tool failure comes back as isError: true content the model can read and self-correct on, which is distinct from a JSON-RPC error response, reserved for protocol failures such as an unknown tool name (-32602). Render that content as untrusted input either way; see Tool result rendering.

7 ← when the server needs input: resultType: "input_required"

The echo tool never needs more than its arguments, so this frame pair is illustrated with a hypothetical create_issue tool on a client that declared "elicitation": { "form": {} } in its per-request capabilities. Instead of pausing the call and sending an elicitation/create request (the pre-2026 shape), the server ends the call with an interim result:

{
"jsonrpc": "2.0",
"id": 4,
"result": {
"resultType": "input_required",
"inputRequests": {
"issue_title": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "What should the issue be titled?",
"requestedSchema": {
"type": "object",
"properties": { "title": { "type": "string" } },
"required": ["title"]
}
}
}
},
"requestState": "opaque-server-blob"
}
}

8 → the retry (request)

{
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "create_issue",
"arguments": { "repo": "acme/site" },
"inputResponses": {
"issue_title": { "action": "accept", "content": { "title": "Fix the login redirect" } }
},
"requestState": "opaque-server-blob",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": { "elicitation": { "form": {} } }
}
}
}

This is the MRTR pattern in two frames. The rules that make it safe and serverless-friendly: the retry is an independent request with a new id; the client MUST answer every key in inputRequests and echo requestState byte-for-byte without inspecting it; and the server MUST treat the returned requestState as attacker-controlled input, integrity-protecting it whenever it influences authorization or logic. Nothing about the original call is held in server memory between frames 7 and 8, which is exactly why the pattern works when every frame is a fresh function invocation. Sampling and roots ride the same rails, with CreateMessageRequest and ListRootsRequest objects in the map.

9 → the listening stream (optional)

POST /api/mcp HTTP/1.1
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: subscriptions/listen
{
"jsonrpc": "2.0",
"id": 6,
"method": "subscriptions/listen",
"params": {
"notifications": { "toolsListChanged": true },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
event: message
data: {"jsonrpc":"2.0","method":"notifications/subscriptions/acknowledged","params":{"_meta":{"io.modelcontextprotocol/subscriptionId":6},"notifications":{"toolsListChanged":true}}}

The 2025-11-25 GET stream is gone (GET now correctly gets 405); a client that wants change notifications opts in with a subscriptions/listen POST whose response stream stays open. The server MUST acknowledge first, echoing the filter subset it will honor, and every notification on the stream carries io.modelcontextprotocol/subscriptionId (the JSON-RPC id of the listen request) in _meta. Streams are not resumable: if this connection drops, the client re-issues subscriptions/listen as a new request. On Vercel, an open listen stream is an open function invocation, with everything that implies for maxDuration.

10 → shutdown

There is nothing to shut down. No session was created, so there is no DELETE (a modern server answers 405), no session id to expire, and no way for the server to “end the conversation” beyond closing its streams. When the client is done, it stops sending requests. Cancelling one in-flight request is transport-level: the client closes that request’s SSE response stream, and the server MUST treat the close as cancellation.

What a trace reveals

A captured trace makes the common failures obvious:

  • 400 with -32020 in the body → header/body mismatch or a missing required header (MCP-Protocol-Version, Mcp-Method, Mcp-Name). Look for proxies rewriting headers.
  • 400 with -32602 mentioning _meta → the request is missing io.modelcontextprotocol/protocolVersion or io.modelcontextprotocol/clientCapabilities; usually a legacy client talking to a modern-only server.
  • 400 with -32022 → version negotiation working as designed. Read error.data.supported and retry at a mutually supported version.
  • 400 with -32021 → the server needed a client capability (elicitation, sampling, roots) this specific request did not declare. Capabilities are per request now; check the _meta of the failing frame, not your connection setup.
  • 403 on the first POST → the server rejected your Origin. That is the DNS-rebinding defense doing its job; fix the client, not the check.
  • 404 with a -32601 error body → the method is not implemented on a modern server. A bare 404 with no JSON-RPC body means a legacy server or a wrong URL.
  • 405 on GET or DELETE → correct modern behavior: no listening stream, no session termination.
  • -32602 “Unknown tool” on tools/call → the tool name is wrong, or the catalog changed; re-run tools/list.
  • A result with no resultType → an earlier-protocol server; clients MUST treat it as "complete".
  • A -32002 error → a legacy server’s resource-not-found; modern servers use -32602 for that.
  • A JSON parse error on a response → the client assumed application/json but got an SSE stream. Both are legal on a POST response; parse by Content-Type, not by hope.

What the deployed stack emits

The trace above is the published 2026-07-28 contract, and it is also what this repo’s deployable stack (mcp-handler 2.1.1 over @modelcontextprotocol/server 2.0.0, verified 2026-08-26) serves: the handler speaks 2026-07-28 natively and falls back to stateless 2025-11-25 Streamable HTTP for older clients, so one endpoint answers both eras. What you capture depends on which era the client speaks.

A client sending modern headers gets the frames above. Reproduce frame 1 with curl against a deployed example or npm run dev (the body’s _meta and the headers must agree, or the answer is 400 with -32020):

Terminal window
curl -sS -X POST https://<deployment>/api/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'

The answer is frame 2: resultType: "complete", supportedVersions: ["2026-07-28"] (the server package’s SUPPORTED_MODERN_PROTOCOL_VERSIONS), serverInfo in _meta, and the cache hints the server is configured with (ttlMs: 0, cacheScope: "private" for minimal-server, which configures none; see the note under frame 2). tools/list and tools/call with the matching Mcp-Method and Mcp-Name headers return frames 4 and 6; a tools/call missing Mcp-Name gets 400 with -32020.

A default SDK Client gets the legacy shape instead. @modelcontextprotocol/client 2.0.0 defaults versionNegotiation.mode to 'legacy', so connect() opens with an initialize request at protocolVersion: "2025-11-25" followed by notifications/initialized; capabilities are exchanged there rather than in per-request _meta, results carry no resultType (treat them as "complete", per the spec’s compatibility rule), list results carry no ttlMs/cacheScope, single responses still arrive as SSE frames like frame 6, and there is no session id even on this path: the fallback transport is constructed with session generation unset, so no Mcp-Session-Id is issued and DELETE has nothing to terminate (see Serverless sessions). Opt a client into the modern frames with versionNegotiation: { mode: 'auto' } (probe server/discover, fall back to initialize on -32601) or by pinning the version.

The spec’s versioning page defines exactly this coexistence: a dual-era client probes and falls back, a dual-era server answers whichever handshake arrives. Note the limit for tests: a bare McpServer over InMemoryTransport answers server/discover with -32601, so the examples’ vitest suites only ever see the legacy shape, and assertions on resultType, ttlMs, or cacheScope belong in an HTTP-level check against the handler, not in the in-memory suites. See Testing.

The same frames over stdio

On the stdio transport (local dev, CLI hosts) the frames above are byte-for-byte the same JSON, but the HTTP layer disappears: each message is one newline-delimited JSON object on the child process pipe, with no headers and no status codes. The mirrored-header machinery (Mcp-Method, Mcp-Name, Mcp-Param-*) simply does not exist there, notifications/cancelled returns as the cancellation signal (there is no per-request stream to close), and the backward-compatibility probe is server/discover itself: a modern answer means a modern server, anything else means fall back to initialize. The pipe is still not a session: the spec is explicit that connection identity carries no conversational state. See Transports for the full comparison.

  • MCP internals overview - the stateless model this trace makes concrete.
  • Transports - the POST semantics, required headers, streaming, and Origin rule in full.
  • Serverless sessions - how per-request statelessness and explicit handles map onto Vercel Functions.
  • Capability primitives - the methods appearing in these frames, and the full MRTR treatment.
  • Testing - how to drive a server in-memory with no HTTP at all, and why that path stays on the legacy handshake.

Bibliography