Skip to content

feat(tracing): per-header verdicts for the mirrored Mcp-* headers (SEP-2243) - #3574

Merged
chelojimenez merged 2 commits into
mainfrom
feat/tracing-header-verdicts
Jul 30, 2026
Merged

feat(tracing): per-header verdicts for the mirrored Mcp-* headers (SEP-2243)#3574
chelojimenez merged 2 commits into
mainfrom
feat/tracing-header-verdicts

Conversation

@chelojimenez

@chelojimenez chelojimenez commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Why

The MCP-headers block in Tracing lists each mirrored header with its familyrouting, protocol version — which restates what the header name already says. FAMILY_LABEL even mapped both method and name to the same string, so it never distinguished rows.

What it never showed is the one thing 2026-07-28 actually added: whether the header still agrees with the body it was copied from. That's the whole point of SEP-2243 — per the spec's Server Validation section, a load balancer may route on the header while the server executes on the body, so the disagreement is the defect. Today the body value only surfaces once something is already broken, which means the normal case gives you no way to tell it's right.

What changes

Same rows, same density. Only the third slot changes:

mcp-protocol-version   2026-07-28        ✓ matches body
mcp-method             server/discover   ✓ matches body
mcp-name               —                 not required here
mcp-name               get_weather       ✕ body says get_forecast → -32020
mcp-session-id         01H8XQ…7F2        session          ← legacy, unchanged

New SDK export evaluateMcpHeaders computes the per-row verdict. findMcpHeaderIssues now derives from it; its output shape and version scoping are unchanged (all 12 of its existing tests pass untouched).

Two things fall out, both subtractions:

  • the mirrored headers are filtered out of the raw request-header map, which was printing mcp-method and mcp-protocol-version twice on one screen;
  • the standing -32020 HeaderMismatch banner is gone — it explained the error class whether or not one occurred. The code now lands on the row that failed.

Version scope

Era-gated identically to the existing validator:

verdict
2026-07-28 standard three cross-checked against the body
≤ 2025-11-25 unchecked — nothing is mirrored before draft, so Mcp-Method/Mcp-Name are never demanded, and a value merely resembling the base64 sentinel is not a defect
Mcp-Param-* unchecked — the captured body carries no arguments, so no verdict is reachable
Mcp-Session-Id / Last-Event-ID family label, as before

An absent header gets an explicit row, because "required and missing" and "correctly absent" are otherwise indistinguishable: per the Standard Request Headers table, Mcp-Name is required only for tools/call, resources/read and prompts/get. On the server/discover request that motivated this, the honest answer is not required here — not a blank.

decoded is now set only when a sentinel decodes successfully, so an undecodable value no longer renders as raw → raw (a failed decode that looks like it round-tripped).

Not in scope

  • Mcp-Param-* verification needs the tool's inputSchema x-mcp-header annotations plus the spec's three-row omit/include table. Shown as unchecked rather than implying a check that didn't run.
  • Numeric comparison. Values compare with string inequality; the spec's Server Validation note says integers SHOULD compare numerically (42.0 == 42). Only reachable once params are verified, since the standard three are all strings.
  • Response-side headers. In the legacy eras the server assigns MCP-Session-Id on the initialize response, and the collector still only runs over request headers.

Verification

  • sdk/tests/mcp-header-mirror.test.ts — 31 pass (12 pre-existing + 19 new: match/mismatch/missing/not-required/undecodable, params.uri source for resources/read, sentinel decode, and five version-scope cases incl. "no undecodable defect on legacy" and "no body captured")
  • sdk/tests/http-exchange-log.test.ts — 9 pass
  • inspector review-surface-snapshots + rpc-log-bus — 12 pass
  • tsc --noEmit clean for both @mcpjam/sdk and the client

🤖 Generated with Claude Code


Note

Medium Risk
Ships a minor SDK API plus a behavior change: missing Mcp-Name on routed task RPCs now surfaces as defects where validation was previously silent; impact is limited to protocol debugging/tracing paths and is covered by new tests.

