feat(network): add Network screen surfacing backend fetch log (#1355) (#1363)

* feat(network): add Network screen surfacing backend fetch log (#1355)

Routes `FetchRequestLogState` to a new Network tab so users can inspect
HTTP-layer request/response headers, status, duration, and category
(auth vs transport) of every fetch the backend makes for an active
session. Adds `NetworkControls` + `NetworkStreamPanel` + `NetworkEntry`
groups, a `monoBreak` Text theme variant for long header values, and
wires onClearNetwork / onExportNetwork (JSON download) from `App.tsx`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(network): include request / response bodies in search filter

Headers were already covered but bodies were not, so searches for
strings that live inside the JSON-RPC payload (e.g. "jsonrpc",
"initialize", a tool name) silently returned no matches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(network): always render Response Body section, note streaming

Streaming responses (text/event-stream, application/x-ndjson, POST to
/mcp) are intentionally not body-captured by the fetch tracker, so the
section was silently omitted. Now whenever a response was received we
render the section, with either the body, a "(empty)" note, or a
"Streaming response — body not captured" note keyed off content-type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(network): capture response bodies via async body-update events

Two related fixes:

1. The fetch tracker was marking every streamable-HTTP POST /mcp call as
   "not captured" because the response content-type is text/event-stream.
   In practice those streams are bounded — the server sends the
   JSON-RPC reply (sometimes preceded by progress events) and closes —
   so we can clone the body and read it. Only GET + SSE / ndjson is the
   unbounded long-lived channel that has to stay uncaptured.

2. Reading the body inline blocked the transport from processing
   progress notifications until the entire stream finished, breaking the
   `resetTimeoutOnProgress` integration test. The body is now read in
   the background and dispatched via a new `fetchRequestBodyUpdate`
   event that the FetchRequestLogState patches into the matching entry
   by id. Plumbing wires through node/transport, the remote SSE channel
   (RemoteSession, RemoteClientTransport), and the remote event types.

Also hides the Network tab when the active server is stdio (no HTTP
traffic to surface) and bumps the inline body preview limit to 100 KB
so typical tools/list responses render rather than tripping the "too
large" notice.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(network): show orange SSE badge for long-lived stream entries

Adds a small "SSE" badge to the right side of the NetworkEntry header
(left of the HTTP status badge) when the response is a long-lived
streaming channel (GET + text/event-stream or application/x-ndjson) —
the only case where the body genuinely isn't captured. Makes the
"not captured" placeholder less surprising at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(network): address PR review findings

- Stop capturing OAuth response bodies (drop updateResponseBody from the
  effectiveAuthFetch tracker). Restores pre-PR behavior for auth-category
  entries so live access_token / refresh_token values can't leak into the
  Network tab body preview during a screen-share. Headers + status are
  still tracked.
- Extract `isLongLivedStreamResponse(method, contentType)` to
  core/mcp/fetchTracking so the rule lives in one place; consumed by the
  tracker and the NetworkEntry UI placeholder.
- Document the intent of NetworkEntry's `useEffect` (the list-level
  toggle is authoritative; per-entry overrides are intentionally
  discarded on each list toggle).
- Rename MAX_INLINE_BODY_BYTES → MAX_INLINE_BODY_CHARS (`.length` is
  UTF-16 code units, not bytes) and update the user-facing notice.
- Switch NetworkStreamPanel's search from joined-string `.includes` to
  per-field `.some(.includes)` so a term can't span field boundaries.
  Regression test added.
- Add aria-label to ListToggle ("Expand all" / "Collapse all") — both an
  a11y improvement and a stable selector that lets the panel test stop
  fishing the button out by text-content negation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(network): use ListToggle aria-label in panel + modal tests

Cleanup spotted in PR review follow-up:

- HistoryListPanel + ServerSettingsModal previously fished the ListToggle
  out by empty text content because the toggle had no accessible name.
  Now that ListToggle exposes "Expand all" / "Collapse all", both tests
  can use getByRole + name and the stale comments go away.
- ListToggle's own tests now assert the aria-label contract so a future
  refactor can't silently drop it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(network): expose category toggle state via aria-pressed

The category UnstyledButtons in NetworkControls previously communicated
their toggled state only via background color. Adds aria-pressed={active}
so assistive tech can discover the on/off state without visuals, with a
test that asserts the attribute tracks the visibleCategories prop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cliff Hall 2026-05-27 08:35:25 -04:00 • committed by GitHub
commit 4641339cc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1891 additions and 67 deletions

View file

@ -0,0 +1,114 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js";
import { NetworkEntry } from "./NetworkEntry";
const meta: Meta<typeof NetworkEntry> = {
title: "Groups/NetworkEntry",
component: NetworkEntry,
};
export default meta;
type Story = StoryObj<typeof NetworkEntry>;
const transportEntry: FetchRequestEntry = {
id: "n-1",
timestamp: new Date("2026-03-17T10:30:00Z"),
method: "POST",
url: "https://example.com/mcp",
requestHeaders: {
"content-type": "application/json",
"x-test": "hello",
},
requestBody: '{"jsonrpc":"2.0","method":"initialize","id":1}',
responseStatus: 200,
responseStatusText: "OK",
responseHeaders: { "content-type": "application/json" },
responseBody: '{"jsonrpc":"2.0","id":1,"result":{}}',
duration: 45,
category: "transport",
};
const authEntry: FetchRequestEntry = {
id: "n-2",
timestamp: new Date("2026-03-17T10:30:05Z"),
method: "POST",
url: "https://example.com/oauth/token",
requestHeaders: { "content-type": "application/x-www-form-urlencoded" },
requestBody: "grant_type=authorization_code&code=abc",
responseStatus: 200,
responseStatusText: "OK",
responseHeaders: { "content-type": "application/json" },
responseBody: '{"access_token":"x","token_type":"bearer"}',
duration: 120,
category: "auth",
};
const errorEntry: FetchRequestEntry = {
id: "n-3",
timestamp: new Date("2026-03-17T10:30:10Z"),
method: "POST",
url: "https://example.com/mcp",
requestHeaders: { "content-type": "application/json" },
responseStatus: 500,
responseStatusText: "Internal Server Error",
responseHeaders: { "content-type": "text/plain" },
responseBody: "Unhandled exception",
duration: 1200,
category: "transport",
};
const streamingEntry: FetchRequestEntry = {
id: "n-stream",
timestamp: new Date("2026-03-17T10:30:12Z"),
method: "POST",
url: "http://localhost:3000/mcp",
requestHeaders: {
accept: "application/json, text/event-stream",
"content-type": "application/json",
"mcp-session-id": "0a0b0a5-fd27-4c95-a805-c0fba67e00fb",
},
requestBody: '{"method":"resources/templates/list","jsonrpc":"2.0","id":4}',
responseStatus: 200,
responseStatusText: "OK",
responseHeaders: {
"cache-control": "no-cache",
"content-type": "text/event-stream",
"mcp-session-id": "0a0b0a5-fd27-4c95-a805-c0fba67e00fb",
},
duration: 26,
category: "transport",
};
const transportError: FetchRequestEntry = {
id: "n-4",
timestamp: new Date("2026-03-17T10:30:15Z"),
method: "POST",
url: "https://example.com/mcp",
requestHeaders: {},
error: "fetch failed: ECONNREFUSED",
category: "transport",
};
export const TransportSuccessCollapsed: Story = {
args: { entry: transportEntry, isListExpanded: false },
};
export const TransportSuccessExpanded: Story = {
args: { entry: transportEntry, isListExpanded: true },
};
export const AuthSuccess: Story = {
args: { entry: authEntry, isListExpanded: true },
};
export const HttpError: Story = {
args: { entry: errorEntry, isListExpanded: true },
};
export const StreamingResponse: Story = {
args: { entry: streamingEntry, isListExpanded: true },
};
export const FetchError: Story = {
args: { entry: transportError, isListExpanded: true },
};

View file

@ -0,0 +1,199 @@
import { describe, it, expect } from "vitest";
import userEvent from "@testing-library/user-event";
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { NetworkEntry } from "./NetworkEntry";
const baseEntry: FetchRequestEntry = {
id: "n-1",
timestamp: new Date("2026-03-17T10:00:00Z"),
method: "POST",
url: "https://example.com/mcp",
requestHeaders: { "x-test": "hello" },
requestBody: '{"foo":"bar"}',
responseStatus: 200,
responseStatusText: "OK",
responseHeaders: { "content-type": "application/json" },
responseBody: '{"ok":true}',
duration: 45,
category: "transport",
};
describe("NetworkEntry", () => {
it("renders timestamp, method, URL, status, duration, and category", () => {
renderWithMantine(
<NetworkEntry entry={baseEntry} isListExpanded={false} />,
);
expect(screen.getByText("POST")).toBeInTheDocument();
expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
expect(screen.getByText("200 OK")).toBeInTheDocument();
expect(screen.getByText("45ms")).toBeInTheDocument();
expect(screen.getByText("transport")).toBeInTheDocument();
});
it("shows body / header detail when expanded", async () => {
const user = userEvent.setup();
renderWithMantine(
<NetworkEntry entry={baseEntry} isListExpanded={false} />,
);
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(screen.getByText("Request Headers")).toBeInTheDocument();
expect(screen.getByText("x-test")).toBeInTheDocument();
expect(screen.getByText("hello")).toBeInTheDocument();
expect(screen.getByText("Request Body")).toBeInTheDocument();
expect(screen.getByText("Response Headers")).toBeInTheDocument();
expect(screen.getByText("Response Body")).toBeInTheDocument();
});
it("renders without response when status is missing (pending)", () => {
const pending: FetchRequestEntry = {
...baseEntry,
responseStatus: undefined,
responseStatusText: undefined,
responseHeaders: undefined,
responseBody: undefined,
duration: undefined,
};
renderWithMantine(<NetworkEntry entry={pending} isListExpanded={false} />);
expect(screen.getByText("Pending")).toBeInTheDocument();
});
it("renders Error label and surfaces the error message when expanded", () => {
const errored: FetchRequestEntry = {
...baseEntry,
responseStatus: undefined,
responseStatusText: undefined,
responseHeaders: undefined,
responseBody: undefined,
error: "ECONNRESET",
};
renderWithMantine(<NetworkEntry entry={errored} isListExpanded={true} />);
expect(screen.getAllByText("Error").length).toBeGreaterThan(0);
expect(screen.getByText("ECONNRESET")).toBeInTheDocument();
});
it("renders status labels across HTTP classes", () => {
const cases: Array<[number, string]> = [
[201, "201"],
[301, "301"],
[404, "404"],
[500, "500"],
];
for (const [status, label] of cases) {
const { unmount } = renderWithMantine(
<NetworkEntry
entry={{
...baseEntry,
id: `n-${status}`,
responseStatus: status,
responseStatusText: undefined,
}}
isListExpanded={false}
/>,
);
expect(screen.getByText(label)).toBeInTheDocument();
unmount();
}
});
it("shows headers placeholder when none are present", async () => {
const user = userEvent.setup();
const noHeaders: FetchRequestEntry = {
...baseEntry,
requestHeaders: {},
responseHeaders: {},
};
renderWithMantine(
<NetworkEntry entry={noHeaders} isListExpanded={false} />,
);
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(screen.getAllByText("(none)").length).toBe(2);
});
it("shows a 'long-lived stream' placeholder when a GET SSE response has no body", async () => {
const user = userEvent.setup();
const sse: FetchRequestEntry = {
...baseEntry,
method: "GET",
responseHeaders: { "content-type": "text/event-stream" },
responseBody: undefined,
};
renderWithMantine(<NetworkEntry entry={sse} isListExpanded={false} />);
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(screen.getByText("Response Body")).toBeInTheDocument();
expect(
screen.getByText(/Long-lived stream — body not captured/),
).toBeInTheDocument();
});
it("shows an SSE badge on long-lived GET event-stream entries", () => {
const sse: FetchRequestEntry = {
...baseEntry,
method: "GET",
responseHeaders: { "content-type": "text/event-stream" },
responseBody: undefined,
};
renderWithMantine(<NetworkEntry entry={sse} isListExpanded={false} />);
expect(screen.getByText("SSE")).toBeInTheDocument();
});
it("does not show an SSE badge on bounded POST entries", () => {
renderWithMantine(
<NetworkEntry entry={baseEntry} isListExpanded={false} />,
);
expect(screen.queryByText("SSE")).not.toBeInTheDocument();
});
it("shows '(empty)' for a POST SSE response with no body (bounded stream where capture failed)", async () => {
const user = userEvent.setup();
const sse: FetchRequestEntry = {
...baseEntry,
method: "POST",
responseHeaders: { "content-type": "text/event-stream" },
responseBody: undefined,
};
renderWithMantine(<NetworkEntry entry={sse} isListExpanded={false} />);
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(screen.getByText("(empty)")).toBeInTheDocument();
});
it("shows '(empty)' for a non-streaming response with no body", async () => {
const user = userEvent.setup();
const empty: FetchRequestEntry = {
...baseEntry,
responseHeaders: { "content-type": "application/json" },
responseBody: undefined,
};
renderWithMantine(<NetworkEntry entry={empty} isListExpanded={false} />);
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(screen.getByText("Response Body")).toBeInTheDocument();
expect(screen.getByText("(empty)")).toBeInTheDocument();
});
it("omits the Response Body section entirely when no response was received", async () => {
const user = userEvent.setup();
const pending: FetchRequestEntry = {
...baseEntry,
responseStatus: undefined,
responseStatusText: undefined,
responseHeaders: undefined,
responseBody: undefined,
duration: undefined,
};
renderWithMantine(<NetworkEntry entry={pending} isListExpanded={false} />);
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(screen.queryByText("Response Body")).not.toBeInTheDocument();
});
it("shows a 'too large' notice when a body exceeds the inline preview limit", async () => {
const user = userEvent.setup();
const huge = "x".repeat(150_000);
const big: FetchRequestEntry = {
...baseEntry,
requestBody: huge,
};
renderWithMantine(<NetworkEntry entry={big} isListExpanded={false} />);
await user.click(screen.getByRole("button", { name: "Expand" }));
expect(screen.getByText(/Body too large to preview/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,237 @@
import { useEffect, useState } from "react";
import {
Badge,
Button,
Card,
Collapse,
Divider,
Group,
Stack,
Table,
Text,
} from "@mantine/core";
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js";
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
export interface NetworkEntryProps {
entry: FetchRequestEntry;
isListExpanded: boolean;
}
const EntryContainer = Card.withProps({
withBorder: true,
padding: "md",
});
const HeaderRow = Group.withProps({
justify: "space-between",
wrap: "nowrap",
});
const TimestampText = Text.withProps({
size: "sm",
c: "dimmed",
ff: "monospace",
});
const UrlText = Text.withProps({
size: "sm",
fw: 500,
truncate: "end",
});
const DurationText = Text.withProps({
size: "sm",
c: "dimmed",
});
const SubtleButton = Button.withProps({
variant: "subtle",
size: "xs",
});
// Cap is in JS string `.length` units (UTF-16 code units), not bytes — for
// multi-byte content the wire size is larger, but the limit's purpose is
// to keep the DOM from drowning in a single Code block so character count
// is the right unit.
const MAX_INLINE_BODY_CHARS = 100_000;
function formatDuration(ms: number): string {
return `${ms}ms`;
}
function formatTimestamp(date: Date): string {
return date.toISOString();
}
function statusColor(entry: FetchRequestEntry): string {
if (entry.error) return "red";
const status = entry.responseStatus;
if (status === undefined) return "gray";
if (status >= 500) return "red";
if (status >= 400) return "orange";
if (status >= 300) return "yellow";
if (status >= 200) return "green";
return "gray";
}
function statusLabel(entry: FetchRequestEntry): string {
if (entry.error) return "Error";
if (entry.responseStatus === undefined) return "Pending";
return entry.responseStatusText
? `${entry.responseStatus} ${entry.responseStatusText}`
: `${entry.responseStatus}`;
}
function categoryColor(category: FetchRequestEntry["category"]): string {
return category === "auth" ? "violet" : "blue";
}
function isLongLivedStream(entry: FetchRequestEntry): boolean {
return isLongLivedStreamResponse(
entry.method,
entry.responseHeaders?.["content-type"],
);
}
function HeadersTable({ headers }: { headers: Record<string, string> }) {
const rows = Object.entries(headers);
if (rows.length === 0) {
return (
<Text size="xs" c="dimmed">
(none)
</Text>
);
}
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>
</Table.Td>
<Table.Td>
<Text size="xs" ff="monospace" variant="monoBreak">
{value}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}
function BodyPreview({ body }: { body: string }) {
const tooLarge = body.length > MAX_INLINE_BODY_CHARS;
if (tooLarge) {
return (
<Text size="xs" c="dimmed">
Body too large to preview ({body.length} characters)
</Text>
);
}
return <ContentViewer block={{ type: "text", text: body }} copyable />;
}
export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
const [isExpanded, setIsExpanded] = useState(isListExpanded);
// The list-level Expand/Collapse toggle is authoritative: each time the
// parent changes `isListExpanded`, every entry snaps to that state and
// any per-entry override is intentionally discarded. Mirrors
// HistoryEntry; do not change without aligning both. This re-runs on
// re-render when `isListExpanded` keeps its reference, but the setter
// is a no-op when the next value equals the current one.
useEffect(() => {
setIsExpanded(isListExpanded);
}, [isListExpanded]);
return (
<EntryContainer>
<Stack gap="sm">
<HeaderRow>
<Group gap="sm" wrap="nowrap" miw={0} flex={1}>
<TimestampText>{formatTimestamp(entry.timestamp)}</TimestampText>
<Badge color="dark">{entry.method}</Badge>
<Badge color={categoryColor(entry.category)} variant="light">
{entry.category}
</Badge>
<UrlText>{entry.url}</UrlText>
</Group>
<Group gap="sm" wrap="nowrap">
{entry.duration != null && (
<DurationText>{formatDuration(entry.duration)}</DurationText>
)}
{isLongLivedStream(entry) && <Badge color="orange">SSE</Badge>}
<Badge color={statusColor(entry)}>{statusLabel(entry)}</Badge>
</Group>
</HeaderRow>
<Group gap="xs">
<SubtleButton onClick={() => setIsExpanded((v) => !v)} ml="auto">
{isExpanded ? "Collapse" : "Expand"}
</SubtleButton>
</Group>
<Collapse in={isExpanded}>
<Stack gap="sm">
<Divider />
<Stack gap="xs">
<Text size="sm" fw={500}>
Request Headers
</Text>
<HeadersTable headers={entry.requestHeaders} />
</Stack>
{entry.requestBody && (
<Stack gap="xs">
<Text size="sm" fw={500}>
Request Body
</Text>
<BodyPreview body={entry.requestBody} />
</Stack>
)}
{entry.responseHeaders && (
<Stack gap="xs">
<Text size="sm" fw={500}>
Response Headers
</Text>
<HeadersTable headers={entry.responseHeaders} />
</Stack>
)}
{entry.responseStatus !== undefined && (
<Stack gap="xs">
<Text size="sm" fw={500}>
Response Body
</Text>
{entry.responseBody ? (
<BodyPreview body={entry.responseBody} />
) : (
<Text size="xs" c="dimmed">
{isLongLivedStream(entry)
? "Long-lived stream — body not captured"
: "(empty)"}
</Text>
)}
</Stack>
)}
{entry.error && (
<Stack gap="xs">
<Text size="sm" fw={500} c="red">
Error
</Text>
<Text size="xs" ff="monospace" c="red">
{entry.error}
</Text>
</Stack>
)}
</Stack>
</Collapse>
</Stack>
</EntryContainer>
);
}