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
36 changes: 36 additions & 0 deletions .changeset/tracing-header-verdicts.md
Original file line number Diff line number Diff line change
@@ -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.
163 changes: 72 additions & 91 deletions mcpjam-inspector/client/src/components/tracing/HttpExchangeDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpHeaderFamily, string> = {
"protocol-version": "protocol version",
method: "routing",
Expand All @@ -40,48 +38,40 @@ const FAMILY_LABEL: Record<McpHeaderFamily, string> = {
resumption: "resumption",
};

type ProtocolHeaderRow = {
name: string;
family: McpHeaderFamily;
raw: string;
decoded?: string;
decodeError?: string;
};

function collectProtocolHeaders(
headers: Record<string, string>,
): 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<string, string>,
): Record<string, string> {
return Object.fromEntries(
Object.entries(headers).filter(([name]) => !classifyMcpHeader(name)),
);
}

function SectionLabel({ children }: { children: React.ReactNode }) {
Expand All @@ -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 =
Expand Down Expand Up @@ -139,51 +129,42 @@ export function HttpExchangeDetails({
</span>
</div>

{issues.length > 0 && (
<div className="rounded-sm border border-red-400 bg-red-50/50 p-2 dark:border-red-500 dark:bg-red-950/20">
<div className="mb-1 flex items-center gap-1 text-xs font-medium text-red-600 dark:text-red-400">
<AlertTriangle className="h-3.5 w-3.5" />
Header/body disagreement — this is what a -32020 HeaderMismatch
reports
</div>
<ul className="space-y-0.5">
{issues.map((issue) => (
<li
key={`${issue.kind}:${issue.header}`}
className="font-mono text-[11px] text-red-600 dark:text-red-400"
>
{describeIssue(issue)}
</li>
))}
</ul>
</div>
)}

{requestProtocolHeaders.length > 0 && (
{mcpHeaders.length > 0 && (
<div>
<SectionLabel>MCP headers</SectionLabel>
<div className="rounded-sm bg-background/60 p-2 space-y-1">
{requestProtocolHeaders.map((row) => (
{mcpHeaders.map((row) => (
<div
key={row.name}
className="flex flex-wrap items-baseline gap-x-2 font-mono text-[11px]"
>
<span className="text-foreground">{row.name}</span>
<span className="text-muted-foreground break-all">
{row.raw}
<span
className={cn(
"break-all",
row.raw === undefined
? "text-muted-foreground/50"
: "text-muted-foreground",
)}
>
{row.raw ?? "—"}
</span>
{row.decoded !== undefined && (
<span className="text-sky-600 dark:text-sky-400 break-all">
→ {row.decoded}
</span>
)}
{row.decodeError && (
<span className="text-red-600 dark:text-red-400">
→ does not decode
</span>
)}
<span className="text-muted-foreground/70">
{FAMILY_LABEL[row.family]}
<span
className={cn(
"break-all",
isFailure(row.status)
? "text-red-600 dark:text-red-400"
: row.status === "match"
? "text-green-600 dark:text-green-400"
: "text-muted-foreground/70",
)}
>
{verdictText(row)}
</span>
</div>
))}
Expand All @@ -192,9 +173,9 @@ export function HttpExchangeDetails({
)}

<div>
<SectionLabel>Request headers</SectionLabel>
<SectionLabel>Other request headers</SectionLabel>
<ScrollableJsonView
value={exchange.request.headers}
value={otherRequestHeaders}
containerClassName="rounded-sm bg-background/60 p-2 max-h-[200px]"
/>
</div>
Expand Down
3 changes: 3 additions & 0 deletions sdk/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
16 changes: 14 additions & 2 deletions sdk/src/mcp-client-manager/http-exchange-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -88,10 +91,19 @@ export function deriveMirroredBodyValues(
params?: {
name?: unknown;
uri?: unknown;
taskId?: unknown;
_meta?: Record<string, unknown>;
};
};
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;
Comment on lines +94 to +106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make taskId authoritative for routed methods.

transport-utils.ts injects Mcp-Name from params.taskId, and mcp-header-mirror.ts validates it against the task ID. However, this expression lets params.name or params.uri override routedTaskId, so a routed request containing both fields can be reported as a false mismatch.

-  const name = params?.name ?? params?.uri ?? routedTaskId;
+  const name =
+    typeof method === "string" && TASK_ROUTED_METHODS.has(method)
+      ? routedTaskId
+      : params?.name ?? params?.uri;

Add a regression test containing both taskId and name.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
taskId?: unknown;
_meta?: Record<string, unknown>;
};
};
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;
taskId?: unknown;
_meta?: Record<string, unknown>;
};
};
// `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 =
typeof method === "string" && TASK_ROUTED_METHODS.has(method)
? routedTaskId
: params?.name ?? params?.uri;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk/src/mcp-client-manager/http-exchange-log.ts` around lines 94 - 106, The
name selection in the HTTP exchange logging flow must prioritize routedTaskId
for methods in TASK_ROUTED_METHODS, preventing params.name or params.uri from
overriding the task ID used for Mcp-Name validation. Update the name expression
near routedTaskId while preserving existing fallback behavior for non-routed
methods, and add a regression test covering a routed request containing both
taskId and name.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: For SEP-2663 routed task methods (tasks/get, tasks/update, tasks/cancel), routedTaskId should be the authoritative source for Mcp-Name, but the current fallback chain params?.name ?? params?.uri ?? routedTaskId lets an incidental params.name or params.uri field take precedence over the actual task id. If a routed request happens to carry both, the derived body value used for cross-checking will be wrong, producing a false mismatch verdict against the correctly-sent Mcp-Name header. Consider making the routed-task case exclusive: typeof method === "string" && TASK_ROUTED_METHODS.has(method) ? routedTaskId : (params?.name ?? params?.uri).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At sdk/src/mcp-client-manager/http-exchange-log.ts, line 106:

<comment>For SEP-2663 routed task methods (tasks/get, tasks/update, tasks/cancel), `routedTaskId` should be the authoritative source for `Mcp-Name`, but the current fallback chain `params?.name ?? params?.uri ?? routedTaskId` lets an incidental `params.name` or `params.uri` field take precedence over the actual task id. If a routed request happens to carry both, the derived body value used for cross-checking will be wrong, producing a false mismatch verdict against the correctly-sent `Mcp-Name` header. Consider making the routed-task case exclusive: `typeof method === "string" && TASK_ROUTED_METHODS.has(method) ? routedTaskId : (params?.name ?? params?.uri)`.</comment>

<file context>
@@ -88,10 +91,19 @@ export function deriveMirroredBodyValues(
+    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 = {
</file context>
Suggested change
const name = params?.name ?? params?.uri ?? routedTaskId;
const name =
typeof method === "string" && TASK_ROUTED_METHODS.has(method)
? routedTaskId
: params?.name ?? params?.uri;

const protocolVersion = params?._meta?.[PROTOCOL_VERSION_META_KEY];
const derived: MirroredBodyValues = {
method: typeof method === "string" ? method : undefined,
Expand Down
Loading
Loading