Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ inspector/
│ ├── json/ # JSON utilities and parameter/argument conversion
│ │ # (xMcpHeader.ts: SEP-2243 `x-mcp-header`
│ │ # annotation scan/validation + mirrored-param
│ │ # derivation, used by the Tools tab — #1632)
│ │ # derivation, used by the Tools tab — #1632;
│ │ # plus `Mcp-Param-*` header building for the
│ │ # wire, used by both `tools/call` paths — #1846)
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime + state stores
│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vi

A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg: its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`). The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes — useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. `test-servers/configs/mrtr-showcase-http.json` bundles every MRTR preset in one modern server for manual testing: `mrtr_confirm` (single round), `mrtr_two_step` (two elicitation rounds via `requestState`), `mrtr_sample` (embedded sampling → the Sampling panel), `mrtr_roots` (embedded `roots/list`, auto-answered silently from configured roots — no modal), `mrtr_edge` (an `inputRequests`-only round then a `requestState`-only round), and `mrtr_loop` (never completes → the `MRTR_MAX_ROUNDS` bound trips). (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.)

`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: `Mcp-Param-*` mirroring is **skipped by the SDK in the browser** (`detectProbeEnvironment() !== "browser"`), so calling `get_weather` from the **web** client omits `Mcp-Param-City` and the strict server answers `-32020` — the same tool is callable from the Node CLI/TUI, where mirroring is active.
`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: the SDK only mirrors `Mcp-Param-*` inside `client.callTool()`, and skips it in the browser (`detectProbeEnvironment() !== "browser"`). The Inspector routes `tools/call` through `client.request()` to drive MRTR manually, so it builds the mirrored headers itself (#1846) — on **every** client, web included, since the web client's upstream request is issued by the Node backend rather than the browser. So `get_weather` is callable from web, CLI, and TUI alike, in both the plain and "Run as task" forms.

`test-servers/configs/xmcpheader-modern-http.json` is the **`x-mcp-header` Tools-tab showcase** (#1632). It serves `echo`, a `get_weather` tool with a **valid** `x-mcp-header: "City"` annotation on its `city` argument, an `invalid_header_tool` whose annotation uses the header name `"Bad Header"` (a space makes it an invalid RFC 9110 token, so the whole tool definition is invalid), and a `trigger_invalid_params` tool that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real `-32602 Invalid params` JSON-RPC error whose message is not about a missing tool. Connect with **Protocol Era = Modern** and open the Tools tab: `get_weather`'s detail panel shows a **"Mirrored request headers (SEP-2243)"** section (`city → Mcp-Param-City`), and `invalid_header_tool` appears struck-through under an **"Excluded (SEP-2243)"** divider in the sidebar with the reason on hover (a conforming Streamable HTTP client MUST drop it from `tools/list`; the Inspector surfaces _why_). Under SDK v2 a `tools/call` that rejects with **`-32602`** now renders as a distinct error panel rather than an `isError` result — headed **"Unknown Tool"** when the message names a missing tool (reproduce by calling a tool the server dropped from its list), or **"Invalid Parameters"** for any other `-32602` (run `trigger_invalid_params`).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,8 @@ export function ToolDetailPanel({
))}
<HeaderParamsNote>
These argument values are mirrored into HTTP headers on the
call. The SDK sends them only on a Node/proxy transport — the
browser omits <Code>Mcp-Param-*</Code> headers.
call. In the web client the <Code>Mcp-Param-*</Code> headers are
applied by the Node backend that issues the upstream request.
</HeaderParamsNote>
</HeaderParamsSection>
)}
Expand Down
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,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();
});
});
Loading
Loading