diff --git a/.changeset/tracing-header-verdicts.md b/.changeset/tracing-header-verdicts.md new file mode 100644 index 0000000000..74116a436c --- /dev/null +++ b/.changeset/tracing-header-verdicts.md @@ -0,0 +1,36 @@ +--- +"@mcpjam/sdk": minor +--- + +Add `evaluateMcpHeaders` (exported from `@mcpjam/sdk/browser`) — per-header +verdicts for the SEP-2243 mirrored `Mcp-*` headers, alongside the existing +defect-list form `findMcpHeaderIssues`. + +A defect list answers "what is broken"; a debugger also has to answer "is this +right", which needs a row per header carrying the body field it was checked +against. Two cases only a verdict list can express: a conforming header (no +defect, but nothing said either) and an ABSENT one — `Mcp-Name` is required +only for `tools/call`, `resources/read`, `prompts/get` and the SEP-2663 routed +task methods, so a blank cannot distinguish a `-32020` from correct behavior. + +Era-gated identically: before `2026-07-28` nothing is mirrored, so every header +comes back `unchecked` rather than judged by rules its version never had. A +`Mcp-Param-*` value cannot be cross-checked either — the captured body values +carry no arguments — though a malformed base64 sentinel in one is still +reported, since servers MUST reject a recognized `Mcp-Param-{Name}` carrying +invalid characters. `Mcp-Session-Id` / `Last-Event-ID` have no encoded form in +any version and are never judged. + +Two behavior changes to shipped code: + +- `Mcp-Name` is now required for `tasks/get`, `tasks/update` and `tasks/cancel` + (SEP-2663 "Streamable HTTP: Routing Headers" makes it a MUST, and + `wrapFetchForTaskRouting` already sends it) — so `findMcpHeaderIssues` reports + a `missing` defect for a routed task request that omits it, where it was + previously silent. `TASK_ROUTED_METHODS` now lives with the header logic and + is read by both the send and judge halves so they cannot drift. +- `deriveMirroredBodyValues` reads `params.taskId` as the `Mcp-Name` source for + those three methods, and for no others. + +`findMcpHeaderIssues` otherwise derives from the same evaluation; its output +shape and version scoping are unchanged. diff --git a/mcpjam-inspector/client/src/components/tracing/HttpExchangeDetails.tsx b/mcpjam-inspector/client/src/components/tracing/HttpExchangeDetails.tsx index 184d0843f4..7d08da9bd5 100644 --- a/mcpjam-inspector/client/src/components/tracing/HttpExchangeDetails.tsx +++ b/mcpjam-inspector/client/src/components/tracing/HttpExchangeDetails.tsx @@ -6,31 +6,29 @@ * screen needs is the opposite — the mirrored `Mcp-*` headers pulled to the * top, sentinel values decoded, and the header/body cross-check run — so it * gets its own presentation rather than growing options on a shared card. + * + * Each mirrored header is one line: name, value, verdict. The third slot + * carries the only thing `2026-07-28` added that a reader cannot see for + * themselves — whether the header still agrees with the body it was copied + * from — rather than restating the family the header name already implies. */ import { useMemo } from "react"; -import { AlertTriangle } from "lucide-react"; import { ScrollableJsonView } from "@/components/ui/json-editor"; import { cn } from "@/lib/utils"; import { classifyMcpHeader, - decodeMcpHeaderValue, - findMcpHeaderIssues, + evaluateMcpHeaders, type HttpExchangeLogEvent, + type McpHeaderAssessment, type McpHeaderFamily, - type McpHeaderIssue, } from "@mcpjam/sdk/browser"; -/** Display order: the routing/cross-check headers first, params after. */ -const FAMILY_ORDER: McpHeaderFamily[] = [ - "protocol-version", - "method", - "name", - "param", - "session", - "resumption", -]; - +/** + * Fallback slot text for a header no cross-check applies to: a legacy request + * mirrors nothing, and `Mcp-Param-*` body values are not captured. Naming the + * family is all that can honestly be said there. + */ const FAMILY_LABEL: Record = { "protocol-version": "protocol version", method: "routing", @@ -40,48 +38,40 @@ const FAMILY_LABEL: Record = { resumption: "resumption", }; -type ProtocolHeaderRow = { - name: string; - family: McpHeaderFamily; - raw: string; - decoded?: string; - decodeError?: string; -}; - -function collectProtocolHeaders( - headers: Record, -): ProtocolHeaderRow[] { - const rows: ProtocolHeaderRow[] = []; - for (const [name, raw] of Object.entries(headers)) { - const family = classifyMcpHeader(name); - if (!family) continue; - const decoded = decodeMcpHeaderValue(raw); - rows.push({ - name, - family, - raw, - // Show the decoded value only when it adds something — an unencoded - // value shown twice reads as if the two could differ. - decoded: decoded.encoded ? decoded.value : undefined, - decodeError: decoded.decodeError, - }); +/** The verdict slot: what the cross-check concluded, in the row's own words. */ +function verdictText(row: McpHeaderAssessment): string { + switch (row.status) { + case "match": + return "✓ matches body"; + case "mismatch": + return `✕ body says ${row.bodyValue} → -32020`; + case "missing": + return `✕ not sent, body says ${row.bodyValue} → -32020`; + case "undecodable": + return "✕ does not decode → -32020"; + case "not-required": + return "not required here"; + case "unchecked": + return FAMILY_LABEL[row.family]; } - return rows.sort( - (a, b) => - FAMILY_ORDER.indexOf(a.family) - FAMILY_ORDER.indexOf(b.family) || - a.name.localeCompare(b.name), +} + +function isFailure(status: McpHeaderAssessment["status"]): boolean { + return ( + status === "mismatch" || status === "missing" || status === "undecodable" ); } -function describeIssue(issue: McpHeaderIssue): string { - switch (issue.kind) { - case "missing": - return `${issue.header} is required for this request and was not sent (body has "${issue.bodyValue}")`; - case "mismatch": - return `${issue.header} is "${issue.headerValue}" but the body says "${issue.bodyValue}"`; - case "undecodable": - return `${issue.header} carries the base64 sentinel but did not decode: "${issue.headerValue}"`; - } +/** + * The mirrored headers are rendered above with their verdicts, so leaving them + * in the raw map prints them twice on one screen. + */ +function withoutMirroredHeaders( + headers: Record, +): Record { + return Object.fromEntries( + Object.entries(headers).filter(([name]) => !classifyMcpHeader(name)), + ); } function SectionLabel({ children }: { children: React.ReactNode }) { @@ -97,14 +87,14 @@ export function HttpExchangeDetails({ }: { exchange: HttpExchangeLogEvent; }) { - const requestProtocolHeaders = useMemo( - () => collectProtocolHeaders(exchange.request.headers), - [exchange.request.headers], - ); - const issues = useMemo( - () => findMcpHeaderIssues(exchange.request.headers, exchange.bodyValues), + const mcpHeaders = useMemo( + () => evaluateMcpHeaders(exchange.request.headers, exchange.bodyValues), [exchange.request.headers, exchange.bodyValues], ); + const otherRequestHeaders = useMemo( + () => withoutMirroredHeaders(exchange.request.headers), + [exchange.request.headers], + ); const status = exchange.response?.status; const statusColor = @@ -139,51 +129,42 @@ export function HttpExchangeDetails({ - {issues.length > 0 && ( -
-
- - Header/body disagreement — this is what a -32020 HeaderMismatch - reports -
-
    - {issues.map((issue) => ( -
  • - {describeIssue(issue)} -
  • - ))} -
-
- )} - - {requestProtocolHeaders.length > 0 && ( + {mcpHeaders.length > 0 && (
MCP headers
- {requestProtocolHeaders.map((row) => ( + {mcpHeaders.map((row) => (
{row.name} - - {row.raw} + + {row.raw ?? "—"} {row.decoded !== undefined && ( → {row.decoded} )} - {row.decodeError && ( - - → does not decode - - )} - - {FAMILY_LABEL[row.family]} + + {verdictText(row)}
))} @@ -192,9 +173,9 @@ export function HttpExchangeDetails({ )}
- Request headers + Other request headers
diff --git a/sdk/src/browser.ts b/sdk/src/browser.ts index 45392b31ed..0d68bd9df8 100644 --- a/sdk/src/browser.ts +++ b/sdk/src/browser.ts @@ -381,12 +381,15 @@ export { MCP_PARAM_HEADER_PREFIX, classifyMcpHeader, decodeMcpHeaderValue, + evaluateMcpHeaders, findMcpHeaderIssues, } from "./mcp-client-manager/mcp-header-mirror.js"; export type { DecodedMcpHeaderValue, + McpHeaderAssessment, McpHeaderFamily, McpHeaderIssue, + McpHeaderStatus, MirroredBodyValues, } from "./mcp-client-manager/mcp-header-mirror.js"; export type { HttpExchangeLogEvent } from "./mcp-client-manager/http-exchange-log.js"; diff --git a/sdk/src/mcp-client-manager/http-exchange-log.ts b/sdk/src/mcp-client-manager/http-exchange-log.ts index 6bc0d9f205..e5ac7b9f1c 100644 --- a/sdk/src/mcp-client-manager/http-exchange-log.ts +++ b/sdk/src/mcp-client-manager/http-exchange-log.ts @@ -19,7 +19,10 @@ * so the cross-check can run later without retaining the body. */ -import type { MirroredBodyValues } from "./mcp-header-mirror.js"; +import { + TASK_ROUTED_METHODS, + type MirroredBodyValues, +} from "./mcp-header-mirror.js"; /** `_meta` key carrying the per-request protocol version in the modern era. */ const PROTOCOL_VERSION_META_KEY = "io.modelcontextprotocol/protocolVersion"; @@ -88,10 +91,19 @@ export function deriveMirroredBodyValues( params?: { name?: unknown; uri?: unknown; + taskId?: unknown; _meta?: Record; }; }; - const name = params?.name ?? params?.uri; + // `params.taskId` is the `Mcp-Name` source for the SEP-2663 routed task + // methods and for nothing else, so it is read only for those — otherwise a + // method that merely carries a taskId would be cross-checked against the + // wrong field. + const routedTaskId = + typeof method === "string" && TASK_ROUTED_METHODS.has(method) + ? params?.taskId + : undefined; + const name = params?.name ?? params?.uri ?? routedTaskId; const protocolVersion = params?._meta?.[PROTOCOL_VERSION_META_KEY]; const derived: MirroredBodyValues = { method: typeof method === "string" ? method : undefined, diff --git a/sdk/src/mcp-client-manager/mcp-header-mirror.ts b/sdk/src/mcp-client-manager/mcp-header-mirror.ts index 632b8d77e9..e67fab679c 100644 --- a/sdk/src/mcp-client-manager/mcp-header-mirror.ts +++ b/sdk/src/mcp-client-manager/mcp-header-mirror.ts @@ -127,84 +127,276 @@ export type MirroredBodyValues = { protocolVersion?: string; }; -/** Methods for which `Mcp-Name` is REQUIRED. */ +/** Methods for which `Mcp-Name` is REQUIRED (SEP-2243 Standard Request Headers). */ const NAME_REQUIRED_METHODS = new Set([ "tools/call", "resources/read", "prompts/get", ]); +/** + * The `io.modelcontextprotocol/tasks` methods that MUST carry + * `Mcp-Name: ` (SEP-2663, "Streamable HTTP: Routing Headers"). + * + * A second required-name set rather than an addition to the one above: the + * source field differs (`params.taskId`, not `params.name`), and the + * requirement comes from the extension, which is versioned independently of + * core. `transport-utils` sends these headers; this module judges them, and + * both read the set from here so the two halves cannot drift. + */ +export const TASK_ROUTED_METHODS = new Set([ + "tasks/get", + "tasks/update", + "tasks/cancel", +]); + +/** Whether `Mcp-Name` is required for `method`, across core and the tasks extension. */ +export function isNameRequiredMethod(method: string | undefined): boolean { + if (method === undefined) return false; + return NAME_REQUIRED_METHODS.has(method) || TASK_ROUTED_METHODS.has(method); +} + export type McpHeaderIssue = | { kind: "mismatch"; header: string; headerValue: string; bodyValue: string } | { kind: "missing"; header: string; bodyValue: string } | { kind: "undecodable"; header: string; headerValue: string }; +/** Report/display order: the cross-checked headers, then params, then legacy state. */ +const FAMILY_ORDER: McpHeaderFamily[] = [ + "protocol-version", + "method", + "name", + "param", + "session", + "resumption", +]; + +/** The families carrying a REQUIRED modern cross-check (SEP-2243 standard headers). */ +const CROSS_CHECKED_FAMILIES = new Set([ + "protocol-version", + "method", + "name", +]); + /** - * Runs the server-side validation a `-32020 HeaderMismatch` reports, locally, - * against the captured headers. Covers all three failure conditions the spec - * lists: a required standard header missing, a value disagreeing with the - * body, and a value that will not decode. + * The outcome of the header/body cross-check for one header. * - * Returns an empty list for any non-modern request: `Mcp-Method`/`Mcp-Name` - * are not required before `2026-07-28`, so asserting them on a `2025-11-25` - * connection would invent failures. + * `unchecked` is the honest answer for anything outside the modern standard + * three: a legacy request mirrors nothing, and `Mcp-Param-*` values are not + * captured, so no verdict can be claimed for them. */ -export function findMcpHeaderIssues( +export type McpHeaderStatus = + | "match" + | "mismatch" + | "missing" + | "not-required" + | "undecodable" + | "unchecked"; + +export type McpHeaderAssessment = { + /** Wire casing when the header was sent; canonical lowercase when it wasn't. */ + name: string; + family: McpHeaderFamily; + status: McpHeaderStatus; + /** The value as it appeared on the wire. Absent when the header was not sent. */ + raw?: string; + /** Set only when `raw` carried a sentinel that decoded — never on `undecodable`. */ + decoded?: string; + /** The body field this header mirrors, when a cross-check ran. */ + bodyField?: string; + /** The body's value, on `mismatch` / `missing`. */ + bodyValue?: string; +}; + +/** Which body field a given method's `Mcp-Name` is mirrored FROM. */ +function nameSourceField(method: string | undefined): string { + if (method !== undefined && TASK_ROUTED_METHODS.has(method)) { + return ".params.taskId"; + } + return method === "resources/read" ? ".params.uri" : ".params.name"; +} + +/** + * Per-header verdicts for the mirrored `Mcp-*` headers — the display form of + * the same validation `findMcpHeaderIssues` reports as a defect list. + * + * A verdict list rather than a defect list is what a debugger needs: a header + * shown without the body value it is supposed to equal cannot be judged, and an + * ABSENT header is ambiguous until it says whether the spec required it here + * (`Mcp-Name` is required only for `tools/call`, `resources/read`, + * `prompts/get`). Both cases therefore get an explicit row. + * + * Era-gated exactly like `findMcpHeaderIssues`: before `2026-07-28` nothing is + * mirrored, so every present header comes back `unchecked` rather than judged + * against rules its version never had. + */ +export function evaluateMcpHeaders( headers: Record, body: MirroredBodyValues | undefined -): McpHeaderIssue[] { +): McpHeaderAssessment[] { const lookup = new Map(); for (const [name, value] of Object.entries(headers)) { lookup.set(name.toLowerCase(), { name, value }); } - const versionHeader = lookup.get("mcp-protocol-version")?.value; - const version = versionHeader ?? body?.protocolVersion; + const version = lookup.get("mcp-protocol-version")?.value ?? body?.protocolVersion; const isModern = !!version && isKnownProtocolVersion(version) && isStatelessProtocolVersion(version); - if (!isModern || !body) return []; + const crossCheck = isModern && !!body; - const issues: McpHeaderIssue[] = []; + const out: McpHeaderAssessment[] = []; + const claimed = new Set(); + + const standard: Array<{ + header: string; + family: McpHeaderFamily; + bodyValue: string | undefined; + bodyField: string; + required: boolean; + }> = + crossCheck && body + ? [ + { + header: "mcp-protocol-version", + family: "protocol-version", + bodyValue: body.protocolVersion, + bodyField: "._meta protocolVersion", + required: true, + }, + { + header: "mcp-method", + family: "method", + bodyValue: body.method, + bodyField: ".method", + required: true, + }, + { + header: "mcp-name", + family: "name", + bodyValue: body.name, + bodyField: nameSourceField(body.method), + required: isNameRequiredMethod(body.method), + }, + ] + : []; + + for (const spec of standard) { + claimed.add(spec.header); + const found = lookup.get(spec.header); - const check = ( - headerName: string, - bodyValue: string | undefined, - required: boolean - ) => { - const found = lookup.get(headerName); - if (bodyValue === undefined) return; if (!found) { - if (required) { - issues.push({ kind: "missing", header: headerName, bodyValue }); + if (spec.required && spec.bodyValue !== undefined) { + out.push({ + name: spec.header, + family: spec.family, + status: "missing", + bodyValue: spec.bodyValue, + bodyField: spec.bodyField, + }); + } else if (!spec.required && body?.method !== undefined) { + // Absent AND not required. Said out loud, because a blank row reads + // identically to the `missing` case above. + out.push({ name: spec.header, family: spec.family, status: "not-required" }); } - return; + continue; } + const decoded = decodeMcpHeaderValue(found.value); + const base = { + name: found.name, + family: spec.family, + raw: found.value, + decoded: decoded.encoded && !decoded.decodeError ? decoded.value : undefined, + }; + if (decoded.decodeError) { - issues.push({ - kind: "undecodable", - header: found.name, - headerValue: found.value, - }); - return; - } - if (decoded.value !== bodyValue) { - issues.push({ - kind: "mismatch", - header: found.name, - headerValue: decoded.value, - bodyValue, + out.push({ ...base, status: "undecodable" }); + } else if (spec.bodyValue === undefined) { + out.push({ ...base, status: "unchecked" }); + } else if (decoded.value === spec.bodyValue) { + out.push({ ...base, status: "match", bodyField: spec.bodyField }); + } else { + out.push({ + ...base, + status: "mismatch", + bodyValue: spec.bodyValue, + bodyField: spec.bodyField, }); } - }; + } + + for (const [lower, found] of lookup) { + if (claimed.has(lower)) continue; + const family = classifyMcpHeader(found.name); + if (!family) continue; + const decoded = decodeMcpHeaderValue(found.value); + // A sentinel that will not decode is a defect only where the spec defines + // the sentinel: `Mcp-Name` (handled above) and `Mcp-Param-{Name}`, whose + // invalid characters servers MUST reject. `Mcp-Session-Id`/`Last-Event-ID` + // have no encoded form at all, and a legacy value merely RESEMBLING the + // sentinel is just a value — claiming -32020 for either would be invented. + const undecodable = crossCheck && family === "param" && !!decoded.decodeError; + out.push({ + name: found.name, + family, + raw: found.value, + decoded: decoded.encoded && !decoded.decodeError ? decoded.value : undefined, + status: undecodable ? "undecodable" : "unchecked", + }); + } - check("mcp-protocol-version", body.protocolVersion, true); - check("mcp-method", body.method, true); - check( - "mcp-name", - body.name, - body.method !== undefined && NAME_REQUIRED_METHODS.has(body.method) + return out.sort( + (a, b) => + FAMILY_ORDER.indexOf(a.family) - FAMILY_ORDER.indexOf(b.family) || + a.name.localeCompare(b.name) ); +} +/** + * Runs the server-side validation a `-32020 HeaderMismatch` reports, locally, + * against the captured headers. Covers all three failure conditions the spec + * lists: a required standard header missing, a value disagreeing with the + * body, and a value that will not decode. + * + * Returns an empty list for any non-modern request: `Mcp-Method`/`Mcp-Name` + * are not required before `2026-07-28`, so asserting them on a `2025-11-25` + * connection would invent failures. + */ +export function findMcpHeaderIssues( + headers: Record, + body: MirroredBodyValues | undefined +): McpHeaderIssue[] { + const issues: McpHeaderIssue[] = []; + for (const row of evaluateMcpHeaders(headers, body)) { + // Only the standard three carry a required cross-check; a `Mcp-Param-*` + // verdict would need the tool's `inputSchema` annotations to be sound. + if (!CROSS_CHECKED_FAMILIES.has(row.family)) continue; + switch (row.status) { + case "mismatch": + issues.push({ + kind: "mismatch", + header: row.name, + headerValue: row.decoded ?? row.raw ?? "", + bodyValue: row.bodyValue ?? "", + }); + break; + case "missing": + issues.push({ + kind: "missing", + header: row.name, + bodyValue: row.bodyValue ?? "", + }); + break; + case "undecodable": + issues.push({ + kind: "undecodable", + header: row.name, + headerValue: row.raw ?? "", + }); + break; + default: + break; + } + } return issues; } diff --git a/sdk/src/mcp-client-manager/transport-utils.ts b/sdk/src/mcp-client-manager/transport-utils.ts index 21485772a1..500e874e21 100644 --- a/sdk/src/mcp-client-manager/transport-utils.ts +++ b/sdk/src/mcp-client-manager/transport-utils.ts @@ -11,6 +11,9 @@ import type { } from "@modelcontextprotocol/client"; import type { RpcLogger } from "./types.js"; import { isTasksExtensionEra } from "./tasks-dispatch.js"; +// The send side and the Tracing verdict read ONE set, so a method added to the +// SEP-2663 routing requirement cannot be sent-but-unjudged (or vice versa). +import { TASK_ROUTED_METHODS } from "./mcp-header-mirror.js"; /** * Normalizes headers from various formats (Headers, string[][], or plain object) @@ -210,16 +213,6 @@ export function createDefaultRpcLogger(): RpcLogger { // Tasks extension: `Mcp-Name` routing header // ============================================================================ -/** - * The `io.modelcontextprotocol/tasks` methods that MUST carry - * `Mcp-Name: ` (SEP-2663, HTTP binding). - */ -const TASK_ROUTED_METHODS = new Set([ - "tasks/get", - "tasks/update", - "tasks/cancel", -]); - function taskRoutingHeadersFor( body: unknown ): { name: string; method: string } | undefined { diff --git a/sdk/tests/http-exchange-log.test.ts b/sdk/tests/http-exchange-log.test.ts index c5e1461fde..32149c9820 100644 --- a/sdk/tests/http-exchange-log.test.ts +++ b/sdk/tests/http-exchange-log.test.ts @@ -171,6 +171,44 @@ describe("deriveMirroredBodyValues", () => { }); }); + it("reads params.taskId as the name source for the SEP-2663 routed methods", () => { + // Without this the tracing verdict has nothing to compare `Mcp-Name` + // against on a task poll, and reports the required header as optional. + expect( + deriveMirroredBodyValues( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tasks/get", + params: { taskId: "task-42" }, + }), + ), + ).toEqual({ + method: "tasks/get", + name: "task-42", + protocolVersion: undefined, + }); + }); + + it("ignores a taskId on a method the routing requirement does not cover", () => { + // Only get/update/cancel mirror the task id; reading it elsewhere would + // cross-check `Mcp-Name` against a field it was never copied from. + expect( + deriveMirroredBodyValues( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tasks/list", + params: { taskId: "task-42" }, + }), + ), + ).toEqual({ + method: "tasks/list", + name: undefined, + protocolVersion: undefined, + }); + }); + it("returns nothing for a batch, a non-JSON body, or a non-string body", () => { expect(deriveMirroredBodyValues(JSON.stringify([{ method: "a" }]))).toBeUndefined(); expect(deriveMirroredBodyValues("not json")).toBeUndefined(); diff --git a/sdk/tests/mcp-header-mirror.test.ts b/sdk/tests/mcp-header-mirror.test.ts index bc57f63234..b0b770cf78 100644 --- a/sdk/tests/mcp-header-mirror.test.ts +++ b/sdk/tests/mcp-header-mirror.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { classifyMcpHeader, decodeMcpHeaderValue, + evaluateMcpHeaders, findMcpHeaderIssues, } from "../src/mcp-client-manager/mcp-header-mirror.js"; @@ -225,3 +226,337 @@ describe("findMcpHeaderIssues", () => { }); }); }); + +describe("evaluateMcpHeaders", () => { + const modernBody = { + method: "tools/call", + name: "execute_sql", + protocolVersion: MODERN, + }; + + function statuses(rows: ReturnType) { + return rows.map((row) => [row.name, row.status]); + } + + it("says which body field each conforming header matched", () => { + const rows = evaluateMcpHeaders( + { + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "tools/call", + "Mcp-Name": "execute_sql", + }, + modernBody, + ); + expect(rows).toEqual([ + { + name: "MCP-Protocol-Version", + family: "protocol-version", + status: "match", + raw: MODERN, + decoded: undefined, + bodyField: "._meta protocolVersion", + }, + { + name: "Mcp-Method", + family: "method", + status: "match", + raw: "tools/call", + decoded: undefined, + bodyField: ".method", + }, + { + name: "Mcp-Name", + family: "name", + status: "match", + raw: "execute_sql", + decoded: undefined, + bodyField: ".params.name", + }, + ]); + }); + + it("reports the body value on the row that disagrees", () => { + const rows = evaluateMcpHeaders( + { + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "tools/call", + "Mcp-Name": "bar", + }, + modernBody, + ); + expect(rows[2]).toMatchObject({ + name: "Mcp-Name", + status: "mismatch", + raw: "bar", + bodyValue: "execute_sql", + }); + }); + + it("distinguishes an absent REQUIRED header from an absent optional one", () => { + // The whole point of the third slot: both are blank on the wire, and only + // one of them is a -32020. + expect( + statuses( + evaluateMcpHeaders( + { "MCP-Protocol-Version": MODERN, "Mcp-Method": "tools/call" }, + modernBody, + ), + ), + ).toEqual([ + ["MCP-Protocol-Version", "match"], + ["Mcp-Method", "match"], + ["mcp-name", "missing"], + ]); + + expect( + statuses( + evaluateMcpHeaders( + { "MCP-Protocol-Version": MODERN, "Mcp-Method": "server/discover" }, + { method: "server/discover", protocolVersion: MODERN }, + ), + ), + ).toEqual([ + ["MCP-Protocol-Version", "match"], + ["Mcp-Method", "match"], + ["mcp-name", "not-required"], + ]); + }); + + it("names params.uri as the source for resources/read", () => { + const rows = evaluateMcpHeaders( + { + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "resources/read", + "Mcp-Name": "file:///a.json", + }, + { + method: "resources/read", + name: "file:///a.json", + protocolVersion: MODERN, + }, + ); + expect(rows[2]).toMatchObject({ status: "match", bodyField: ".params.uri" }); + }); + + it("surfaces the decoded value alongside the raw sentinel", () => { + const rows = evaluateMcpHeaders( + { + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "tools/call", + "Mcp-Name": "=?base64?SGVsbG8sIOS4lueVjA==?=", + }, + { ...modernBody, name: "Hello, 世界" }, + ); + expect(rows[2]).toMatchObject({ + status: "match", + raw: "=?base64?SGVsbG8sIOS4lueVjA==?=", + decoded: "Hello, 世界", + }); + }); + + it("leaves `decoded` unset when the sentinel does not decode", () => { + // Otherwise the row would print the raw value twice, as if it were a + // successful decode to itself. + const rows = evaluateMcpHeaders( + { + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "tools/call", + "Mcp-Name": "=?base64?not valid!?=", + }, + modernBody, + ); + expect(rows[2]).toMatchObject({ status: "undecodable", decoded: undefined }); + }); + + it("shows Mcp-Param-* without claiming a verdict it cannot reach", () => { + // The captured body values carry no arguments, so there is nothing to + // compare against; a green check here would be a lie. + const rows = evaluateMcpHeaders( + { + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "tools/call", + "Mcp-Name": "execute_sql", + "Mcp-Param-Region": "us-west1", + }, + modernBody, + ); + expect(rows[3]).toMatchObject({ + name: "Mcp-Param-Region", + family: "param", + status: "unchecked", + }); + // ...and an unverifiable param must not leak into the -32020 defect list. + expect( + findMcpHeaderIssues( + { + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "tools/call", + "Mcp-Name": "execute_sql", + "Mcp-Param-Region": "=?base64?not valid!?=", + }, + modernBody, + ), + ).toEqual([]); + }); + + describe("version scope", () => { + it("judges nothing on a legacy request — the headers are not mirrored there", () => { + expect( + statuses( + evaluateMcpHeaders( + { + "MCP-Protocol-Version": "2025-11-25", + "MCP-Session-Id": "01H8XQ", + "Last-Event-ID": "42", + }, + { method: "tools/call", name: "execute_sql" }, + ), + ), + ).toEqual([ + ["MCP-Protocol-Version", "unchecked"], + ["MCP-Session-Id", "unchecked"], + ["Last-Event-ID", "unchecked"], + ]); + }); + + it("does not invent a missing-header row on a legacy request", () => { + // `Mcp-Method`/`Mcp-Name` do not exist before 2026-07-28. + expect( + evaluateMcpHeaders( + { "MCP-Protocol-Version": "2025-06-18" }, + { method: "tools/call", name: "execute_sql" }, + ), + ).toEqual([ + { + name: "MCP-Protocol-Version", + family: "protocol-version", + status: "unchecked", + raw: "2025-06-18", + decoded: undefined, + }, + ]); + }); + + it("does not flag an undecodable sentinel on a legacy request", () => { + // A legacy value that merely resembles the sentinel is just a value. + const rows = evaluateMcpHeaders( + { + "MCP-Protocol-Version": "2025-11-25", + "Mcp-Name": "=?base64?not valid!?=", + }, + { method: "tools/call" }, + ); + expect(rows.map((row) => row.status)).toEqual(["unchecked", "unchecked"]); + }); + + it("uses the body version when the header was dropped", () => { + expect( + statuses( + evaluateMcpHeaders( + { "Mcp-Method": "tools/list" }, + { method: "tools/list", protocolVersion: MODERN }, + ), + ), + ).toEqual([ + ["mcp-protocol-version", "missing"], + ["Mcp-Method", "match"], + ["mcp-name", "not-required"], + ]); + }); + + it("cross-checks nothing when no body was captured", () => { + expect( + statuses( + evaluateMcpHeaders( + { "MCP-Protocol-Version": MODERN, "Mcp-Method": "tools/list" }, + undefined, + ), + ), + ).toEqual([ + ["MCP-Protocol-Version", "unchecked"], + ["Mcp-Method", "unchecked"], + ]); + }); + }); +}); + +describe("SEP-2663 task routing headers", () => { + // The extension makes `Mcp-Name: ` a MUST for these three methods + // (SEP-2663, "Streamable HTTP: Routing Headers"). `transport-utils` already + // SENDS it; a verdict that called it optional would hide the exact routing + // defect a routed deployment fails on. + for (const method of ["tasks/get", "tasks/update", "tasks/cancel"]) { + it(`requires Mcp-Name for ${method} and names taskId as its source`, () => { + const rows = evaluateMcpHeaders( + { "MCP-Protocol-Version": MODERN, "Mcp-Method": method }, + // `name` is what `deriveMirroredBodyValues` reads out of + // `params.taskId` for these methods — the header is what's absent. + { method, name: "task-42", protocolVersion: MODERN }, + ); + expect(rows[2]).toMatchObject({ + name: "mcp-name", + status: "missing", + bodyField: ".params.taskId", + }); + }); + } + + it("matches Mcp-Name against the task id when it was sent", () => { + const rows = evaluateMcpHeaders( + { + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "tasks/get", + "Mcp-Name": "task-42", + }, + { method: "tasks/get", name: "task-42", protocolVersion: MODERN }, + ); + expect(rows[2]).toMatchObject({ status: "match", bodyField: ".params.taskId" }); + }); + + it("leaves a non-routed tasks method optional", () => { + // Only get/update/cancel are listed; `tasks/list` carries no routing key. + const rows = evaluateMcpHeaders( + { "MCP-Protocol-Version": MODERN, "Mcp-Method": "tasks/list" }, + { method: "tasks/list", protocolVersion: MODERN }, + ); + expect(rows[2]).toMatchObject({ name: "mcp-name", status: "not-required" }); + }); +}); + +describe("undecodable is claimed only where the sentinel is defined", () => { + it("flags a malformed sentinel in Mcp-Param-*", () => { + // Servers MUST reject a recognized `Mcp-Param-{Name}` carrying invalid + // characters, so this one IS a defect even though the value cannot be + // cross-checked against the body. + const rows = evaluateMcpHeaders( + { + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "tools/call", + "Mcp-Name": "execute_sql", + "Mcp-Param-Region": "=?base64?not valid!?=", + }, + { method: "tools/call", name: "execute_sql", protocolVersion: MODERN }, + ); + expect(rows[3]).toMatchObject({ + name: "Mcp-Param-Region", + status: "undecodable", + }); + }); + + it("does NOT flag a sentinel-looking session or resumption value", () => { + // Neither header has an encoded form in any version, so a value that + // resembles the sentinel is just a value — not a -32020. + const rows = evaluateMcpHeaders( + { + "MCP-Protocol-Version": MODERN, + "Mcp-Method": "tools/list", + "MCP-Session-Id": "=?base64?not valid!?=", + "Last-Event-ID": "=?base64?not valid!?=", + }, + { method: "tools/list", protocolVersion: MODERN }, + ); + const byName = Object.fromEntries(rows.map((row) => [row.name, row.status])); + expect(byName["MCP-Session-Id"]).toBe("unchecked"); + expect(byName["Last-Event-ID"]).toBe("unchecked"); + }); +});