Overview
Adds evaluateMcpHeaders to @mcpjam/sdk/browser, returning a per-header verdict (match, mismatch, missing, not-required, undecodable, unchecked) with optional body field/value context—so Tracing can show whether each mirrored header agrees with the captured body, not only a defect list when something breaks.

The Tracing HTTP exchange detail switches from findMcpHeaderIssues + family labels to row-level verdict text (including explicit rows for absent optional vs required Mcp-Name), drops the global -32020 banner, and hides mirrored headers from the raw “Other request headers” JSON to avoid duplicates.

findMcpHeaderIssues is refactored to filter defects from evaluateMcpHeaders; shape and era gating stay the same, but Mcp-Name is now required for tasks/get, tasks/update, and tasks/cancel (SEP-2663), with TASK_ROUTED_METHODS shared between send (transport-utils) and judge logic. deriveMirroredBodyValues maps params.taskId to the mirrored name for those methods only.

Reviewed by Cursor Bugbot for commit 643aed4. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Adds per-header verdicts for mirrored Mcp-* request headers (SEP-2243) and updates the tracing inspector to show if each header matches the body. Also requires Mcp-Name for routed task methods (SEP-2663) and limits undecodable verdicts to Mcp-Param-*.

  • New Features

    • Expose evaluateMcpHeaders in @mcpjam/sdk/browser; inspector shows a verdict per header and removes the global HeaderMismatch banner. Mirrored headers are hidden from “Other request headers.”
    • Statuses: match, mismatch, missing, not-required, undecodable, unchecked. Decoded values show only when the sentinel decodes; resources/read compares Mcp-Name to .params.uri; routed tasks use .params.taskId.
  • Bug Fixes

    • Mcp-Name is required for tasks/get, tasks/update, and tasks/cancel (SEP-2663); findMcpHeaderIssues now reports a missing defect for these. Send and judge share TASK_ROUTED_METHODS; deriveMirroredBodyValues reads .params.taskId for those methods.
    • undecodable applies only to Mcp-Param-*; session/resumption headers are never decoded. Legacy eras and uncaptured bodies return unchecked.

Written for commit 643aed4. Summary will update on new commits.

Review in cubic

…P-2243)

The MCP-headers block listed each mirrored header with its family
("routing", "protocol version") — a category restating what the header
name already says. It never showed the one thing 2026-07-28 added that a
reader cannot see for themselves: whether the header still agrees with
the body it was copied from, which is the entire point of SEP-2243 (a
router may forward on the header while the server executes on the body).

Same rows, same density; the third slot now carries the verdict:

  mcp-method   server/discover   ✓ matches body
  mcp-name     get_weather       ✕ body says get_forecast → -32020
  mcp-name     —                 not required here

New SDK export `evaluateMcpHeaders` computes it, era-gated exactly like
`findMcpHeaderIssues` (which now derives from it, output unchanged): no
verdict before 2026-07-28, where nothing is mirrored, and none for
`Mcp-Param-*`, whose body values are not captured. An absent header gets
an explicit row because "missing, required" and "correctly absent" are
otherwise indistinguishable — `Mcp-Name` is required only for
tools/call, resources/read and prompts/get.

Two subtractions come with it: the mirrored headers are filtered out of
the raw request-header map, which was printing mcp-method twice on one
screen, and the standing "-32020 HeaderMismatch" banner is gone — the
code now lands on the row that actually failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Jul 30, 2026
@dosubot dosubot Bot added the enhancement New feature or request label Jul 30, 2026
@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_6f97b9fa-5d89-48c6-a6a7-83b5fc550731)

@dosubot

dosubot Bot commented Jul 30, 2026

Copy link
Copy Markdown

📄 Knowledge review

Dosu skipped reviewing this PR because your organization has used its 200 included credits for the month. Your usage will reset on 2026-08-01. To have Dosu review this PR before then, ask your organization admin to upgrade to a pro account.


Leave Feedback Ask Dosu about inspector Add Dosu to your team

@chelojimenez

chelojimenez commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c2e36e11d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +309 to +311
// A sentinel that will not decode is only a defect where the spec defines
// the sentinel at all — a legacy value that merely resembles it is not.
const undecodable = crossCheck && !!decoded.decodeError;

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 Badge Keep unverifiable parameter headers unchecked

When a modern request contains an Mcp-Param-* value with a malformed base64 sentinel, this condition marks the row undecodable; HttpExchangeDetails then presents it as a definite -32020 failure. Parameter arguments and their x-mcp-header annotations are not captured, so this API cannot validate these rows and promises to report them as unchecked; restrict the undecodable verdict to the standard cross-checked families.

Useful? React with 👍 / 👎.

Comment on lines +272 to +275
} 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" });

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 Badge Do not mark routed task headers as optional

For tasks/get, tasks/update, and tasks/cancel, the repository's SEP-2663 transport explicitly requires Mcp-Name: <taskId>, but deriveMirroredBodyValues does not capture params.taskId and these methods are absent from NAME_REQUIRED_METHODS. Consequently, a captured task request missing Mcp-Name reaches this branch and is displayed as “not required here,” concealing the exact routing defect the tracing verdict is meant to expose; capture the task ID and treat these routed task methods as requiring the header.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Internal preview

Preview URL: https://mcp-inspector-pr-3574.up.railway.app
Deployed commit: 60b5046
PR head commit: 643aed4
Backend target: staging fallback.
Health: ✅ Convex reachable
Access is employee-only in non-production environments.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The SDK adds evaluateMcpHeaders, assessment statuses, and exported assessment types for mirrored MCP headers. Routed task methods now share their method set across transport extraction, body derivation, and header evaluation, with params.taskId supplying Mcp-Name. findMcpHeaderIssues derives results from the shared evaluation logic. Tests cover matching, missing headers, sentinel decoding, parameter headers, task routing, version scoping, and absent bodies. The tracing interface displays per-header verdicts and separates mirrored headers from other request headers.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

sdk/src/mcp-client-manager/http-exchange-log.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

YAMLException: Cannot read config file: /sdk/eslint.config.mjs
Error: end of the stream or a document separator is expected (10:20)

7 | ...tseslint.configs.recommended,
8 | prettier,
9 | {
10 | languageOptions: {
-------------------------^
11 | parserOptions: {
12 | project: ["./tsconfig.json", "./tests/tsco ...
at generateError (/node_modules/js-yaml/lib/loader.js:199:10)
at throwError (/node_modules/js-yaml/lib/loader.js:203:9)
at readDocument (/node_modules/js-yaml/lib/loader.js:1651:5)
at loadDocuments (/node_modules/js-yaml/lib/loader.js:1694:5)
at Object.load (/node_modules/js-yaml/lib/loader.js:1720:19)
at loadLegacyConfigFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2565:21)
at loadConfigFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2680:20)
at ConfigArrayFactory._loadConfigData (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:42)
at ConfigArrayFactory.loadFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
at createCLIConfigArray (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)

sdk/src/mcp-client-manager/mcp-header-mirror.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

YAMLException: Cannot read config file: /sdk/eslint.config.mjs
Error: end of the stream or a document separator is expected (10:20)

7 | ...tseslint.configs.recommended,
8 | prettier,
9 | {
10 | languageOptions: {
-------------------------^
11 | parserOptions: {
12 | project: ["./tsconfig.json", "./tests/tsco ...
at generateError (/node_modules/js-yaml/lib/loader.js:199:10)
at throwError (/node_modules/js-yaml/lib/loader.js:203:9)
at readDocument (/node_modules/js-yaml/lib/loader.js:1651:5)
at loadDocuments (/node_modules/js-yaml/lib/loader.js:1694:5)
at Object.load (/node_modules/js-yaml/lib/loader.js:1720:19)
at loadLegacyConfigFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2565:21)
at loadConfigFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2680:20)
at ConfigArrayFactory._loadConfigData (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:42)
at ConfigArrayFactory.loadFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
at createCLIConfigArray (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)

sdk/src/mcp-client-manager/transport-utils.ts

Oops! Something went wrong! :(

ESLint: 8.57.1

YAMLException: Cannot read config file: /sdk/eslint.config.mjs
Error: end of the stream or a document separator is expected (10:20)

7 | ...tseslint.configs.recommended,
8 | prettier,
9 | {
10 | languageOptions: {
-------------------------^
11 | parserOptions: {
12 | project: ["./tsconfig.json", "./tests/tsco ...
at generateError (/node_modules/js-yaml/lib/loader.js:199:10)
at throwError (/node_modules/js-yaml/lib/loader.js:203:9)
at readDocument (/node_modules/js-yaml/lib/loader.js:1651:5)
at loadDocuments (/node_modules/js-yaml/lib/loader.js:1694:5)
at Object.load (/node_modules/js-yaml/lib/loader.js:1720:19)
at loadLegacyConfigFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2565:21)
at loadConfigFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2680:20)
at ConfigArrayFactory._loadConfigData (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2984:42)
at ConfigArrayFactory.loadFile (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:2850:40)
at createCLIConfigArray (/soundcheck/node_modules/@eslint/eslintrc/dist/eslintrc.cjs:3660:35)

  • 2 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (2)
sdk/tests/mcp-header-mirror.test.ts (1)

402-480: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The version-scope block is thorough; one uncovered corner remains.

Every era path is pinned here, but nothing exercises a modern tools/call whose captured body lacks name while the header is absent — the case where the evaluator emits no row at all (see the note on evaluateMcpHeaders lines 263-278). Pinning whatever behavior you settle on would stop that silence from drifting unnoticed.

🤖 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/tests/mcp-header-mirror.test.ts` around lines 402 - 480, Extend the
“version scope” tests around evaluateMcpHeaders with a modern tools/call request
whose captured body omits name and whose corresponding header is absent. Assert
the intended behavior for this case, specifically whether the evaluator should
emit a missing/not-required row or no row, so the current silent outcome is
explicitly pinned.
sdk/src/mcp-client-manager/mcp-header-mirror.ts (1)

263-278: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

A required-but-absent header with no captured body value vanishes from the list entirely.

The doc above promises that an absent header always earns an explicit row, yet when spec.required holds and spec.bodyValue is undefined (say tools/call captured without params.name), neither branch fires and the row is dropped. Silence there reads the same as "header not applicable" — the very ambiguity this evaluator exists to dispel. An unchecked row would keep the promise.

♻️ Optional: emit an unchecked row instead of nothing
     if (!found) {
       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" });
+      } else if (spec.required) {
+        // Required, absent, and no body value to judge it against.
+        out.push({ name: spec.header, family: spec.family, status: "unchecked" });
       }
       continue;
     }
🤖 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/mcp-header-mirror.ts` around lines 263 - 278,
Update the absent-header handling in the mirror-building logic so every required
missing header emits a row, including when spec.bodyValue is undefined. Preserve
the existing missing row when a body value is available, and emit an unchecked
status row otherwise; keep the not-required branch unchanged.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@sdk/src/mcp-client-manager/mcp-header-mirror.ts`:
- Around line 208-220: Update evaluateMcpHeaders to decode the selected
MCP-Protocol-Version value before calculating isModern and crossCheck. Apply the
existing sentinel-decoding utility once to the header/body-resolved version,
then use the decoded value for protocol validation and all cross-checks so
matching encapsulated versions follow the modern path.

---

Nitpick comments:
In `@sdk/src/mcp-client-manager/mcp-header-mirror.ts`:
- Around line 263-278: Update the absent-header handling in the mirror-building
logic so every required missing header emits a row, including when
spec.bodyValue is undefined. Preserve the existing missing row when a body value
is available, and emit an unchecked status row otherwise; keep the not-required
branch unchanged.

In `@sdk/tests/mcp-header-mirror.test.ts`:
- Around line 402-480: Extend the “version scope” tests around
evaluateMcpHeaders with a modern tools/call request whose captured body omits
name and whose corresponding header is absent. Assert the intended behavior for
this case, specifically whether the evaluator should emit a missing/not-required
row or no row, so the current silent outcome is explicitly pinned.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e35d8fb8-3605-4717-a382-15fa259a1b10

📥 Commits

Reviewing files that changed from the base of the PR and between ba2102b and 3c2e36e.

📒 Files selected for processing (5)
  • .changeset/tracing-header-verdicts.md
  • mcpjam-inspector/client/src/components/tracing/HttpExchangeDetails.tsx
  • sdk/src/browser.ts
  • sdk/src/mcp-client-manager/mcp-header-mirror.ts
  • sdk/tests/mcp-header-mirror.test.ts

Comment on lines +208 to +220
export function evaluateMcpHeaders(
headers: Record<string, string>,
body: MirroredBodyValues | undefined
): McpHeaderIssue[] {
): McpHeaderAssessment[] {
const lookup = new Map<string, { name: string; value: string }>();
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;

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

🧩 Analysis chain

🌐 Web query:

SEP-2243 Mcp-Protocol-Version header base64 sentinel encoding allowed

💡 Result:

Under SEP-2243, which standardized HTTP headers for the Model Context Protocol (MCP) Streamable HTTP transport, Base64 sentinel encoding is explicitly allowed and required for certain header values [1][2]. The encoding uses the format =?base64?{Base64EncodedValue}?= [1][3]. This format must be used for values in Mcp-Name and Mcp-Param-{Name} headers if the value contains non-ASCII characters, control characters, or leading/trailing whitespace that cannot be safely represented as plain ASCII [2][3][4]. Crucially, to avoid ambiguity, clients must also apply this Base64 encoding to any plain-ASCII value that happens to match the sentinel pattern (i.e., starts with =?base64? and ends with ?=) [1][5][3]. The sentinel markers are case-sensitive and must appear exactly as shown in lowercase [1][2][3]. Servers and intermediaries are required to decode these values before performing comparisons or policy enforcement [1][3][4].

Citations:


🌐 Web query:

SEP-2243 Mcp-Protocol-Version header value base64 sentinel encoding

💡 Result:

SEP-2243 (HTTP Header Standardization for Streamable HTTP Transport) introduces a requirement for Base64 sentinel encoding for MCP HTTP header values that cannot be represented as plain ASCII [1][2]. When a value (such as Mcp-Name or Mcp-Param-{Name}) contains non-ASCII characters, control characters, or has leading/trailing whitespace, clients must encode the UTF-8 representation of that value using the following format [2][3]: =?base64?{Base64EncodedValue}?= The key requirements for this encoding are: 1. Sentinel Markers: The prefix =?base64? and suffix ?= are case-sensitive and must be provided exactly as lowercase [2][3]. 2. Ambiguity Prevention: To avoid ambiguity, clients must also apply this Base64 sentinel encoding to any plain-ASCII value that happens to match this specific pattern (i.e., starts with =?base64? and ends with ?= [2][3]). 3. Decoding: Servers and intermediaries must decode these values before performing any validation or inspection (such as comparing the header value to the corresponding request body value) [2][3]. This encoding is part of the broader standard for MCP HTTP header standardization, which enables infrastructure like proxies and load balancers to inspect request metadata without parsing the JSON-RPC body [1][4]. The protocol uses version gating, where header enforcement—including these encoding rules—applies when the MCP-Protocol-Version header indicates a version that requires header–body validation (e.g., DRAFT-2026-v1 or later) [4][2].

Citations:


🌐 Web query:

site:github.com/modelcontextprotocol modelcontextprotocol docs/seps/2243 Mcp-Protocol-Version

💡 Result:

SEP-2243 (HTTP Header Standardization) is a finalized specification for the Model Context Protocol (MCP) that enhances the Streamable HTTP transport by mirroring key JSON-RPC metadata into standard HTTP headers [1][2]. This allows network intermediaries—such as load balancers, proxies, and observability tools—to route, trace, and prioritize MCP traffic without performing deep packet inspection of the request body [1][3]. Key technical aspects include: 1. Protocol Versioning and Gating: The SEP introduces a "version-gating" mechanism for intermediaries [4]. When the MCP-Protocol-Version header indicates a version that requires header-body validation (such as DRAFT-2026-v1), intermediaries can reliably enforce policy or routing decisions based on the mirrored headers [4][3]. If a client sends an unsupported or invalid MCP-Protocol-Version header, the server must respond with a 400 Bad Request error [5]. 2. Header Mirroring: Clients are required to include specific headers on all Streamable HTTP POST requests, including Mcp-Method (corresponding to the JSON-RPC method field) and Mcp-Name (corresponding to params.name) [1][2]. 3. Dynamic Parameter Mirroring: The specification defines an x-mcp-header annotation for tool parameters [1][4]. When a tool parameter is so annotated, clients must mirror the parameter's value into a custom Mcp-Param-{Name} HTTP header [1][3]. 4. Implementation and Validation: - Implementations are expected to handle encoding (e.g., Base64 wrapping for non-ASCII characters) and validation of these headers [2][3]. - Validation failures (e.g., mismatch between a header and the actual JSON-RPC body) result in a specific JSON-RPC error, McpErrorCode.HeaderMismatch (code -32001) [3]. - Clarifications for this SEP, such as those addressed in pull request #2772, refined the syntax for header names to align with RFC 9110 and clarified that header filtering/validation is scoped specifically to the Streamable HTTP transport [4][6]. The Mcp-Protocol-Version header itself is a standard requirement for HTTP-based MCP communication, serving as the primary mechanism for clients and servers to negotiate and declare the protocol version being used for each request [5][7]. Every request must declare the protocol version in its _meta field, which should align with the value provided in the MCP-Protocol-Version HTTP header [7][8].

Citations:


🌐 Web query:

MCP Protocol Version header Mcp-Protocol-Version date sentinel base64

💡 Result:

The Model Context Protocol (MCP) utilizes the Mcp-Protocol-Version header to manage protocol versioning, specifically when using HTTP-based transports [1][2]. Mcp-Protocol-Version Header The Mcp-Protocol-Version header is a required HTTP header in requests sent to an MCP server [2][3]. It allows the server to identify and respond according to the specific MCP protocol version being used [3][4]. Key aspects include: - Negotiation: The version specified in this header should match the version negotiated during the initialization phase [3][5]. - Validation: Servers must reject requests with an invalid or unsupported protocol version with a 400 Bad Request error [3][4]. - Synchronization: The header value must match the io.modelcontextprotocol/protocolVersion field provided in the request body's _meta field [2]. - Backward Compatibility: Servers may assume a default version (such as 2025-03-26) if the header is absent and no other versioning information is available, though modern specifications increasingly require explicit versioning [2][3][4]. Base64 Encoding In the context of the MCP protocol, base64 encoding is primarily used for the transmission of binary data, such as images or other non-textual resources [6][7]. When a resource contains binary data, it is typically represented as a base64-encoded string within the blob field or data field of the content object [6][8][9]. The specification uses the @format byte annotation in its schema definitions to indicate that these strings contain base64-encoded binary data [8][10].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate relevant files:"
fd -a 'mcp-header-mirror.ts|.*specs|.*spec' . \
  | sed 's#^\./##' \
  | head -80

echo
echo "Candidate file outline:"
ast-grep outline sdk/src/mcp-client-manager/mcp-header-mirror.ts --view expanded || true

echo
echo "Relevant code slices:"
sed -n '1,260p' sdk/src/mcp-client-manager/mcp-header-mirror.ts | cat -n

echo
echo "Search for sentinel decode helpers/usages:"
rg -n "base64|sentinel|decode|HeaderMismatch|isKnownProtocolVersion|Mcp-Protocol-Version|MCP-Protocol-Version|protocolVersion" sdk/src -S

Repository: MCPJam/inspector

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '260,380p' sdk/src/mcp-client-manager/mcp-header-mirror.ts | cat -n

echo
echo "Protocol version guard implementation:"
sed -n '1,180p' sdk/src/mcp-client-manager/mcp-protocol-version.ts | cat -n

echo
echo "Behavioral probe for sentinel-encoded protocol version:"
node - <<'JS'
const MCP_HEADER_SENTINEL_PREFIX = "=?base64?";
const MCP_HEADER_SENTINEL_SUFFIX = "?=";
function base64ToUtf8(encoded) {
  const binary = atob(encoded);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
  return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
function isKnownProtocolVersion(v) {
  return /^(\d{4}-\d{2}-\d{2}|2026-01-26|2026-02-26)$/.test(v ?? "");
}
function isStatelessProtocolVersion(v) {
  return !(v && (v.startsWith("2026-03-26") || v.startsWith("2026-06-26")));
}
function currentProbe(lookup, body) {
  const version = lookup.get("mcp-protocol-version")?.value ?? body?.protocolVersion;
  return { version, isModern: !!version && isKnownProtocolVersion(version) && isStatelessProtocolVersion(version) };
}
function decodedProbe(lookup, body) {
  const raw = lookup.get("mcp-protocol-version")?.value ?? body?.protocolVersion;
  const decoded = raw && raw.startsWith(MCP_HEADER_SENTINEL_PREFIX) && raw.endsWith(MCP_HEADER_SENTINEL_SUFFIX)
    ? base64ToUtf8(raw.slice(MCP_HEADER_SENTINEL_PREFIX.length, raw.length - MCP_HEADER_SENTINEL_SUFFIX.length))
    : raw;
  return { decoded, isModern: !!decoded && isKnownProtocolVersion(decoded) && isStatelessProtocolVersion(decoded) };
}
const raw = Buffer.from("2026-01-26", "utf8").toString("base64");
console.log(currentProbe(new Map([["mcp-protocol-version", `=?base64?${raw}?=`]]]), {}));
console.log(decodedProbe(new Map([["mcp-protocol-version", `=?base64?${raw}?=`]]]), {}));
JS

Repository: MCPJam/inspector

Length of output: 8729


Decode MCP-Protocol-Version before the modern/gating check.

SEP-2243 applies sentinel decoding across MCP HTTP header values before validation. A sentinel-encapsulated version like =?base64?MjAyNi0wMS0yNg==?=, even when matching the body, is treated as unknown and sends the flow back to unchecked rows. Decode once and use that value for both the protocol-mode probe and cross-checks.

🤖 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/mcp-header-mirror.ts` around lines 208 - 220,
Update evaluateMcpHeaders to decode the selected MCP-Protocol-Version value
before calculating isModern and crossCheck. Apply the existing sentinel-decoding
utility once to the header/body-resolved version, then use the decoded value for
protocol validation and all cross-checks so matching encapsulated versions
follow the modern path.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 5 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread sdk/src/mcp-client-manager/mcp-header-mirror.ts Outdated
Comment thread sdk/src/mcp-client-manager/mcp-header-mirror.ts
…dable verdict

Two review findings on the per-header verdicts, both real.

SEP-2663 ("Streamable HTTP: Routing Headers") makes `Mcp-Name: <taskId>`
a MUST for tasks/get, tasks/update and tasks/cancel, and
`wrapFetchForTaskRouting` already sends it — but the required-name set
covered only the three SEP-2243 core methods, so a routed task request
that omitted the header rendered as "not required here". The new row
turned a silent omission into an affirmative wrong claim about exactly
the routing defect it exists to expose. `TASK_ROUTED_METHODS` moves to
`mcp-header-mirror` (the pure leaf module) and is now read by both the
send side and the judge side so the two cannot drift, and
`deriveMirroredBodyValues` reads `params.taskId` for those three methods
— and no others, so a method merely carrying a taskId is not
cross-checked against a field it never mirrored.

The `undecodable` verdict also applied to any mirrored family on a
modern request, so a sentinel-looking `Mcp-Session-Id` or `Last-Event-ID`
rendered as `✕ does not decode → -32020`. Neither header has an encoded
form in any version. It now applies to `Mcp-Param-*` only, where servers
MUST reject a recognized param header carrying invalid characters.

Declined: decoding `MCP-Protocol-Version` through the sentinel before the
era probe. The spec defines the sentinel for `Mcp-Name` and
`Mcp-Param-{Name}` only; a protocol version is a plain date token, so
accepting an encoded one would invent a wire form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Jul 30, 2026
@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_f561af81-bfa3-474d-a2c8-e8221f6ae9fe)

@chelojimenez

Copy link
Copy Markdown
Contributor Author

Review triage — 3 findings after dedupe

Codex and cubic reported the same two issues; CodeRabbit added a third. Both P2s were real and are fixed in 643aed4.

1. Mcp-Name treated as optional for routed task methods — FIXED

Codex P2 / cubic P2 (confidence 6). Confirmed against the extension spec: SEP-2663 "Streamable HTTP: Routing Headers""When tasks/get, tasks/update, or tasks/cancel is sent over the Streamable HTTP transport, the client MUST set the Mcp-Name header … to the value of params.taskId". This repo already sends it (wrapFetchForTaskRouting), so the omission was only on the judging side.

The severity is higher than "pre-existing gap": before this PR an absent Mcp-Name produced no row at all, so nothing was claimed. The new not-required row turned that silence into an affirmative "not required here" on exactly the routing defect the verdict exists to expose.

Fix: TASK_ROUTED_METHODS moved into mcp-header-mirror.ts (the pure leaf module) and is now read by both the send side and the judge side, so a method added to one cannot be missed by the other — the drift that caused this. deriveMirroredBodyValues reads params.taskId for those three methods and no others, so a method merely carrying a taskId is not cross-checked against a field it never mirrored. nameSourceField reports .params.taskId for them.

Note this also changes shipped findMcpHeaderIssues behavior — it now reports a missing defect for a routed task request that omits the header. Called out in the changeset.

2. undecodable claimed on families with no sentinel — FIXED, partially

Codex P2 / cubic P2 (confidence 9). Correct for Mcp-Session-Id and Last-Event-ID: neither has an encoded form in any version, so rendering ✕ does not decode → -32020 for a value that merely resembles the sentinel was invented.

Not correct for Mcp-Param-*, so I did not take cubic's suggested const undecodable = false. The draft transport spec requires the opposite: "Servers MUST reject requests with a recognized Mcp-Param-{Name} header that contains invalid characters (see Value Encoding)", and Value Encoding defines the sentinel for Mcp-Param-{Name} explicitly. A malformed param sentinel is a -32020-class defect, and it is the one param verdict reachable without the tool's inputSchema.

Fix: undecodable now applies to param only. The changeset wording that claimed params are always unchecked was the actual inconsistency the bots caught, and is corrected — params can't be matched, but a malformed sentinel is still reported.

3. Decode MCP-Protocol-Version before the era probe — DECLINED

CodeRabbit, Minor. The spec defines the base64 sentinel for Mcp-Name and Mcp-Param-{Name} only ("The same encoding rule applies to the Mcp-Name header value"); MCP-Protocol-Version is a plain date token that never needs encoding, and no version sanctions an encoded form. Accepting =?base64?MjAyNi0wNy0yOA==?= as a protocol version would invent a wire form and make the debugger validate something no conforming server would send. CodeRabbit's own comment carries a phantom fingerprint.

Verification

Full SDK suite: 186 files, 3327 passed, 1 skipped — including 38 header-mirror tests (7 new: three routed methods require the header, taskId as match source, non-routed tasks method stays optional, param malformed sentinel flagged, session/resumption sentinel-lookalike not flagged) and 11 http-exchange-log tests (2 new on params.taskId capture and its scoping). tsc --noEmit clean for @mcpjam/sdk and the client.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In `@sdk/src/mcp-client-manager/http-exchange-log.ts`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a76229f-7333-4fcd-a25e-b34b48a813a1

📥 Commits

Reviewing files that changed from the base of the PR and between 3c2e36e and 643aed4.

📒 Files selected for processing (6)
  • .changeset/tracing-header-verdicts.md
  • sdk/src/mcp-client-manager/http-exchange-log.ts
  • sdk/src/mcp-client-manager/mcp-header-mirror.ts
  • sdk/src/mcp-client-manager/transport-utils.ts
  • sdk/tests/http-exchange-log.test.ts
  • sdk/tests/mcp-header-mirror.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • sdk/tests/mcp-header-mirror.test.ts

Comment on lines +94 to +106
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;

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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="sdk/src/mcp-client-manager/http-exchange-log.ts">

<violation number="1" location="sdk/src/mcp-client-manager/http-exchange-log.ts:106">
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)`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

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

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;

@chelojimenez
chelojimenez merged commit 874f4b5 into main Jul 30, 2026
13 checks passed
@chelojimenez
chelojimenez deleted the feat/tracing-header-verdicts branch July 30, 2026 03:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant