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>
432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
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");
|
|
}
|