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

@ -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();
},
};

View file

@ -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(<NetworkEntry entry={entry} isListExpanded={true} />);
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(<NetworkEntry entry={entry} isListExpanded={true} />);
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(<NetworkEntry entry={entry} isListExpanded={true} />);
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(
<NetworkEntry
entry={baseEntry}
isListExpanded={false}
revealed
onRevealComplete={onRevealComplete}
/>,
);
// 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 (
<NetworkEntry
entry={baseEntry}
isListExpanded={false}
revealed={revealed}
onRevealComplete={() => setRevealed(false)}
/>
);
};
renderWithMantine(<Harness />);
// 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(
<NetworkEntry entry={cancelled} isListExpanded={true} />,
);
expect(screen.getByText("Cancelled")).toBeInTheDocument();
expect(screen.getByText("Request cancelled")).toBeInTheDocument();
expect(screen.getByText(/notifications\/cancelled/)).toBeInTheDocument();
});
});
});

View file

@ -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<string, string> }) {
// 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 (
<Group gap="xs" wrap="nowrap" align="center">
{mismatch ? (
<MismatchValueText>{decoded.value}</MismatchValueText>
) : (
<HeaderValueText>{decoded.value}</HeaderValueText>
)}
{decoded.encoded && (
<Tooltip
label={`base64 sentinel — raw: ${decoded.raw}`}
withArrow
multiline
w={280}
>
<Badge size="xs" color="gray" variant="light">
base64
</Badge>
</Tooltip>
)}
{mismatch && (
<Tooltip
label={`Expected: ${consistency.expected}`}
withArrow
multiline
w={280}
>
<MismatchMarker
aria-label={`Header does not match body; expected ${consistency.expected}`}
>
<RiErrorWarningLine />
</MismatchMarker>
</Tooltip>
)}
</Group>
);
}
function HeadersTable({
headers,
consistency,
}: {
headers: Record<string, string>;
/** 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<string, string> }) {
</Text>
);
}
const byHeader = new Map((consistency ?? []).map((row) => [row.header, row]));
return (
<Table striped withColumnBorders fz="xs">
<Table.Tbody>
{rows.map(([name, value]) => (
<Table.Tr key={name}>
<Table.Td>
<Text size="xs" ff="monospace" fw={500}>
{name}
</Text>
{isMcpHeader(name) ? (
<McpHeaderNameText>{name}</McpHeaderNameText>
) : (
<HeaderNameText>{name}</HeaderNameText>
)}
</Table.Td>
<Table.Td>
<Text size="xs" ff="monospace" variant="monoBreak">
{value}
</Text>
<HeaderValueCell
name={name}
value={value}
consistency={byHeader.get(name.toLowerCase())}
/>
</Table.Td>
</Table.Tr>
))}
@ -160,6 +284,13 @@ function HeadersTable({ headers }: { headers: Record<string, string> }) {
);
}
const CancellationAlert = Alert.withProps({
variant: "light",
color: "gray",
title: "Request cancelled",
icon: <RiErrorWarningLine />,
});
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<HTMLDivElement>(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({
</Badge>
) : 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 (
<EntryContainer>
<EntryContainer ref={rootRef}>
<Stack gap="sm">
{embedded ? (
// Compact two-line header for the narrow column.
@ -339,11 +501,26 @@ export function NetworkEntry({
<Collapse in={isExpanded}>
<Stack gap="sm">
<Divider />
{aborted && (
<CancellationAlert>
<Text size="xs">
Cancellation appears as a connection abort — the modern
transport aborts the request stream instead of sending a{" "}
<Text span ff="monospace">
notifications/cancelled
</Text>{" "}
frame (SEP-2575).
</Text>
</CancellationAlert>
)}
<Stack gap="xs">
<Text size="sm" fw={500}>
Request Headers
</Text>
<HeadersTable headers={entry.requestHeaders} />
<HeadersTable
headers={entry.requestHeaders}
consistency={headerConsistency}
/>
</Stack>
{entry.requestBody && (
<Stack gap="xs">