Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions clients/web/src/test/core/xMcpHeader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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({});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
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("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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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: () =>
Expand Down
Loading
Loading