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>
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|