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
14 changed files with 720 additions and 34 deletions
|
|
@ -43,7 +43,9 @@ inspector/
|
||||||
│ ├── json/ # JSON utilities and parameter/argument conversion
|
│ ├── json/ # JSON utilities and parameter/argument conversion
|
||||||
│ │ # (xMcpHeader.ts: SEP-2243 `x-mcp-header`
|
│ │ # (xMcpHeader.ts: SEP-2243 `x-mcp-header`
|
||||||
│ │ # annotation scan/validation + mirrored-param
|
│ │ # 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
|
│ ├── logging/ # Silent pino logger singleton
|
||||||
│ ├── mcp/ # InspectorClient runtime + state stores
|
│ ├── mcp/ # InspectorClient runtime + state stores
|
||||||
│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks
|
│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,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.)
|
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`).
|
`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`).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -306,8 +306,8 @@ export function ToolDetailPanel({
|
||||||
))}
|
))}
|
||||||
<HeaderParamsNote>
|
<HeaderParamsNote>
|
||||||
These argument values are mirrored into HTTP headers on the
|
These argument values are mirrored into HTTP headers on the
|
||||||
call. The SDK sends them only on a Node/proxy transport — the
|
call. In the web client the <Code>Mcp-Param-*</Code> headers are
|
||||||
browser omits <Code>Mcp-Param-*</Code> headers.
|
applied by the Node backend that issues the upstream request.
|
||||||
</HeaderParamsNote>
|
</HeaderParamsNote>
|
||||||
</HeaderParamsSection>
|
</HeaderParamsSection>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
scanXMcpHeaderDeclarations,
|
scanXMcpHeaderDeclarations,
|
||||||
getMirroredHeaderParams,
|
getMirroredHeaderParams,
|
||||||
|
buildMcpParamHeaders,
|
||||||
|
mcpParamHeadersForTool,
|
||||||
MCP_PARAM_HEADER_PREFIX,
|
MCP_PARAM_HEADER_PREFIX,
|
||||||
X_MCP_HEADER_KEY,
|
X_MCP_HEADER_KEY,
|
||||||
} from "@inspector/core/json/xMcpHeader.js";
|
} from "@inspector/core/json/xMcpHeader.js";
|
||||||
|
|
@ -279,3 +281,153 @@ describe("getMirroredHeaderParams", () => {
|
||||||
).toEqual([]);
|
).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();
|
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 () => {
|
it("throws Remote send failed with status on non-OK send", async () => {
|
||||||
const t = makeTransport({
|
const t = makeTransport({
|
||||||
events: () =>
|
events: () =>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import type pinoType from "pino";
|
||||||
import {
|
import {
|
||||||
createRemoteApp,
|
createRemoteApp,
|
||||||
requestIdForSendWait,
|
requestIdForSendWait,
|
||||||
|
mcpParamHeadersOnly,
|
||||||
} from "@inspector/core/mcp/remote/node/server.js";
|
} from "@inspector/core/mcp/remote/node/server.js";
|
||||||
import {
|
import {
|
||||||
InMemorySecretStore,
|
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", () => {
|
describe("/api/log forwardLogEvent shapes", () => {
|
||||||
let h: Harness;
|
let h: Harness;
|
||||||
let records: Array<{ level: string; args: unknown[] }>;
|
let records: Array<{ level: string; args: unknown[] }>;
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,13 @@ import {
|
||||||
createTestServerHttp,
|
createTestServerHttp,
|
||||||
getTestMcpServerCommand,
|
getTestMcpServerCommand,
|
||||||
createEchoTool,
|
createEchoTool,
|
||||||
|
createGetWeatherTool,
|
||||||
createTestServerInfo,
|
createTestServerInfo,
|
||||||
} from "@modelcontextprotocol/inspector-test-server";
|
} 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 {
|
interface StartRemoteServerOptions {
|
||||||
logger?: pino.Logger;
|
logger?: pino.Logger;
|
||||||
|
|
@ -447,6 +451,70 @@ describe("Remote transport e2e", () => {
|
||||||
await client.disconnect();
|
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", () => {
|
describe("authentication", () => {
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,125 @@ export function scanXMcpHeaderDeclarations(
|
||||||
: { valid: false, reason: fault };
|
: { valid: false, reason: fault };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `=?base64?…?=` sentinel wrapping a value that cannot be sent as a plain
|
||||||
|
* ASCII HTTP field value (SEP-2243 value-encoding rules).
|
||||||
|
*/
|
||||||
|
const BASE64_SENTINEL_PREFIX = "=?base64?";
|
||||||
|
const BASE64_SENTINEL_SUFFIX = "?=";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a primitive argument value to its string form per the spec's
|
||||||
|
* type-conversion rules: strings pass through, booleans become lowercase
|
||||||
|
* `'true'`/`'false'`, integers/numbers become their decimal string. Non-finite
|
||||||
|
* numbers and integers outside the safe range are refused (returns `undefined`,
|
||||||
|
* meaning "do not emit a header for this value"). Anything non-primitive
|
||||||
|
* (object/array/null/undefined) also yields `undefined`.
|
||||||
|
*/
|
||||||
|
function mcpParamPrimitiveToString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "boolean") return value ? "true" : "false";
|
||||||
|
if (typeof value === "number") {
|
||||||
|
if (!Number.isFinite(value)) return undefined;
|
||||||
|
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `true` when `s` cannot be safely represented as a plain ASCII HTTP field
|
||||||
|
* value (RFC 9110 §5.5): it is empty, contains a byte outside `0x20–0x7E`/`0x09`,
|
||||||
|
* has leading/trailing whitespace (which field parsing strips), or already
|
||||||
|
* matches the Base64 sentinel pattern (the spec's "to avoid ambiguity" rule).
|
||||||
|
*/
|
||||||
|
function needsBase64(s: string): boolean {
|
||||||
|
if (s.length === 0) return true;
|
||||||
|
if (
|
||||||
|
s.startsWith(BASE64_SENTINEL_PREFIX) &&
|
||||||
|
s.endsWith(BASE64_SENTINEL_SUFFIX)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (s !== s.trim()) return true;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
// Non-null: `i` is always in bounds, so `codePointAt` returns a number.
|
||||||
|
const c = s.codePointAt(i)!;
|
||||||
|
if (c === 9 || (c >= 32 && c <= 126)) continue;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utf8ToBase64(s: string): string {
|
||||||
|
const bytes = new TextEncoder().encode(s);
|
||||||
|
let bin = "";
|
||||||
|
for (const b of bytes) bin += String.fromCodePoint(b);
|
||||||
|
return btoa(bin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode a string value as an HTTP field value per SEP-2243: a value that is
|
||||||
|
* already a safe plain-ASCII field value passes through unchanged; anything
|
||||||
|
* else is wrapped as `=?base64?{b64-of-utf8}?=`.
|
||||||
|
*/
|
||||||
|
function encodeMcpParamValue(value: string): string {
|
||||||
|
return needsBase64(value)
|
||||||
|
? `${BASE64_SENTINEL_PREFIX}${utf8ToBase64(value)}${BASE64_SENTINEL_SUFFIX}`
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueAtPath(root: unknown, path: string[]): unknown {
|
||||||
|
let node: unknown = root;
|
||||||
|
for (const key of path) {
|
||||||
|
if (node === null || typeof node !== "object") return undefined;
|
||||||
|
node = (node as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the `Mcp-Param-{Name}` headers for one `tools/call` from validated
|
||||||
|
* `x-mcp-header` declarations and the call's `arguments`. A declaration whose
|
||||||
|
* value is `null` or absent is omitted (the spec's "client MUST omit the header"
|
||||||
|
* rows); a value that is not a primitive of the declared kind is omitted rather
|
||||||
|
* than emitted malformed. Faithful port of the SDK's internal helper (not part
|
||||||
|
* of its public surface), so the headers match what a conforming client sends.
|
||||||
|
*/
|
||||||
|
export function buildMcpParamHeaders(
|
||||||
|
declarations: XMcpHeaderDeclaration[],
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const decl of declarations) {
|
||||||
|
const raw = valueAtPath(args, decl.path);
|
||||||
|
if (raw === undefined || raw === null) continue;
|
||||||
|
const stringValue = mcpParamPrimitiveToString(raw);
|
||||||
|
if (stringValue === undefined) continue;
|
||||||
|
out[`${MCP_PARAM_HEADER_PREFIX}${decl.headerName}`] =
|
||||||
|
encodeMcpParamValue(stringValue);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEP-2243 `Mcp-Param-*` headers a `tools/call` must carry for a given tool and
|
||||||
|
* arguments. Returns `{}` when the tool declares no `x-mcp-header`, when its
|
||||||
|
* annotations are invalid (such a tool is excluded from `tools/list`), or when
|
||||||
|
* no declared argument has a mirrorable value. Callers attach the result to the
|
||||||
|
* `tools/call` request headers on a modern connection.
|
||||||
|
*/
|
||||||
|
export function mcpParamHeadersForTool(
|
||||||
|
tool: Tool,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): Record<string, string> {
|
||||||
|
const scan = scanXMcpHeaderDeclarations(tool.inputSchema);
|
||||||
|
if (!scan.valid || scan.declarations.length === 0) return {};
|
||||||
|
return buildMcpParamHeaders(scan.declarations, args);
|
||||||
|
}
|
||||||
|
|
||||||
/** A tool the Inspector keeps, paired with its mirrored-header declarations. */
|
/** A tool the Inspector keeps, paired with its mirrored-header declarations. */
|
||||||
export interface MirroredHeaderParam {
|
export interface MirroredHeaderParam {
|
||||||
/** Dot-joined property path (e.g. `region` or `filter.city`). */
|
/** Dot-joined property path (e.g. `region` or `filter.city`). */
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ import type {
|
||||||
ResourceSubscriptionStreamState,
|
ResourceSubscriptionStreamState,
|
||||||
ExcludedTool,
|
ExcludedTool,
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
import { scanXMcpHeaderDeclarations } from "../json/xMcpHeader.js";
|
import {
|
||||||
|
scanXMcpHeaderDeclarations,
|
||||||
|
mcpParamHeadersForTool,
|
||||||
|
} from "../json/xMcpHeader.js";
|
||||||
// Re-export so v1.5 tests that do `import { InspectorClientOptions } from
|
// Re-export so v1.5 tests that do `import { InspectorClientOptions } from
|
||||||
// "@inspector/core/mcp/inspectorClient.js"` keep resolving.
|
// "@inspector/core/mcp/inspectorClient.js"` keep resolving.
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -3304,6 +3307,52 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce the string-valued entries of a tool's arguments to the types its
|
||||||
|
* `inputSchema` declares (the Tools form hands everything over as text).
|
||||||
|
* Shared by the two `tools/call` entry points — {@link attemptToolCall} and
|
||||||
|
* {@link callToolStream} — so both put the SAME arguments on the wire.
|
||||||
|
*/
|
||||||
|
private convertStringToolArgs(
|
||||||
|
tool: Tool,
|
||||||
|
args: Record<string, JsonValue>,
|
||||||
|
): Record<string, JsonValue> {
|
||||||
|
const stringArgs: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(args)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
stringArgs[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(stringArgs).length === 0) return args;
|
||||||
|
return { ...args, ...convertToolParameters(tool, stringArgs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEP-2243: mirror `x-mcp-header`-annotated arguments into `Mcp-Param-*`
|
||||||
|
* headers on a modern connection. The SDK only does this inside
|
||||||
|
* `client.callTool()` (and skips it in the browser), but we route
|
||||||
|
* `tools/call` through `client.request()` for manual MRTR driving (#1704), so
|
||||||
|
* we mirror ourselves. `Protocol.request` forwards `headers` (preserved
|
||||||
|
* across MRTR retry legs) to the transport, and the remote transport relays
|
||||||
|
* them to the backend's upstream send — issued server-side, where the browser
|
||||||
|
* skip doesn't apply. No-op on legacy/stdio (no annotations).
|
||||||
|
*
|
||||||
|
* Applied by BOTH `tools/call` entry points: a plain call
|
||||||
|
* ({@link attemptToolCall}) and a task-augmented one
|
||||||
|
* ({@link callToolStream}) — a strict modern server rejects either with
|
||||||
|
* `-32020` when the mirrored header is missing.
|
||||||
|
*/
|
||||||
|
private applyMirroredParamHeaders(
|
||||||
|
tool: Tool,
|
||||||
|
convertedArgs: Record<string, JsonValue>,
|
||||||
|
requestOptions: RequestOptions,
|
||||||
|
): void {
|
||||||
|
if (this.protocolEra !== "modern") return;
|
||||||
|
const paramHeaders = mcpParamHeadersForTool(tool, convertedArgs);
|
||||||
|
if (Object.keys(paramHeaders).length === 0) return;
|
||||||
|
requestOptions.headers = { ...requestOptions.headers, ...paramHeaders };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run a single tools/call attempt: convert args, issue the request, validate,
|
* Run a single tools/call attempt: convert args, issue the request, validate,
|
||||||
* and return a successful {@link ToolCallInvocation}. Throws on any error
|
* and return a successful {@link ToolCallInvocation}. Throws on any error
|
||||||
|
|
@ -3326,17 +3375,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Client is not connected");
|
throw new Error("Client is not connected");
|
||||||
}
|
}
|
||||||
let convertedArgs: Record<string, JsonValue> = args;
|
const convertedArgs = this.convertStringToolArgs(tool, args);
|
||||||
const stringArgs: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(args)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
stringArgs[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(stringArgs).length > 0) {
|
|
||||||
const convertedStringArgs = convertToolParameters(tool, stringArgs);
|
|
||||||
convertedArgs = { ...args, ...convertedStringArgs };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge general metadata with tool-specific metadata; tool-specific wins.
|
// Merge general metadata with tool-specific metadata; tool-specific wins.
|
||||||
const callMetadata: Record<string, string> | undefined =
|
const callMetadata: Record<string, string> | undefined =
|
||||||
|
|
@ -3367,6 +3406,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
metadata?.progressToken,
|
metadata?.progressToken,
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
|
this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions);
|
||||||
// Route through the MRTR driver (`requestWithInputRequired`) so a modern
|
// Route through the MRTR driver (`requestWithInputRequired`) so a modern
|
||||||
// `input_required` result pauses at the pending-request UI and retries with
|
// `input_required` result pauses at the pending-request UI and retries with
|
||||||
// the user's answer (#1704). Both eras use `client.request` with
|
// the user's answer (#1704). Both eras use `client.request` with
|
||||||
|
|
@ -3949,17 +3989,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
throw new Error("Client is not connected");
|
throw new Error("Client is not connected");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
let convertedArgs: Record<string, JsonValue> = args;
|
const convertedArgs = this.convertStringToolArgs(tool, args);
|
||||||
const stringArgs: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(args)) {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
stringArgs[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(stringArgs).length > 0) {
|
|
||||||
const convertedStringArgs = convertToolParameters(tool, stringArgs);
|
|
||||||
convertedArgs = { ...args, ...convertedStringArgs };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge general metadata with tool-specific metadata; tool-specific wins.
|
// Merge general metadata with tool-specific metadata; tool-specific wins.
|
||||||
const callMetadata: Record<string, string> | undefined =
|
const callMetadata: Record<string, string> | undefined =
|
||||||
|
|
@ -3999,6 +4029,9 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
// emit requestorTaskProgress) for task calls only, bypassing the toggle
|
// emit requestorTaskProgress) for task calls only, bypassing the toggle
|
||||||
// that governs every other call path.
|
// that governs every other call path.
|
||||||
const requestOptions = this.getRequestOptions(metadata?.progressToken);
|
const requestOptions = this.getRequestOptions(metadata?.progressToken);
|
||||||
|
// The task-augmented `tools/call` needs the same SEP-2243 mirroring as the
|
||||||
|
// plain one — a strict modern server rejects it with -32020 otherwise.
|
||||||
|
this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions);
|
||||||
if (this.progress) {
|
if (this.progress) {
|
||||||
const innerOnProgress = requestOptions.onprogress;
|
const innerOnProgress = requestOptions.onprogress;
|
||||||
requestOptions.onprogress = (progress: Progress) => {
|
requestOptions.onprogress = (progress: Progress) => {
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,12 @@
|
||||||
* on the browser path and reach the server through this proxy.
|
* on the browser path and reach the server through this proxy.
|
||||||
* - The custom `Mcp-Param-*` headers are gated on a non-browser environment in
|
* - The custom `Mcp-Param-*` headers are gated on a non-browser environment in
|
||||||
* the SDK (`detectProbeEnvironment() !== "browser"`, avoiding a CORS
|
* the SDK (`detectProbeEnvironment() !== "browser"`, avoiding a CORS
|
||||||
* preflight), so they are NEVER built in the browser — the proxy would
|
* preflight), so the SDK never builds them here. The Inspector builds them
|
||||||
* forward them, but the SDK never creates them here. A tool with an
|
* itself instead (`mcpParamHeadersForTool`, #1846) — but they do NOT travel
|
||||||
* `x-mcp-header` annotation is therefore uncallable from the web client
|
* this path: they ride the transport's per-send `headers` through
|
||||||
* against a strict modern server (it answers `-32020 HeaderMismatch`); such
|
* `/api/mcp/send`, where the Node backend applies them to the upstream
|
||||||
* tools work from the Node CLI/TUI, where mirroring is active.
|
* request it issues. So an `x-mcp-header`-annotated tool IS callable from
|
||||||
|
* the web client against a strict modern server, same as from the CLI/TUI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface RemoteFetchOptions {
|
export interface RemoteFetchOptions {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import type {
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
|
import type { JSONRPCMessage } from "@modelcontextprotocol/client";
|
||||||
import { AuthChallengeError } from "../../../auth/challenge.js";
|
import { AuthChallengeError } from "../../../auth/challenge.js";
|
||||||
|
import { MCP_PARAM_HEADER_PREFIX } from "../../../json/xMcpHeader.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_MAX_FETCH_REQUESTS,
|
DEFAULT_MAX_FETCH_REQUESTS,
|
||||||
DEFAULT_TASK_TTL_MS,
|
DEFAULT_TASK_TTL_MS,
|
||||||
|
|
@ -362,6 +363,28 @@ export function requestIdForSendWait(
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restrict client-supplied per-send headers to SEP-2243 `Mcp-Param-*` mirroring
|
||||||
|
* with string values. The sanctioned channel for arbitrary upstream headers is
|
||||||
|
* the server's configured `settings.headers`; this per-send channel must not let
|
||||||
|
* a client inject other headers (e.g. `Authorization`) onto the upstream fetch.
|
||||||
|
*/
|
||||||
|
export function mcpParamHeadersOnly(
|
||||||
|
headers: unknown,
|
||||||
|
): Record<string, string> | undefined {
|
||||||
|
if (headers === null || typeof headers !== "object") return undefined;
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (
|
||||||
|
typeof value === "string" &&
|
||||||
|
key.toLowerCase().startsWith(MCP_PARAM_HEADER_PREFIX.toLowerCase())
|
||||||
|
) {
|
||||||
|
out[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function createRemoteApp(
|
export function createRemoteApp(
|
||||||
options: RemoteServerOptions,
|
options: RemoteServerOptions,
|
||||||
): CreateRemoteAppResult {
|
): CreateRemoteAppResult {
|
||||||
|
|
@ -705,7 +728,7 @@ export function createRemoteApp(
|
||||||
return c.json({ error: "Invalid JSON body" }, 400);
|
return c.json({ error: "Invalid JSON body" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionId, message, relatedRequestId } = body;
|
const { sessionId, message, relatedRequestId, headers } = body;
|
||||||
if (!sessionId || !message) {
|
if (!sessionId || !message) {
|
||||||
return c.json({ error: "Missing sessionId or message" }, 400);
|
return c.json({ error: "Missing sessionId or message" }, 400);
|
||||||
}
|
}
|
||||||
|
|
@ -730,8 +753,14 @@ export function createRemoteApp(
|
||||||
void responseWait.catch(() => {});
|
void responseWait.catch(() => {});
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
// SEP-2243: apply the client's mirrored headers to the upstream request,
|
||||||
|
// restricted to the `Mcp-Param-` prefix so a client can't inject other
|
||||||
|
// upstream headers. The StreamableHTTP transport merges these onto the
|
||||||
|
// outbound fetch; other transports ignore unknown send options.
|
||||||
|
const paramHeaders = mcpParamHeadersOnly(headers);
|
||||||
await session.transport.send(message, {
|
await session.transport.send(message, {
|
||||||
relatedRequestId: relatedRequestId as string | number | undefined,
|
relatedRequestId: relatedRequestId as string | number | undefined,
|
||||||
|
...(paramHeaders && { headers: paramHeaders }),
|
||||||
});
|
});
|
||||||
if (responseWait) {
|
if (responseWait) {
|
||||||
await responseWait;
|
await responseWait;
|
||||||
|
|
|
||||||
|
|
@ -704,6 +704,9 @@ export class RemoteClientTransport implements Transport {
|
||||||
...(options?.relatedRequestId != null && {
|
...(options?.relatedRequestId != null && {
|
||||||
relatedRequestId: options.relatedRequestId,
|
relatedRequestId: options.relatedRequestId,
|
||||||
}),
|
}),
|
||||||
|
// Forward per-send `Mcp-Param-*` headers (SEP-2243) for the backend to
|
||||||
|
// apply to the upstream send; the browser can't set them cross-origin.
|
||||||
|
...(options?.headers != null && { headers: options.headers }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {
|
const res = await this.fetchFn(`${this.baseUrl}/api/mcp/send`, {
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,11 @@ export interface RemoteSendRequest {
|
||||||
message: JSONRPCMessage;
|
message: JSONRPCMessage;
|
||||||
/** Optional, for associating response with request (e.g. streamable-http) */
|
/** Optional, for associating response with request (e.g. streamable-http) */
|
||||||
relatedRequestId?: string | number;
|
relatedRequestId?: string | number;
|
||||||
|
/**
|
||||||
|
* Per-send `Mcp-Param-*` headers (SEP-2243 mirroring). The backend applies
|
||||||
|
* them to the upstream `transport.send`, filtered to the `Mcp-Param-` prefix.
|
||||||
|
*/
|
||||||
|
headers?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RemoteEventType =
|
export type RemoteEventType =
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue