Network: modern Mcp-* headers, new error codes, cancellation-as-abort (#1628) (#1722)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled

* Network: modern Mcp-* headers, new error codes, cancellation-as-abort (#1628)

Upgrade the Network tab for the modern Streamable HTTP transport
(SEP-2243 / SEP-2575):

- Recognise and highlight the mirrored headers (Mcp-Method, Mcp-Name,
  Mcp-Param-*, MCP-Protocol-Version), decode =?base64?…?= sentinel values
  inline, and cross-check each against the request body so a HeaderMismatch
  is visible before the server rejects it.
- Show the new spec error codes distinctly: -32020 HeaderMismatch,
  -32021 MissingRequiredClientCapability, -32022 UnsupportedProtocolVersion
  (with the server-advertised `supported` versions), and modern -32601
  MethodNotFound on HTTP 404 (distinguished from a legacy bare 404 by its
  JSON-RPC body).
- Reflect cancellation as a connection abort ("Cancelled") rather than a
  hard error, per SEP §7.5.
- Confirm and document browser-path header mirroring: the SDK builds the
  Mcp-* headers client-side and the /api/fetch proxy forwards them verbatim
  (new integration test).

New pure util `mcpNetworkHeaders.ts` holds the recognition/decode/classify
logic; new `McpErrorBadge` element renders the distinct error chips. The
optional raw-request controls (cacheMode bypass/refresh, single-page fetch)
are split to #1721 — they are cross-cutting core + feature-tab work beyond
the Network tab.

Also refreshes the AGENTS.md board Status option-ID table (the Building /
MCP Apps Extension columns were removed).

Closes #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network smoke-test showcase + fix redaction clobbering JSON-RPC error codes

Adds a composable "modern-network" showcase server for exercising the #1628
Network-tab work end-to-end, and fixes a real bug the smoke test surfaced.

Bug fix (core): the fetch-tracking body redaction masked ANY field named
`code` (targeting the OAuth authorization code), including the numeric
JSON-RPC `error.code`. That destroyed the field the Network tab classifies
the modern spec errors on, so -32020/-32021/-32022/-32601 never rendered
against real traffic. Redaction now only masks STRING values of a sensitive
field (secrets are always strings; a numeric code is never one).

Test-server showcase (test-servers): `modern-network-http.json` serves a
`get_weather` tool with an `x-mcp-header: "City"` annotation plus four
`trigger_*` tools. A new `transport.modern.injectSpecErrors` flag installs a
middleware on the modern leg that answers the triggers with real HTTP status
+ JSON-RPC error bodies (400/-32020, 400/-32021, 400/-32022 w/ `supported`,
404/-32601), so the Inspector's error rendering can be driven live.

Mirroring finding (verified live, comment corrected in createRemoteFetch.ts):
the SDK builds the STANDARD mirrored headers (Mcp-Method / Mcp-Name /
MCP-Protocol-Version) unconditionally — they reach the server through the
web proxy — but gates `Mcp-Param-*` on a non-browser environment
(`detectProbeEnvironment() !== "browser"`), so the web client never emits
them. An x-mcp-header tool is therefore uncallable from the web client
against a strict modern server (it answers -32020); it works from the Node
CLI/TUI. The earlier "mirroring works through the proxy" note was corrected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: move the spec-error badge off the crowded compact top row

In the narrow monitoring sidebar (embedded layout) the McpErrorBadge shared
the top row with the timestamp/method/category/duration/status chips, which
truncated the method and category ("PO…", "TRANSP…") and clipped the error
name. Render the badge separately from `metaBadges` and place it on the
second row, to the right of the URL, so the top row stays readable and the
error name shows in full. The full-width layout keeps it in the top-right
cluster (where there's room).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: keep the spec error name's casing in McpErrorBadge (no uppercase)

The four spec/SDK error identifiers are PascalCase (`HeaderMismatch`,
`MissingRequiredClientCapability`, `UnsupportedProtocolVersion`,
`MethodNotFound`). Mantine's Badge uppercases label text by default, which
flattened them to a hard-to-read run-together string
("UNSUPPORTEDPROTOCOLVERSION"). Set `tt="none"` so the badge shows the
spec's own casing with word boundaries intact; short badges (POST, status)
keep the default uppercase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: in the full-width layout, move the spec-error badge to the second row

The full-width entry kept the McpErrorBadge in the top-right cluster, where a
long spec name (e.g. "MissingRequiredClientCapability") crowded the top row
and truncated the method chips and URL. Move it to the start of the second
row (the expand-toggle row) so the top row stays readable; the toggle stays
at the end (`space-between` only when the badge is present). The compact
sidebar already places it on its second row beside the URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: show Replay as the right-side icon in the wide layout too

The compact (sidebar) ProtocolEntry already renders Replay as an icon next to
the pin/expand icons; the wide layout used a left-aligned "Replay" text
button instead. Make the wide layout match — Replay as the ReplayButton icon
at the start of the right-side action cluster (Replay, Pin, Expand) — so the
two layouts are consistent. Drops the now-unused SubtleButton constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Move the modern spec-error chip + alert from the Network tab to the Protocol tab

The Network tab is focused on the HTTP transaction; the distinct protocol-error
taxonomy (-32020/-32021/-32022/-32601) belongs with the protocol messages. So:

- ProtocolEntry now renders the McpErrorBadge chip on the top line (right after
  the direction badge, both layouts) plus an explanatory alert in the expanded
  detail, classified by JSON-RPC error code via `classifyProtocolSpecError`.
- NetworkEntry drops the chip + alert; it keeps the HTTP-level features (status
  badge, Mcp-* header highlight/sentinel-decode, header/body mismatch marker,
  cancellation-as-abort).
- The SDK delivers -32020/21/22 (HTTP 400) as JSON-RPC error responses, so they
  reach the Protocol log directly. -32601 (HTTP 404) and other non-enveloped
  errors are *thrown* — the request stays pending. `enrichProtocolEntries`
  (App) correlates the Protocol log to the Network log by JSON-RPC id (parsed
  from the transport fetch's requestBody) and folds the thrown error onto its
  pending request, so it too appears in Protocol. This is a derived view; the
  underlying log is not mutated.

Verified live against the modern-network showcase: both a -32020 error response
and a thrown -32601 render distinctly in the Protocol tab.

Follow-up: a "reveal in Network" link on the Protocol alert (cross-tab
scroll-to + expand of the correlated HTTP entry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: show the spec-error chip only in the wide layout, after the method

Refine the placement of the spec-error chip added in the previous commit:
- Wide layout: move it from after the direction badge to right after the
  method chip (reads as "tools/call  -32020 HeaderMismatch").
- Compact sidebar row: drop the chip entirely — the two-line row stays
  uncluttered and relies on its ERROR status badge. The explanatory alert
  still appears in the expanded detail in both layouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: "reveal in Network" link — jump from a spec error to its HTTP entry

Adds the cross-tab navigation that was the follow-up to relocating the
spec-error rendering. The expanded Protocol alert now shows a "View the HTTP
request in the Network tab" link when the entry has a correlated Network
request; clicking it:

- correlates the Protocol message to its transport fetch by JSON-RPC id
  (`revealableMessageIds` gates the link; `correlateFetchEntry` finds the
  target on click),
- clears whatever filter would hide the target (network filterText +
  category, and the embedded monitorSearch),
- switches to the Network view in whichever mode is active — the pinned
  column (`pinMonitor`) when the sidebar is open, or the full-screen header
  tab (`onActiveTabChange`) when it's closed,
- scrolls the matching NetworkEntry into view and force-expands it.

The reveal is a one-shot: a new `revealId` threads NetworkScreen →
NetworkStreamPanel → NetworkEntry, where the targeted entry scrolls itself
into view (rAF, after `useScrollMemory`'s restore) + expands, then clears the
signal via `onRevealComplete`. Verified live in both the pinned-sidebar and
closed/full-screen layouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: only show the "reveal in Network" link when it adds value

Gate the link on a new `httpRelevant` flag: show it only when the error is
either thrown by the SDK (its real HTTP response lives only in the Network
log — the Protocol entry is a synthetic fold) OR tied to the HTTP request
headers. A delivered, protocol-only error gets no link, since the raw HTTP
entry adds nothing over the alert.

- -32020 HeaderMismatch  → link (the mirrored headers are the problem)
- -32601 MethodNotFound  → link (thrown; the 404 response is only in Network)
- -32021 MissingRequiredClientCapability → no link (delivered, protocol-only)
- -32022 UnsupportedProtocolVersion      → no link (supported list is in the alert)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: cite SEP-2575 for cancellation-as-abort, not the internal doc's §7.5

The cancellation note referenced "(SEP §7.5)", but §7.5 is a section of the
internal v2_new_spec_impact.md doc, not a SEP. The behavior — closing the
Streamable HTTP request stream is the cancellation signal; notifications/cancelled
is stdio-only — is defined in SEP-2575 (stateless-mcp). Cite SEP-2575 in the
user-facing alert and the two related comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: fix reveal one-shot canceling its own scroll; review doc/comment nits

Address @claude review of #1722:
- Reveal one-shot: move onRevealComplete() inside the rAF (after scrollIntoView)
  so clearing revealId (which flips revealed->false and runs the effect cleanup)
  can no longer race/cancel the pending scroll. Add a flip-back test.
- Comment the intentional double-mount of revealId (pinned monitor + full-screen
  ScreenStage both match; clear is idempotent).
- Doc drift after the Network->Protocol relocation: McpErrorBadge + the
  mcpNetworkHeaders module header now say the spec-error chip lives in Protocol.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: clarify redaction array-of-scalars edge + revealableIds superset

Address second @claude review of #1722 (both non-blocking polish notes):
- fetchTracking redaction: note that masking assumes a sensitive value is a
  scalar or object; an array-of-scalars under a sensitive key would slip through
  (no OAuth/token payload has that shape).
- revealableMessageIds: document it's a superset of the linked entries; the
  spec-error/httpRelevant narrowing is applied downstream at render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: gate modern -32601 framing to a genuine 404, not in-band 200

Address third @claude review of #1722. classifyProtocolSpecError recognised
-32601 from the code alone, so an ordinary in-band 'method not found' returned
on HTTP 200 was dressed up as the modern MethodNotFound taxonomy: the spec
badge, a 404-asserting alert, and a 'reveal in Network' link that jumped to a
plain 200 (claiming diagnostic value that wasn't there).

Gate the modern -32601 treatment on the correlated fetch actually being a 404
(mirroring classifyMcpSpecError). -32020/-32021/-32022 are SEP-reserved and
unaffected. The correlated HTTP status is threaded from InspectorView (new
correlatedFetchStatusById) through ProtocolScreen/ProtocolListPanel to
ProtocolEntry; when unknown, -32601 falls back to code-only recognition (the
thrown-404 path always carries a 404). Badge, alert copy, and link now resolve
together, so a plain in-band -32601 falls through to ordinary error rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: require a genuine 404 for modern -32601 (drop unknown-status fallback)

Address fourth @claude review of #1722. The prior fix only rejected -32601 when
the status was *known* and not 404; an unknown status fell back to code-only
recognition. But the thrown-404 path always carries a 404 (folded in from a 404
fetch), so the unknown-status branch is only ever reached by an ordinary in-band
-32601 with no correlated 404 — most notably a stdio connection (no HTTP at all)
— which then wrongly got the modern MethodNotFound badge and 404-asserting copy.

Drop the escape hatch: -32601 is the modern taxonomy only on httpStatus === 404.
Nothing intended is lost (the genuine case always has its 404). Update the tests
(code-only/undefined -32601 now expects null; add a stdio no-correlated-fetch
ProtocolEntry case) and mark the MethodNotFound story with correlatedHttpStatus
404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-07-18 17:32:05 -04:00 • committed by GitHub
commit d552564966
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 2592 additions and 42 deletions

View file

@ -0,0 +1,219 @@
import { describe, expect, it } from "vitest";
import type {
MessageEntry,
FetchRequestEntry,
} from "@inspector/core/mcp/types.js";
import {
correlateFetchEntry,
correlatedFetchStatusById,
enrichProtocolEntries,
messageJsonRpcId,
revealableMessageIds,
} from "./correlateTransportErrors";
function requestEntry(id: number, method = "tools/call"): MessageEntry {
return {
id: `msg-${id}`,
timestamp: new Date("2026-07-28T10:30:00Z"),
direction: "request",
origin: "client",
message: { jsonrpc: "2.0", id, method, params: { name: "t" } },
};
}
function transportFetch(
jsonRpcId: number | string,
responseBody: string | undefined,
overrides: Partial<FetchRequestEntry> = {},
): FetchRequestEntry {
return {
id: `fetch-${jsonRpcId}`,
timestamp: new Date("2026-07-28T10:30:00Z"),
method: "POST",
url: "https://example.com/mcp",
requestHeaders: {},
requestBody: JSON.stringify({
jsonrpc: "2.0",
id: jsonRpcId,
method: "tools/call",
}),
responseStatus: 404,
responseBody,
category: "transport",
...overrides,
};
}
const errorBody = (code: number, data?: unknown) =>
JSON.stringify({
jsonrpc: "2.0",
id: 1,
error: { code, message: "x", ...(data ? { data } : {}) },
});
describe("messageJsonRpcId", () => {
it("returns the request id, undefined for notifications", () => {
expect(messageJsonRpcId(requestEntry(7))).toBe(7);
const note: MessageEntry = {
id: "n",
timestamp: new Date(),
direction: "notification",
message: { jsonrpc: "2.0", method: "notifications/message" },
};
expect(messageJsonRpcId(note)).toBeUndefined();
});
});
describe("enrichProtocolEntries", () => {
it("folds a thrown transport error onto its pending request (by JSON-RPC id)", () => {
const messages = [requestEntry(1)];
const fetches = [transportFetch(1, errorBody(-32601))];
const [out] = enrichProtocolEntries(messages, fetches);
expect(
out.response && "error" in out.response && out.response.error.code,
).toBe(-32601);
});
it("returns the SAME array reference when nothing matches", () => {
const messages = [requestEntry(1)];
// A fetch for a different id, and one with no error body.
const fetches = [
transportFetch(2, errorBody(-32601)),
transportFetch(1, '{"jsonrpc":"2.0","id":1,"result":{}}'),
];
expect(enrichProtocolEntries(messages, fetches)).toBe(messages);
});
it("leaves an already-answered request untouched", () => {
const answered: MessageEntry = {
...requestEntry(1),
response: { jsonrpc: "2.0", id: 1, result: {} },
};
const fetches = [transportFetch(1, errorBody(-32601))];
const [out] = enrichProtocolEntries([answered], fetches);
expect("result" in (out.response ?? {})).toBe(true);
});
it("ignores auth-category fetches", () => {
const messages = [requestEntry(1)];
const fetches = [
transportFetch(1, errorBody(-32020), { category: "auth" }),
];
expect(enrichProtocolEntries(messages, fetches)).toBe(messages);
});
});
describe("correlateFetchEntry", () => {
it("finds the transport fetch with a matching JSON-RPC id (most recent wins)", () => {
const older = transportFetch(1, errorBody(-32020), { id: "fetch-old" });
const newer = transportFetch(1, errorBody(-32020), { id: "fetch-new" });
expect(correlateFetchEntry(requestEntry(1), [older, newer])?.id).toBe(
"fetch-new",
);
});
it("returns undefined for a notification or when no fetch matches", () => {
const note: MessageEntry = {
id: "n",
timestamp: new Date(),
direction: "notification",
message: { jsonrpc: "2.0", method: "notifications/message" },
};
expect(
correlateFetchEntry(note, [transportFetch(1, undefined)]),
).toBeUndefined();
expect(
correlateFetchEntry(requestEntry(9), [transportFetch(1, undefined)]),
).toBeUndefined();
});
});
describe("request-body id parsing edge cases", () => {
it("skips a non-JSON or batch (array) request body", () => {
const messages = [requestEntry(1)];
const fetches = [
{ ...transportFetch(1, errorBody(-32601)), requestBody: "not json" },
{ ...transportFetch(1, errorBody(-32601)), requestBody: "[1,2]" },
{ ...transportFetch(1, errorBody(-32601)), requestBody: undefined },
];
expect(enrichProtocolEntries(messages, fetches)).toBe(messages);
});
it("matches a string JSON-RPC id and preserves the error data", () => {
const message: MessageEntry = {
...requestEntry(1),
message: { jsonrpc: "2.0", id: "abc", method: "tools/call" },
};
const fetch = transportFetch(
"abc",
errorBody(-32022, { supported: ["2026-07-28"] }),
);
const [out] = enrichProtocolEntries([message], [fetch]);
const folded =
out.response && "error" in out.response ? out.response.error : undefined;
expect(folded?.code).toBe(-32022);
expect((folded?.data as { supported: string[] }).supported).toEqual([
"2026-07-28",
]);
});
});
describe("revealableMessageIds", () => {
it("returns message ids that have a correlated transport fetch", () => {
const messages = [requestEntry(1), requestEntry(2)];
// Only id 1 has a transport fetch; id 2 has only an auth fetch.
const fetches = [
transportFetch(1, undefined),
transportFetch(2, undefined, { category: "auth" }),
];
const ids = revealableMessageIds(messages, fetches);
expect(ids.has("msg-1")).toBe(true);
expect(ids.has("msg-2")).toBe(false);
});
it("ignores notifications (no JSON-RPC id) and unparseable fetch bodies", () => {
const note: MessageEntry = {
id: "note",
timestamp: new Date(),
direction: "notification",
message: { jsonrpc: "2.0", method: "notifications/message" },
};
const fetches = [{ ...transportFetch(1, undefined), requestBody: "nope" }];
expect(revealableMessageIds([note, requestEntry(1)], fetches).size).toBe(0);
});
});
describe("correlatedFetchStatusById", () => {
it("maps each message id to its correlated transport fetch's HTTP status", () => {
const messages = [requestEntry(1), requestEntry(2)];
const fetches = [
transportFetch(1, undefined, { responseStatus: 404 }),
transportFetch(2, undefined, { responseStatus: 200 }),
];
const byId = correlatedFetchStatusById(messages, fetches);
expect(byId.get("msg-1")).toBe(404);
expect(byId.get("msg-2")).toBe(200);
});
it("skips non-transport fetches, statusless fetches, and uncorrelated messages", () => {
const messages = [requestEntry(1), requestEntry(2), requestEntry(3)];
const fetches = [
transportFetch(1, undefined, { category: "auth", responseStatus: 200 }),
transportFetch(2, undefined, { responseStatus: undefined }),
// id 3 has no fetch at all
];
const byId = correlatedFetchStatusById(messages, fetches);
expect(byId.has("msg-1")).toBe(false); // auth category ignored
expect(byId.has("msg-2")).toBe(false); // no responseStatus recorded
expect(byId.has("msg-3")).toBe(false); // uncorrelated
});
it("keeps the most recent status when a JSON-RPC id repeats", () => {
const messages = [requestEntry(1)];
const fetches = [
transportFetch(1, undefined, { id: "fetch-a", responseStatus: 202 }),
transportFetch(1, undefined, { id: "fetch-b", responseStatus: 404 }),
];
expect(correlatedFetchStatusById(messages, fetches).get("msg-1")).toBe(404);
});
});

View file

@ -0,0 +1,180 @@
import type {
MessageEntry,
FetchRequestEntry,
} from "@inspector/core/mcp/types.js";
import type { JSONRPCErrorResponse } from "@modelcontextprotocol/client";
import { parseJsonRpcError } from "./mcpNetworkHeaders";
/**
* Correlation between the Protocol log (`MessageEntry`) and the Network log
* (`FetchRequestEntry`). The two logs share no key, but a transport fetch's
* `requestBody` is the serialized JSON-RPC message, so its `id` matches the
* Protocol request's `message.id`. This lets the Protocol view (a) surface the
* transport errors the SDK *throws* rather than delivers (e.g. `-32601`, which
* comes back as HTTP 404) and (b) link a Protocol error to its HTTP entry.
*/
/** The JSON-RPC `id` from a request/response body string, or null. */
function parseJsonRpcId(body: string | undefined): string | number | null {
if (!body) return null;
try {
const parsed: unknown = JSON.parse(body);
if (
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed) &&
"id" in parsed
) {
const id = (parsed as { id: unknown }).id;
if (typeof id === "string" || typeof id === "number") return id;
}
} catch {
/* not JSON — no id */
}
return null;
}
/** The JSON-RPC `id` a MessageEntry carries (request id; notifications have none). */
export function messageJsonRpcId(
entry: MessageEntry,
): string | number | undefined {
// Requests / results / errors carry an `id`; a notification does not. When
// present it is a well-typed `RequestId` (string | number).
const msg = entry.message;
return "id" in msg ? msg.id : undefined;
}
/**
* Index the transport fetch entries that carry a JSON-RPC *error* response body,
* keyed by the JSON-RPC id of the request that produced them. When ids repeat
* (retries / reconnects), the last (most recent) wins.
*/
function indexTransportFetchErrors(
fetchEntries: FetchRequestEntry[],
): Map<string, JSONRPCErrorResponse> {
const byId = new Map<string, JSONRPCErrorResponse>();
for (const fetchEntry of fetchEntries) {
if (fetchEntry.category !== "transport") continue;
const requestId = parseJsonRpcId(fetchEntry.requestBody);
if (requestId === null) continue;
const error = parseJsonRpcError(fetchEntry.responseBody);
if (!error) continue;
byId.set(String(requestId), {
jsonrpc: "2.0",
id: requestId,
error: {
code: error.code,
message: error.message,
...(error.data !== undefined ? { data: error.data } : {}),
},
});
}
return byId;
}
/**
* The transport `FetchRequestEntry` whose request carried the same JSON-RPC id
* as `entry` — the HTTP entry to reveal when the user clicks through from a
* Protocol error. Returns the most recent match, or undefined.
*/
export function correlateFetchEntry(
entry: MessageEntry,
fetchEntries: FetchRequestEntry[],
): FetchRequestEntry | undefined {
const targetId = messageJsonRpcId(entry);
if (targetId === undefined) return undefined;
let match: FetchRequestEntry | undefined;
for (const fetchEntry of fetchEntries) {
if (fetchEntry.category !== "transport") continue;
const requestId = parseJsonRpcId(fetchEntry.requestBody);
if (requestId !== null && String(requestId) === String(targetId)) {
match = fetchEntry;
}
}
return match;
}
/**
* The set of Protocol `MessageEntry` ids that have a correlated transport fetch.
* This is a **superset** of the entries that actually show a "reveal in Network"
* link — it includes any correlated message (even a successful `tools/call`), not
* just spec errors. The narrowing gate is applied downstream at render:
* `ProtocolListPanel` only wires `onRevealInNetwork` for ids in this set, and
* `ProtocolEntry` only renders the anchor when the entry's spec error is
* `httpRelevant` — so a non-error correlated message never produces a stray link.
* Indexes the fetch log once by JSON-RPC id, so this is O(messages + fetches).
*/
export function revealableMessageIds(
messages: MessageEntry[],
fetchEntries: FetchRequestEntry[],
): Set<string> {
const fetchIdByRequestId = new Set<string>();
for (const fetchEntry of fetchEntries) {
if (fetchEntry.category !== "transport") continue;
const requestId = parseJsonRpcId(fetchEntry.requestBody);
if (requestId !== null) fetchIdByRequestId.add(String(requestId));
}
const ids = new Set<string>();
for (const entry of messages) {
const id = messageJsonRpcId(entry);
if (id !== undefined && fetchIdByRequestId.has(String(id)))
ids.add(entry.id);
}
return ids;
}
/**
* Map each Protocol `MessageEntry` id to the HTTP status of its correlated
* transport fetch (when the fetch recorded one; last match wins on repeated ids).
* This lets the Protocol view apply the HTTP-status-dependent classification it
* otherwise can't — notably gating the generic `-32601` to a genuine modern 404
* rather than an ordinary in-band error on a 200. O(messages + fetches).
*/
export function correlatedFetchStatusById(
messages: MessageEntry[],
fetchEntries: FetchRequestEntry[],
): Map<string, number> {
const statusByRequestId = new Map<string, number>();
for (const fetchEntry of fetchEntries) {
if (fetchEntry.category !== "transport") continue;
if (fetchEntry.responseStatus === undefined) continue;
const requestId = parseJsonRpcId(fetchEntry.requestBody);
if (requestId !== null)
statusByRequestId.set(String(requestId), fetchEntry.responseStatus);
}
const byMessageId = new Map<string, number>();
for (const entry of messages) {
const id = messageJsonRpcId(entry);
if (id === undefined) continue;
const status = statusByRequestId.get(String(id));
if (status !== undefined) byMessageId.set(entry.id, status);
}
return byMessageId;
}
/**
* Fold a synthetic error `response` into any still-pending request whose
* correlated transport fetch carried a JSON-RPC error. This surfaces the errors
* the SDK throws instead of delivering (notably `-32601` on HTTP 404) into the
* Protocol view, without mutating the underlying log — the returned list is a
* shallow copy that only differs for the enriched entries (same object when
* nothing matched, so referential equality is preserved for memoisation).
*/
export function enrichProtocolEntries(
messages: MessageEntry[],
fetchEntries: FetchRequestEntry[],
): MessageEntry[] {
const errorsById = indexTransportFetchErrors(fetchEntries);
if (errorsById.size === 0) return messages;
let changed = false;
const enriched = messages.map((entry) => {
if (entry.direction !== "request" || entry.response) return entry;
const id = messageJsonRpcId(entry);
if (id === undefined) return entry;
const error = errorsById.get(String(id));
if (!error) return entry;
changed = true;
return { ...entry, response: error };
});
return changed ? enriched : messages;
}

View file

@ -0,0 +1,367 @@
import { describe, expect, it } from "vitest";
import {
checkHeaderConsistency,
classifyMcpSpecError,
classifyProtocolSpecError,
decodeMcpParamValue,
HEADER_MISMATCH_ERROR_CODE,
isCancellationAbort,
isLegacyBare404,
isMcpHeader,
isMcpParamHeader,
isMcpStandardHeader,
mismatchedHeaders,
parseJsonRpcError,
PROTOCOL_VERSION_META_KEY,
} from "./mcpNetworkHeaders";
/** Mirror of the SDK's sentinel encoding, for building test inputs. */
function encodeSentinel(value: string): string {
const bytes = new TextEncoder().encode(value);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return `=?base64?${btoa(bin)}?=`;
}
describe("header name recognition", () => {
it("recognises standard mirrored headers case-insensitively", () => {
expect(isMcpStandardHeader("Mcp-Method")).toBe(true);
expect(isMcpStandardHeader("mcp-name")).toBe(true);
expect(isMcpStandardHeader("MCP-Protocol-Version")).toBe(true);
expect(isMcpStandardHeader("content-type")).toBe(false);
});
it("recognises Mcp-Param-* headers", () => {
expect(isMcpParamHeader("Mcp-Param-City")).toBe(true);
expect(isMcpParamHeader("mcp-param-x")).toBe(true);
expect(isMcpParamHeader("mcp-method")).toBe(false);
});
it("isMcpHeader covers both families", () => {
expect(isMcpHeader("mcp-method")).toBe(true);
expect(isMcpHeader("Mcp-Param-Foo")).toBe(true);
expect(isMcpHeader("authorization")).toBe(false);
});
});
describe("decodeMcpParamValue", () => {
it("passes plain ASCII values through unchanged", () => {
expect(decodeMcpParamValue("tools/call")).toEqual({
value: "tools/call",
encoded: false,
raw: "tools/call",
});
});
it("decodes a base64 sentinel value (incl. non-ASCII)", () => {
const raw = encodeSentinel("café ☕");
expect(decodeMcpParamValue(raw)).toEqual({
value: "café ☕",
encoded: true,
raw,
});
});
it("decodes an empty sentinel payload", () => {
const raw = encodeSentinel("");
expect(decodeMcpParamValue(raw)).toEqual({ value: "", encoded: true, raw });
});
it("reports encoded but keeps the raw when the payload is not valid base64", () => {
const raw = "=?base64?not!base64!?=";
const result = decodeMcpParamValue(raw);
expect(result.encoded).toBe(true);
expect(result.raw).toBe(raw);
});
it("does not treat a too-short lookalike as a sentinel", () => {
expect(decodeMcpParamValue("?=").encoded).toBe(false);
});
});
describe("parseJsonRpcError", () => {
it("returns null for empty / non-JSON / non-error bodies", () => {
expect(parseJsonRpcError(undefined)).toBeNull();
expect(parseJsonRpcError("")).toBeNull();
expect(parseJsonRpcError("not json")).toBeNull();
expect(
parseJsonRpcError('{"jsonrpc":"2.0","id":1,"result":{}}'),
).toBeNull();
});
it("extracts a JSON-RPC error object", () => {
const body = JSON.stringify({
jsonrpc: "2.0",
id: 1,
error: { code: -32020, message: "header mismatch", data: { x: 1 } },
});
expect(parseJsonRpcError(body)).toEqual({
code: -32020,
message: "header mismatch",
data: { x: 1 },
});
});
it("defaults a missing message to empty string", () => {
const body = JSON.stringify({ error: { code: -32601 } });
expect(parseJsonRpcError(body)).toEqual({
code: -32601,
message: "",
data: undefined,
});
});
it("finds the first error in a batched response", () => {
const body = JSON.stringify([
{ jsonrpc: "2.0", id: 1, result: {} },
{ jsonrpc: "2.0", id: 2, error: { code: -32021, message: "cap" } },
]);
expect(parseJsonRpcError(body)?.code).toBe(-32021);
});
it("ignores an error object with a non-numeric code", () => {
const body = JSON.stringify({ error: { code: "nope", message: "x" } });
expect(parseJsonRpcError(body)).toBeNull();
});
it("ignores a null error and non-object candidates", () => {
expect(parseJsonRpcError(JSON.stringify({ error: null }))).toBeNull();
expect(parseJsonRpcError(JSON.stringify([null, 5]))).toBeNull();
});
});
describe("classifyMcpSpecError", () => {
it("classifies -32020 HeaderMismatch on a 400", () => {
const err = classifyMcpSpecError({
responseStatus: 400,
responseBody: JSON.stringify({
error: { code: HEADER_MISMATCH_ERROR_CODE },
}),
});
expect(err?.name).toBe("HeaderMismatch");
expect(err?.expectedHttpStatus).toBe(400);
expect(err?.actualHttpStatus).toBe(400);
});
it("classifies -32022 and extracts supported versions", () => {
const err = classifyMcpSpecError({
responseStatus: 400,
responseBody: JSON.stringify({
error: {
code: -32022,
data: { supported: ["2025-11-25", "2026-07-28", 5] },
},
}),
});
expect(err?.name).toBe("UnsupportedProtocolVersion");
expect(err?.supported).toEqual(["2025-11-25", "2026-07-28"]);
});
it("omits supported when data is missing or malformed", () => {
const err = classifyMcpSpecError({
responseStatus: 400,
responseBody: JSON.stringify({ error: { code: -32022, data: {} } }),
});
expect(err?.supported).toBeUndefined();
});
it("treats -32601 on a 404 as the modern method-not-found marker", () => {
const err = classifyMcpSpecError({
responseStatus: 404,
responseBody: JSON.stringify({ error: { code: -32601 } }),
});
expect(err?.name).toBe("MethodNotFound");
expect(err?.expectedHttpStatus).toBe(404);
});
it("does NOT classify an in-band -32601 on a 200", () => {
expect(
classifyMcpSpecError({
responseStatus: 200,
responseBody: JSON.stringify({ error: { code: -32601 } }),
}),
).toBeNull();
});
it("returns null for a non-spec error code", () => {
expect(
classifyMcpSpecError({
responseStatus: 500,
responseBody: JSON.stringify({ error: { code: -32603 } }),
}),
).toBeNull();
});
it("returns null when there is no error body", () => {
expect(
classifyMcpSpecError({ responseStatus: 200, responseBody: "{}" }),
).toBeNull();
});
});
describe("classifyProtocolSpecError", () => {
it("classifies the 400-status spec errors from the code alone", () => {
// -32020/-32021/-32022 are SEP-reserved and unambiguous — no status needed.
expect(classifyProtocolSpecError(HEADER_MISMATCH_ERROR_CODE)?.name).toBe(
"HeaderMismatch",
);
});
it("treats -32601 as MethodNotFound only on a genuine 404", () => {
// Thrown modern 404 → the transport taxonomy.
expect(classifyProtocolSpecError(-32601, undefined, 404)?.name).toBe(
"MethodNotFound",
);
// Ordinary in-band method-not-found on a 200 → not the modern taxonomy.
expect(classifyProtocolSpecError(-32601, undefined, 200)).toBeNull();
// Unknown status (e.g. a stdio connection with no HTTP at all) is not a 404,
// so it is likewise not the modern taxonomy.
expect(classifyProtocolSpecError(-32601)).toBeNull();
expect(classifyProtocolSpecError(-32601, undefined, undefined)).toBeNull();
});
it("does not gate the 400-status spec errors on HTTP status", () => {
// -32020/-32021/-32022 are SEP-reserved and unambiguous, so a non-404 status
// (they arrive on 400) must not suppress them.
expect(
classifyProtocolSpecError(HEADER_MISMATCH_ERROR_CODE, undefined, 400)
?.name,
).toBe("HeaderMismatch");
expect(
classifyProtocolSpecError(-32022, { supported: ["2026-07-28"] }, 400)
?.name,
).toBe("UnsupportedProtocolVersion");
});
it("extracts supported versions for -32022", () => {
expect(
classifyProtocolSpecError(-32022, { supported: ["2026-07-28"] })
?.supported,
).toEqual(["2026-07-28"]);
});
it("returns null for a non-spec code", () => {
expect(classifyProtocolSpecError(-32000)).toBeNull();
});
});
describe("isLegacyBare404", () => {
it("is true for a 404 with no JSON-RPC body", () => {
expect(
isLegacyBare404({ responseStatus: 404, responseBody: "Not Found" }),
).toBe(true);
expect(
isLegacyBare404({ responseStatus: 404, responseBody: undefined }),
).toBe(true);
});
it("is false for a modern -32601 404 or a non-404", () => {
expect(
isLegacyBare404({
responseStatus: 404,
responseBody: JSON.stringify({ error: { code: -32601 } }),
}),
).toBe(false);
expect(isLegacyBare404({ responseStatus: 200, responseBody: "" })).toBe(
false,
);
});
});
describe("checkHeaderConsistency", () => {
const version = "2026-07-28";
it("returns [] when the body is not a JSON-RPC request", () => {
expect(
checkHeaderConsistency({ requestHeaders: {}, requestBody: "not json" }),
).toEqual([]);
expect(
checkHeaderConsistency({ requestHeaders: {}, requestBody: "[1,2]" }),
).toEqual([]);
});
it("reports agreeing headers as ok", () => {
const rows = checkHeaderConsistency({
requestHeaders: {
"mcp-method": "tools/call",
"mcp-name": encodeSentinel("get_weather"),
"mcp-protocol-version": version,
},
requestBody: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "get_weather",
_meta: { [PROTOCOL_VERSION_META_KEY]: version },
},
}),
});
expect(rows).toHaveLength(3);
expect(rows.every((r) => r.ok)).toBe(true);
});
it("flags a method mismatch and a name mismatch", () => {
const rows = checkHeaderConsistency({
requestHeaders: {
"mcp-method": "tools/list",
"mcp-name": "wrong_tool",
},
requestBody: JSON.stringify({
method: "tools/call",
params: { name: "get_weather" },
}),
});
const byHeader = Object.fromEntries(rows.map((r) => [r.header, r]));
expect(byHeader["mcp-method"].ok).toBe(false);
expect(byHeader["mcp-method"].expected).toBe("tools/call");
expect(byHeader["mcp-name"].ok).toBe(false);
});
it("uses params.uri for mcp-name on resources/read", () => {
const rows = checkHeaderConsistency({
requestHeaders: { "mcp-name": "file:///a.txt" },
requestBody: JSON.stringify({
method: "resources/read",
params: { uri: "file:///a.txt" },
}),
});
expect(rows[0]).toMatchObject({ header: "mcp-name", ok: true });
});
it("skips headers with no derivable body counterpart", () => {
const rows = checkHeaderConsistency({
requestHeaders: { "mcp-protocol-version": version },
requestBody: JSON.stringify({ method: "ping", params: {} }),
});
// No _meta version in the body → the version row is skipped, not flagged.
expect(
rows.find((r) => r.header === "mcp-protocol-version"),
).toBeUndefined();
});
it("mismatchedHeaders returns only the failing header names", () => {
const set = mismatchedHeaders({
requestHeaders: { "mcp-method": "tools/list" },
requestBody: JSON.stringify({ method: "tools/call", params: {} }),
});
expect(set.has("mcp-method")).toBe(true);
expect(set.size).toBe(1);
});
});
describe("isCancellationAbort", () => {
it("detects abort / cancellation error text", () => {
expect(isCancellationAbort({ error: "The operation was aborted" })).toBe(
true,
);
expect(isCancellationAbort({ error: "Request cancelled by user" })).toBe(
true,
);
});
it("is false for other errors or no error", () => {
expect(isCancellationAbort({ error: "ECONNREFUSED" })).toBe(false);
expect(isCancellationAbort({ error: undefined })).toBe(false);
});
});

View file

@ -0,0 +1,432 @@
import { ProtocolErrorCode } from "@modelcontextprotocol/client";
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
/**
* SEP-2243 / modern Streamable HTTP transport awareness for the monitoring tabs.
*
* The modern (≥2026-07-28) transport mirrors key JSON-RPC body fields into HTTP
* headers so intermediaries can route/police MCP traffic without parsing bodies
* (`Mcp-Method`, `Mcp-Name`, `Mcp-Param-*`, `MCP-Protocol-Version`), and defines
* a small family of spec error codes returned as JSON-RPC bodies over specific
* HTTP statuses. This module is the pure logic those tabs use to recognise,
* decode, and validate them — no rendering, fully unit-testable. The header
* recognition/decoding/consistency helpers back the Network tab; the spec-error
* classification (`classifyProtocolSpecError`) backs the Protocol tab. Shared
* here because both are the same modern HTTP/spec vocabulary.
*
* Recorded header keys are lowercased (the fetch tracker reads them through the
* `Headers` API, which normalises names), so every comparison here is
* case-insensitive on the name.
*/
/**
* `-32020 HeaderMismatch` (SEP-2243). The client SDK keeps this in an internal
* chunk that isn't re-exported from its public barrel, so the spec-reserved
* value is pinned locally. The other three codes come from the SDK's
* {@link ProtocolErrorCode} enum.
*/
export const HEADER_MISMATCH_ERROR_CODE = -32020;
const BASE64_SENTINEL_PREFIX = "=?base64?";
const BASE64_SENTINEL_SUFFIX = "?=";
const MCP_STANDARD_HEADER_NAMES: ReadonlySet<string> = new Set([
"mcp-method",
"mcp-name",
"mcp-protocol-version",
]);
const MCP_PARAM_HEADER_PREFIX = "mcp-param-";
/** JSON-RPC `_meta` key carrying the negotiated protocol version. */
export const PROTOCOL_VERSION_META_KEY =
"io.modelcontextprotocol/protocolVersion";
/** Whether `name` is one of the standard mirrored headers (case-insensitive). */
export function isMcpStandardHeader(name: string): boolean {
return MCP_STANDARD_HEADER_NAMES.has(name.toLowerCase());
}
/** Whether `name` is an opt-in `Mcp-Param-*` custom header (case-insensitive). */
export function isMcpParamHeader(name: string): boolean {
return name.toLowerCase().startsWith(MCP_PARAM_HEADER_PREFIX);
}
/** Whether `name` is any modern MCP mirrored header (standard or `Mcp-Param-*`). */
export function isMcpHeader(name: string): boolean {
return isMcpStandardHeader(name) || isMcpParamHeader(name);
}
export interface DecodedMcpParamValue {
/** The value shown to the user: decoded when sentinel-encoded, else the raw. */
value: string;
/** True when the raw header used the `=?base64?{b64}?=` sentinel form. */
encoded: boolean;
/** The original, undecoded header value. */
raw: string;
}
/**
* Decode a mirrored header value per SEP-2243's value-encoding rules. A value
* wrapped as `=?base64?{base64-of-utf8}?=` is decoded to its UTF-8 string;
* anything else is passed through unchanged. A sentinel wrapper whose inner
* payload is not valid Base64 is reported as `encoded: true` but left as the raw
* string (best-effort — never throws).
*/
export function decodeMcpParamValue(raw: string): DecodedMcpParamValue {
const isSentinel =
raw.length >=
BASE64_SENTINEL_PREFIX.length + BASE64_SENTINEL_SUFFIX.length &&
raw.startsWith(BASE64_SENTINEL_PREFIX) &&
raw.endsWith(BASE64_SENTINEL_SUFFIX);
if (!isSentinel) return { value: raw, encoded: false, raw };
const b64 = raw.slice(
BASE64_SENTINEL_PREFIX.length,
raw.length - BASE64_SENTINEL_SUFFIX.length,
);
try {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, (ch) => ch.charCodeAt(0));
return { value: new TextDecoder().decode(bytes), encoded: true, raw };
} catch {
return { value: raw, encoded: true, raw };
}
}
export interface JsonRpcError {
code: number;
message: string;
data?: unknown;
}
/**
* Extract the first JSON-RPC `error` object from a (possibly batched) response
* body. Returns `null` for an empty, non-JSON, or error-free body. Best-effort
* and never throws.
*/
export function parseJsonRpcError(
body: string | undefined,
): JsonRpcError | null {
if (!body) return null;
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
return null;
}
const candidates = Array.isArray(parsed) ? parsed : [parsed];
for (const candidate of candidates) {
if (candidate === null || typeof candidate !== "object") continue;
if (!("error" in candidate)) continue;
const err = (candidate as { error: unknown }).error;
if (err === null || typeof err !== "object") continue;
const { code, message, data } = err as {
code?: unknown;
message?: unknown;
data?: unknown;
};
if (typeof code !== "number") continue;
return {
code,
message: typeof message === "string" ? message : "",
data,
};
}
return null;
}
export interface McpSpecError {
code: number;
/** Spec name, e.g. `HeaderMismatch`. */
name: string;
/** One-line explanation for the Network UI. */
description: string;
/** HTTP status the spec pairs this code with. */
expectedHttpStatus: number;
/** The actual HTTP status recorded on the entry, when present. */
actualHttpStatus?: number;
/**
* For `-32022 UnsupportedProtocolVersion`: the versions the server advertises
* as supported (from `error.data.supported`), when present.
*/
supported?: string[];
/**
* Whether a "view in Network" link is worth offering on the Protocol alert.
* True when the error is EITHER thrown by the SDK (its real HTTP response
* lives only in the Network log — the Protocol entry is a synthetic fold from
* the correlated fetch) OR tied to the HTTP request/response headers. A
* delivered, protocol-only error (a missing capability, an unsupported version
* whose `supported` list is already in the alert) sets this false: the raw
* HTTP entry adds nothing.
*/
httpRelevant: boolean;
}
const SPEC_ERROR_META: Record<
number,
{
name: string;
description: string;
expectedHttpStatus: number;
httpRelevant: boolean;
}
> = {
[HEADER_MISMATCH_ERROR_CODE]: {
name: "HeaderMismatch",
description:
"An Mcp-* header did not match the JSON-RPC body (SEP-2243). The server rejected the request pre-dispatch.",
expectedHttpStatus: 400,
// The mirrored headers are the whole story — the Network entry shows them.
httpRelevant: true,
},
[ProtocolErrorCode.MissingRequiredClientCapability]: {
name: "MissingRequiredClientCapability",
description:
"The server requires a client capability that was not declared (SEP-2575).",
expectedHttpStatus: 400,
// Protocol-only: the capability requirement is in the error, not the HTTP.
httpRelevant: false,
},
[ProtocolErrorCode.UnsupportedProtocolVersion]: {
name: "UnsupportedProtocolVersion",
description:
"The requested protocol version is not supported (SEP-2575). The error body lists the supported versions.",
expectedHttpStatus: 400,
// Protocol-only: the supported-versions list is already shown in the alert.
httpRelevant: false,
},
[ProtocolErrorCode.MethodNotFound]: {
name: "MethodNotFound",
description:
"Unknown method. A JSON-RPC error body on an HTTP 404 marks a modern server — a legacy HTTP+SSE server returns a bare 404 with no body.",
expectedHttpStatus: 404,
// Thrown by the SDK (HTTP 404, not delivered as a frame) — the real
// response lives only in the Network log, so the link is essential.
httpRelevant: true,
},
};
function extractSupportedVersions(data: unknown): string[] | undefined {
if (data === null || typeof data !== "object") return undefined;
const supported = (data as { supported?: unknown }).supported;
if (!Array.isArray(supported)) return undefined;
const strings = supported.filter((v): v is string => typeof v === "string");
return strings.length > 0 ? strings : undefined;
}
/**
* Classify a Network entry's response as one of the modern spec errors, or
* `null` if it isn't one. `-32601 MethodNotFound` is only treated as the modern
* marker when it arrives on an HTTP 404 (an in-band `-32601` on a 200 response
* is an ordinary result, not the transport-level taxonomy this surfaces).
*/
export function classifyMcpSpecError(
entry: Pick<FetchRequestEntry, "responseBody" | "responseStatus">,
): McpSpecError | null {
const err = parseJsonRpcError(entry.responseBody);
if (!err) return null;
const meta = SPEC_ERROR_META[err.code];
if (!meta) return null;
if (
err.code === ProtocolErrorCode.MethodNotFound &&
entry.responseStatus !== 404
) {
return null;
}
const result: McpSpecError = {
code: err.code,
...meta,
actualHttpStatus: entry.responseStatus,
};
if (err.code === ProtocolErrorCode.UnsupportedProtocolVersion) {
const supported = extractSupportedVersions(err.data);
if (supported) result.supported = supported;
}
return result;
}
/**
* Classify a JSON-RPC error *code* (e.g. from a Protocol message's
* `response.error`) as one of the modern spec errors, or `null`.
*
* `-32020`/`-32021`/`-32022` are SEP-reserved and unambiguous, so they're
* recognised from the code alone. `-32601 MethodNotFound`, by contrast, is the
* most generic standard JSON-RPC error — any server can return it *in-band* for
* an unsupported method, which is not the modern transport taxonomy. So it's
* treated as the modern marker only when the correlated fetch was an actual 404
* (`httpStatus === 404`), mirroring {@link classifyMcpSpecError}. The genuine
* modern case is thrown by the SDK on a 404 and folded in by
* `enrichProtocolEntries`, which always carries that 404 — so requiring 404 loses
* nothing intended. An unknown status (`undefined`) is *not* a 404: that path is
* only reached by an ordinary in-band `-32601` with no correlated 404 (most
* commonly a **stdio** connection, which has no HTTP at all), so it must not get
* the modern framing.
*/
export function classifyProtocolSpecError(
code: number,
data?: unknown,
httpStatus?: number,
): McpSpecError | null {
const meta = SPEC_ERROR_META[code];
if (!meta) return null;
if (code === ProtocolErrorCode.MethodNotFound && httpStatus !== 404) {
return null;
}
const result: McpSpecError = { code, ...meta };
if (code === ProtocolErrorCode.UnsupportedProtocolVersion) {
const supported = extractSupportedVersions(data);
if (supported) result.supported = supported;
}
return result;
}
/**
* A bare HTTP 404 with no JSON-RPC body is how a legacy HTTP+SSE endpoint (or a
* non-MCP server) answers an unknown route — distinct from a modern server's
* `-32601` 404 (see {@link classifyMcpSpecError}). Surfacing it helps explain
* why a connection fell back to the legacy transport.
*/
export function isLegacyBare404(
entry: Pick<FetchRequestEntry, "responseBody" | "responseStatus">,
): boolean {
return (
entry.responseStatus === 404 &&
parseJsonRpcError(entry.responseBody) === null
);
}
export interface HeaderConsistency {
/** Canonical lowercase header name. */
header: string;
/** The value derived from the JSON-RPC body that the header should mirror. */
expected: string;
/** The header's value (sentinel-decoded), as actually sent. */
actual: string;
/** Whether the header and body agree. */
ok: boolean;
}
interface JsonRpcRequestBody {
method?: unknown;
params?: {
name?: unknown;
uri?: unknown;
_meta?: Record<string, unknown>;
};
}
function parseRequestBody(body: string | undefined): JsonRpcRequestBody | null {
if (!body) return null;
try {
const parsed: unknown = JSON.parse(body);
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
return null;
}
return parsed as JsonRpcRequestBody;
} catch {
return null;
}
}
function findHeaderValue(
headers: Record<string, string>,
name: string,
): string | undefined {
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === name) return value;
}
return undefined;
}
/**
* Cross-check the mirrored standard headers against the request body they
* derive from, so a `HeaderMismatch` is visible at a glance before the server
* even rejects it. A row is produced only when BOTH the header is present AND
* the corresponding body field can be derived — an unverifiable pair (e.g. a
* connection-level `mcp-protocol-version` on a body with no version envelope) is
* skipped rather than falsely flagged.
*
* Checks: `mcp-method` ↔ body `method`; `mcp-name` (decoded) ↔ body
* `params.name` / `params.uri`; `mcp-protocol-version` ↔ body
* `params._meta["io.modelcontextprotocol/protocolVersion"]`.
*/
export function checkHeaderConsistency(
entry: Pick<FetchRequestEntry, "requestHeaders" | "requestBody">,
): HeaderConsistency[] {
const body = parseRequestBody(entry.requestBody);
if (!body) return [];
const rows: HeaderConsistency[] = [];
const methodHeader = findHeaderValue(entry.requestHeaders, "mcp-method");
if (methodHeader !== undefined && typeof body.method === "string") {
rows.push({
header: "mcp-method",
expected: body.method,
actual: methodHeader,
ok: methodHeader === body.method,
});
}
const nameHeader = findHeaderValue(entry.requestHeaders, "mcp-name");
const bodyName =
typeof body.params?.name === "string"
? body.params.name
: typeof body.params?.uri === "string"
? body.params.uri
: undefined;
if (nameHeader !== undefined && bodyName !== undefined) {
const decoded = decodeMcpParamValue(nameHeader).value;
rows.push({
header: "mcp-name",
expected: bodyName,
actual: decoded,
ok: decoded === bodyName,
});
}
const versionHeader = findHeaderValue(
entry.requestHeaders,
"mcp-protocol-version",
);
const bodyVersion = body.params?._meta?.[PROTOCOL_VERSION_META_KEY];
if (versionHeader !== undefined && typeof bodyVersion === "string") {
rows.push({
header: "mcp-protocol-version",
expected: bodyVersion,
actual: versionHeader,
ok: versionHeader === bodyVersion,
});
}
return rows;
}
/** The `header` names from {@link checkHeaderConsistency} rows that mismatched. */
export function mismatchedHeaders(
entry: Pick<FetchRequestEntry, "requestHeaders" | "requestBody">,
): Set<string> {
return new Set(
checkHeaderConsistency(entry)
.filter((row) => !row.ok)
.map((row) => row.header),
);
}
/**
* Whether an entry's error is a cancellation surfaced as a connection abort.
* Under the modern transport, cancelling an in-flight request aborts the
* connection instead of sending a `notifications/cancelled` frame (SEP-2575), so
* a cancelled request lands here as an `AbortError` rather than a tracked frame.
*/
export function isCancellationAbort(
entry: Pick<FetchRequestEntry, "error">,
): boolean {
if (!entry.error) return false;
const message = entry.error.toLowerCase();
return message.includes("abort") || message.includes("cancel");
}