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
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:
parent
9b2c188d11
commit
d552564966
33 changed files with 2592 additions and 42 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<typeof McpErrorBadge> = {
|
||||
title: "Elements/McpErrorBadge",
|
||||
component: McpErrorBadge,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof McpErrorBadge>;
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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(<McpErrorBadge code={-32020} name="HeaderMismatch" />);
|
||||
expect(screen.getByText("-32020 HeaderMismatch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("wraps the badge in a tooltip when a description is provided", () => {
|
||||
renderWithMantine(
|
||||
<McpErrorBadge
|
||||
code={-32022}
|
||||
name="UnsupportedProtocolVersion"
|
||||
description="The requested protocol version is not supported."
|
||||
/>,
|
||||
);
|
||||
// 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(<McpErrorBadge code={-32000} name="ServerError" />);
|
||||
expect(screen.getByText("-32000 ServerError")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<number, string> = {
|
||||
[-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 = (
|
||||
<Badge
|
||||
color={filledBadgeColor(COLOR_BY_CODE[code] ?? "red")}
|
||||
variant="filled"
|
||||
autoContrast
|
||||
// Keep the spec/SDK identifier's own casing (e.g. "UnsupportedProtocolVersion")
|
||||
// rather than Mantine's default uppercase, which runs these long
|
||||
// PascalCase names together and hurts readability.
|
||||
tt="none"
|
||||
>
|
||||
{code} {name}
|
||||
</Badge>
|
||||
);
|
||||
if (!description) return badge;
|
||||
return (
|
||||
<Tooltip label={description} multiline w={280} withArrow>
|
||||
{badge}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ProtocolListPanel
|
||||
{...baseProps}
|
||||
compact={false}
|
||||
entries={[errorEntry]}
|
||||
revealableIds={new Set(["err-1"])}
|
||||
onRevealInNetwork={onRevealInNetwork}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: /View the HTTP request in the Network tab/,
|
||||
}),
|
||||
);
|
||||
expect(onRevealInNetwork).toHaveBeenCalledWith("err-1");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
/**
|
||||
* Message-entry id → correlated Network fetch HTTP status. Gates the generic
|
||||
* `-32601` to a genuine modern 404 (see {@link ProtocolEntry}).
|
||||
*/
|
||||
correlatedStatusById?: Map<string, number>;
|
||||
}
|
||||
|
||||
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)}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</ScreenLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
/**
|
||||
* Message-entry id → correlated Network fetch HTTP status. Gates the generic
|
||||
* `-32601` to a genuine modern 404 (see {@link ProtocolListPanel}).
|
||||
*/
|
||||
correlatedStatusById?: Map<string, number>;
|
||||
}
|
||||
|
||||
// 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}
|
||||
/>
|
||||
</ScreenLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<string | undefined>(
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue