Skip to content

[v2] No supported way to emit Mcp-Param-* headers on a legacy-era connection, but GitHub's hosted server requires them there #2639

Description

@jss79

What happened?

There is no way to make the v2 client emit Mcp-Param-* headers on a legacy-era connection — mirroring is gated on the modern era (packages/client/src/client/client.ts, mirroringActive = this.getProtocolEra() === 'modern' && …), and a warm tools/list cache or an explicit options.toolDefinition doesn't change that. That gate matches the spec (x-mcp-header is 2026-07-28-only), but it collides with a large real-world server:

GitHub's hosted MCP server (https://api.githubcopilot.com/mcp/) answers server/discover with -32601, so versionNegotiation: { mode: 'auto' } lands on the legacy initialize handshake at 2025-11-25 — and it then requires Mcp-Param-* on tools/call over that same legacy session, rejecting with -32020 (missing Mcp-Param-owner header) for any tool whose schema carries x-mcp-header (most of its catalog annotates owner/repo). Observed with a PAT in early August 2026 from a non-SDK client; the era gate means the v2 Client sends no headers on that path either, and 1.30.0 has no Mcp-Param support at all — so as far as I can tell no published SDK version can call those tools on that server today.

SEP-2243's backward-compatibility section permits this server behavior ("Servers MAY support older clients by accepting requests without headers when negotiating an older protocol version" — GitHub declines the MAY). So both sides are within spec, and the interop hole is real.

Two questions rather than a demand:

  1. Is per-request options.headers the intended escape hatch? It works — RESERVED_REQUEST_HEADER_NAMES doesn't cover mcp-param-*, so a caller can run scanXMcpHeaderDeclarations/buildMcpParamHeaders themselves and pass the result per call. But those functions live in core-internal (private: true), so today that means vendoring mcpParamHeaders.ts. If this is the blessed path, exporting the codec (or documenting the vendoring) would close this issue.
  2. Would you take an opt-in for legacy-era mirroring — e.g. a mirrorMcpParamHeaders: 'auto' | 'always' | 'never' client option, or mirroring whenever options.toolDefinition is explicitly supplied? Opt-in seems right rather than a default: SEP-2243's intermediary note says infrastructure on older negotiated versions SHOULD reject requests carrying header values it can't validate, so unconditional emission could break other servers. Happy to PR whichever shape you'd accept.

What did you expect?

Some supported way — even opt-in — to satisfy a server that enforces SEP-2243 header/body validation on a legacy-era connection, or an exported/documented path to build the headers myself.

Code to reproduce

Self-contained (no GitHub credentials needed): one server, two runs. Modern negotiation emits the headers; the legacy default emits none, with the same warm cache and the same explicit toolDefinition.

// node repro.mjs — @modelcontextprotocol/{client,server,node} 2.0.0
import http from "node:http";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { McpServer, fromJsonSchema, createMcpHandler } from "@modelcontextprotocol/server";
import { toNodeHandler } from "@modelcontextprotocol/node";

const inputSchema = {
  type: "object",
  properties: {
    owner: { type: "string", "x-mcp-header": "owner" },
    repo: { type: "string", "x-mcp-header": "repo" },
  },
  required: ["owner", "repo"],
};

function buildServer() {
  const server = new McpServer({ name: "upstream", version: "0.0.0" });
  server.registerTool(
    "get_file_contents",
    { description: "Reads a file.", inputSchema: fromJsonSchema(inputSchema) },
    async () => ({ content: [{ type: "text", text: "ok" }] }),
  );
  return server;
}

const nodeHandler = toNodeHandler(createMcpHandler(() => buildServer(), { legacy: "stateless" }));
const seen = [];
const httpServer = http.createServer((req, res) => {
  seen.push(req.headers);
  void nodeHandler(req, res);
});
await new Promise((r) => httpServer.listen(0, "127.0.0.1", r));
const url = new URL(`http://127.0.0.1:${httpServer.address().port}/`);

async function run(negotiation) {
  seen.length = 0;
  const client = new Client(
    { name: "repro", version: "0.0.0" },
    negotiation ? { versionNegotiation: negotiation } : {},
  );
  await client.connect(new StreamableHTTPClientTransport(url));
  const tool = (await client.listTools()).tools.find((t) => t.name === "get_file_contents");
  await client.callTool(
    { name: "get_file_contents", arguments: { owner: "octo", repo: "hello" } },
    undefined,
    { toolDefinition: tool }, // explicit definition; same result without it
  );
  await client.close();
  const call = seen.find((h) => h["mcp-method"] === "tools/call") ?? seen.at(-1);
  return {
    negotiated: negotiation ? "modern (mode:auto)" : "legacy (default)",
    protocolVersionHeader: call["mcp-protocol-version"],
    mcpParamHeaders: Object.fromEntries(Object.entries(call).filter(([k]) => k.startsWith("mcp-param-"))),
  };
}

console.log(await run({ mode: "auto" }));
console.log(await run(undefined));
httpServer.close();

Output:

{ negotiated: 'modern (mode:auto)', protocolVersionHeader: '2026-07-28',
  mcpParamHeaders: { 'mcp-param-owner': 'octo', 'mcp-param-repo': 'hello' } }
{ negotiated: 'legacy (default)', protocolVersionHeader: '2025-11-25',
  mcpParamHeaders: {} }

SDK version

@modelcontextprotocol/client@2.0.0, @modelcontextprotocol/server@2.0.0, @modelcontextprotocol/node@2.0.0 (also checked @modelcontextprotocol/sdk@1.30.0: no Mcp-Param support). Node 26.7.0.

Area

Client

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions