Request Headers

Body fields mirrored into headers, validated for divergence.

Modern clients mirror key body fields into HTTP headers so a gateway can route, authorize, or rate-limit without parsing the JSON-RPC body. h3-mcp validates header-vs-body equality on every modern POST and rejects any divergence with 400 + -32020 (HeaderMismatch).

HeaderSourceRequired for
MCP-Protocol-Version_meta protocol versionall requests
Mcp-Methodmethodall requests
Mcp-Nameparams.name or params.uritools/call, resources/read, prompts/get
Mcp-Param-{Name}x-mcp-header annotationannotated tool parameters
curl -X POST http://localhost:3000/mcp \
  -H 'content-type: application/json' \
  -H 'accept: application/json, text/event-stream' \
  -H 'mcp-protocol-version: 2026-07-28' \
  -H 'mcp-method: tools/call' \
  -H 'mcp-name: hello' \
  -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
        "params": { "name": "hello", "arguments": {},
        "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28",
                   "io.modelcontextprotocol/clientCapabilities": {} } } }'

#Non-ASCII Values

Values that cannot be represented as plain ASCII arrive wrapped in a sentinel — =?base64?{value}?= — and are decoded before comparison. Integer parameters must use the decimal representation (42 or 42.0); hexadecimal, exponent, and zero-padded forms are rejected rather than normalized.

#Notifications Are Checked Too

The headers are validated on every modern POST, requests and notification-shaped bodies alike. A body whose method is a request method but which carries no id is rejected with -32600 and never executed.

This closes an obvious bypass: if header checks were gated on the presence of an id, a client could drop the id and slip past a gateway that routes on Mcp-Method.

#Mirroring Tool Parameters

Annotate a tool parameter with x-mcp-header and clients will mirror it, letting an intermediary route on a value from inside arguments:

const queryTool = defineTool({
  name: "execute_sql",
  // `x-mcp-header` is not a Zod concept, but it does not need to be: `.meta()`
  // merges arbitrary keywords into the emitted JSON Schema, so the annotation
  // rides along and the arguments are still validated on the way in.
  inputSchema: z.object({
    region: z.string().meta({ "x-mcp-header": "Region" }),
    query: z.string(),
  }),
  outputSchema: z.object({ region: z.string(), rows: z.number() }),
  handler: async ({ region, query }) => {
    const rows = await runQuery(region, query);
    return {
      content: [{ type: "text", text: `${rows.length} rows from ${region}` }],
      structuredContent: { region, rows: rows.length },
    };
  },
});

A client then sends Mcp-Param-Region: eu-west-1 alongside the body, and a mismatch is a -32020.

The annotation is read off the resolved schema, so it survives whatever produced the JSON Schema — but carrying a non-standard keyword through is library-specific. Zod's .meta() merges arbitrary keywords verbatim; Valibot's converter emits only the keywords it knows, so v.metadata({ "x-mcp-header": … }) is dropped (valibot#1301) and an annotated parameter has to be declared as plain JSON Schema instead.

x-mcp-header is only honored on string / integer / boolean properties reachable from the schema root through a chain of properties keys. Annotations that violate those constraints are ignored rather than enforced.

Important

Never annotate a sensitive value. Mirrored parameters are visible to every intermediary on the path and routinely end up in access logs. Annotate a region or a tenant id, not a token, a query, or personal data.

Note

Header/body divergence is a security control, not a formality. An intermediary may act on the header while your handler acts on the body; if the two are allowed to disagree, every gateway decision becomes advisory. That is why -32020 is never relaxed.

#Accept

When a modern POST sends Accept, it must list both application/json and text/event-stream — the server picks between them per request. Anything else is a 406.

h3-mcp  MCP servers, built on H3.