Skip to content

Commit 4641339

Browse files
cliffhallclaude
andauthored
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>
1 parent 491a522 commit 4641339

36 files changed

Lines changed: 1891 additions & 67 deletions

clients/web/src/App.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { useManagedResourceTemplates } from "@inspector/core/react/useManagedRes
3838
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
3939
import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js";
4040
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
41+
import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js";
4142
import { InspectorView } from "./components/views/InspectorView/InspectorView";
4243
import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen";
4344
import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen";
@@ -268,6 +269,7 @@ function App() {
268269
resourceSubscriptionsState,
269270
);
270271
const { messages } = useMessageLog(messageLogState);
272+
const { fetchRequests } = useFetchRequestLog(fetchRequestLogState);
271273

272274
// Capture observed handshake latency at the connecting → connected edge.
273275
// Reset when the status leaves "connected" so the next connect starts
@@ -671,6 +673,15 @@ function App() {
671673
messageLogState?.clearMessages();
672674
}, [messageLogState]);
673675

676+
const onClearNetwork = useCallback(() => {
677+
fetchRequestLogState?.clearFetchRequests();
678+
}, [fetchRequestLogState]);
679+
680+
const onExportNetwork = useCallback(() => {
681+
if (fetchRequests.length === 0) return;
682+
downloadJsonFile("network.json", JSON.stringify(fetchRequests, null, 2));
683+
}, [fetchRequests]);
684+
674685
// Action stubs — these UI affordances exist but require additional
675686
// wiring (server CRUD, history pinning, app sandbox round-trip, log
676687
// export). Tracked separately; the noop keeps the prop interface
@@ -857,6 +868,7 @@ function App() {
857868
logs={logs}
858869
tasks={tasks}
859870
history={messages}
871+
network={fetchRequests}
860872
toolCallState={toolCallState}
861873
getPromptState={getPromptState}
862874
readResourceState={effectiveReadResourceState}
@@ -910,6 +922,8 @@ function App() {
910922
onExportHistory={todoNoop}
911923
onReplayHistory={todoNoop}
912924
onTogglePinHistory={todoNoop}
925+
onClearNetwork={onClearNetwork}
926+
onExportNetwork={onExportNetwork}
913927
onSelectApp={todoNoop}
914928
onOpenApp={todoNoop}
915929
onCloseApp={todoNoop}

clients/web/src/components/elements/ListToggle/ListToggle.test.tsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,34 @@ import userEvent from "@testing-library/user-event";
44
import { ListToggle } from "./ListToggle";
55

66
describe("ListToggle", () => {
7-
it("renders a Button by default", () => {
7+
it("renders a Button by default with 'Expand all' aria-label when compact", () => {
88
renderWithMantine(<ListToggle compact onToggle={() => {}} />);
9-
expect(screen.getByRole("button")).toBeInTheDocument();
9+
expect(
10+
screen.getByRole("button", { name: "Expand all" }),
11+
).toBeInTheDocument();
1012
});
1113

12-
it("renders an ActionIcon for the subtle variant", () => {
14+
it("renders 'Collapse all' aria-label when not compact", () => {
15+
renderWithMantine(<ListToggle compact={false} onToggle={() => {}} />);
16+
expect(
17+
screen.getByRole("button", { name: "Collapse all" }),
18+
).toBeInTheDocument();
19+
});
20+
21+
it("renders an ActionIcon for the subtle variant and labels it", () => {
1322
renderWithMantine(
1423
<ListToggle compact variant="subtle" onToggle={() => {}} />,
1524
);
16-
expect(screen.getByRole("button")).toBeInTheDocument();
25+
expect(
26+
screen.getByRole("button", { name: "Expand all" }),
27+
).toBeInTheDocument();
1728
});
1829

1930
it("invokes onToggle when clicked (default variant)", async () => {
2031
const user = userEvent.setup();
2132
const onToggle = vi.fn();
2233
renderWithMantine(<ListToggle compact onToggle={onToggle} />);
23-
await user.click(screen.getByRole("button"));
34+
await user.click(screen.getByRole("button", { name: "Expand all" }));
2435
expect(onToggle).toHaveBeenCalledTimes(1);
2536
});
2637

@@ -30,7 +41,7 @@ describe("ListToggle", () => {
3041
renderWithMantine(
3142
<ListToggle compact={false} variant="subtle" onToggle={onToggle} />,
3243
);
33-
await user.click(screen.getByRole("button"));
44+
await user.click(screen.getByRole("button", { name: "Collapse all" }));
3445
expect(onToggle).toHaveBeenCalledTimes(1);
3546
});
3647
});

clients/web/src/components/elements/ListToggle/ListToggle.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,24 @@ export function ListToggle({
1313
variant = "default",
1414
}: ListToggleProps) {
1515
const Icon = compact ? RiExpandVerticalLine : RiCollapseVerticalLine;
16+
const label = compact ? "Expand all" : "Collapse all";
1617

1718
if (variant === "subtle") {
1819
return (
19-
<ActionIcon variant="subtle" color="gray" size="md" onClick={onToggle}>
20+
<ActionIcon
21+
variant="subtle"
22+
color="gray"
23+
size="md"
24+
aria-label={label}
25+
onClick={onToggle}
26+
>
2027
<Icon size={16} />
2128
</ActionIcon>
2229
);
2330
}
2431

2532
return (
26-
<Button size="sm" variant="subtle" onClick={onToggle}>
33+
<Button size="sm" variant="subtle" aria-label={label} onClick={onToggle}>
2734
<Icon size={20} />
2835
</Button>
2936
);

clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -224,19 +224,13 @@ describe("HistoryListPanel", () => {
224224
renderWithMantine(
225225
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
226226
);
227-
// Initially expanded — Collapse buttons exist on each entry
227+
// Initially expanded — Collapse buttons exist on each entry, and the
228+
// ListToggle exposes its aria-label as "Collapse all".
228229
expect(
229230
screen.getAllByRole("button", { name: "Collapse" }).length,
230231
).toBeGreaterThan(0);
231232

232-
// Find the ListToggle button (last subtle toolbar button at top — has no accessible name)
233-
const buttons = screen.getAllByRole("button");
234-
const toggle = buttons.find(
235-
(b) =>
236-
b.textContent === "" && b.classList.contains("mantine-Button-root"),
237-
);
238-
expect(toggle).toBeDefined();
239-
await user.click(toggle!);
233+
await user.click(screen.getByRole("button", { name: "Collapse all" }));
240234

241235
// After toggle, entries collapsed — they show Expand
242236
expect(
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Meta, StoryObj } from "@storybook/react-vite";
2+
import { fn } from "storybook/test";
3+
import { NetworkControls } from "./NetworkControls";
4+
5+
const meta: Meta<typeof NetworkControls> = {
6+
title: "Groups/NetworkControls",
7+
component: NetworkControls,
8+
args: {
9+
filterText: "",
10+
visibleCategories: { auth: true, transport: true },
11+
onFilterChange: fn(),
12+
onToggleCategory: fn(),
13+
onToggleAllCategories: fn(),
14+
},
15+
};
16+
17+
export default meta;
18+
type Story = StoryObj<typeof NetworkControls>;
19+
20+
export const AllVisible: Story = {};
21+
22+
export const NoneVisible: Story = {
23+
args: {
24+
visibleCategories: { auth: false, transport: false },
25+
},
26+
};
27+
28+
export const OnlyAuth: Story = {
29+
args: {
30+
visibleCategories: { auth: true, transport: false },
31+
},
32+
};
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import userEvent from "@testing-library/user-event";
3+
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
4+
import { NetworkControls } from "./NetworkControls";
5+
6+
const baseProps = {
7+
filterText: "",
8+
visibleCategories: { auth: true, transport: true } as const,
9+
onFilterChange: vi.fn(),
10+
onToggleCategory: vi.fn(),
11+
onToggleAllCategories: vi.fn(),
12+
};
13+
14+
describe("NetworkControls", () => {
15+
it("renders the title and inputs", () => {
16+
renderWithMantine(<NetworkControls {...baseProps} />);
17+
expect(screen.getByText("Network")).toBeInTheDocument();
18+
expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument();
19+
expect(screen.getByRole("button", { name: "auth" })).toBeInTheDocument();
20+
expect(
21+
screen.getByRole("button", { name: "transport" }),
22+
).toBeInTheDocument();
23+
});
24+
25+
it("fires onFilterChange when the user types", async () => {
26+
const user = userEvent.setup();
27+
const onFilterChange = vi.fn();
28+
renderWithMantine(
29+
<NetworkControls {...baseProps} onFilterChange={onFilterChange} />,
30+
);
31+
await user.type(screen.getByPlaceholderText("Search..."), "x");
32+
expect(onFilterChange).toHaveBeenLastCalledWith("x");
33+
});
34+
35+
it("reflects category visibility via aria-pressed", () => {
36+
renderWithMantine(
37+
<NetworkControls
38+
{...baseProps}
39+
visibleCategories={{ auth: true, transport: false }}
40+
/>,
41+
);
42+
expect(screen.getByRole("button", { name: "auth" })).toHaveAttribute(
43+
"aria-pressed",
44+
"true",
45+
);
46+
expect(screen.getByRole("button", { name: "transport" })).toHaveAttribute(
47+
"aria-pressed",
48+
"false",
49+
);
50+
});
51+
52+
it("fires onToggleCategory with inverted visibility when clicked", async () => {
53+
const user = userEvent.setup();
54+
const onToggleCategory = vi.fn();
55+
renderWithMantine(
56+
<NetworkControls {...baseProps} onToggleCategory={onToggleCategory} />,
57+
);
58+
await user.click(screen.getByRole("button", { name: "auth" }));
59+
expect(onToggleCategory).toHaveBeenCalledWith("auth", false);
60+
});
61+
62+
it("toggles between Select All and Deselect All", () => {
63+
const { rerender } = renderWithMantine(<NetworkControls {...baseProps} />);
64+
expect(
65+
screen.getByRole("button", { name: "Deselect All" }),
66+
).toBeInTheDocument();
67+
rerender(
68+
<NetworkControls
69+
{...baseProps}
70+
visibleCategories={{ auth: false, transport: false }}
71+
/>,
72+
);
73+
expect(
74+
screen.getByRole("button", { name: "Select All" }),
75+
).toBeInTheDocument();
76+
});
77+
78+
it("fires onToggleAllCategories when the toggle button is clicked", async () => {
79+
const user = userEvent.setup();
80+
const onToggleAllCategories = vi.fn();
81+
renderWithMantine(
82+
<NetworkControls
83+
{...baseProps}
84+
onToggleAllCategories={onToggleAllCategories}
85+
/>,
86+
);
87+
await user.click(screen.getByRole("button", { name: "Deselect All" }));
88+
expect(onToggleAllCategories).toHaveBeenCalledTimes(1);
89+
});
90+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
Button,
3+
Group,
4+
Stack,
5+
Text,
6+
TextInput,
7+
Title,
8+
UnstyledButton,
9+
} from "@mantine/core";
10+
import type { FetchRequestCategory } from "@inspector/core/mcp/types.js";
11+
12+
const NETWORK_CATEGORIES: FetchRequestCategory[] = ["auth", "transport"];
13+
14+
const CATEGORY_COLORS: Record<FetchRequestCategory, string> = {
15+
auth: "violet",
16+
transport: "blue",
17+
};
18+
19+
const SubtleButton = Button.withProps({
20+
variant: "subtle",
21+
size: "xs",
22+
});
23+
24+
export interface NetworkControlsProps {
25+
filterText: string;
26+
visibleCategories: Record<FetchRequestCategory, boolean>;
27+
onFilterChange: (text: string) => void;
28+
onToggleCategory: (category: FetchRequestCategory, visible: boolean) => void;
29+
onToggleAllCategories: () => void;
30+
}
31+
32+
export function NetworkControls({
33+
filterText,
34+
visibleCategories,
35+
onFilterChange,
36+
onToggleCategory,
37+
onToggleAllCategories,
38+
}: NetworkControlsProps) {
39+
const allSelected = NETWORK_CATEGORIES.every((c) => visibleCategories[c]);
40+
return (
41+
<Stack gap="md">
42+
<Title order={4}>Network</Title>
43+
44+
<TextInput
45+
placeholder="Search..."
46+
value={filterText}
47+
onChange={(e) => onFilterChange(e.currentTarget.value)}
48+
/>
49+
50+
<Group justify="space-between">
51+
<Title order={5}>Filter by Category</Title>
52+
<SubtleButton onClick={onToggleAllCategories}>
53+
{allSelected ? "Deselect All" : "Select All"}
54+
</SubtleButton>
55+
</Group>
56+
<Stack gap="xs">
57+
{NETWORK_CATEGORIES.map((category) => {
58+
const active = visibleCategories[category];
59+
return (
60+
<UnstyledButton
61+
key={category}
62+
w="100%"
63+
p="sm"
64+
variant="listItem"
65+
aria-pressed={active}
66+
bg={active ? "var(--mantine-primary-color-light)" : undefined}
67+
onClick={() => onToggleCategory(category, !active)}
68+
>
69+
<Text c={CATEGORY_COLORS[category]} ta="center" fw={500}>
70+
{category}
71+
</Text>
72+
</UnstyledButton>
73+
);
74+
})}
75+
</Stack>
76+
</Stack>
77+
);
78+
}

0 commit comments

Comments
 (0)