diff --git a/AGENTS.md b/AGENTS.md index b0c6e67c..8cf77bb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ All work should be driven by items on the project board. Set the label at create time (`gh issue create --label v2 ...`, `gh pr create --label v2 ...`) — don't rely on backfilling later, since unlabeled PRs are easy to miss when filtering by version. - **Add the issue to the board and set Status.** After creating an issue, add it to board #28 and set its Status. (PRs are never added to the board — they're tracked through their linked issue's card.) This is the step most easily forgotten because it needs several IDs — copy the recipes below verbatim. -- When work begins, create a feature branch and set the item's Status to **In progress** (or one of the building statuses below). +- When work begins, create a feature branch and set the item's Status to **In progress** (or **SDK V2 + New Spec** for a card in that stack). - When work is complete: - Run format, lint, typecheck, build, and test — ensure all checks pass - Open a PR against the matching base branch (`main` for v1, `v2/main` for v2) and set the item's Status to **In review** @@ -140,20 +140,17 @@ gh project field-list 28 --owner modelcontextprotocol --format json \ | Project node ID | `PVT_kwDOCt2Azc4BJVxt` | | Status field ID | `PVTSSF_lADOCt2Azc4BJVxtzg5iI8c` | -Status option IDs (`--single-select-option-id`) — **last verified 2026-07-09**: +Status option IDs (`--single-select-option-id`) — **last verified 2026-07-18** (the `Building …` and `MCP Apps Extension` columns were removed; their old IDs `4ac261ee` / `c28da89f` / `73d0b807` are now rejected): | Status | Option ID | | --- | --- | | Todo | `fbdaf21e` | -| Building CLI / TUI / CORE | `4ac261ee` | -| Building Web | `c28da89f` | -| MCP Apps Extension | `73d0b807` | | SDK V2 + New Spec | `1bbb6f57` | | In Progress | `195df262` | | In Review | `159c8a02` | | Done | `248a3910` | -Use **Todo** for approved-but-not-started work, **In Progress** for general active work, one of the **Building** statuses (or **MCP Apps Extension** / **SDK V2 + New Spec**) while actively coding that surface, **In Review** once a PR is open, and **Done** on merge. +Use **Todo** for approved-but-not-started work, **In Progress** for general active work (regardless of surface), **SDK V2 + New Spec** for cards in that stack, **In Review** once a PR is open, and **Done** on merge. > ⚠️ **Never add, rename, or remove a board column (Status option) with the `updateProjectV2Field` GraphQL mutation unless you pass every existing option's `id`.** That mutation does a **full replace** of the option list: if you resend options by name/color/description but omit their `id`s, GitHub **deletes all existing options and mints new ones**, which **orphans the Status of every card on the board** (all items go blank) *and* invalidates every option id in the table above. This has happened once (required reconstructing ~197 items' statuses by inference). Safe alternatives, in order of preference: > 1. **Add/rename/remove a column in the GitHub web UI** (Project #28 → Status field settings). This preserves ids of untouched options and never orphans cards. diff --git a/README.md b/README.md index 7789d36c..6239b18a 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ The Vite alias `@modelcontextprotocol/inspector-test-server` (in `clients/web/vi A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era** via the SDK's `createMcpHandler` — set `transport.modern` in the JSON config (`true` for dual-era stateless serving, or `{ "legacy": "reject" }` for modern-only strict), or pass `modern` on the `ServerConfig` for an in-process `createTestServerHttp`. This is what lets an Inspector connection negotiating `protocolEra: "auto" | "modern"` reach the modern leg (populated `server/discover`, sessionless). See `test-servers/configs/modern-http.json`. `test-servers/configs/modern-mrtr-http.json` additionally serves the `mrtr_confirm` tool (preset `mrtr_confirm`, `createMrtrTool`) over the modern leg: its handler returns `inputRequired(...)` embedding a form elicitation, so invoking it produces a real MRTR round-trip (`input_required` → the client fulfils the embedded elicitation and retries with a new id → `complete`). The Inspector drives MRTR manually (`inputRequired: { autoFulfill: false }`), so the embedded elicitation pauses at the pending-request modal (tagged "input_required") for you to answer, then the retry completes — useful for eyeballing both that pending-request UX and the Protocol view's MRTR conversation grouping. `test-servers/configs/mrtr-showcase-http.json` bundles every MRTR preset in one modern server for manual testing: `mrtr_confirm` (single round), `mrtr_two_step` (two elicitation rounds via `requestState`), `mrtr_sample` (embedded sampling → the Sampling panel), `mrtr_roots` (embedded `roots/list`, auto-answered silently from configured roots — no modal), `mrtr_edge` (an `inputRequests`-only round then a `requestState`-only round), and `mrtr_loop` (never completes → the `MRTR_MAX_ROUNDS` bound trips). (The legacy `collect_elicitation` preset calls `server.elicitInput`, which errors on the 2026-07-28 leg — server→client requests aren't allowed there; MRTR is the modern replacement.) +`test-servers/configs/modern-network-http.json` is the **Network-tab showcase** for the standardized HTTP headers and new error taxonomy (SEP-2243 / SEP-2575). It serves a `get_weather` tool whose `city` argument carries an `x-mcp-header: "City"` annotation (so a modern client mirrors it to `Mcp-Param-City`), plus four `trigger_*` tools that the modern leg's spec-error injector (`transport.modern.injectSpecErrors: true`) answers with a real HTTP status + JSON-RPC error body: `trigger_header_mismatch` → `400 / -32020`, `trigger_missing_capability` → `400 / -32021`, `trigger_unsupported_version` → `400 / -32022` (with `data.supported`), `trigger_method_not_found` → `404 / -32601`. Connect to it with **Protocol Era = Modern** and open the Network tab to see the mirrored `Mcp-*` headers highlighted, sentinel values decoded, and each error rendered distinctly. Note: `Mcp-Param-*` mirroring is **skipped by the SDK in the browser** (`detectProbeEnvironment() !== "browser"`), so calling `get_weather` from the **web** client omits `Mcp-Param-City` and the strict server answers `-32020` — the same tool is callable from the Node CLI/TUI, where mirroring is active. + ## Building ```bash diff --git a/clients/web/src/App.css b/clients/web/src/App.css index a5d50ba2..9b278431 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -122,6 +122,10 @@ --inspector-badge-transport-fg: var(--mantine-color-blue-light-color); --inspector-badge-auth-bg: var(--mantine-color-violet-light); --inspector-badge-auth-fg: var(--mantine-color-violet-light-color); + + /* Accent for the modern MCP-mirrored header names (Mcp-Method / Mcp-Name / + Mcp-Param-* / MCP-Protocol-Version) in the Network tab's header tables. */ + --inspector-mcp-header-accent: var(--mantine-color-violet-7); } /* Dark mode overrides — tokens that need adjustment in dark scheme */ @@ -190,6 +194,7 @@ --inspector-badge-transport-fg: var(--mantine-color-blue-0); --inspector-badge-auth-bg: var(--mantine-color-violet-6); --inspector-badge-auth-fg: var(--mantine-color-violet-0); + --inspector-mcp-header-accent: var(--mantine-color-violet-4); /* ── A11y contrast (dark) ──────────────────────────────── */ /* Mantine's dark `dimmed` (dark-2) drops below AA on the lighter dark diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 600a7f5b..0a11e7d0 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -153,6 +153,7 @@ import { } from "./components/groups/PendingClientRequestModal/PendingClientRequestModal"; import { buildExportFilename, downloadJsonFile } from "./lib/downloadFile"; import { INSPECTOR_SERVERS_TAB } from "./utils/inspectorTabs"; +import { enrichProtocolEntries } from "./utils/correlateTransportErrors"; import { parseDeepLink, deepLinkConfigEquals, @@ -939,6 +940,15 @@ function App() { const { fetchRequests } = useFetchRequestLog(fetchRequestLogState); const { stderrLogs } = useStderrLog(stderrLogState); + // Fold the transport errors the SDK throws rather than delivers (e.g. -32601 + // on HTTP 404) onto their still-pending Protocol requests, by correlating with + // the Network log via JSON-RPC id. Returns `messages` unchanged when nothing + // matched, so the Protocol view only re-renders when an enrichment applies. + const protocolEntries = useMemo( + () => enrichProtocolEntries(messages, fetchRequests), + [messages, fetchRequests], + ); + // Surface the otherwise-invisible "response body dropped after rotation" case // (#1390) as a deduped toast that links to this server's Network Log Size // setting. The state manager only emits this when the drop is genuinely due @@ -3871,7 +3881,7 @@ function App() { logs={logs} tasks={tasks} progressByTaskId={progressByTaskId} - protocol={messages} + protocol={protocolEntries} protocolEra={protocolEra} network={fetchRequests} stderrLogs={stderrLogs} diff --git a/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.stories.tsx b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.stories.tsx new file mode 100644 index 00000000..4616b4fa --- /dev/null +++ b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { McpErrorBadge } from "./McpErrorBadge"; + +const meta: Meta = { + title: "Elements/McpErrorBadge", + component: McpErrorBadge, +}; + +export default meta; +type Story = StoryObj; + +export const HeaderMismatch: Story = { + args: { code: -32020, name: "HeaderMismatch" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("-32020 HeaderMismatch")).toBeInTheDocument(); + }, +}; + +export const MissingCapability: Story = { + args: { code: -32021, name: "MissingRequiredClientCapability" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText("-32021 MissingRequiredClientCapability"), + ).toBeInTheDocument(); + }, +}; + +export const UnsupportedVersion: Story = { + args: { code: -32022, name: "UnsupportedProtocolVersion" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByText("-32022 UnsupportedProtocolVersion"), + ).toBeInTheDocument(); + }, +}; + +export const MethodNotFound: Story = { + args: { + code: -32601, + name: "MethodNotFound", + description: + "Unknown method. A JSON-RPC body on a 404 marks a modern server.", + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("-32601 MethodNotFound")).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.test.tsx b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.test.tsx new file mode 100644 index 00000000..3fe36bf4 --- /dev/null +++ b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.test.tsx @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithMantine } from "../../../test/renderWithMantine"; +import { McpErrorBadge } from "./McpErrorBadge"; + +describe("McpErrorBadge", () => { + it("renders the code and spec name", () => { + renderWithMantine(); + expect(screen.getByText("-32020 HeaderMismatch")).toBeInTheDocument(); + }); + + it("wraps the badge in a tooltip when a description is provided", () => { + renderWithMantine( + , + ); + // The badge renders inside the tooltip target; the label is aria-described. + expect( + screen.getByText("-32022 UnsupportedProtocolVersion"), + ).toBeInTheDocument(); + }); + + it("falls back to red for an unmapped code", () => { + renderWithMantine(); + expect(screen.getByText("-32000 ServerError")).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx new file mode 100644 index 00000000..e7913b22 --- /dev/null +++ b/clients/web/src/components/elements/McpErrorBadge/McpErrorBadge.tsx @@ -0,0 +1,52 @@ +import { Badge, Tooltip } from "@mantine/core"; +import { filledBadgeColor } from "../filledBadgeColor"; + +export interface McpErrorBadgeProps { + /** JSON-RPC error code, e.g. -32020. */ + code: number; + /** Spec name, e.g. "HeaderMismatch". */ + name: string; + /** Optional explanation shown on hover. */ + description?: string; +} + +// Each modern spec error gets a distinct colour so the four are told apart at a +// glance in a dense Protocol stream (SEP-2243 / SEP-2575). Falls back to red for +// any other code routed here. +const COLOR_BY_CODE: Record = { + [-32020]: "red", // HeaderMismatch + [-32021]: "orange", // MissingRequiredClientCapability + [-32022]: "grape", // UnsupportedProtocolVersion + [-32601]: "yellow", // MethodNotFound (modern 404) +}; + +/** + * Distinct badge for one of the modern Streamable HTTP spec error codes shown in + * the Protocol tab. Labels the code and spec name (e.g. "-32020 HeaderMismatch") + * and, when a description is supplied, explains it on hover. + * + * Uses the filled + `autoContrast` treatment (via {@link filledBadgeColor}) like + * the other semantic badges, so the amber fills clear WCAG AA — a light-variant + * tint of these hues does not. + */ +export function McpErrorBadge({ code, name, description }: McpErrorBadgeProps) { + const badge = ( + + {code} {name} + + ); + if (!description) return badge; + return ( + + {badge} + + ); +} diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx index 6d9f64df..a4f05254 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx @@ -143,3 +143,123 @@ export const StreamingResponse: Story = { export const FetchError: Story = { args: { entry: transportError, isListExpanded: true }, }; + +/** Mirror of the SDK's SEP-2243 sentinel encoding, for building fixtures. */ +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)}?=`; +} + +// A modern Streamable HTTP tools/call: mirrored Mcp-Method / Mcp-Name headers, +// a Mcp-Param-* custom header, and a sentinel-encoded value that decodes to a +// non-ASCII string. +const modernHeadersEntry: FetchRequestEntry = { + id: "n-modern", + timestamp: new Date("2026-07-28T10:30:20Z"), + method: "POST", + url: "https://example.com/mcp", + requestHeaders: { + "content-type": "application/json", + "mcp-method": "tools/call", + "mcp-name": "get_weather", + "mcp-param-city": encodeSentinel("São Paulo"), + "mcp-protocol-version": "2026-07-28", + }, + requestBody: JSON.stringify({ + jsonrpc: "2.0", + id: 7, + method: "tools/call", + params: { + name: "get_weather", + arguments: { city: "São Paulo" }, + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + }, + }), + responseStatus: 200, + responseStatusText: "OK", + responseHeaders: { "content-type": "application/json" }, + responseBody: '{"jsonrpc":"2.0","id":7,"result":{}}', + duration: 33, + category: "transport", +}; + +// A HeaderMismatch: the sent Mcp-Method disagrees with the body's method, and +// the server rejects it with -32020 / HTTP 400. +const headerMismatchEntry: FetchRequestEntry = { + id: "n-mismatch", + timestamp: new Date("2026-07-28T10:30:22Z"), + method: "POST", + url: "https://example.com/mcp", + requestHeaders: { + "content-type": "application/json", + "mcp-method": "tools/list", + "mcp-protocol-version": "2026-07-28", + }, + requestBody: JSON.stringify({ + jsonrpc: "2.0", + id: 8, + method: "tools/call", + params: { + name: "get_weather", + _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" }, + }, + }), + responseStatus: 400, + responseStatusText: "Bad Request", + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ + jsonrpc: "2.0", + id: 8, + error: { code: -32020, message: "Mcp-Method header does not match body" }, + }), + duration: 12, + category: "transport", +}; + +const cancelledEntry: FetchRequestEntry = { + id: "n-cancel", + timestamp: new Date("2026-07-28T10:30:28Z"), + method: "POST", + url: "https://example.com/mcp", + requestHeaders: { "mcp-method": "tools/call" }, + requestBody: '{"jsonrpc":"2.0","id":11,"method":"tools/call"}', + error: "The operation was aborted", + category: "transport", +}; + +export const ModernHeaders: Story = { + args: { entry: modernHeadersEntry, isListExpanded: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The sentinel Mcp-Param value decodes to its non-ASCII string and is + // flagged as base64-encoded. + await expect(canvas.getByText("São Paulo")).toBeInTheDocument(); + await expect(canvas.getByText("base64")).toBeInTheDocument(); + }, +}; + +// The header/body consistency marker is an HTTP-header concern that stays in the +// Network tab (the -32020 spec-error chip itself moved to the Protocol tab). +export const HeaderBodyMismatch: Story = { + args: { entry: headerMismatchEntry, isListExpanded: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // The disagreeing header is called out against the body... + await expect( + canvas.getByLabelText(/expected tools\/call/), + ).toBeInTheDocument(); + // ...but the spec-error chip is NOT here (it lives in the Protocol tab now). + await expect(canvas.queryByText("-32020 HeaderMismatch")).toBeNull(); + }, +}; + +export const Cancelled: Story = { + args: { entry: cancelledEntry, isListExpanded: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Cancelled")).toBeInTheDocument(); + await expect(canvas.getByText("Request cancelled")).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index 2d971ac4..d0ea1fc4 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -1,7 +1,12 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; +import { useState } from "react"; import userEvent from "@testing-library/user-event"; import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; -import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { + renderWithMantine, + screen, + waitFor, +} from "../../../test/renderWithMantine"; import { NetworkEntry } from "./NetworkEntry"; const baseEntry: FetchRequestEntry = { @@ -309,4 +314,126 @@ describe("NetworkEntry", () => { expect(screen.queryByText("Token")).not.toBeInTheDocument(); expect(screen.queryByText("Discovery")).not.toBeInTheDocument(); }); + + describe("modern Streamable HTTP awareness (SEP-2243 / SEP-2575)", () => { + /** 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)}?=`; + } + + it("decodes a sentinel-encoded Mcp-Name header and flags it as base64", () => { + const entry: FetchRequestEntry = { + ...baseEntry, + requestHeaders: { + "mcp-method": "tools/call", + "mcp-name": encodeSentinel("get weather ☀"), + }, + requestBody: JSON.stringify({ + method: "tools/call", + params: { name: "get weather ☀" }, + }), + }; + renderWithMantine(); + expect(screen.getByText("get weather ☀")).toBeInTheDocument(); + expect(screen.getByText("base64")).toBeInTheDocument(); + }); + + it("marks a header that disagrees with the request body", () => { + const entry: FetchRequestEntry = { + ...baseEntry, + requestHeaders: { "mcp-method": "tools/list" }, + requestBody: JSON.stringify({ method: "tools/call", params: {} }), + }; + renderWithMantine(); + expect( + screen.getByLabelText( + /Header does not match body; expected tools\/call/, + ), + ).toBeInTheDocument(); + }); + + it("does NOT badge a spec error (those live in the Protocol tab now)", () => { + // A -32020 response is still a plain HTTP entry here; the distinct + // spec-error chip/alert moved to the Protocol tab. + const entry: FetchRequestEntry = { + ...baseEntry, + responseStatus: 400, + responseStatusText: "Bad Request", + responseBody: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + error: { code: -32020, message: "Mcp-Method mismatch" }, + }), + }; + renderWithMantine(); + expect(screen.getByText("400 Bad Request")).toBeInTheDocument(); + expect( + screen.queryByText("-32020 HeaderMismatch"), + ).not.toBeInTheDocument(); + expect(screen.queryByText(/Server supports:/)).not.toBeInTheDocument(); + }); + + it("when revealed, force-expands, scrolls into view, and clears the signal", async () => { + const scrollIntoView = vi.fn(); + // happy-dom doesn't implement scrollIntoView; stub it on the prototype. + Element.prototype.scrollIntoView = scrollIntoView; + const onRevealComplete = vi.fn(); + renderWithMantine( + , + ); + // Force-expanded even though isListExpanded is false. + expect(screen.getByText("Request Headers")).toBeInTheDocument(); + // The scroll runs in a rAF, and the signal clears *after* it (inside the + // same frame) — not synchronously — so a re-render can't cancel the scroll. + await waitFor(() => expect(scrollIntoView).toHaveBeenCalled()); + expect(onRevealComplete).toHaveBeenCalledTimes(1); + }); + + it("still scrolls when clearing the signal flips `revealed` back to false", async () => { + // Reproduces the real integration: onRevealComplete clears the parent's + // revealId, which flips this entry's `revealed` prop true→false and runs + // the effect cleanup (cancelAnimationFrame). The scroll must survive. + const scrollIntoView = vi.fn(); + Element.prototype.scrollIntoView = scrollIntoView; + const Harness = () => { + const [revealed, setRevealed] = useState(true); + return ( + setRevealed(false)} + /> + ); + }; + renderWithMantine(); + // The scroll fired inside the rAF before the clear could cancel it. + await waitFor(() => expect(scrollIntoView).toHaveBeenCalledTimes(1)); + }); + + it("labels a cancelled request as an abort, not a hard error", () => { + const cancelled: FetchRequestEntry = { + ...baseEntry, + responseStatus: undefined, + responseStatusText: undefined, + responseHeaders: undefined, + responseBody: undefined, + error: "The operation was aborted", + }; + renderWithMantine( + , + ); + expect(screen.getByText("Cancelled")).toBeInTheDocument(); + expect(screen.getByText("Request cancelled")).toBeInTheDocument(); + expect(screen.getByText(/notifications\/cancelled/)).toBeInTheDocument(); + }); + }); }); diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 93965544..6d64ffd9 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { + Alert, Badge, Button, Card, @@ -10,7 +11,9 @@ import { Stack, Table, Text, + Tooltip, } from "@mantine/core"; +import { RiErrorWarningLine } from "react-icons/ri"; import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; @@ -23,6 +26,13 @@ import { oauthNetworkPhase, oauthNetworkPhaseLabel, } from "../../../utils/oauthNetworkPhase"; +import { + checkHeaderConsistency, + decodeMcpParamValue, + isCancellationAbort, + isMcpHeader, + type HeaderConsistency, +} from "../../../utils/mcpNetworkHeaders"; export interface NetworkEntryProps { entry: FetchRequestEntry; @@ -33,6 +43,13 @@ export interface NetworkEntryProps { * horizontal scroll area with the expand toggle on the right. */ embedded?: boolean; + /** + * When true, this entry was targeted by a "reveal in Network" jump (from a + * correlated Protocol error): it scrolls itself into view and force-expands + * once, then calls {@link onRevealComplete} so the one-shot signal clears. + */ + revealed?: boolean; + onRevealComplete?: () => void; } const EntryContainer = Card.withProps({ @@ -104,6 +121,9 @@ function formatTimestampCompact(date: Date): string { } function statusColor(entry: FetchRequestEntry): string { + // A cancelled request surfaces as a connection abort under the modern + // transport; render it neutrally rather than as a hard error (SEP-2575). + if (isCancellationAbort(entry)) return "gray"; if (entry.error) return "red"; const status = entry.responseStatus; if (status === undefined) return "gray"; @@ -115,6 +135,7 @@ function statusColor(entry: FetchRequestEntry): string { } function statusLabel(entry: FetchRequestEntry): string { + if (isCancellationAbort(entry)) return "Cancelled"; if (entry.error) return "Error"; if (entry.responseStatus === undefined) return "Pending"; return entry.responseStatusText @@ -129,7 +150,105 @@ function isLongLivedStream(entry: FetchRequestEntry): boolean { ); } -function HeadersTable({ headers }: { headers: Record }) { +// Header-table cell text. A modern MCP-mirrored header name gets a violet accent +// so the spec headers (Mcp-Method / Mcp-Name / Mcp-Param-* / MCP-Protocol-Version) +// stand out from ordinary ones; a value that disagrees with the request body is +// shown in the danger colour. +const HeaderNameText = Text.withProps({ + size: "xs", + ff: "monospace", + fw: 500, +}); + +const McpHeaderNameText = Text.withProps({ + size: "xs", + ff: "monospace", + fw: 600, + c: "var(--inspector-mcp-header-accent)", +}); + +const HeaderValueText = Text.withProps({ + size: "xs", + ff: "monospace", + variant: "monoBreak", +}); + +const MismatchValueText = Text.withProps({ + size: "xs", + ff: "monospace", + variant: "monoBreak", + c: "var(--inspector-danger-text)", +}); + +const MismatchMarker = Text.withProps({ + component: "span", + // role="img" makes the aria-label permitted on the span (it wraps a decorative + // icon) and announces the mismatch to assistive tech. + role: "img", + c: "var(--inspector-danger-text)", +}); + +function HeaderValueCell({ + name, + value, + consistency, +}: { + name: string; + value: string; + consistency?: HeaderConsistency; +}) { + // Only modern MCP headers carry sentinel-encoded values; a plain header is + // shown verbatim (never re-interpreted as base64). + const decoded = isMcpHeader(name) + ? decodeMcpParamValue(value) + : { value, encoded: false, raw: value }; + const mismatch = consistency !== undefined && !consistency.ok; + + return ( + + {mismatch ? ( + {decoded.value} + ) : ( + {decoded.value} + )} + {decoded.encoded && ( + + + base64 + + + )} + {mismatch && ( + + + + + + )} + + ); +} + +function HeadersTable({ + headers, + consistency, +}: { + headers: Record; + /** Header/body cross-checks (request side only) to flag mismatches. */ + consistency?: HeaderConsistency[]; +}) { const rows = Object.entries(headers); if (rows.length === 0) { return ( @@ -138,20 +257,25 @@ function HeadersTable({ headers }: { headers: Record }) { ); } + const byHeader = new Map((consistency ?? []).map((row) => [row.header, row])); return ( {rows.map(([name, value]) => ( - - {name} - + {isMcpHeader(name) ? ( + {name} + ) : ( + {name} + )} - - {value} - + ))} @@ -160,6 +284,13 @@ function HeadersTable({ headers }: { headers: Record }) { ); } +const CancellationAlert = Alert.withProps({ + variant: "light", + color: "gray", + title: "Request cancelled", + icon: , +}); + const RevealButton = Button.withProps({ variant: "subtle", size: "compact-xs", @@ -238,8 +369,11 @@ export function NetworkEntry({ entry, isListExpanded, embedded = false, + revealed = false, + onRevealComplete, }: NetworkEntryProps) { const [isExpanded, setIsExpanded] = useState(isListExpanded); + const rootRef = useRef(null); // The list-level Expand/Collapse toggle is authoritative: each time the // parent changes `isListExpanded`, every entry snaps to that state and @@ -251,6 +385,24 @@ export function NetworkEntry({ setIsExpanded(isListExpanded); }, [isListExpanded]); + // "Reveal in Network" one-shot: when targeted, force this entry open and + // scroll it into view, then clear the signal. The scroll runs in a rAF so it + // lands after `useScrollMemory`'s layout-effect restore (which would otherwise + // fight it) and after the force-expand has grown the row. `onRevealComplete` + // clears the parent's `revealId`, which flips `revealed` back to false and re- + // runs this effect's cleanup — so it must fire *inside* the rAF, after the + // scroll, otherwise the cleanup's `cancelAnimationFrame` would race and could + // cancel the very frame doing the scroll. + useEffect(() => { + if (!revealed) return; + setIsExpanded(true); + const raf = requestAnimationFrame(() => { + rootRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + onRevealComplete?.(); + }); + return () => cancelAnimationFrame(raf); + }, [revealed, onRevealComplete]); + // OAuth flow phase for `auth`-category requests (discovery / registration / // authorize / token), so the Network tab labels the auth conversation. const oauthPhase = @@ -261,6 +413,16 @@ export function NetworkEntry({ ) : null; + // Request header/body cross-checks so a mirrored-header mismatch is visible + // before the server rejects it. (Protocol errors like -32020 are surfaced + // distinctly in the Protocol tab, not here — the Network tab stays focused on + // the HTTP transaction.) + const headerConsistency = useMemo( + () => checkHeaderConsistency(entry), + [entry], + ); + const aborted = isCancellationAbort(entry); + const metaBadges = ( <> {entry.duration != null && ( @@ -280,7 +442,7 @@ export function NetworkEntry({ ); return ( - + {embedded ? ( // Compact two-line header for the narrow column. @@ -339,11 +501,26 @@ export function NetworkEntry({ + {aborted && ( + + + Cancellation appears as a connection abort — the modern + transport aborts the request stream instead of sending a{" "} + + notifications/cancelled + {" "} + frame (SEP-2575). + + + )} Request Headers - + {entry.requestBody && ( diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index c809c553..3bd8d00d 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -25,6 +25,10 @@ export interface NetworkStreamPanelProps { onToggleCompact: () => void; /** See LogStreamPanel: fills the flex parent instead of the viewport calc. */ embedded?: boolean; + /** Fetch-entry id targeted by a "reveal in Network" jump — that entry scrolls + * into view and force-expands, then clears the signal via onRevealComplete. */ + revealId?: string; + onRevealComplete?: () => void; } const PanelContainer = Paper.withProps({ @@ -101,6 +105,8 @@ export function NetworkStreamPanel({ compact, onToggleCompact, embedded = false, + revealId, + onRevealComplete, }: NetworkStreamPanelProps) { const viewportRef = useScrollMemory("network-stream"); const filteredEntries = useMemo(() => { @@ -156,6 +162,8 @@ export function NetworkStreamPanel({ entry={entry} isListExpanded={!compact} embedded={embedded} + revealed={entry.id === revealId} + onRevealComplete={onRevealComplete} /> ))} diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx index bf2c4a85..1366bcbf 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { Box } from "@mantine/core"; import type { MessageEntry } from "../../../../../../core/mcp/types.js"; -import { fn } from "storybook/test"; +import { expect, fn, within } from "storybook/test"; import { ProtocolEntry } from "./ProtocolEntry"; const meta: Meta = { @@ -267,3 +267,90 @@ export const EmbeddedLongUri: Story = { ), ], }; + +// Modern spec errors (SEP-2243 / SEP-2575) are surfaced distinctly in the +// Protocol tab: a colour-coded chip on the header row plus an explanatory alert. +const headerMismatchEntry: MessageEntry = { + id: "req-err-20", + timestamp: new Date("2026-07-28T10:30:22Z"), + direction: "request", + origin: "client", + message: { + jsonrpc: "2.0", + id: 20, + method: "tools/call", + params: { name: "get_weather" }, + }, + response: { + jsonrpc: "2.0", + id: 20, + error: { code: -32020, message: "Mcp-Method header does not match body" }, + }, + duration: 12, +}; + +const unsupportedVersionEntry: MessageEntry = { + ...headerMismatchEntry, + id: "req-err-22", + response: { + jsonrpc: "2.0", + id: 20, + error: { + code: -32022, + message: "Unsupported protocol version", + data: { supported: ["2025-06-18", "2025-11-25", "2026-07-28"] }, + }, + }, +}; + +// -32601 arrives as HTTP 404 (thrown by the SDK) and is folded onto its pending +// request by enrichProtocolEntries, so it too reaches the Protocol tab. +const methodNotFoundEntry: MessageEntry = { + ...headerMismatchEntry, + id: "req-err-601", + response: { + jsonrpc: "2.0", + id: 20, + error: { code: -32601, message: "Method not found" }, + }, +}; + +export const HeaderMismatch: Story = { + args: { entry: headerMismatchEntry, isListExpanded: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getAllByText("-32020 HeaderMismatch").length, + ).toBeGreaterThan(0); + await expect( + canvas.getByText(/An Mcp-\* header did not match/), + ).toBeInTheDocument(); + }, +}; + +export const UnsupportedVersion: Story = { + args: { entry: unsupportedVersionEntry, isListExpanded: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText(/Server supports: 2025-06-18, 2025-11-25, 2026-07-28/), + ).toBeInTheDocument(); + }, +}; + +export const MethodNotFound: Story = { + // `correlatedHttpStatus: 404` marks this as the genuine modern thrown-404 + // taxonomy; an in-band -32601 on a 200 (or over stdio, with no HTTP at all) + // renders as an ordinary error instead. + args: { + entry: methodNotFoundEntry, + isListExpanded: false, + correlatedHttpStatus: 404, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getAllByText("-32601 MethodNotFound").length, + ).toBeGreaterThan(0); + }, +}; diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx index d478df5d..19511e2a 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx @@ -500,4 +500,207 @@ describe("ProtocolEntry — modern vocabulary", () => { ); expect(screen.getByText("input required")).toBeInTheDocument(); }); + + describe("modern spec-error chip (SEP-2243 / SEP-2575)", () => { + const headerMismatchEntry: MessageEntry = { + id: "req-err-20", + timestamp: new Date("2026-07-28T10:30:00Z"), + direction: "request", + origin: "client", + message: { + jsonrpc: "2.0", + id: 20, + method: "tools/call", + params: { name: "get_weather" }, + }, + response: { + jsonrpc: "2.0", + id: 20, + error: { code: -32020, message: "Mcp-Method mismatch" }, + }, + duration: 12, + }; + + it("chips a -32020 HeaderMismatch and explains it in the expanded alert", () => { + renderWithMantine( + , + ); + // Chip on the header row + title in the expanded alert. + expect( + screen.getAllByText("-32020 HeaderMismatch").length, + ).toBeGreaterThan(0); + expect( + screen.getByText(/An Mcp-\* header did not match/), + ).toBeInTheDocument(); + }); + + it("lists the supported versions for a -32022", () => { + const entry: MessageEntry = { + ...headerMismatchEntry, + id: "req-err-22", + response: { + jsonrpc: "2.0", + id: 20, + error: { + code: -32022, + message: "unsupported", + data: { supported: ["2025-11-25", "2026-07-28"] }, + }, + }, + }; + renderWithMantine( + , + ); + expect( + screen.getByText(/Server supports: 2025-11-25, 2026-07-28/), + ).toBeInTheDocument(); + }); + + const methodNotFoundEntry: MessageEntry = { + ...headerMismatchEntry, + id: "req-err-601", + response: { + jsonrpc: "2.0", + id: 20, + error: { code: -32601, message: "Method not found" }, + }, + }; + + it("recognises a -32601 folded onto the request when the correlated fetch is a 404", () => { + // -32601 arrives as HTTP 404 and is folded onto the pending request by + // enrichProtocolEntries; the 404 status marks it as the modern taxonomy. + renderWithMantine( + , + ); + expect( + screen.getAllByText("-32601 MethodNotFound").length, + ).toBeGreaterThan(0); + }); + + it("does NOT dress up an ordinary in-band -32601 on a 200 as the modern spec error", () => { + // A server returning -32601 in-band on HTTP 200 for an unsupported method + // is a plain JSON-RPC error, not the modern 404 taxonomy — no spec chip, + // no 404-asserting alert, and no reveal link. + renderWithMantine( + {}} + />, + ); + expect(screen.queryByText("-32601 MethodNotFound")).toBeNull(); + expect( + screen.queryByRole("button", { name: /View the HTTP request/ }), + ).not.toBeInTheDocument(); + // ...but it still renders as an ordinary Error. + expect(screen.getAllByText("Error").length).toBeGreaterThan(0); + }); + + it("does NOT modern-frame a -32601 with no correlated fetch (e.g. stdio)", () => { + // Over stdio there is no Network entry at all, so correlatedHttpStatus is + // undefined. An unknown status is not a 404, so an unsupported-method probe + // is a plain error — no modern badge, no 404-asserting copy. + renderWithMantine( + , + ); + expect(screen.queryByText("-32601 MethodNotFound")).toBeNull(); + expect(screen.getAllByText("Error").length).toBeGreaterThan(0); + }); + + it("does not chip an ordinary (non-spec) error like -32000", () => { + renderWithMantine(); + // errorEntry carries -32000 (implementation-defined) → no spec chip. + expect(screen.queryByText(/HeaderMismatch|MethodNotFound/)).toBeNull(); + // ...but it still renders as an Error. + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("shows a 'view in Network' link when onRevealInNetwork is provided, and calls it", async () => { + const user = userEvent.setup(); + const onReveal = vi.fn(); + renderWithMantine( + , + ); + const link = screen.getByRole("button", { + name: /View the HTTP request in the Network tab/, + }); + await user.click(link); + expect(onReveal).toHaveBeenCalledTimes(1); + }); + + it("omits the reveal link when there is no correlated Network entry", () => { + renderWithMantine( + , + ); + expect( + screen.queryByRole("button", { name: /View the HTTP request/ }), + ).not.toBeInTheDocument(); + }); + + it("omits the reveal link for a protocol-only error even with a correlation", () => { + // -32021 MissingRequiredClientCapability is not header/HTTP related, so + // no link — even though a callback (correlated entry) is supplied. + const capabilityEntry: MessageEntry = { + ...headerMismatchEntry, + id: "req-err-21", + response: { + jsonrpc: "2.0", + id: 20, + error: { code: -32021, message: "missing capability" }, + }, + }; + renderWithMantine( + {}} + />, + ); + expect( + screen.getAllByText("-32021 MissingRequiredClientCapability").length, + ).toBeGreaterThan(0); + expect( + screen.queryByRole("button", { name: /View the HTTP request/ }), + ).not.toBeInTheDocument(); + }); + + it("shows the chip only in the wide layout, not the compact sidebar row", () => { + // Compact: only the expanded-detail alert carries the label (1 match); no + // row chip. Wide: the row chip AND the alert (2 matches). + const { unmount } = renderWithMantine( + , + ); + expect(screen.getAllByText("-32020 HeaderMismatch")).toHaveLength(1); + unmount(); + + renderWithMantine( + , + ); + expect(screen.getAllByText("-32020 HeaderMismatch")).toHaveLength(2); + }); + }); }); diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx index 892fc38a..2853ffdc 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { + Alert, + Anchor, Badge, - Button, Card, Collapse, Divider, @@ -10,14 +11,20 @@ import { Stack, Text, } from "@mantine/core"; +import { RiErrorWarningLine } from "react-icons/ri"; import type { MessageEntry } from "@inspector/core/mcp/types.js"; import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { CopyButton } from "../../elements/CopyButton/CopyButton"; import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge"; import { MethodBadge } from "../../elements/MethodBadge/MethodBadge"; +import { McpErrorBadge } from "../../elements/McpErrorBadge/McpErrorBadge"; import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { PinToggle } from "../../elements/PinToggle/PinToggle"; import { ReplayButton } from "../../elements/ReplayButton/ReplayButton"; +import { + classifyProtocolSpecError, + type McpSpecError, +} from "../../../utils/mcpNetworkHeaders"; import { extractMethod, extractResultType, @@ -38,6 +45,19 @@ export interface ProtocolEntryProps { * the controls — Replay as an icon — on the right. */ embedded?: boolean; + /** + * When provided (a spec-error entry with a correlated Network request), the + * expanded alert shows a "view in Network" link that jumps to, and expands, + * the matching HTTP entry. + */ + onRevealInNetwork?: () => void; + /** + * HTTP status of this entry's correlated Network fetch, when known. Used to + * gate the generic `-32601` to a genuine modern 404 (an in-band `-32601` on a + * 200 is an ordinary error, not the modern taxonomy). Omitted when there is no + * correlated HTTP record. + */ + correlatedHttpStatus?: number; } const EntryContainer = Card.withProps({ @@ -129,11 +149,6 @@ function resultTypeLabel(resultType: "complete" | "input_required"): string { return resultType === "input_required" ? "input required" : "complete"; } -const SubtleButton = Button.withProps({ - variant: "subtle", - size: "xs", -}); - function formatDuration(ms: number): string { return `${ms}ms`; } @@ -196,6 +211,57 @@ function serializeMessage(value: unknown): string { return JSON.stringify(value); } +// The JSON-RPC error carried by a message — either the folded error `response` +// on a request, or an error message frame itself — classified as a modern spec +// error (SEP-2243 / SEP-2575) or null. Protocol errors the SDK throws rather +// than delivers (e.g. -32601) are folded onto the pending request upstream (see +// `enrichProtocolEntries`), so they land here too. +function extractSpecError( + entry: MessageEntry, + httpStatus?: number, +): McpSpecError | null { + const error = + entry.response && "error" in entry.response + ? entry.response.error + : "error" in entry.message + ? entry.message.error + : undefined; + if (!error || typeof error.code !== "number") return null; + return classifyProtocolSpecError(error.code, error.data, httpStatus); +} + +// Friendly summary of a modern spec error, shown in the expanded detail. The +// HTTP-level facts (status, mirrored headers) live on the correlated Network +// entry, reachable via the "view in Network" link when one exists. +function McpSpecErrorAlert({ + error, + onReveal, +}: { + error: McpSpecError; + onReveal?: () => void; +}) { + return ( + } + > + + {error.description} + {error.supported && ( + Server supports: {error.supported.join(", ")} + )} + {onReveal && ( + + View the HTTP request in the Network tab → + + )} + + + ); +} + export function ProtocolEntry({ entry, isPinned, @@ -203,6 +269,8 @@ export function ProtocolEntry({ onReplay, onTogglePin, embedded = false, + onRevealInNetwork, + correlatedHttpStatus, }: ProtocolEntryProps) { const [isExpanded, setIsExpanded] = useState(isListExpanded); const method = extractMethod(entry); @@ -223,6 +291,17 @@ export function ProtocolEntry({ direction={entry.origin === "client" ? "outgoing" : "incoming"} /> ); + // Distinct chip for a modern spec error (SEP-2243 / SEP-2575). Shown only in + // the wide layout (right after the method chip); the compact sidebar relies on + // its ERROR status badge to keep the two-line row uncluttered. + const specError = extractSpecError(entry, correlatedHttpStatus); + const specErrorBadge = specError && ( + + ); // The modern `resultType` on the paired result (spec §7.3): `input_required` // (the operation isn't done — it needs input and will be retried) vs the // ordinary `complete`. Only present on modern results, so it doubles as a @@ -315,6 +394,7 @@ export function ProtocolEntry({ {directionBadge} + {specErrorBadge} {modernFrameBadge} {subscriptionBadge} {target && ( @@ -331,19 +411,13 @@ export function ProtocolEntry({ - - - {canReplay && ( - Replay - )} - - - - setIsExpanded((v) => !v)} - /> - + + {canReplay && } + + setIsExpanded((v) => !v)} + /> )} @@ -351,6 +425,16 @@ export function ProtocolEntry({ + {specError && ( + + )} {"params" in entry.message && entry.message.params && ( Parameters: diff --git a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.test.tsx b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.test.tsx index 0a5aaa20..086011cf 100644 --- a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.test.tsx +++ b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.test.tsx @@ -514,4 +514,36 @@ describe("ProtocolListPanel", () => { expect(screen.getByText("2 rounds")).toBeInTheDocument(); expect(screen.getByTestId("mrtr-status")).toHaveTextContent("Complete"); }); + + it("wires the reveal link for a revealable spec-error entry", async () => { + const user = userEvent.setup(); + const onRevealInNetwork = vi.fn(); + const errorEntry: MessageEntry = { + id: "err-1", + timestamp: new Date("2026-07-28T10:30:00Z"), + direction: "request", + origin: "client", + message: { jsonrpc: "2.0", id: 20, method: "tools/call" }, + response: { + jsonrpc: "2.0", + id: 20, + error: { code: -32020, message: "Mcp-Method mismatch" }, + }, + }; + renderWithMantine( + , + ); + await user.click( + screen.getByRole("button", { + name: /View the HTTP request in the Network tab/, + }), + ); + expect(onRevealInNetwork).toHaveBeenCalledWith("err-1"); + }); }); diff --git a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx index 8b69fa6d..2d76d12f 100644 --- a/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx +++ b/clients/web/src/components/groups/ProtocolListPanel/ProtocolListPanel.tsx @@ -55,6 +55,15 @@ export interface ProtocolListPanelProps { onToggleCompact: () => void; /** See LogStreamPanel: fills the flex parent instead of the viewport calc. */ embedded?: boolean; + /** Jump from a spec-error entry to its correlated Network HTTP entry. */ + onRevealInNetwork?: (id: string) => void; + /** Message-entry ids that have a correlated Network entry (link is shown). */ + revealableIds?: Set; + /** + * Message-entry id → correlated Network fetch HTTP status. Gates the generic + * `-32601` to a genuine modern 404 (see {@link ProtocolEntry}). + */ + correlatedStatusById?: Map; } const PanelContainer = Paper.withProps({ @@ -237,6 +246,9 @@ export function ProtocolListPanel({ compact, onToggleCompact, embedded = false, + onRevealInNetwork, + revealableIds, + correlatedStatusById, }: ProtocolListPanelProps) { const viewportRef = useScrollMemory("protocol-list"); // Per-section expand/collapse, like the LogControls level toggles. Both start @@ -310,6 +322,12 @@ export function ProtocolListPanel({ embedded={embedded} onReplay={() => onReplay(row.entry.id)} onTogglePin={() => onTogglePin(row.entry.id)} + onRevealInNetwork={ + onRevealInNetwork && revealableIds?.has(row.entry.id) + ? () => onRevealInNetwork(row.entry.id) + : undefined + } + correlatedHttpStatus={correlatedStatusById?.get(row.entry.id)} /> ), ); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx index ce135227..43c2affd 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx @@ -23,6 +23,9 @@ export interface NetworkScreenProps { onToggleCompact: () => void; /** See LoggingScreen: fills the parent height and drops the filter sidebar. */ embedded?: boolean; + /** "Reveal in Network" target (a fetch-entry id) + its one-shot clear. */ + revealId?: string; + onRevealComplete?: () => void; } // Filter text + visible-category set — controlled by the parent (App) as one @@ -60,6 +63,8 @@ export function NetworkScreen({ compact, onToggleCompact, embedded = false, + revealId, + onRevealComplete, }: NetworkScreenProps) { const { filterText, visibleCategories } = ui; @@ -114,6 +119,8 @@ export function NetworkScreen({ compact={compact} onToggleCompact={onToggleCompact} embedded={embedded} + revealId={revealId} + onRevealComplete={onRevealComplete} /> ); diff --git a/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx b/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx index b0647cea..cf6698d2 100644 --- a/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx +++ b/clients/web/src/components/screens/ProtocolScreen/ProtocolScreen.tsx @@ -30,6 +30,15 @@ export interface ProtocolScreenProps { onToggleCompact: () => void; /** See LoggingScreen: fills the parent height and drops the filter sidebar. */ embedded?: boolean; + /** Jump from a spec-error entry to its correlated Network HTTP entry. */ + onRevealInNetwork?: (id: string) => void; + /** Message-entry ids that have a correlated Network entry (link is shown). */ + revealableIds?: Set; + /** + * Message-entry id → correlated Network fetch HTTP status. Gates the generic + * `-32601` to a genuine modern 404 (see {@link ProtocolListPanel}). + */ + correlatedStatusById?: Map; } // Search text, method filter, and per-direction visibility — controlled by the @@ -76,6 +85,9 @@ export function ProtocolScreen({ compact, onToggleCompact, embedded = false, + onRevealInNetwork, + revealableIds, + correlatedStatusById, }: ProtocolScreenProps) { const { search, methodFilter, visibleDirections } = ui; @@ -148,6 +160,9 @@ export function ProtocolScreen({ compact={compact} onToggleCompact={onToggleCompact} embedded={embedded} + onRevealInNetwork={onRevealInNetwork} + revealableIds={revealableIds} + correlatedStatusById={correlatedStatusById} /> ); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 7f459487..af9e31e5 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -92,6 +92,11 @@ import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle"; import { getServerType } from "@inspector/core/mcp/config.js"; import { INSPECTOR_SERVERS_TAB } from "../../../utils/inspectorTabs"; +import { + correlateFetchEntry, + correlatedFetchStatusById, + revealableMessageIds, +} from "../../../utils/correlateTransportErrors"; import { collectSchemaDefaults, toFormSchema } from "../../../utils/jsonUtils"; import { MONITOR_COLUMN_ANIM_MS } from "./monitorColumnAnimation"; @@ -958,6 +963,30 @@ export function InspectorView({ setMonitorTab(tab); setMonitorPinned(true); } + // Jump from a Protocol spec error to its correlated Network HTTP entry: + // reveal the Network view (in whichever mode is active), un-hide the target by + // clearing whatever filter would drop it, then hand the fetch id to the panel + // which scrolls to and expands that entry. + function handleRevealInNetwork(messageId: string) { + const entry = protocol.find((m) => m.id === messageId); + if (!entry) return; + const fetchEntry = correlateFetchEntry(entry, network); + if (!fetchEntry) return; + onNetworkUiChange({ + filterText: "", + visibleCategories: { + ...networkUi.visibleCategories, + [fetchEntry.category]: true, + }, + }); + setMonitorSearch(""); + if (effectivePinned) { + pinMonitor(NETWORK_TAB); + } else { + onActiveTabChange(NETWORK_TAB); + } + setRevealNetworkId(fetchEntry.id); + } function handleMonitorTabChange(tab: string) { // The SegmentedControl's data only holds valid monitor tabs, so the guard's // false arm is unreachable through the UI. @@ -998,6 +1027,24 @@ export function InspectorView({ // column and are bypassed there. const [monitorSearch, setMonitorSearch] = useState(""); + // "Reveal in Network": a fetch-entry id to scroll to + expand, set when the + // user clicks through from a correlated Protocol spec error. `revealableIds` + // is the set of Protocol entries that have a Network counterpart (link shown). + const [revealNetworkId, setRevealNetworkId] = useState( + undefined, + ); + const revealableProtocolIds = useMemo( + () => revealableMessageIds(protocol, network), + [protocol, network], + ); + // Correlated HTTP status per Protocol entry, so the Protocol view can gate the + // generic `-32601` to a genuine modern 404 (an in-band `-32601` on a 200 is an + // ordinary error, not the modern transport taxonomy). + const protocolStatusById = useMemo( + () => correlatedFetchStatusById(protocol, network), + [protocol, network], + ); + // Merge the parent's `serversInput` (static config) with the runtime // connection state owned by the parent — only the active server reflects // the live status; the rest render as `disconnected`. Handshake errors @@ -1077,6 +1124,9 @@ export function InspectorView({ onSortChange: setProtocolSort, compact: protocolCompact, onToggleCompact: () => setProtocolCompact((c) => !c), + onRevealInNetwork: handleRevealInNetwork, + revealableIds: revealableProtocolIds, + correlatedStatusById: protocolStatusById, }; const networkScreenProps = { entries: network, @@ -1088,6 +1138,14 @@ export function InspectorView({ onSortChange: setNetworkSort, compact: networkCompact, onToggleCompact: () => setNetworkCompact((c) => !c), + // `revealId` is spread into both the pinned-sidebar monitor instance and the + // primary full-screen ScreenStage instance, and ScreenStage keeps inactive + // children mounted — so when the sidebar is pinned two NetworkEntry instances + // match this id. Both fire (the hidden one's scrollIntoView is a no-op) and + // both call onRevealComplete; the clear is idempotent, so the double-fire is + // expected and harmless. + revealId: revealNetworkId, + onRevealComplete: () => setRevealNetworkId(undefined), }; const consoleScreenProps = { entries: stderrLogs, diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index bae371ae..9e63044d 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -610,6 +610,36 @@ describe("redactBody", () => { expect(JSON.parse(out!)).toEqual({ access_token: REDACTED_VALUE }); }); + it("does NOT redact a numeric JSON-RPC error `code` (only string secrets)", () => { + // The OAuth `code` name collides with a JSON-RPC error `code`, but the + // latter is a number and never a secret — masking it would break the + // Network tab's modern spec-error classification (-32020/-32021/…). + const out = redactBody( + JSON.stringify({ + jsonrpc: "2.0", + id: 7, + error: { code: -32020, message: "header mismatch" }, + }), + "application/json", + ); + const parsed = JSON.parse(out!) as { + error: { code: number; message: string }; + }; + expect(parsed.error.code).toBe(-32020); + expect(parsed.error.message).toBe("header mismatch"); + }); + + it("still redacts a string-valued OAuth `code`", () => { + const out = redactBody( + JSON.stringify({ grant_type: "authorization_code", code: "SECRET" }), + "application/json", + ); + expect(JSON.parse(out!)).toEqual({ + grant_type: "authorization_code", + code: REDACTED_VALUE, + }); + }); + it("leaves a JSON scalar (no field names) unchanged", () => { expect(redactBody('"just a string"', "application/json")).toBe( '"just a string"', diff --git a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts index 59e81c89..bcffb3e8 100644 --- a/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts +++ b/clients/web/src/test/integration/mcp/remote/server-extra-coverage.test.ts @@ -238,6 +238,18 @@ describe("server.ts supplemental coverage", () => { res.end(JSON.stringify({ got: req.headers["x-probe"] ?? null })); return; } + if (req.url === "/echo-mcp") { + // Echo back every mirrored MCP header the proxy forwarded, so a test + // can assert the browser-built Mcp-* headers survive the /api/fetch + // hop verbatim (SEP-2243 header mirroring works through the proxy). + const mcp: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (k.toLowerCase().startsWith("mcp-")) mcp[k] = v; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(mcp)); + return; + } res.writeHead(200, { "Content-Type": "text/plain" }); res.end("plain body"); }); @@ -282,6 +294,34 @@ describe("server.ts supplemental coverage", () => { expect(JSON.parse(body.body!)).toEqual({ got: "abc" }); }); + it("forwards modern MCP mirrored headers verbatim (SEP-2243 mirroring through the proxy)", async () => { + // The browser-side SDK builds Mcp-Method / Mcp-Name / Mcp-Param-* / + // MCP-Protocol-Version before calling the (proxied) fetch; /api/fetch must + // re-send them unchanged so mirroring is preserved on the browser path. + const mirrored = { + "Mcp-Method": "tools/call", + "Mcp-Name": "get_weather", + "Mcp-Param-City": "=?base64?U8OjbyBQYXVsbw==?=", + "MCP-Protocol-Version": "2026-07-28", + }; + const res = await fetch(`${h.baseUrl}/api/fetch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: `${targetUrl}/echo-mcp`, + method: "POST", + headers: mirrored, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { body?: string }; + const echoed = JSON.parse(body.body!) as Record; + expect(echoed["mcp-method"]).toBe("tools/call"); + expect(echoed["mcp-name"]).toBe("get_weather"); + expect(echoed["mcp-param-city"]).toBe("=?base64?U8OjbyBQYXVsbw==?="); + expect(echoed["mcp-protocol-version"]).toBe("2026-07-28"); + }); + it("omits the body for an event-stream content type", async () => { const res = await fetch(`${h.baseUrl}/api/fetch`, { method: "POST", diff --git a/clients/web/src/utils/correlateTransportErrors.test.ts b/clients/web/src/utils/correlateTransportErrors.test.ts new file mode 100644 index 00000000..b285b63a --- /dev/null +++ b/clients/web/src/utils/correlateTransportErrors.test.ts @@ -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 { + 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); + }); +}); diff --git a/clients/web/src/utils/correlateTransportErrors.ts b/clients/web/src/utils/correlateTransportErrors.ts new file mode 100644 index 00000000..3c2d5c02 --- /dev/null +++ b/clients/web/src/utils/correlateTransportErrors.ts @@ -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 { + const byId = new Map(); + 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 { + const fetchIdByRequestId = new Set(); + 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(); + 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 { + const statusByRequestId = new Map(); + 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(); + 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; +} diff --git a/clients/web/src/utils/mcpNetworkHeaders.test.ts b/clients/web/src/utils/mcpNetworkHeaders.test.ts new file mode 100644 index 00000000..c2ff9692 --- /dev/null +++ b/clients/web/src/utils/mcpNetworkHeaders.test.ts @@ -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); + }); +}); diff --git a/clients/web/src/utils/mcpNetworkHeaders.ts b/clients/web/src/utils/mcpNetworkHeaders.ts new file mode 100644 index 00000000..93adf6bc --- /dev/null +++ b/clients/web/src/utils/mcpNetworkHeaders.ts @@ -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 = 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, +): 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, +): 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; + }; +} + +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, + 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, +): 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, +): Set { + 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, +): boolean { + if (!entry.error) return false; + const message = entry.error.toLowerCase(); + return message.includes("abort") || message.includes("cancel"); +} diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 737bdc9c..7e1e5864 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -115,7 +115,22 @@ function redactJsonValue(value: unknown): unknown { if (value !== null && typeof value === "object") { const out: Record = {}; for (const [key, val] of Object.entries(value)) { - out[key] = isSensitiveField(key) ? REDACTED_VALUE : redactJsonValue(val); + // Only STRING values of a sensitive-named field are masked. The secrets + // this targets (OAuth `code`, `access_token`, `client_secret`, …) are + // always strings; a non-string is never one of them. This is important + // for JSON-RPC bodies, whose numeric `error.code` (e.g. -32020) collides + // with the OAuth authorization-`code` name — masking it would destroy the + // very field the Network tab classifies the modern spec errors on. + // + // Assumes a sensitive value is a scalar or an object (recursed, so an inner + // sensitive string key is still masked). A sensitive key whose value is an + // *array of scalars* (e.g. `{ password: ["a", "b"] }`) would recurse + // element-by-element with no key context and slip through — no OAuth/token + // payload has that shape, so it is not handled. + out[key] = + isSensitiveField(key) && typeof val === "string" + ? REDACTED_VALUE + : redactJsonValue(val); } return out; } diff --git a/core/mcp/remote/createRemoteFetch.ts b/core/mcp/remote/createRemoteFetch.ts index 2633cffe..cfcec90d 100644 --- a/core/mcp/remote/createRemoteFetch.ts +++ b/core/mcp/remote/createRemoteFetch.ts @@ -1,6 +1,23 @@ /** * Creates a fetch implementation that POSTs requests to the remote /api/fetch endpoint. * Use in the browser to bypass CORS for OAuth and MCP HTTP requests. + * + * SEP-2243 header mirroring note (verified end-to-end against a modern server): + * the modern Streamable HTTP client builds its mirrored headers *before* it + * calls this fetch, and `serializeRequest` copies every request header verbatim + * into the JSON payload, which the Node `/api/fetch` handler re-sends with + * `new Headers(headers)`. So the proxy preserves whatever the SDK emits. But the + * two header families are built differently in the SDK: + * - The STANDARD headers (`Mcp-Method` / `Mcp-Name` / `MCP-Protocol-Version`) + * are derived unconditionally from the request envelope, so they ARE present + * on the browser path and reach the server through this proxy. + * - The custom `Mcp-Param-*` headers are gated on a non-browser environment in + * the SDK (`detectProbeEnvironment() !== "browser"`, avoiding a CORS + * preflight), so they are NEVER built in the browser — the proxy would + * forward them, but the SDK never creates them here. A tool with an + * `x-mcp-header` annotation is therefore uncallable from the web client + * against a strict modern server (it answers `-32020 HeaderMismatch`); such + * tools work from the Node CLI/TUI, where mirroring is active. */ export interface RemoteFetchOptions { diff --git a/test-servers/configs/modern-network-http.json b/test-servers/configs/modern-network-http.json new file mode 100644 index 00000000..a3b1f99e --- /dev/null +++ b/test-servers/configs/modern-network-http.json @@ -0,0 +1,20 @@ +{ + "serverInfo": { + "name": "modern-network-showcase", + "version": "1.0.0" + }, + "tools": [ + { "preset": "echo" }, + { "preset": "get_weather" }, + { "preset": "trigger_header_mismatch" }, + { "preset": "trigger_missing_capability" }, + { "preset": "trigger_unsupported_version" }, + { "preset": "trigger_method_not_found" } + ], + "resources": [{ "preset": "architecture" }], + "transport": { + "type": "streamable-http", + "port": 3110, + "modern": { "legacy": "stateless", "injectSpecErrors": true } + } +} diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 6779ea70..3ddb7f9c 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -563,6 +563,14 @@ export interface ServerConfig { */ modern?: { legacy?: "stateless" | "reject"; + /** + * When true, the modern HTTP leg installs a middleware that returns the + * SEP-2243 / SEP-2575 spec error codes for specially-named trigger tool + * calls (see `SPEC_ERROR_TRIGGERS` in test-server-http.ts). Used by the + * `modern-network-http` showcase to exercise the Inspector's Network-tab + * error rendering (a conformant server never produces these on demand). + */ + injectSpecErrors?: boolean; }; /** * Optional server control for orderly shutdown (test HTTP server). diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts index fd176365..3f0384f0 100644 --- a/test-servers/src/load-config.ts +++ b/test-servers/src/load-config.ts @@ -72,7 +72,9 @@ export interface ConfigFile { * is shorthand for dual-era stateless serving; the object form selects the * legacy-fallback posture. See {@link ServerConfig.modern}. */ - modern?: boolean | { legacy?: "stateless" | "reject" }; + modern?: + | boolean + | { legacy?: "stateless" | "reject"; injectSpecErrors?: boolean }; }; } diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 0869b7bb..5fe149c5 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -15,6 +15,8 @@ import { createGetEnvTool, createAddTool, createGetSumTool, + createGetWeatherTool, + createSpecErrorTriggerTool, createWriteToStderrTool, createCollectSampleTool, createListRootsTool, @@ -94,6 +96,28 @@ function resolveToolPreset( return createAddTool(); case "get_sum": return createGetSumTool(); + case "get_weather": + return createGetWeatherTool(); + case "trigger_header_mismatch": + return createSpecErrorTriggerTool( + "trigger_header_mismatch", + "Returns -32020 HeaderMismatch (HTTP 400)", + ); + case "trigger_missing_capability": + return createSpecErrorTriggerTool( + "trigger_missing_capability", + "Returns -32021 MissingRequiredClientCapability (HTTP 400)", + ); + case "trigger_unsupported_version": + return createSpecErrorTriggerTool( + "trigger_unsupported_version", + "Returns -32022 UnsupportedProtocolVersion (HTTP 400)", + ); + case "trigger_method_not_found": + return createSpecErrorTriggerTool( + "trigger_method_not_found", + "Returns -32601 MethodNotFound (HTTP 404)", + ); case "write_to_stderr": return createWriteToStderrTool(); case "collect_sample": diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index b278dffd..2cd1cdb6 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -218,6 +218,49 @@ export function createAddTool(): ToolDefinition { }; } +/** + * Create a "get_weather" tool carrying a SEP-2243 `x-mcp-header` annotation on + * its `city` argument, so a modern client mirrors the value into a + * `Mcp-Param-City` header (Base64-sentinel-encoded when the value is non-ASCII). + * Used by the `modern-network-http` showcase to exercise the Network tab's + * `Mcp-Param-*` decode. + */ +export function createGetWeatherTool(): ToolDefinition { + return { + name: "get_weather", + description: + "Get the weather for a city (its `city` argument mirrors to Mcp-Param-City)", + inputSchema: { + city: z + .string() + .describe("City name") + .meta({ "x-mcp-header": "City" }), + }, + handler: async (params: Record) => { + return toToolResult(`Weather in ${params.city as string}: sunny, 24°C`); + }, + }; +} + +/** + * Create a no-op "trigger" tool whose `tools/call` is intercepted by the modern + * leg's spec-error injector (`injectSpecErrors`) to return a crafted + * SEP-2243/SEP-2575 error. The handler is a harmless fallback for the + * (never-reached) case where the injector is off. + */ +export function createSpecErrorTriggerTool( + name: string, + description: string, +): ToolDefinition { + return { + name, + description, + inputSchema: {}, + handler: async () => + toToolResult(`(${name}) injector disabled — no error returned`), + }; +} + /** * Create a "get_sum" tool that returns the sum of two numbers (alias for add) */ diff --git a/test-servers/src/test-server-http.ts b/test-servers/src/test-server-http.ts index 000b5813..a8cc73d6 100644 --- a/test-servers/src/test-server-http.ts +++ b/test-servers/src/test-server-http.ts @@ -125,6 +125,67 @@ function extractHeaders(req: Request): Record { return headers; } +/** + * Maps a trigger `tools/call` name to the crafted spec-error response the + * injector returns (see {@link ServerConfig.modern}.injectSpecErrors). Each is a + * real HTTP status + JSON-RPC error body per the 2026-07-28 transport spec, so + * the Inspector's Network tab renders the taxonomy exactly as it would from a + * real modern server. + */ +const SPEC_ERROR_TRIGGERS: Record< + string, + { httpStatus: number; code: number; message: string; data?: unknown } +> = { + trigger_header_mismatch: { + httpStatus: 400, + code: -32020, + message: "Mcp-Method header does not match the JSON-RPC body", + }, + trigger_missing_capability: { + httpStatus: 400, + code: -32021, + message: "Client is missing a required capability", + }, + trigger_unsupported_version: { + httpStatus: 400, + code: -32022, + message: "Unsupported protocol version", + data: { supported: ["2025-06-18", "2025-11-25", "2026-07-28"] }, + }, + trigger_method_not_found: { + httpStatus: 404, + code: -32601, + message: "Method not found", + }, +}; + +interface JsonRpcCallBody { + id?: string | number | null; + method?: string; + params?: { name?: string }; +} + +const specErrorInjector: express.RequestHandler = (req, res, next) => { + const body = req.body as JsonRpcCallBody | undefined; + const trigger = + body?.method === "tools/call" && typeof body.params?.name === "string" + ? SPEC_ERROR_TRIGGERS[body.params.name] + : undefined; + if (!trigger) { + next(); + return; + } + res.status(trigger.httpStatus).json({ + jsonrpc: "2.0", + id: body?.id ?? null, + error: { + code: trigger.code, + message: trigger.message, + ...(trigger.data !== undefined ? { data: trigger.data } : {}), + }, + }); +}; + // With this test server, your test can hold an instance and you can get the server's recorded message history at any time. // export class TestServerHttp { @@ -296,7 +357,15 @@ export class TestServerHttp { } }; - app.post("/mcp", ...mcpMiddleware, route); + // Optional spec-error injector: returns the SEP-2243 / SEP-2575 error codes + // for the trigger tool calls so the Inspector's Network tab can be exercised + // against them (a conformant server never emits these on demand). + const extraMiddleware: express.RequestHandler[] = this.config.modern + ?.injectSpecErrors + ? [specErrorInjector] + : []; + + app.post("/mcp", ...mcpMiddleware, ...extraMiddleware, route); app.get("/mcp", ...mcpMiddleware, route); app.delete("/mcp", ...mcpMiddleware, route);