fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1847)
* fix(core): mirror SEP-2243 x-mcp-header args to Mcp-Param-* on tools/call (#1846) A strict modern (2026-07-28) HTTP server (e.g. the GitHub MCP Server) rejects a tools/call whose argument carries an `x-mcp-header` annotation when the matching `Mcp-Param-*` request header is missing (-32020 "header mismatch"). The Inspector never sent that header. The SDK only mirrors inside `client.callTool()` (and skips it entirely in a browser environment), but the Inspector routes tools/call through `client.request()` for manual MRTR driving (#1704), so the SDK never mirrors for us — on any transport. On top of that, the remote (web) transport dropped per-request headers before they reached the backend's upstream fetch. - core/json/xMcpHeader.ts: port the SDK's non-public `buildMcpParamHeaders` + value encoding (Base64 sentinel for non-ASCII/whitespace/empty values) and add `mcpParamHeadersForTool`. - InspectorClient.attemptToolCall: on a modern connection, build the headers from the tool's declarations + call arguments and attach them to the tools/call request options. `Protocol.request` forwards `headers` to the transport and preserves them across MRTR retry legs, so the direct StreamableHTTP transport applies them. Fixes CLI/TUI. - RemoteClientTransport.requestSend + /api/mcp/send + RemoteSendRequest: forward per-send headers and apply them to the backend's upstream transport.send. The browser can't set cross-origin headers, but the Node backend can — where the Inspector's real request is issued. Fixes web. Verified end-to-end against the live GitHub MCP Server (modern HTTP). Closes #1846 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd * review: harden backend header allowlist, fix breadcrumbs, trim comments Address Copilot review on #1847: - server.ts: restrict client-supplied per-send headers to the `Mcp-Param-` prefix (string values only) via `mcpParamHeadersOnly` before applying them upstream, so the per-send channel can't inject arbitrary headers (e.g. Authorization). `settings.headers` remains the channel for configured upstream headers. Covered by new unit tests. - Fix stale `#1810` breadcrumbs → `#1846` in the two new tests. - Trim the verbose mirroring comments in inspectorClient/types/remote transport. - Drop the redundant base64-on-the-wire integration test (encoding is unit- tested; wire delivery is proven by the verbatim test), remove two unneeded test casts, and cover the boolean `true` branch. Not changed (declined with reason on the PR): buildMcpParamHeaders remains a faithful port of the SDK's helper and does not re-validate values against the declared JSON Schema type — arg types are enforced by schema validation on both sides, and matching the SDK's conforming wire output is the module's goal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd * review: mirror Mcp-Param-* on the task-augmented tools/call too Address the maintainer review on #1847: - `callToolStream` (the "Run as task" path) built its own request options and never mirrored, so a task-augmented `tools/call` on an annotated tool was still rejected by a strict modern server with -32020. It now mirrors like the plain path. - Extract the two blocks both `tools/call` entry points had duplicated — `convertStringToolArgs` (the string-arg coercion) and `applyMirroredParamHeaders` (the SEP-2243 mirroring) — so the paths can't drift again. That duplication is why the task path was missed. - Regression test: `callToolStream` against the modern `get_weather` server asserts the POST carries `Mcp-Param-City` (verified failing without the fix). - README: the modern-network showcase claimed mirroring is skipped in the browser so the web client gets -32020, and that CLI/TUI mirror. Both are now wrong — the Inspector mirrors itself, on every client. AGENTS: note that xMcpHeader.ts now also builds the wire headers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t * docs: correct the stale "browser omits Mcp-Param-*" copy Both statements predate this PR and are now the opposite of true, per the wire capture on #1847 (proxy-observed upstream headers, web leg included). - ToolDetailPanel: the mirrored-headers note claimed the browser omits `Mcp-Param-*`. It now says the web client's headers are applied by the Node backend that issues the upstream request. - createRemoteFetch: the header comment claimed an `x-mcp-header`-annotated tool is "uncallable from the web client against a strict modern server" — the exact wrong conclusion to reach while debugging a -32020. It now records that the Inspector builds the headers itself, and that they do NOT travel this `/api/fetch` proxy path: they ride the transport's per-send `headers` through `/api/mcp/send`. Copy only — no test asserted the old note text (ToolDetailPanel.test.tsx matches the section title only). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkhhCHryVp5H3dqEnhwZ7t --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: cliffhall <cliff@futurescale.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Copilot-Session: 36dbeb09-12b0-4c19-af36-2573772a7dbd
This commit is contained in:
parent
4d30d1cd1e
commit
5d91d4acd0
5 changed files with 720 additions and 34 deletions
|
|
@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
|
|||
import {
|
||||
scanXMcpHeaderDeclarations,
|
||||
getMirroredHeaderParams,
|
||||
buildMcpParamHeaders,
|
||||
mcpParamHeadersForTool,
|
||||
MCP_PARAM_HEADER_PREFIX,
|
||||
X_MCP_HEADER_KEY,
|
||||
} from "@inspector/core/json/xMcpHeader.js";
|
||||
|
|
@ -279,3 +281,153 @@ describe("getMirroredHeaderParams", () => {
|
|||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function declsFor(inputSchema: Tool["inputSchema"]) {
|
||||
const scan = scanXMcpHeaderDeclarations(inputSchema);
|
||||
if (!scan.valid) throw new Error(`expected valid scan: ${scan.reason}`);
|
||||
return scan.declarations;
|
||||
}
|
||||
|
||||
const P = MCP_PARAM_HEADER_PREFIX;
|
||||
|
||||
function decodeSentinel(value: string): string {
|
||||
const inner = value.slice("=?base64?".length, -"?=".length);
|
||||
const bin = atob(inner);
|
||||
const bytes = Uint8Array.from(bin, (ch) => ch.codePointAt(0) ?? 0);
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
describe("buildMcpParamHeaders", () => {
|
||||
const decls = declsFor({
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", [X_MCP_HEADER_KEY]: "owner" },
|
||||
count: { type: "integer", [X_MCP_HEADER_KEY]: "Count" },
|
||||
flag: { type: "boolean", [X_MCP_HEADER_KEY]: "Flag" },
|
||||
ratio: { type: "number", [X_MCP_HEADER_KEY]: "Ratio" },
|
||||
},
|
||||
});
|
||||
|
||||
it("mirrors a string argument verbatim into Mcp-Param-{Name}", () => {
|
||||
expect(buildMcpParamHeaders(decls, { owner: "octocat" })).toEqual({
|
||||
[`${P}owner`]: "octocat",
|
||||
});
|
||||
});
|
||||
|
||||
it("stringifies boolean and numeric values per the spec", () => {
|
||||
expect(
|
||||
buildMcpParamHeaders(decls, {
|
||||
count: 42,
|
||||
flag: false,
|
||||
ratio: 3.5,
|
||||
}),
|
||||
).toEqual({
|
||||
[`${P}Count`]: "42",
|
||||
[`${P}Flag`]: "false",
|
||||
[`${P}Ratio`]: "3.5",
|
||||
});
|
||||
expect(buildMcpParamHeaders(decls, { flag: true })).toEqual({
|
||||
[`${P}Flag`]: "true",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits declarations whose value is absent or null", () => {
|
||||
expect(buildMcpParamHeaders(decls, { owner: null })).toEqual({});
|
||||
expect(buildMcpParamHeaders(decls, {})).toEqual({});
|
||||
});
|
||||
|
||||
it("omits non-primitive values rather than emitting malformed headers", () => {
|
||||
expect(
|
||||
buildMcpParamHeaders(decls, {
|
||||
owner: { nested: true },
|
||||
count: [1, 2],
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("omits non-finite numbers and unsafe integers", () => {
|
||||
expect(buildMcpParamHeaders(decls, { ratio: Infinity })).toEqual({});
|
||||
expect(buildMcpParamHeaders(decls, { ratio: NaN })).toEqual({});
|
||||
expect(
|
||||
buildMcpParamHeaders(decls, { count: Number.MAX_SAFE_INTEGER + 2 }),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("base64-wraps values that are not safe plain-ASCII field values", () => {
|
||||
const out = buildMcpParamHeaders(decls, { owner: "münchen" });
|
||||
const encoded = out[`${P}owner`];
|
||||
expect(encoded.startsWith("=?base64?")).toBe(true);
|
||||
expect(encoded.endsWith("?=")).toBe(true);
|
||||
expect(decodeSentinel(encoded)).toBe("münchen");
|
||||
});
|
||||
|
||||
it("base64-wraps empty, whitespace-padded, and sentinel-colliding values", () => {
|
||||
const empty = buildMcpParamHeaders(decls, { owner: "" })[`${P}owner`];
|
||||
expect(decodeSentinel(empty)).toBe("");
|
||||
const padded = buildMcpParamHeaders(decls, { owner: " x " })[`${P}owner`];
|
||||
expect(decodeSentinel(padded)).toBe(" x ");
|
||||
const collide = "=?base64?zzz?=";
|
||||
const wrapped = buildMcpParamHeaders(decls, { owner: collide })[
|
||||
`${P}owner`
|
||||
];
|
||||
expect(wrapped).not.toBe(collide);
|
||||
expect(decodeSentinel(wrapped)).toBe(collide);
|
||||
});
|
||||
|
||||
it("reads a nested property path", () => {
|
||||
const nested = declsFor({
|
||||
type: "object",
|
||||
properties: {
|
||||
filter: {
|
||||
type: "object",
|
||||
properties: {
|
||||
city: { type: "string", [X_MCP_HEADER_KEY]: "City" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
buildMcpParamHeaders(nested, { filter: { city: "London" } }),
|
||||
).toEqual({ [`${P}City`]: "London" });
|
||||
expect(buildMcpParamHeaders(nested, { filter: "not-an-object" })).toEqual(
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mcpParamHeadersForTool", () => {
|
||||
it("builds headers for a tool's valid annotations", () => {
|
||||
expect(
|
||||
mcpParamHeadersForTool(
|
||||
tool({
|
||||
type: "object",
|
||||
properties: {
|
||||
owner: { type: "string", [X_MCP_HEADER_KEY]: "owner" },
|
||||
},
|
||||
}),
|
||||
{ owner: "octocat" },
|
||||
),
|
||||
).toEqual({ [`${P}owner`]: "octocat" });
|
||||
});
|
||||
|
||||
it("returns {} for a tool with no annotations", () => {
|
||||
expect(
|
||||
mcpParamHeadersForTool(
|
||||
tool({ type: "object", properties: { a: { type: "string" } } }),
|
||||
{ a: "x" },
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("returns {} for a tool whose annotations are invalid", () => {
|
||||
expect(
|
||||
mcpParamHeadersForTool(
|
||||
tool({
|
||||
type: "object",
|
||||
properties: { a: { type: "object", [X_MCP_HEADER_KEY]: "A" } },
|
||||
}),
|
||||
{ a: "x" },
|
||||
),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
|
||||
import {
|
||||
createTestServerHttp,
|
||||
type TestServerHttp,
|
||||
createTestServerInfo,
|
||||
createEchoTool,
|
||||
createGetWeatherTool,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
|
||||
/**
|
||||
* SEP-2243 `x-mcp-header` → `Mcp-Param-*` mirroring on `tools/call` (#1846).
|
||||
*
|
||||
* The SDK only mirrors inside `client.callTool()` (and skips it in a browser
|
||||
* environment); the Inspector routes `tools/call` through `client.request()`
|
||||
* for manual MRTR driving (#1704), so it mirrors the headers itself. A strict
|
||||
* modern server (e.g. GitHub's) rejects a call whose annotated argument isn't
|
||||
* mirrored, so this must ride the wire. Here we spy on the transport `fetch` and
|
||||
* assert the `tools/call` POST carries the mirrored header with the spec's
|
||||
* value encoding.
|
||||
*/
|
||||
describe("x-mcp-header Mcp-Param-* mirroring on tools/call", () => {
|
||||
let client: InspectorClient | null = null;
|
||||
let server: TestServerHttp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
if (server) {
|
||||
try {
|
||||
await server.stop();
|
||||
} catch {
|
||||
// Ignore server stop errors
|
||||
}
|
||||
server = null;
|
||||
}
|
||||
});
|
||||
|
||||
/** Records the request headers of every `tools/call` POST the client sends. */
|
||||
function makeSpyFetch(): {
|
||||
fetch: typeof fetch;
|
||||
toolCallHeaders: Headers[];
|
||||
} {
|
||||
const toolCallHeaders: Headers[] = [];
|
||||
const spy: typeof fetch = async (input, init) => {
|
||||
const body = init?.body;
|
||||
if (typeof body === "string" && body.includes('"tools/call"')) {
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { method?: string };
|
||||
if (parsed.method === "tools/call") {
|
||||
toolCallHeaders.push(new Headers(init?.headers));
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON body — ignore.
|
||||
}
|
||||
}
|
||||
return fetch(input, init);
|
||||
};
|
||||
return { fetch: spy, toolCallHeaders };
|
||||
}
|
||||
|
||||
async function connectModern(
|
||||
url: string,
|
||||
fetchFn: typeof fetch,
|
||||
): Promise<InspectorClient> {
|
||||
const connected = new InspectorClient(
|
||||
{ type: "streamable-http", url },
|
||||
{
|
||||
environment: { transport: createTransportNode, fetch: fetchFn },
|
||||
versionNegotiation: eraToVersionNegotiation("auto"),
|
||||
},
|
||||
);
|
||||
await connected.connect();
|
||||
client = connected;
|
||||
return connected;
|
||||
}
|
||||
|
||||
async function startWeatherServer(): Promise<TestServerHttp> {
|
||||
const started = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("xmcpheader-test", "1.0.0"),
|
||||
tools: [createEchoTool(), createGetWeatherTool()],
|
||||
modern: {},
|
||||
});
|
||||
await started.start();
|
||||
server = started;
|
||||
return started;
|
||||
}
|
||||
|
||||
async function weatherTool(c: InspectorClient): Promise<Tool> {
|
||||
const { tools } = await c.listTools();
|
||||
const weather = tools.find((t) => t.name === "get_weather");
|
||||
expect(weather).toBeDefined();
|
||||
return weather!;
|
||||
}
|
||||
|
||||
it("mirrors an annotated argument into the Mcp-Param-* request header", async () => {
|
||||
const started = await startWeatherServer();
|
||||
const spy = makeSpyFetch();
|
||||
const connected = await connectModern(started.url, spy.fetch);
|
||||
expect(connected.getProtocolEra()).toBe("modern");
|
||||
|
||||
const result = await connected.callTool(await weatherTool(connected), {
|
||||
city: "London",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(spy.toolCallHeaders.length).toBeGreaterThan(0);
|
||||
const sent = spy.toolCallHeaders.at(-1)!;
|
||||
expect(sent.get("Mcp-Param-City")).toBe("London");
|
||||
});
|
||||
|
||||
it("mirrors on the task-augmented tools/call too (callToolStream)", async () => {
|
||||
// The "Run as task" path builds its own request options, so it needs the
|
||||
// same mirroring — a strict modern server rejects the task-augmented
|
||||
// `tools/call` with -32020 when the header is missing, exactly as it does
|
||||
// the plain one.
|
||||
const started = await startWeatherServer();
|
||||
const spy = makeSpyFetch();
|
||||
const connected = await connectModern(started.url, spy.fetch);
|
||||
|
||||
const result = await connected.callToolStream(
|
||||
await weatherTool(connected),
|
||||
{ city: "London" },
|
||||
undefined,
|
||||
undefined,
|
||||
{ ttl: 60_000 },
|
||||
);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(spy.toolCallHeaders.at(-1)!.get("Mcp-Param-City")).toBe("London");
|
||||
});
|
||||
|
||||
it("sends no Mcp-Param-* header for a tool without annotations", async () => {
|
||||
const started = await startWeatherServer();
|
||||
const spy = makeSpyFetch();
|
||||
const connected = await connectModern(started.url, spy.fetch);
|
||||
|
||||
const { tools } = await connected.listTools();
|
||||
const echo = tools.find((t) => t.name === "echo")!;
|
||||
await connected.callTool(echo, { message: "hi" });
|
||||
|
||||
const sent = spy.toolCallHeaders.at(-1)!;
|
||||
let sawMcpParam = false;
|
||||
sent.forEach((_v, k) => {
|
||||
if (k.toLowerCase().startsWith("mcp-param-")) sawMcpParam = true;
|
||||
});
|
||||
expect(sawMcpParam).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mirror on a legacy connection", async () => {
|
||||
const started = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("xmcpheader-legacy", "1.0.0"),
|
||||
tools: [createGetWeatherTool()],
|
||||
modern: { legacy: "stateless" },
|
||||
});
|
||||
await started.start();
|
||||
server = started;
|
||||
|
||||
const spy = makeSpyFetch();
|
||||
const connected = new InspectorClient(
|
||||
{ type: "streamable-http", url: started.url },
|
||||
{
|
||||
environment: { transport: createTransportNode, fetch: spy.fetch },
|
||||
versionNegotiation: eraToVersionNegotiation("legacy"),
|
||||
},
|
||||
);
|
||||
await connected.connect();
|
||||
client = connected;
|
||||
expect(connected.getProtocolEra()).toBe("legacy");
|
||||
|
||||
await connected.callTool(await weatherTool(connected), { city: "London" });
|
||||
const sent = spy.toolCallHeaders.at(-1)!;
|
||||
expect(sent.get("Mcp-Param-City")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -642,6 +642,58 @@ describe("RemoteClientTransport (focused branch coverage)", () => {
|
|||
await t.close();
|
||||
});
|
||||
|
||||
it("forwards per-send headers (SEP-2243 Mcp-Param-*) in the send body", async () => {
|
||||
let sentBody: { headers?: Record<string, string> } | undefined;
|
||||
const encoder = new TextEncoder();
|
||||
let sseController: ReadableStreamDefaultController<Uint8Array> | null =
|
||||
null;
|
||||
const pushSseMessage = (message: JSONRPCMessage) => {
|
||||
const payload = JSON.stringify({ type: "message", data: message });
|
||||
sseController?.enqueue(encoder.encode(`data: ${payload}\n\n`));
|
||||
};
|
||||
const fetchFn = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/connect")) return jsonResponse({ sessionId: "s" });
|
||||
if (url.includes("/events")) {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
sseController = controller;
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/send")) {
|
||||
sentBody = JSON.parse(init!.body as string);
|
||||
const requestId = (
|
||||
sentBody as { message: { id?: string | number } }
|
||||
).message.id;
|
||||
pushSseMessage({ jsonrpc: "2.0", id: requestId!, result: {} });
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
},
|
||||
);
|
||||
const t = new RemoteClientTransport(
|
||||
{
|
||||
baseUrl: "http://remote.test",
|
||||
fetchFn: fetchFn as unknown as typeof fetch,
|
||||
sseResponseTimeoutMs: 2000,
|
||||
},
|
||||
CONFIG,
|
||||
);
|
||||
await t.start();
|
||||
await t.send(
|
||||
{ jsonrpc: "2.0", id: 7, method: "tools/call" },
|
||||
{ headers: { "Mcp-Param-City": "London" } },
|
||||
);
|
||||
expect(sentBody?.headers).toEqual({ "Mcp-Param-City": "London" });
|
||||
await t.close();
|
||||
});
|
||||
|
||||
it("throws Remote send failed with status on non-OK send", async () => {
|
||||
const t = makeTransport({
|
||||
events: () =>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type pinoType from "pino";
|
|||
import {
|
||||
createRemoteApp,
|
||||
requestIdForSendWait,
|
||||
mcpParamHeadersOnly,
|
||||
} from "@inspector/core/mcp/remote/node/server.js";
|
||||
import {
|
||||
InMemorySecretStore,
|
||||
|
|
@ -139,6 +140,43 @@ describe("server.ts supplemental coverage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("mcpParamHeadersOnly (SEP-2243 upstream header allowlist, #1846)", () => {
|
||||
it("keeps only Mcp-Param-* headers (case-insensitive)", () => {
|
||||
expect(
|
||||
mcpParamHeadersOnly({
|
||||
"Mcp-Param-City": "London",
|
||||
"mcp-param-owner": "octocat",
|
||||
}),
|
||||
).toEqual({ "Mcp-Param-City": "London", "mcp-param-owner": "octocat" });
|
||||
});
|
||||
|
||||
it("drops non-Mcp-Param headers a client tries to inject", () => {
|
||||
expect(
|
||||
mcpParamHeadersOnly({
|
||||
Authorization: "Bearer evil",
|
||||
"X-Custom": "nope",
|
||||
"Mcp-Param-City": "London",
|
||||
}),
|
||||
).toEqual({ "Mcp-Param-City": "London" });
|
||||
});
|
||||
|
||||
it("drops non-string values", () => {
|
||||
expect(
|
||||
mcpParamHeadersOnly({
|
||||
"Mcp-Param-Bad": 5,
|
||||
"Mcp-Param-City": "London",
|
||||
}),
|
||||
).toEqual({ "Mcp-Param-City": "London" });
|
||||
});
|
||||
|
||||
it("returns undefined when nothing survives the filter", () => {
|
||||
expect(mcpParamHeadersOnly({ Authorization: "x" })).toBeUndefined();
|
||||
expect(mcpParamHeadersOnly(undefined)).toBeUndefined();
|
||||
expect(mcpParamHeadersOnly(null)).toBeUndefined();
|
||||
expect(mcpParamHeadersOnly("not-an-object")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/log forwardLogEvent shapes", () => {
|
||||
let h: Harness;
|
||||
let records: Array<{ level: string; args: unknown[] }>;
|
||||
|
|
|
|||
|
|
@ -30,9 +30,13 @@ import {
|
|||
createTestServerHttp,
|
||||
getTestMcpServerCommand,
|
||||
createEchoTool,
|
||||
createGetWeatherTool,
|
||||
createTestServerInfo,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
import {
|
||||
eraToVersionNegotiation,
|
||||
type MCPServerConfig,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
|
||||
interface StartRemoteServerOptions {
|
||||
logger?: pino.Logger;
|
||||
|
|
@ -447,6 +451,70 @@ describe("Remote transport e2e", () => {
|
|||
await client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
it("end-to-end: SEP-2243 Mcp-Param-* mirroring reaches the upstream modern server (#1846)", async () => {
|
||||
// A modern (2026-07-28) server whose `get_weather` tool annotates `city`
|
||||
// with `x-mcp-header: "City"`. The SDK's modern handler validates the
|
||||
// mirrored header and rejects the call with -32020 when it is missing, so
|
||||
// a *successful* call proves the header rode browser → backend → upstream.
|
||||
mcpHttpServer = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createGetWeatherTool()],
|
||||
serverType: "streamable-http",
|
||||
modern: {},
|
||||
});
|
||||
await mcpHttpServer.start();
|
||||
|
||||
const { baseUrl, server, authToken } = await startRemoteServer(0);
|
||||
remoteServer = server;
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
type: "streamable-http",
|
||||
url: mcpHttpServer.url,
|
||||
};
|
||||
const createTransport = createRemoteTransport({ baseUrl, authToken });
|
||||
const client = new InspectorClient(config, {
|
||||
environment: { transport: createTransport },
|
||||
versionNegotiation: eraToVersionNegotiation("auto"),
|
||||
});
|
||||
const fetchRequestLogState = new FetchRequestLogState(client);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
expect(client.getProtocolEra()).toBe("modern");
|
||||
|
||||
const { tools } = await client.listTools();
|
||||
const weather = tools.find((t) => t.name === "get_weather");
|
||||
expect(weather).toBeDefined();
|
||||
|
||||
const invocation = await client.callTool(weather!, { city: "London" });
|
||||
// Success only happens if the strict modern server saw Mcp-Param-City.
|
||||
expect(invocation.success).toBe(true);
|
||||
|
||||
// And the backend's upstream fetch tracking shows the mirrored header on
|
||||
// the wire it sent to the modern server.
|
||||
const toolCallPost = fetchRequestLogState
|
||||
.getFetchRequests()
|
||||
.filter((r) => r.method === "POST")
|
||||
.find((r) => {
|
||||
const lowered: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(r.requestHeaders ?? {})) {
|
||||
lowered[k.toLowerCase()] = v;
|
||||
}
|
||||
return lowered["mcp-param-city"] !== undefined;
|
||||
});
|
||||
expect(toolCallPost).toBeDefined();
|
||||
const lowered: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(
|
||||
toolCallPost?.requestHeaders ?? {},
|
||||
)) {
|
||||
lowered[k.toLowerCase()] = v;
|
||||
}
|
||||
expect(lowered["mcp-param-city"]).toBe("London");
|
||||
} finally {
|
||||
await client.disconnect();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue