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

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

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

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

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

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

Closes #1628

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

View file

@ -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<typeof ProtocolEntry> = {
@ -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);
},
};

View file

@ -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(
<ProtocolEntry
{...baseProps}
entry={headerMismatchEntry}
isListExpanded={true}
/>,
);
// 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(
<ProtocolEntry {...baseProps} entry={entry} isListExpanded={true} />,
);
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(
<ProtocolEntry
{...baseProps}
entry={methodNotFoundEntry}
correlatedHttpStatus={404}
/>,
);
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(
<ProtocolEntry
{...baseProps}
entry={methodNotFoundEntry}
isListExpanded={true}
correlatedHttpStatus={200}
onRevealInNetwork={() => {}}
/>,
);
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(
<ProtocolEntry
{...baseProps}
entry={methodNotFoundEntry}
isListExpanded={true}
/>,
);
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(<ProtocolEntry {...baseProps} entry={errorEntry} />);
// 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(
<ProtocolEntry
{...baseProps}
entry={headerMismatchEntry}
isListExpanded={true}
onRevealInNetwork={onReveal}
/>,
);
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(
<ProtocolEntry
{...baseProps}
entry={headerMismatchEntry}
isListExpanded={true}
/>,
);
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(
<ProtocolEntry
{...baseProps}
entry={capabilityEntry}
isListExpanded={true}
onRevealInNetwork={() => {}}
/>,
);
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(
<ProtocolEntry {...baseProps} entry={headerMismatchEntry} embedded />,
);
expect(screen.getAllByText("-32020 HeaderMismatch")).toHaveLength(1);
unmount();
renderWithMantine(
<ProtocolEntry {...baseProps} entry={headerMismatchEntry} />,
);
expect(screen.getAllByText("-32020 HeaderMismatch")).toHaveLength(2);
});
});
});

View file

@ -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 (
<Alert
variant="light"
color="red"
title={`${error.code} ${error.name}`}
icon={<RiErrorWarningLine />}
>
<Stack gap="xs">
<Text size="xs">{error.description}</Text>
{error.supported && (
<Text size="xs">Server supports: {error.supported.join(", ")}</Text>
)}
{onReveal && (
<Anchor component="button" type="button" size="xs" onClick={onReveal}>
View the HTTP request in the Network tab →
</Anchor>
)}
</Stack>
</Alert>
);
}
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 && (
<McpErrorBadge
code={specError.code}
name={specError.name}
description={specError.description}
/>
);
// 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({
</TimestampText>
{directionBadge}
<MethodBadge method={method} />
{specErrorBadge}
{modernFrameBadge}
{subscriptionBadge}
{target && (
@ -331,19 +411,13 @@ export function ProtocolEntry({
</Group>
</HeaderRow>
<Group gap="xs" justify="space-between">
<Group gap="xs">
{canReplay && (
<SubtleButton onClick={onReplay}>Replay</SubtleButton>
)}
</Group>
<Group gap="xs">
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
<ExpandToggle
expanded={isExpanded}
onToggle={() => setIsExpanded((v) => !v)}
/>
</Group>
<Group gap="xs" justify="flex-end">
{canReplay && <ReplayButton onReplay={onReplay} />}
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
<ExpandToggle
expanded={isExpanded}
onToggle={() => setIsExpanded((v) => !v)}
/>
</Group>
</>
)}
@ -351,6 +425,16 @@ export function ProtocolEntry({
<Collapse in={isExpanded}>
<Stack gap="sm">
<Divider />
{specError && (
<McpSpecErrorAlert
error={specError}
// Only header/HTTP-status errors gain from the raw HTTP entry;
// a protocol-only error (e.g. -32021) shows no link.
onReveal={
specError.httpRelevant ? onRevealInNetwork : undefined
}
/>
)}
{"params" in entry.message && entry.message.params && (
<Stack gap="xs">
<Text size="sm">Parameters:</Text>