From c101579718c18818640e3da4018b5c68c8de5078 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 26 May 2026 15:43:51 -0400 Subject: [PATCH 1/8] 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) --- clients/web/src/App.tsx | 14 ++ .../NetworkControls.stories.tsx | 32 +++ .../NetworkControls/NetworkControls.test.tsx | 73 ++++++ .../NetworkControls/NetworkControls.tsx | 77 +++++++ .../NetworkEntry/NetworkEntry.stories.tsx | 88 ++++++++ .../groups/NetworkEntry/NetworkEntry.test.tsx | 124 +++++++++++ .../groups/NetworkEntry/NetworkEntry.tsx | 210 ++++++++++++++++++ .../NetworkStreamPanel.stories.tsx | 50 +++++ .../NetworkStreamPanel.test.tsx | 96 ++++++++ .../NetworkStreamPanel/NetworkStreamPanel.tsx | 123 ++++++++++ .../NetworkScreen/NetworkScreen.stories.tsx | 116 ++++++++++ .../NetworkScreen/NetworkScreen.test.tsx | 130 +++++++++++ .../screens/NetworkScreen/NetworkScreen.tsx | 89 ++++++++ .../InspectorView/InspectorView.stories.tsx | 38 ++++ .../InspectorView/InspectorView.test.tsx | 3 + .../views/InspectorView/InspectorView.tsx | 17 ++ clients/web/src/theme/Text.ts | 14 ++ clients/web/src/theme/index.ts | 1 + clients/web/src/theme/theme.ts | 2 + 19 files changed, 1297 insertions(+) create mode 100644 clients/web/src/components/groups/NetworkControls/NetworkControls.stories.tsx create mode 100644 clients/web/src/components/groups/NetworkControls/NetworkControls.test.tsx create mode 100644 clients/web/src/components/groups/NetworkControls/NetworkControls.tsx create mode 100644 clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx create mode 100644 clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx create mode 100644 clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx create mode 100644 clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx create mode 100644 clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx create mode 100644 clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx create mode 100644 clients/web/src/components/screens/NetworkScreen/NetworkScreen.stories.tsx create mode 100644 clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx create mode 100644 clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx create mode 100644 clients/web/src/theme/Text.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 68edb7248..d0d25e560 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -38,6 +38,7 @@ import { useManagedResourceTemplates } from "@inspector/core/react/useManagedRes import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js"; import { useResourceSubscriptions } from "@inspector/core/react/useResourceSubscriptions.js"; import { useMessageLog } from "@inspector/core/react/useMessageLog.js"; +import { useFetchRequestLog } from "@inspector/core/react/useFetchRequestLog.js"; import { InspectorView } from "./components/views/InspectorView/InspectorView"; import type { ToolCallState } from "./components/screens/ToolsScreen/ToolsScreen"; import type { GetPromptState } from "./components/screens/PromptsScreen/PromptsScreen"; @@ -268,6 +269,7 @@ function App() { resourceSubscriptionsState, ); const { messages } = useMessageLog(messageLogState); + const { fetchRequests } = useFetchRequestLog(fetchRequestLogState); // Capture observed handshake latency at the connecting → connected edge. // Reset when the status leaves "connected" so the next connect starts @@ -671,6 +673,15 @@ function App() { messageLogState?.clearMessages(); }, [messageLogState]); + const onClearNetwork = useCallback(() => { + fetchRequestLogState?.clearFetchRequests(); + }, [fetchRequestLogState]); + + const onExportNetwork = useCallback(() => { + if (fetchRequests.length === 0) return; + downloadJsonFile("network.json", JSON.stringify(fetchRequests, null, 2)); + }, [fetchRequests]); + // Action stubs — these UI affordances exist but require additional // wiring (server CRUD, history pinning, app sandbox round-trip, log // export). Tracked separately; the noop keeps the prop interface @@ -857,6 +868,7 @@ function App() { logs={logs} tasks={tasks} history={messages} + network={fetchRequests} toolCallState={toolCallState} getPromptState={getPromptState} readResourceState={effectiveReadResourceState} @@ -910,6 +922,8 @@ function App() { onExportHistory={todoNoop} onReplayHistory={todoNoop} onTogglePinHistory={todoNoop} + onClearNetwork={onClearNetwork} + onExportNetwork={onExportNetwork} onSelectApp={todoNoop} onOpenApp={todoNoop} onCloseApp={todoNoop} diff --git a/clients/web/src/components/groups/NetworkControls/NetworkControls.stories.tsx b/clients/web/src/components/groups/NetworkControls/NetworkControls.stories.tsx new file mode 100644 index 000000000..20b703257 --- /dev/null +++ b/clients/web/src/components/groups/NetworkControls/NetworkControls.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import { NetworkControls } from "./NetworkControls"; + +const meta: Meta = { + title: "Groups/NetworkControls", + component: NetworkControls, + args: { + filterText: "", + visibleCategories: { auth: true, transport: true }, + onFilterChange: fn(), + onToggleCategory: fn(), + onToggleAllCategories: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const AllVisible: Story = {}; + +export const NoneVisible: Story = { + args: { + visibleCategories: { auth: false, transport: false }, + }, +}; + +export const OnlyAuth: Story = { + args: { + visibleCategories: { auth: true, transport: false }, + }, +}; diff --git a/clients/web/src/components/groups/NetworkControls/NetworkControls.test.tsx b/clients/web/src/components/groups/NetworkControls/NetworkControls.test.tsx new file mode 100644 index 000000000..e95958e81 --- /dev/null +++ b/clients/web/src/components/groups/NetworkControls/NetworkControls.test.tsx @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { NetworkControls } from "./NetworkControls"; + +const baseProps = { + filterText: "", + visibleCategories: { auth: true, transport: true } as const, + onFilterChange: vi.fn(), + onToggleCategory: vi.fn(), + onToggleAllCategories: vi.fn(), +}; + +describe("NetworkControls", () => { + it("renders the title and inputs", () => { + renderWithMantine(); + expect(screen.getByText("Network")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "auth" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "transport" }), + ).toBeInTheDocument(); + }); + + it("fires onFilterChange when the user types", async () => { + const user = userEvent.setup(); + const onFilterChange = vi.fn(); + renderWithMantine( + , + ); + await user.type(screen.getByPlaceholderText("Search..."), "x"); + expect(onFilterChange).toHaveBeenLastCalledWith("x"); + }); + + it("fires onToggleCategory with inverted visibility when clicked", async () => { + const user = userEvent.setup(); + const onToggleCategory = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "auth" })); + expect(onToggleCategory).toHaveBeenCalledWith("auth", false); + }); + + it("toggles between Select All and Deselect All", () => { + const { rerender } = renderWithMantine(); + expect( + screen.getByRole("button", { name: "Deselect All" }), + ).toBeInTheDocument(); + rerender( + , + ); + expect( + screen.getByRole("button", { name: "Select All" }), + ).toBeInTheDocument(); + }); + + it("fires onToggleAllCategories when the toggle button is clicked", async () => { + const user = userEvent.setup(); + const onToggleAllCategories = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Deselect All" })); + expect(onToggleAllCategories).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx b/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx new file mode 100644 index 000000000..e2a711a6b --- /dev/null +++ b/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx @@ -0,0 +1,77 @@ +import { + Button, + Group, + Stack, + Text, + TextInput, + Title, + UnstyledButton, +} from "@mantine/core"; +import type { FetchRequestCategory } from "@inspector/core/mcp/types.js"; + +const NETWORK_CATEGORIES: FetchRequestCategory[] = ["auth", "transport"]; + +const CATEGORY_COLORS: Record = { + auth: "violet", + transport: "blue", +}; + +const SubtleButton = Button.withProps({ + variant: "subtle", + size: "xs", +}); + +export interface NetworkControlsProps { + filterText: string; + visibleCategories: Record; + onFilterChange: (text: string) => void; + onToggleCategory: (category: FetchRequestCategory, visible: boolean) => void; + onToggleAllCategories: () => void; +} + +export function NetworkControls({ + filterText, + visibleCategories, + onFilterChange, + onToggleCategory, + onToggleAllCategories, +}: NetworkControlsProps) { + const allSelected = NETWORK_CATEGORIES.every((c) => visibleCategories[c]); + return ( + + Network + + onFilterChange(e.currentTarget.value)} + /> + + + Filter by Category + + {allSelected ? "Deselect All" : "Select All"} + + + + {NETWORK_CATEGORIES.map((category) => { + const active = visibleCategories[category]; + return ( + onToggleCategory(category, !active)} + > + + {category} + + + ); + })} + + + ); +} diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx new file mode 100644 index 000000000..cff5c168a --- /dev/null +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx @@ -0,0 +1,88 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js"; +import { NetworkEntry } from "./NetworkEntry"; + +const meta: Meta = { + title: "Groups/NetworkEntry", + component: NetworkEntry, +}; + +export default meta; +type Story = StoryObj; + +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 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 FetchError: Story = { + args: { entry: transportError, isListExpanded: true }, +}; diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx new file mode 100644 index 000000000..dab39b03e --- /dev/null +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -0,0 +1,124 @@ +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( + , + ); + 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( + , + ); + 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(); + 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(); + 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( + , + ); + 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( + , + ); + await user.click(screen.getByRole("button", { name: "Expand" })); + expect(screen.getAllByText("(none)").length).toBe(2); + }); + + it("shows a 'too large' notice when a body exceeds the inline preview limit", async () => { + const user = userEvent.setup(); + const huge = "x".repeat(5000); + const big: FetchRequestEntry = { + ...baseEntry, + requestBody: huge, + }; + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Expand" })); + expect(screen.getByText(/Body too large to preview/)).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx new file mode 100644 index 000000000..71dfdffae --- /dev/null +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -0,0 +1,210 @@ +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 { 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", +}); + +const MAX_INLINE_BODY_BYTES = 4096; + +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 HeadersTable({ headers }: { headers: Record }) { + const rows = Object.entries(headers); + if (rows.length === 0) { + return ( + + (none) + + ); + } + return ( + + + {rows.map(([name, value]) => ( + + + + {name} + + + + + {value} + + + + ))} + +
+ ); +} + +function BodyPreview({ body }: { body: string }) { + const tooLarge = body.length > MAX_INLINE_BODY_BYTES; + if (tooLarge) { + return ( + + Body too large to preview ({body.length} bytes) + + ); + } + return ; +} + +export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { + const [isExpanded, setIsExpanded] = useState(isListExpanded); + + useEffect(() => { + setIsExpanded(isListExpanded); + }, [isListExpanded]); + + return ( + + + + + {formatTimestamp(entry.timestamp)} + {entry.method} + + {entry.category} + + {entry.url} + + + {entry.duration != null && ( + {formatDuration(entry.duration)} + )} + {statusLabel(entry)} + + + + + setIsExpanded((v) => !v)} ml="auto"> + {isExpanded ? "Collapse" : "Expand"} + + + + + + + + + Request Headers + + + + {entry.requestBody && ( + + + Request Body + + + + )} + {entry.responseHeaders && ( + + + Response Headers + + + + )} + {entry.responseBody && ( + + + Response Body + + + + )} + {entry.error && ( + + + Error + + + {entry.error} + + + )} + + + + + ); +} diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx new file mode 100644 index 000000000..e5446d168 --- /dev/null +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { fn } from "storybook/test"; +import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js"; +import { NetworkStreamPanel } from "./NetworkStreamPanel"; + +const meta: Meta = { + title: "Groups/NetworkStreamPanel", + component: NetworkStreamPanel, + parameters: { layout: "fullscreen" }, + args: { + filterText: "", + visibleCategories: { auth: true, transport: true }, + onClear: fn(), + onExport: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +const sample: FetchRequestEntry[] = [ + { + id: "n-1", + timestamp: new Date("2026-03-17T10:00:00Z"), + method: "POST", + url: "https://example.com/mcp", + requestHeaders: { "content-type": "application/json" }, + responseStatus: 200, + duration: 45, + category: "transport", + }, + { + id: "n-2", + timestamp: new Date("2026-03-17T10:00:05Z"), + method: "POST", + url: "https://example.com/oauth/token", + requestHeaders: {}, + responseStatus: 200, + duration: 120, + category: "auth", + }, +]; + +export const WithEntries: Story = { + args: { entries: sample }, +}; + +export const Empty: Story = { + args: { entries: [] }, +}; diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx new file mode 100644 index 000000000..e03a7e300 --- /dev/null +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } 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 { NetworkStreamPanel } from "./NetworkStreamPanel"; + +const entry: FetchRequestEntry = { + id: "n-1", + timestamp: new Date("2026-03-17T10:00:00Z"), + method: "POST", + url: "https://example.com/mcp", + requestHeaders: { authorization: "Bearer abc" }, + responseStatus: 200, + category: "transport", +}; + +const baseProps = { + entries: [entry], + filterText: "", + visibleCategories: { auth: true, transport: true } as const, + onClear: vi.fn(), + onExport: vi.fn(), +}; + +describe("NetworkStreamPanel", () => { + it("renders entries when present", () => { + renderWithMantine(); + expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); + expect(screen.getByText("Requests (1)")).toBeInTheDocument(); + }); + + it("renders the empty state when filters hide everything", () => { + renderWithMantine( + , + ); + expect(screen.getByText("No network requests")).toBeInTheDocument(); + }); + + it("filters entries by search text", () => { + renderWithMantine(); + expect(screen.getByText("No network requests")).toBeInTheDocument(); + }); + + it("filters entries by URL match", () => { + renderWithMantine( + , + ); + expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); + }); + + it("invokes onClear and onExport", async () => { + const user = userEvent.setup(); + const onClear = vi.fn(); + const onExport = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("button", { name: "Clear" })); + await user.click(screen.getByRole("button", { name: "Export" })); + expect(onClear).toHaveBeenCalledTimes(1); + expect(onExport).toHaveBeenCalledTimes(1); + }); + + it("disables Clear / Export when there are no entries at all", () => { + renderWithMantine(); + expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Export" })).toBeDisabled(); + }); + + it("toggles between compact and expanded list views", async () => { + const user = userEvent.setup(); + renderWithMantine(); + // List starts compact -> entry should show Expand button + expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument(); + // The first icon button (ListToggle) is the toggle next to Clear/Export. + // Identify it by being the first button that is neither Clear, Export, + // nor an Expand/Collapse inside an entry. + const buttons = screen.getAllByRole("button"); + const toggle = buttons.find((b) => { + const text = b.textContent ?? ""; + return !/Clear|Export|Expand|Collapse/.test(text); + }); + expect(toggle).toBeDefined(); + await user.click(toggle!); + expect( + screen.getByRole("button", { name: "Collapse" }), + ).toBeInTheDocument(); + }); +}); diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx new file mode 100644 index 000000000..60d535807 --- /dev/null +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -0,0 +1,123 @@ +import { useMemo, useState } from "react"; +import { + Button, + Group, + Paper, + ScrollArea, + Stack, + Text, + Title, +} from "@mantine/core"; +import type { + FetchRequestCategory, + FetchRequestEntry, +} from "@inspector/core/mcp/types.js"; +import { NetworkEntry } from "../NetworkEntry/NetworkEntry"; +import { ListToggle } from "../../elements/ListToggle/ListToggle"; + +export interface NetworkStreamPanelProps { + entries: FetchRequestEntry[]; + filterText: string; + visibleCategories: Record; + onClear: () => void; + onExport: () => void; +} + +const PanelContainer = Paper.withProps({ + withBorder: true, + p: "lg", + flex: 1, + variant: "panel", +}); + +const EmptyState = Text.withProps({ + c: "dimmed", + ta: "center", + py: "xl", +}); + +function formatTitle(count: number): string { + return `Requests (${count})`; +} + +function matchesFilters( + entry: FetchRequestEntry, + filterText: string, + visibleCategories: Record, +): boolean { + if (!visibleCategories[entry.category]) return false; + if (filterText) { + const term = filterText.toLowerCase(); + const requestHeadersText = Object.entries(entry.requestHeaders) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); + const responseHeadersText = entry.responseHeaders + ? Object.entries(entry.responseHeaders) + .map(([k, v]) => `${k}: ${v}`) + .join("\n") + : ""; + const status = + entry.responseStatus !== undefined ? String(entry.responseStatus) : ""; + const searchable = + `${entry.method} ${entry.url} ${status} ${requestHeadersText} ${responseHeadersText} ${entry.error ?? ""}`.toLowerCase(); + if (!searchable.includes(term)) return false; + } + return true; +} + +export function NetworkStreamPanel({ + entries, + filterText, + visibleCategories, + onClear, + onExport, +}: NetworkStreamPanelProps) { + const [compact, setCompact] = useState(true); + + const filteredEntries = useMemo( + () => + entries.filter((e) => matchesFilters(e, filterText, visibleCategories)), + [entries, filterText, visibleCategories], + ); + + const hasEntries = entries.length > 0; + const hasResults = filteredEntries.length > 0; + + return ( + + + {formatTitle(filteredEntries.length)} + + {hasResults && ( + setCompact((c) => !c)} + /> + )} + + + + + + {!hasResults ? ( + No network requests + ) : ( + + + {filteredEntries.map((entry) => ( + + ))} + + + )} + + ); +} diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.stories.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.stories.tsx new file mode 100644 index 000000000..26c855654 --- /dev/null +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, userEvent, within } from "storybook/test"; +import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js"; +import { NetworkScreen } from "./NetworkScreen"; + +const meta: Meta = { + title: "Screens/NetworkScreen", + component: NetworkScreen, + parameters: { layout: "fullscreen" }, + args: { + onClear: fn(), + onExport: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +const sampleEntries: FetchRequestEntry[] = [ + { + id: "fetch-1", + timestamp: new Date("2026-03-17T10:00: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", + }, + { + id: "fetch-2", + timestamp: new Date("2026-03-17T10:00: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", + }, + { + id: "fetch-3", + timestamp: new Date("2026-03-17T10:00:08Z"), + method: "GET", + url: "https://example.com/oauth/authorize", + requestHeaders: {}, + responseStatus: 302, + responseStatusText: "Found", + responseHeaders: { location: "https://example.com/callback" }, + duration: 22, + category: "auth", + }, + { + id: "fetch-4", + timestamp: new Date("2026-03-17T10:00:12Z"), + method: "POST", + url: "https://example.com/mcp", + requestHeaders: { "content-type": "application/json" }, + requestBody: '{"jsonrpc":"2.0","method":"tools/call","id":2}', + responseStatus: 500, + responseStatusText: "Internal Server Error", + responseHeaders: { "content-type": "text/plain" }, + responseBody: "Unhandled exception", + duration: 1200, + category: "transport", + }, + { + id: "fetch-5", + timestamp: new Date("2026-03-17T10:00:18Z"), + method: "POST", + url: "https://example.com/mcp", + requestHeaders: { "content-type": "application/json" }, + error: "fetch failed: ECONNREFUSED", + category: "transport", + }, +]; + +export const WithEntries: Story = { + args: { + entries: sampleEntries, + }, +}; + +export const Empty: Story = { + args: { + entries: [], + }, +}; + +export const FilterByCategory: Story = { + args: { + entries: sampleEntries, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Initially both auth and transport entries are visible + await expect( + canvas.getByText("https://example.com/oauth/token"), + ).toBeInTheDocument(); + // Hide the auth category — the oauth entry should disappear + await userEvent.click(canvas.getByRole("button", { name: "auth" })); + await expect( + canvas.queryByText("https://example.com/oauth/token"), + ).not.toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx new file mode 100644 index 000000000..9bb51f39c --- /dev/null +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } 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 { NetworkScreen } from "./NetworkScreen"; + +const transportEntry: FetchRequestEntry = { + id: "t-1", + timestamp: new Date("2026-03-17T10:00:00Z"), + method: "POST", + url: "https://example.com/mcp", + requestHeaders: { "x-test": "hello" }, + responseStatus: 200, + responseStatusText: "OK", + responseHeaders: { "content-type": "application/json" }, + duration: 45, + category: "transport", +}; + +const authEntry: FetchRequestEntry = { + id: "a-1", + timestamp: new Date("2026-03-17T10:01:00Z"), + method: "POST", + url: "https://example.com/oauth/token", + requestHeaders: { "content-type": "application/x-www-form-urlencoded" }, + responseStatus: 200, + responseHeaders: { "content-type": "application/json" }, + duration: 120, + category: "auth", +}; + +const errorEntry: FetchRequestEntry = { + id: "e-1", + timestamp: new Date("2026-03-17T10:02:00Z"), + method: "GET", + url: "https://example.com/mcp", + requestHeaders: {}, + error: "Network error", + category: "transport", +}; + +const baseProps = { + entries: [transportEntry, authEntry, errorEntry], + onClear: vi.fn(), + onExport: vi.fn(), +}; + +describe("NetworkScreen", () => { + it("renders the network controls and panel", () => { + renderWithMantine(); + expect(screen.getByText("Network")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search...")).toBeInTheDocument(); + expect(screen.getByText("Filter by Category")).toBeInTheDocument(); + }); + + it("renders empty state when there are no entries", () => { + renderWithMantine(); + expect(screen.getByText("No network requests")).toBeInTheDocument(); + }); + + it("shows entry headers when expanded", async () => { + const user = userEvent.setup(); + renderWithMantine( + , + ); + expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); + 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("Response Headers")).toBeInTheDocument(); + }); + + it("filters by category when a category is toggled off", async () => { + const user = userEvent.setup(); + renderWithMantine(); + expect( + screen.getByText("https://example.com/oauth/token"), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "auth" })); + expect( + screen.queryByText("https://example.com/oauth/token"), + ).not.toBeInTheDocument(); + }); + + it("Deselect All hides every entry; Select All restores them", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Deselect All" })); + expect(screen.getByText("No network requests")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Select All" })); + expect( + screen.getByText("https://example.com/oauth/token"), + ).toBeInTheDocument(); + }); + + it("filters by search text across URL, method, status, and headers", async () => { + const user = userEvent.setup(); + renderWithMantine(); + await user.type(screen.getByPlaceholderText("Search..."), "oauth"); + expect( + screen.getByText("https://example.com/oauth/token"), + ).toBeInTheDocument(); + expect( + screen.queryByText("https://example.com/mcp"), + ).not.toBeInTheDocument(); + }); + + it("invokes onClear when Clear is clicked", async () => { + const user = userEvent.setup(); + const onClear = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("invokes onExport when Export is clicked", async () => { + const user = userEvent.setup(); + const onExport = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Export" })); + expect(onExport).toHaveBeenCalledTimes(1); + }); + + it("disables Clear / Export when there are no entries", () => { + renderWithMantine(); + expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Export" })).toBeDisabled(); + }); +}); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx new file mode 100644 index 000000000..d5d197b19 --- /dev/null +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; +import { Card, Flex, Stack } from "@mantine/core"; +import type { + FetchRequestCategory, + FetchRequestEntry, +} from "@inspector/core/mcp/types.js"; +import { NetworkControls } from "../../groups/NetworkControls/NetworkControls"; +import { NetworkStreamPanel } from "../../groups/NetworkStreamPanel/NetworkStreamPanel"; + +export interface NetworkScreenProps { + entries: FetchRequestEntry[]; + onClear: () => void; + onExport: () => void; +} + +const ALL_CATEGORIES_VISIBLE: Record = { + auth: true, + transport: true, +}; + +const NO_CATEGORIES_VISIBLE: Record = { + auth: false, + transport: false, +}; + +const ScreenLayout = Flex.withProps({ + variant: "screen", + h: "calc(100vh - var(--app-shell-header-height, 0px))", + gap: "md", + p: "xl", +}); + +const Sidebar = Stack.withProps({ + w: 340, + flex: "0 0 auto", +}); + +const SidebarCard = Card.withProps({ + withBorder: true, + padding: "lg", +}); + +export function NetworkScreen({ + entries, + onClear, + onExport, +}: NetworkScreenProps) { + const [filterText, setFilterText] = useState(""); + const [visibleCategories, setVisibleCategories] = useState< + Record + >(ALL_CATEGORIES_VISIBLE); + + function handleToggleCategory( + category: FetchRequestCategory, + visible: boolean, + ) { + setVisibleCategories((prev) => ({ ...prev, [category]: visible })); + } + + function handleToggleAllCategories() { + const allSelected = Object.values(visibleCategories).every(Boolean); + setVisibleCategories( + allSelected ? NO_CATEGORIES_VISIBLE : ALL_CATEGORIES_VISIBLE, + ); + } + + return ( + + + + + + + + + ); +} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx index 95a2eebab..dd33d7cfb 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.stories.tsx @@ -8,6 +8,7 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; import type { + FetchRequestEntry, InspectorResourceSubscription, MessageEntry, ServerEntry, @@ -249,6 +250,40 @@ const demoHistory: MessageEntry[] = [ }, ]; +const demoNetwork: FetchRequestEntry[] = [ + { + id: "fetch-1", + timestamp: new Date("2026-03-17T10:00: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", + }, + { + id: "fetch-2", + timestamp: new Date("2026-03-17T10:00: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 demoInitializeResult: InitializeResult = { protocolVersion: "2025-06-18", capabilities: {}, @@ -271,6 +306,7 @@ const meta: Meta = { tasks: demoTasks, progressByTaskId: demoProgressByTaskId, history: demoHistory, + network: demoNetwork, // Connection state — stories default to "disconnected"; per-story // overrides drive the connected / error narratives. @@ -319,6 +355,8 @@ const meta: Meta = { onExportHistory: fn(), onReplayHistory: fn(), onTogglePinHistory: fn(), + onClearNetwork: fn(), + onExportNetwork: fn(), onSelectApp: fn(), onOpenApp: fn(), onCloseApp: fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index be0f6bc5b..550f18534 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -49,6 +49,7 @@ function makeProps( logs: [], tasks: [], history: [], + network: [], currentLogLevel: "info", sandboxPath: "about:blank", bridgeFactory: noopBridgeFactory, @@ -83,6 +84,8 @@ function makeProps( onExportHistory: vi.fn(), onReplayHistory: vi.fn(), onTogglePinHistory: vi.fn(), + onClearNetwork: vi.fn(), + onExportNetwork: vi.fn(), onSelectApp: vi.fn(), onOpenApp: vi.fn(), onCloseApp: vi.fn(), diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index edfc1c1d7..18382ce26 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -11,6 +11,7 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import type { ConnectionStatus, + FetchRequestEntry, InspectorResourceSubscription, MessageEntry, ServerEntry, @@ -40,6 +41,7 @@ import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; import { TasksScreen } from "../../screens/TasksScreen/TasksScreen"; import type { TaskProgress } from "../../groups/TaskCard/TaskCard"; import { HistoryScreen } from "../../screens/HistoryScreen/HistoryScreen"; +import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen"; const SERVERS_TAB = "Servers"; @@ -52,6 +54,7 @@ const ALL_TABS: string[] = [ "Tasks", "Logs", "History", + "Network", ]; const SCREEN_ENTER_MS = 350; @@ -122,6 +125,7 @@ export interface InspectorViewProps { tasks: Task[]; progressByTaskId?: Record; history: MessageEntry[]; + network: FetchRequestEntry[]; // Per-screen "operation in flight" states (panel-level; optional because // the underlying screens accept them as optional). @@ -200,6 +204,9 @@ export interface InspectorViewProps { onReplayHistory: (id: string) => void; onTogglePinHistory: (id: string) => void; + onClearNetwork: () => void; + onExportNetwork: () => void; + onSelectApp: (name: string) => void; onOpenApp: (name: string, args: Record) => void; onCloseApp: () => void; @@ -222,6 +229,7 @@ export function InspectorView({ tasks, progressByTaskId, history, + network, toolCallState, getPromptState, readResourceState, @@ -265,6 +273,8 @@ export function InspectorView({ onExportHistory, onReplayHistory, onTogglePinHistory, + onClearNetwork, + onExportNetwork, onSelectApp, onOpenApp, onCloseApp, @@ -450,6 +460,13 @@ export function InspectorView({ onTogglePin={onTogglePinHistory} /> + + + diff --git a/clients/web/src/theme/Text.ts b/clients/web/src/theme/Text.ts new file mode 100644 index 000000000..9f08922e4 --- /dev/null +++ b/clients/web/src/theme/Text.ts @@ -0,0 +1,14 @@ +import { Text } from "@mantine/core"; + +export const ThemeText = Text.extend({ + styles: (_theme, props) => { + if (props.variant === "monoBreak") { + return { + root: { + wordBreak: "break-all", + }, + }; + } + return { root: {} }; + }, +}); diff --git a/clients/web/src/theme/index.ts b/clients/web/src/theme/index.ts index 1c3ee13d3..af1d60281 100644 --- a/clients/web/src/theme/index.ts +++ b/clients/web/src/theme/index.ts @@ -11,5 +11,6 @@ export { ThemeInput } from "./Input"; export { ThemePaper } from "./Paper"; export { ThemeSelect } from "./Select"; export { ThemeSwitch } from "./Switch"; +export { ThemeText } from "./Text"; export { ThemeTextInput } from "./TextInput"; export { ThemeUnstyledButton } from "./UnstyledButton"; diff --git a/clients/web/src/theme/theme.ts b/clients/web/src/theme/theme.ts index cdbfc7555..1759e278e 100644 --- a/clients/web/src/theme/theme.ts +++ b/clients/web/src/theme/theme.ts @@ -13,6 +13,7 @@ import { ThemePaper, ThemeSelect, ThemeSwitch, + ThemeText, ThemeTextInput, ThemeUnstyledButton, } from "./index"; @@ -66,6 +67,7 @@ export const theme = createTheme({ Paper: ThemePaper, Select: ThemeSelect, Switch: ThemeSwitch, + Text: ThemeText, TextInput: ThemeTextInput, UnstyledButton: ThemeUnstyledButton, }, From f837838d11e477c246b7b98601035dedfca08d7e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 26 May 2026 16:13:42 -0400 Subject: [PATCH 2/8] 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) --- .../NetworkStreamPanel.test.tsx | 42 ++++++++++++++++++- .../NetworkStreamPanel/NetworkStreamPanel.tsx | 30 ++++++++----- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx index e03a7e300..77d3a20bf 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx @@ -9,8 +9,15 @@ const entry: FetchRequestEntry = { timestamp: new Date("2026-03-17T10:00:00Z"), method: "POST", url: "https://example.com/mcp", - requestHeaders: { authorization: "Bearer abc" }, + requestHeaders: { + authorization: "Bearer abc", + "content-type": "application/json", + }, + requestBody: '{"jsonrpc":"2.0","method":"initialize","id":1}', responseStatus: 200, + responseStatusText: "OK", + responseHeaders: { "content-type": "application/json" }, + responseBody: '{"jsonrpc":"2.0","id":1,"result":{}}', category: "transport", }; @@ -51,6 +58,39 @@ describe("NetworkStreamPanel", () => { expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); }); + it("matches against header keys and values", () => { + renderWithMantine( + , + ); + expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); + }); + + it("matches against the request body", () => { + renderWithMantine( + , + ); + expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); + }); + + it("matches against the response body", () => { + renderWithMantine( + , + ); + expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); + }); + + it("matches against responseStatusText", () => { + renderWithMantine(); + expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); + }); + + it("is case-insensitive", () => { + renderWithMantine( + , + ); + expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); + }); + it("invokes onClear and onExport", async () => { const user = userEvent.setup(); const onClear = vi.fn(); diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index 60d535807..7dae44b85 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -40,6 +40,13 @@ function formatTitle(count: number): string { return `Requests (${count})`; } +function headersToString(headers: Record | undefined): string { + if (!headers) return ""; + return Object.entries(headers) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); +} + function matchesFilters( entry: FetchRequestEntry, filterText: string, @@ -48,18 +55,21 @@ function matchesFilters( if (!visibleCategories[entry.category]) return false; if (filterText) { const term = filterText.toLowerCase(); - const requestHeadersText = Object.entries(entry.requestHeaders) - .map(([k, v]) => `${k}: ${v}`) - .join("\n"); - const responseHeadersText = entry.responseHeaders - ? Object.entries(entry.responseHeaders) - .map(([k, v]) => `${k}: ${v}`) - .join("\n") - : ""; const status = entry.responseStatus !== undefined ? String(entry.responseStatus) : ""; - const searchable = - `${entry.method} ${entry.url} ${status} ${requestHeadersText} ${responseHeadersText} ${entry.error ?? ""}`.toLowerCase(); + const searchable = [ + entry.method, + entry.url, + status, + entry.responseStatusText ?? "", + headersToString(entry.requestHeaders), + headersToString(entry.responseHeaders), + entry.requestBody ?? "", + entry.responseBody ?? "", + entry.error ?? "", + ] + .join(" ") + .toLowerCase(); if (!searchable.includes(term)) return false; } return true; From c2f7aa619f8e533315c555886a54107f758f9c70 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 26 May 2026 17:55:33 -0400 Subject: [PATCH 3/8] fix(network): always render Response Body section, note streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../NetworkEntry/NetworkEntry.stories.tsx | 26 +++++++++++ .../groups/NetworkEntry/NetworkEntry.test.tsx | 43 +++++++++++++++++++ .../groups/NetworkEntry/NetworkEntry.tsx | 20 ++++++++- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx index cff5c168a..e056c23b2 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx @@ -57,6 +57,28 @@ const errorEntry: FetchRequestEntry = { 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"), @@ -83,6 +105,10 @@ 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 }, }; diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index dab39b03e..45ee6a8b9 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -110,6 +110,49 @@ describe("NetworkEntry", () => { expect(screen.getAllByText("(none)").length).toBe(2); }); + it("shows a 'streaming' placeholder when responseBody is missing but content-type is SSE", async () => { + const user = userEvent.setup(); + const sse: FetchRequestEntry = { + ...baseEntry, + responseHeaders: { "content-type": "text/event-stream" }, + responseBody: undefined, + }; + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Expand" })); + expect(screen.getByText("Response Body")).toBeInTheDocument(); + expect( + screen.getByText(/Streaming response — body not captured/), + ).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(); + 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(); + 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(5000); diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 71dfdffae..61527c616 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -83,6 +83,14 @@ function categoryColor(category: FetchRequestEntry["category"]): string { return category === "auth" ? "violet" : "blue"; } +function isStreamingResponse(entry: FetchRequestEntry): boolean { + const contentType = entry.responseHeaders?.["content-type"] ?? ""; + return ( + contentType.includes("text/event-stream") || + contentType.includes("application/x-ndjson") + ); +} + function HeadersTable({ headers }: { headers: Record }) { const rows = Object.entries(headers); if (rows.length === 0) { @@ -184,12 +192,20 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { )} - {entry.responseBody && ( + {entry.responseStatus !== undefined && ( Response Body - + {entry.responseBody ? ( + + ) : ( + + {isStreamingResponse(entry) + ? "Streaming response — body not captured" + : "(empty)"} + + )} )} {entry.error && ( From a25bd4dababc7726927e903c7a0a5794f937bb78 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 26 May 2026 19:00:46 -0400 Subject: [PATCH 4/8] fix(network): capture response bodies via async body-update events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../groups/NetworkEntry/NetworkEntry.test.tsx | 20 ++- .../groups/NetworkEntry/NetworkEntry.tsx | 12 +- .../InspectorView/InspectorView.test.tsx | 46 +++++++ .../views/InspectorView/InspectorView.tsx | 18 ++- .../src/test/core/mcp/fetchTracking.test.ts | 114 ++++++++++++++++-- .../mcp/state/fetchRequestLogState.test.ts | 29 +++++ core/mcp/fetchTracking.ts | 78 +++++++----- core/mcp/inspectorClient.ts | 12 ++ core/mcp/inspectorClientEventTarget.ts | 2 + core/mcp/node/transport.ts | 16 ++- core/mcp/remote/createRemoteTransport.ts | 1 + core/mcp/remote/node/remote-session.ts | 7 ++ core/mcp/remote/node/server.ts | 2 + core/mcp/remote/remoteClientTransport.ts | 11 ++ core/mcp/remote/types.ts | 7 ++ core/mcp/state/fetchRequestLogState.ts | 31 +++++ core/mcp/types.ts | 8 ++ 17 files changed, 362 insertions(+), 52 deletions(-) diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index 45ee6a8b9..16f5c92d8 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -110,10 +110,11 @@ describe("NetworkEntry", () => { expect(screen.getAllByText("(none)").length).toBe(2); }); - it("shows a 'streaming' placeholder when responseBody is missing but content-type is SSE", async () => { + 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, }; @@ -121,10 +122,23 @@ describe("NetworkEntry", () => { await user.click(screen.getByRole("button", { name: "Expand" })); expect(screen.getByText("Response Body")).toBeInTheDocument(); expect( - screen.getByText(/Streaming response — body not captured/), + screen.getByText(/Long-lived stream — body not captured/), ).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(); + 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 = { @@ -155,7 +169,7 @@ describe("NetworkEntry", () => { it("shows a 'too large' notice when a body exceeds the inline preview limit", async () => { const user = userEvent.setup(); - const huge = "x".repeat(5000); + const huge = "x".repeat(150_000); const big: FetchRequestEntry = { ...baseEntry, requestBody: huge, diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 61527c616..690e5d457 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -50,7 +50,7 @@ const SubtleButton = Button.withProps({ size: "xs", }); -const MAX_INLINE_BODY_BYTES = 4096; +const MAX_INLINE_BODY_BYTES = 100_000; function formatDuration(ms: number): string { return `${ms}ms`; @@ -83,7 +83,11 @@ function categoryColor(category: FetchRequestEntry["category"]): string { return category === "auth" ? "violet" : "blue"; } -function isStreamingResponse(entry: FetchRequestEntry): boolean { +function isLongLivedStream(entry: FetchRequestEntry): boolean { + // Matches the fetch tracker's `isLongLivedStream` rule. Only the GET + + // SSE / ndjson case is unbounded; bounded POST SSE responses now have + // their bodies captured, so they would not reach this placeholder. + if (entry.method !== "GET") return false; const contentType = entry.responseHeaders?.["content-type"] ?? ""; return ( contentType.includes("text/event-stream") || @@ -201,8 +205,8 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { ) : ( - {isStreamingResponse(entry) - ? "Streaming response — body not captured" + {isLongLivedStream(entry) + ? "Long-lived stream — body not captured" : "(empty)"} )} diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 550f18534..6eca8f4c7 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -215,6 +215,52 @@ describe("InspectorView", () => { ).toBeInTheDocument(); }); + it("hides the Network tab when the active server is stdio", async () => { + renderWithMantine( + , + ); + // ViewHeader renders the tab radiogroup as accessible radios; check the + // radio list directly so the assertion isn't fooled by hidden options. + const radios = await screen.findAllByRole("radio"); + const labels = radios.map((r) => r.getAttribute("value")); + expect(labels).toContain("Tools"); + expect(labels).not.toContain("Network"); + }); + + it("shows the Network tab when the active server is streamable-http", async () => { + const httpServer: ServerEntry = { + id: "beta", + name: "Beta", + config: { type: "streamable-http", url: "http://localhost:3000/mcp" }, + connection: { status: "connected" }, + }; + const httpInit: InitializeResult = { + protocolVersion: "2025-06-18", + capabilities: {}, + serverInfo: { name: "Beta", version: "1.0.0" }, + }; + renderWithMantine( + , + ); + const radios = await screen.findAllByRole("radio"); + const labels = radios.map((r) => r.getAttribute("value")); + expect(labels).toContain("Network"); + }); + it("filters tools to apps and auto-launches a no-fields app on the Apps tab", async () => { const user = userEvent.setup(); const opsApp: Tool = { diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 18382ce26..ba98919f5 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -42,8 +42,10 @@ import { TasksScreen } from "../../screens/TasksScreen/TasksScreen"; import type { TaskProgress } from "../../groups/TaskCard/TaskCard"; import { HistoryScreen } from "../../screens/HistoryScreen/HistoryScreen"; import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen"; +import { getServerType } from "@inspector/core/mcp/config.js"; const SERVERS_TAB = "Servers"; +const NETWORK_TAB = "Network"; const ALL_TABS: string[] = [ SERVERS_TAB, @@ -54,7 +56,7 @@ const ALL_TABS: string[] = [ "Tasks", "Logs", "History", - "Network", + NETWORK_TAB, ]; const SCREEN_ENTER_MS = 350; @@ -287,13 +289,17 @@ export function InspectorView({ const [autoScroll, setAutoScroll] = useState(true); const appRendererRef = useRef(null); - // Only show the non-Servers tabs when actually connected. Capability-aware + // Only show the non-Servers tabs when actually connected. Network is + // additionally hidden for stdio servers — there is no HTTP traffic to + // surface there, so the tab would always be empty. Capability-aware // tab gating (hide Tools when the server doesn't advertise `tools`, etc.) // can layer in later once the parent passes capabilities through. - const availableTabs = useMemo( - () => (connectionStatus === "connected" ? ALL_TABS : [SERVERS_TAB]), - [connectionStatus], - ); + const availableTabs = useMemo(() => { + if (connectionStatus !== "connected") return [SERVERS_TAB]; + const active = serversInput.find((s) => s.id === activeServer); + const isStdio = active ? getServerType(active.config) === "stdio" : false; + return isStdio ? ALL_TABS.filter((t) => t !== NETWORK_TAB) : ALL_TABS; + }, [connectionStatus, serversInput, activeServer]); // Clamp the rendered tab to whatever's currently available. If the user // had "Tools" selected and the connection drops, `availableTabs` becomes diff --git a/clients/web/src/test/core/mcp/fetchTracking.test.ts b/clients/web/src/test/core/mcp/fetchTracking.test.ts index 0c8e8e946..c83057cc3 100644 --- a/clients/web/src/test/core/mcp/fetchTracking.test.ts +++ b/clients/web/src/test/core/mcp/fetchTracking.test.ts @@ -2,8 +2,14 @@ import { describe, it, expect, vi } from "vitest"; import { createFetchTracker } from "@inspector/core/mcp/fetchTracking.js"; import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js"; +// The tracker fires `trackRequest` synchronously with an entry whose +// responseBody is always undefined, then reads the body in the background +// and calls `updateResponseBody(id, body)` when done. This helper waits a +// microtask so the background read can complete before assertions. +const flush = () => new Promise((r) => setTimeout(r, 0)); + describe("createFetchTracker", () => { - it("tracks a successful GET request with response body", async () => { + it("tracks a successful GET request and emits the response body asynchronously", async () => { const baseFetch = vi.fn( async () => new Response("hello", { @@ -13,8 +19,10 @@ describe("createFetchTracker", () => { }), ); const tracked: FetchRequestEntryBase[] = []; + const bodies: Array<{ id: string; body: string }> = []; const fetcher = createFetchTracker(baseFetch as typeof fetch, { trackRequest: (entry) => tracked.push(entry), + updateResponseBody: (id, body) => bodies.push({ id, body }), }); const res = await fetcher("https://example.com/data"); @@ -22,8 +30,11 @@ describe("createFetchTracker", () => { expect(tracked).toHaveLength(1); expect(tracked[0]?.method).toBe("GET"); expect(tracked[0]?.url).toBe("https://example.com/data"); - expect(tracked[0]?.responseBody).toBe("hello"); + expect(tracked[0]?.responseBody).toBeUndefined(); expect(tracked[0]?.responseStatus).toBe(200); + + await flush(); + expect(bodies).toEqual([{ id: tracked[0]!.id, body: "hello" }]); }); it("accepts URL objects and Request instances as input", async () => { @@ -99,7 +110,7 @@ describe("createFetchTracker", () => { expect(tracked[0]?.error).toBe("stringly-typed"); }); - it("skips body reading on event-stream responses", async () => { + it("skips body reading on GET event-stream responses (long-lived stream)", async () => { const baseFetch = vi.fn( async () => new Response("ignored", { @@ -107,21 +118,106 @@ describe("createFetchTracker", () => { }), ); const tracked: FetchRequestEntryBase[] = []; + const bodies: Array<{ id: string; body: string }> = []; const fetcher = createFetchTracker(baseFetch as typeof fetch, { trackRequest: (entry) => tracked.push(entry), + updateResponseBody: (id, body) => bodies.push({ id, body }), }); - await fetcher("https://example.com/events"); + await fetcher("https://example.com/events", { method: "GET" }); + await flush(); expect(tracked[0]?.responseBody).toBeUndefined(); + expect(bodies).toHaveLength(0); + }); + + it("skips body reading on GET application/x-ndjson responses", async () => { + const baseFetch = vi.fn( + async () => + new Response("ignored", { + headers: { "content-type": "application/x-ndjson" }, + }), + ); + const tracked: FetchRequestEntryBase[] = []; + const bodies: Array<{ id: string; body: string }> = []; + const fetcher = createFetchTracker(baseFetch as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + updateResponseBody: (id, body) => bodies.push({ id, body }), + }); + await fetcher("https://example.com/events", { method: "GET" }); + await flush(); + expect(bodies).toHaveLength(0); }); - it("skips body reading for POST /mcp streamable responses", async () => { - const baseFetch = vi.fn(async () => new Response("streamed")); + it("emits the body for a POST event-stream response after the stream closes (bounded)", async () => { + // Streamable HTTP POST /mcp answers with SSE that closes after the + // reply. The tracker must NOT block on this read — the transport + // needs to consume the stream first to drive progress notifications. + // Body therefore arrives asynchronously via updateResponseBody. + const sse = + 'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"tools":[]}}\n\n'; + const baseFetch = vi.fn( + async () => + new Response(sse, { + headers: { "content-type": "text/event-stream" }, + }), + ); const tracked: FetchRequestEntryBase[] = []; + const bodies: Array<{ id: string; body: string }> = []; const fetcher = createFetchTracker(baseFetch as typeof fetch, { trackRequest: (entry) => tracked.push(entry), + updateResponseBody: (id, body) => bodies.push({ id, body }), }); await fetcher("https://example.com/mcp", { method: "POST" }); expect(tracked[0]?.responseBody).toBeUndefined(); + await flush(); + expect(bodies).toEqual([{ id: tracked[0]!.id, body: sse }]); + }); + + it("emits the body for a POST /mcp JSON response asynchronously", async () => { + const baseFetch = vi.fn( + async () => + new Response('{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}', { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ); + const tracked: FetchRequestEntryBase[] = []; + const bodies: Array<{ id: string; body: string }> = []; + const fetcher = createFetchTracker(baseFetch as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + updateResponseBody: (id, body) => bodies.push({ id, body }), + }); + await fetcher("https://example.com/mcp", { method: "POST" }); + expect(tracked[0]?.responseBody).toBeUndefined(); + await flush(); + expect(bodies).toEqual([ + { + id: tracked[0]!.id, + body: '{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}', + }, + ]); + }); + + it("does not block the caller awaiting the response body", async () => { + // If the body promise hangs forever (simulating a long-lived stream + // mid-flight), the tracker still has to resolve the outer fetcher + // promise immediately. Otherwise the transport blocks waiting on us. + const neverEnding = new ReadableStream({ + start() { + // Never enqueue, never close — `.text()` on a clone of this would hang. + }, + }); + const baseFetch = vi.fn( + async () => new Response(neverEnding, { status: 200 }), + ); + const tracked: FetchRequestEntryBase[] = []; + const fetcher = createFetchTracker(baseFetch as typeof fetch, { + trackRequest: (entry) => tracked.push(entry), + }); + const res = await fetcher("https://example.com/slow", { method: "POST" }); + expect(res.status).toBe(200); + expect(tracked).toHaveLength(1); + expect(tracked[0]?.responseBody).toBeUndefined(); }); it("survives a Request whose body cannot be cloned/read", async () => { @@ -145,8 +241,9 @@ describe("createFetchTracker", () => { expect(tracked[0]?.requestBody).toBeUndefined(); }); - it("falls back to undefined when response.clone() throws", async () => { + it("does not call updateResponseBody when response.clone() throws", async () => { const tracked: FetchRequestEntryBase[] = []; + const bodies: Array<{ id: string; body: string }> = []; const baseFetch = vi.fn(async () => { const r = new Response("body"); Object.defineProperty(r, "clone", { @@ -158,8 +255,11 @@ describe("createFetchTracker", () => { }); const fetcher = createFetchTracker(baseFetch as typeof fetch, { trackRequest: (entry) => tracked.push(entry), + updateResponseBody: (id, body) => bodies.push({ id, body }), }); await fetcher("https://example.com/data"); + await flush(); expect(tracked[0]?.responseBody).toBeUndefined(); + expect(bodies).toHaveLength(0); }); }); diff --git a/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts b/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts index 349ece349..e2cec0366 100644 --- a/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts +++ b/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts @@ -102,6 +102,35 @@ describe("FetchRequestLogState", () => { expect(dispatched).toBe(false); }); + it("patches the matching entry's responseBody and re-emits on fetchRequestBodyUpdate", () => { + client.dispatchTypedEvent("fetchRequest", entry("a")); + client.dispatchTypedEvent("fetchRequest", entry("b")); + const seen: FetchRequestEntry[][] = []; + state.addEventListener("fetchRequestsChange", (e) => seen.push(e.detail)); + + client.dispatchTypedEvent("fetchRequestBodyUpdate", { + id: "b", + responseBody: "hello", + }); + + const entries = state.getFetchRequests(); + expect(entries.map((e) => e.id)).toEqual(["a", "b"]); + expect(entries[0]?.responseBody).toBeUndefined(); + expect(entries[1]?.responseBody).toBe("hello"); + expect(seen).toHaveLength(1); + }); + + it("ignores fetchRequestBodyUpdate for unknown ids", () => { + client.dispatchTypedEvent("fetchRequest", entry("a")); + let changes = 0; + state.addEventListener("fetchRequestsChange", () => changes++); + client.dispatchTypedEvent("fetchRequestBodyUpdate", { + id: "nonexistent", + responseBody: "x", + }); + expect(changes).toBe(0); + }); + it("does NOT clear on connect or disconnect", () => { client.dispatchTypedEvent("fetchRequest", entry("a")); client.dispatchTypedEvent("connect"); diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 349054ec4..0747a2706 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -2,6 +2,14 @@ import type { FetchRequestEntryBase } from "./types.js"; export interface FetchTrackingCallbacks { trackRequest?: (entry: FetchRequestEntryBase) => void; + /** + * Called after the response body has been read asynchronously. Lets the + * consumer patch the already-dispatched entry with the body without + * blocking the transport on body reading. Fires only on success — if the + * body couldn't be read (long-lived stream, clone failure), this is + * never invoked and the entry's responseBody stays undefined. + */ + updateResponseBody?: (id: string, responseBody: string) => void; } /** @@ -99,37 +107,27 @@ export function createFetchTracker( responseHeaders[key] = value; }); - // Check if this is a streaming response - if so, skip body reading entirely - // For streamable-http POST requests to /mcp, the response is always a stream - // that the transport needs to consume, so we should never try to read it + // Skip body reading only for *long-lived* streams. On streamable HTTP, + // GET /mcp opens an unbounded SSE channel for server-to-client pushes + // — calling `.text()` on a clone of that would buffer forever. POST + // responses with the same content-type are bounded: the server emits + // the JSON-RPC reply (sometimes preceded by progress events) and + // closes the connection, so cloning + reading is safe and gives the + // user the raw SSE payload they were missing. const contentType = response.headers.get("content-type"); - const isStream = - contentType?.includes("text/event-stream") || - contentType?.includes("application/x-ndjson") || - (method === "POST" && url.includes("/mcp")); + const isLongLivedStream = + method === "GET" && + (contentType?.includes("text/event-stream") || + contentType?.includes("application/x-ndjson")); - let responseBody: string | undefined; - let duration: number; + const duration = Date.now() - startTime; - if (isStream) { - // For streams, don't try to read the body - just record metadata and return immediately - // The transport needs to consume the stream, so we can't clone/read it - duration = Date.now() - startTime; - } else { - // For regular responses, try to read the body (clone so we don't consume it) - if (response.body && !response.bodyUsed) { - try { - const cloned = response.clone(); - responseBody = await cloned.text(); - } catch { - // Can't read body (might be consumed, not readable, or other issue) - responseBody = undefined; - } - } - duration = Date.now() - startTime; - } - - // Create entry and track it + // Create entry and track it immediately. The body is read asynchronously + // below to avoid blocking the transport — for streaming responses (POST + // + SSE), the server keeps the connection open until it has delivered + // every progress notification plus the final reply, so awaiting + // `.text()` here would force the transport to wait for all events + // before it could process any of them. const entry: FetchRequestEntryBase = { id, timestamp, @@ -140,12 +138,34 @@ export function createFetchTracker( responseStatus, responseStatusText, responseHeaders, - responseBody, + responseBody: undefined, duration, }; callbacks.trackRequest?.(entry); + // Kick off a fire-and-forget read of the cloned body. The clone is an + // independent tee'd stream so the transport keeps consuming the + // original at its own pace. When the read resolves we patch the entry + // via `updateResponseBody`. Skipped for long-lived streams (GET + + // SSE / ndjson) because `.text()` would never resolve on those. + if (!isLongLivedStream && response.body && !response.bodyUsed) { + try { + const cloned = response.clone(); + cloned + .text() + .then((body) => { + callbacks.updateResponseBody?.(id, body); + }) + .catch(() => { + // Stream errored after clone — leave the body undefined. + }); + } catch { + // Clone failed (consumed body, transport quirks). Leave body + // undefined; the entry is already dispatched. + } + } + return response; }; } diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index f91ea506d..aa6b3c62a 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -312,6 +312,8 @@ export class InspectorClient extends InspectorClientEventTarget { return createFetchTracker(base, { trackRequest: (entry) => this.dispatchFetchRequest({ ...entry, category: "auth" }), + updateResponseBody: (id, body) => + this.dispatchFetchRequestBodyUpdate(id, body), }); } @@ -569,6 +571,9 @@ export class InspectorClient extends InspectorClientEventTarget { onFetchRequest: (entry: FetchRequestEntryBase) => { this.dispatchFetchRequest({ ...entry, category: "transport" }); }, + onFetchResponseBody: (id: string, body: string) => { + this.dispatchFetchRequestBodyUpdate(id, body); + }, ...(this.serverSettings && { settings: this.serverSettings }), }; const oauthManager = this.oauthManager; @@ -1943,6 +1948,13 @@ export class InspectorClient extends InspectorClientEventTarget { this.dispatchTypedEvent("fetchRequest", entry); } + private dispatchFetchRequestBodyUpdate( + id: string, + responseBody: string, + ): void { + this.dispatchTypedEvent("fetchRequestBodyUpdate", { id, responseBody }); + } + /** * Get current session ID (from OAuth state authId) */ diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index 85a0a5a15..8e2c85cd9 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -58,6 +58,8 @@ export interface InspectorClientEventMap { message: MessageEntry; stderrLog: StderrLogEntry; fetchRequest: FetchRequestEntry; + /** Fired when an in-flight fetch's response body is read asynchronously. */ + fetchRequestBodyUpdate: { id: string; responseBody: string }; error: Error; resourceUpdated: { uri: string }; progressNotification: Progress & { progressToken?: ProgressToken }; diff --git a/core/mcp/node/transport.ts b/core/mcp/node/transport.ts index c74936b5c..81ebe5ddc 100644 --- a/core/mcp/node/transport.ts +++ b/core/mcp/node/transport.ts @@ -43,6 +43,7 @@ export function createTransportNode( onStderr, pipeStderr = false, onFetchRequest, + onFetchResponseBody, authProvider, settings, } = options; @@ -80,7 +81,10 @@ export function createTransportNode( const sseFetch = (sseConfig.eventSourceInit?.fetch as typeof fetch) || baseFetch; const trackedFetch = onFetchRequest - ? createFetchTracker(sseFetch, { trackRequest: onFetchRequest }) + ? createFetchTracker(sseFetch, { + trackRequest: onFetchRequest, + updateResponseBody: onFetchResponseBody, + }) : sseFetch; const headers = headersFromSettings(settings); @@ -97,7 +101,10 @@ export function createTransportNode( }; const postFetch = onFetchRequest - ? createFetchTracker(baseFetch, { trackRequest: onFetchRequest }) + ? createFetchTracker(baseFetch, { + trackRequest: onFetchRequest, + updateResponseBody: onFetchResponseBody, + }) : baseFetch; const transport = new SSEClientTransport(url, { @@ -121,7 +128,10 @@ export function createTransportNode( }; const transportFetch = onFetchRequest - ? createFetchTracker(baseFetch, { trackRequest: onFetchRequest }) + ? createFetchTracker(baseFetch, { + trackRequest: onFetchRequest, + updateResponseBody: onFetchResponseBody, + }) : baseFetch; const transport = new StreamableHTTPClientTransport(url, { diff --git a/core/mcp/remote/createRemoteTransport.ts b/core/mcp/remote/createRemoteTransport.ts index adcf6c4f9..5e720c530 100644 --- a/core/mcp/remote/createRemoteTransport.ts +++ b/core/mcp/remote/createRemoteTransport.ts @@ -57,6 +57,7 @@ export function createRemoteTransport( fetchFn: options.fetchFn, onStderr: transportOptions.onStderr, onFetchRequest: transportOptions.onFetchRequest, + onFetchResponseBody: transportOptions.onFetchResponseBody, authProvider: transportOptions.authProvider, settings: transportOptions.settings, }, diff --git a/core/mcp/remote/node/remote-session.ts b/core/mcp/remote/node/remote-session.ts index 16394dd60..af4c81dd7 100644 --- a/core/mcp/remote/node/remote-session.ts +++ b/core/mcp/remote/node/remote-session.ts @@ -97,6 +97,13 @@ export class RemoteSession { }); } + onFetchResponseBody(id: string, responseBody: string): void { + this.pushEvent({ + type: "fetch_request_body_update", + data: { id, responseBody }, + }); + } + onStderr(entry: { timestamp: Date; message: string }): void { this.pushEvent({ type: "stdio_log", diff --git a/core/mcp/remote/node/server.ts b/core/mcp/remote/node/server.ts index 3ab6f8671..c6cddaa65 100644 --- a/core/mcp/remote/node/server.ts +++ b/core/mcp/remote/node/server.ts @@ -477,6 +477,8 @@ export function createRemoteApp( pipeStderr: true, onStderr: (entry) => session.onStderr(entry), onFetchRequest: (entry) => session.onFetchRequest(entry), + onFetchResponseBody: (id, body) => + session.onFetchResponseBody(id, body), authProvider, settings: body.settings, }); diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 08329bbca..d9647449c 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -35,6 +35,9 @@ export interface RemoteTransportOptions { /** Callback for fetch request tracking (forwarded via remote) */ onFetchRequest?: (entry: FetchRequestEntryBase) => void; + /** Callback for async response-body updates to a previously tracked fetch. */ + onFetchResponseBody?: (id: string, responseBody: string) => void; + /** Optional OAuth client provider for Bearer authentication */ authProvider?: import("@modelcontextprotocol/sdk/client/auth.js").OAuthClientProvider; @@ -243,6 +246,14 @@ export class RemoteClientTransport implements Transport { ? new Date(entry.timestamp) : entry.timestamp, }); + } else if ( + parsed.type === "fetch_request_body_update" && + this.options.onFetchResponseBody + ) { + this.options.onFetchResponseBody( + parsed.data.id, + parsed.data.responseBody, + ); } else if (parsed.type === "stdio_log" && this.options.onStderr) { this.options.onStderr({ timestamp: new Date(parsed.data.timestamp), diff --git a/core/mcp/remote/types.ts b/core/mcp/remote/types.ts index 0ec823ed4..c2cc1728b 100644 --- a/core/mcp/remote/types.ts +++ b/core/mcp/remote/types.ts @@ -40,6 +40,7 @@ export interface RemoteSendRequest { export type RemoteEventType = | "message" | "fetch_request" + | "fetch_request_body_update" | "stdio_log" | "transport_error"; @@ -53,6 +54,11 @@ export interface RemoteEventFetchRequest { data: FetchRequestEntryBase; } +export interface RemoteEventFetchRequestBodyUpdate { + type: "fetch_request_body_update"; + data: { id: string; responseBody: string }; +} + export interface RemoteEventStdioLog { type: "stdio_log"; data: { timestamp: string; message: string }; @@ -69,5 +75,6 @@ export interface RemoteEventTransportError { export type RemoteEvent = | RemoteEventMessage | RemoteEventFetchRequest + | RemoteEventFetchRequestBodyUpdate | RemoteEventStdioLog | RemoteEventTransportError; diff --git a/core/mcp/state/fetchRequestLogState.ts b/core/mcp/state/fetchRequestLogState.ts index 3f45c603d..cfa99936e 100644 --- a/core/mcp/state/fetchRequestLogState.ts +++ b/core/mcp/state/fetchRequestLogState.ts @@ -76,6 +76,29 @@ export class FetchRequestLogState extends TypedEventTarget, + ): void => { + const { id, responseBody } = event.detail; + const idx = this.fetchRequests.findIndex((e) => e.id === id); + if (idx === -1) return; + this.fetchRequests[idx] = { + ...this.fetchRequests[idx]!, + responseBody, + }; + this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests()); + }; + this.client.addEventListener( + "fetchRequestBodyUpdate", + onFetchRequestBodyUpdate, + ); + const sessionStorage = options.sessionStorage; const sessionId = options.sessionId; @@ -97,6 +120,10 @@ export class FetchRequestLogState extends TypedEventTarget { if (this.client) { this.client.removeEventListener("fetchRequest", onFetchRequest); + this.client.removeEventListener( + "fetchRequestBodyUpdate", + onFetchRequestBodyUpdate, + ); this.client.removeEventListener("saveSession", onSaveSession); } this.client = null; @@ -119,6 +146,10 @@ export class FetchRequestLogState extends TypedEventTarget { if (this.client) { this.client.removeEventListener("fetchRequest", onFetchRequest); + this.client.removeEventListener( + "fetchRequestBodyUpdate", + onFetchRequestBodyUpdate, + ); } this.client = null; }; diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 4c6be77c1..f7e18b295 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -392,6 +392,14 @@ export interface CreateTransportOptions { */ onFetchRequest?: (entry: FetchRequestEntryBase) => void; + /** + * Optional callback fired asynchronously when a previously tracked + * fetch's response body has been read. Lets the consumer update the + * already-dispatched entry without blocking the transport on body + * reading (critical for SSE responses that include progress events). + */ + onFetchResponseBody?: (id: string, responseBody: string) => void; + /** * Optional OAuth client provider for Bearer authentication (SSE, streamable-http). * When set, the SDK injects tokens and handles 401 via the provider. From 5460b7cd516ad8e6d3b5875f57591ba047f0ca5a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 26 May 2026 19:17:37 -0400 Subject: [PATCH 5/8] feat(network): show orange SSE badge for long-lived stream entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../groups/NetworkEntry/NetworkEntry.test.tsx | 18 ++++++++++++++++++ .../groups/NetworkEntry/NetworkEntry.tsx | 1 + 2 files changed, 19 insertions(+) diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index 16f5c92d8..6dc1381a2 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -126,6 +126,24 @@ describe("NetworkEntry", () => { ).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(); + expect(screen.getByText("SSE")).toBeInTheDocument(); + }); + + it("does not show an SSE badge on bounded POST entries", () => { + renderWithMantine( + , + ); + 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 = { diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 690e5d457..557db399c 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -161,6 +161,7 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { {entry.duration != null && ( {formatDuration(entry.duration)} )} + {isLongLivedStream(entry) && SSE} {statusLabel(entry)} From 80a2bc11c342e1e751eefbba1d003a14cee34714 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 26 May 2026 20:40:50 -0400 Subject: [PATCH 6/8] fix(network): address PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../elements/ListToggle/ListToggle.tsx | 11 +++++-- .../groups/NetworkEntry/NetworkEntry.tsx | 28 ++++++++++------- .../NetworkStreamPanel.test.tsx | 21 ++++++------- .../NetworkStreamPanel/NetworkStreamPanel.tsx | 11 +++---- core/mcp/fetchTracking.ts | 30 +++++++++++++++---- core/mcp/inspectorClient.ts | 9 ++++-- 6 files changed, 75 insertions(+), 35 deletions(-) diff --git a/clients/web/src/components/elements/ListToggle/ListToggle.tsx b/clients/web/src/components/elements/ListToggle/ListToggle.tsx index 7d4ec1061..44085f669 100644 --- a/clients/web/src/components/elements/ListToggle/ListToggle.tsx +++ b/clients/web/src/components/elements/ListToggle/ListToggle.tsx @@ -13,17 +13,24 @@ export function ListToggle({ variant = "default", }: ListToggleProps) { const Icon = compact ? RiExpandVerticalLine : RiCollapseVerticalLine; + const label = compact ? "Expand all" : "Collapse all"; if (variant === "subtle") { return ( - + ); } return ( - ); diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 557db399c..b7040832d 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -11,6 +11,7 @@ import { 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 { @@ -50,7 +51,11 @@ const SubtleButton = Button.withProps({ size: "xs", }); -const MAX_INLINE_BODY_BYTES = 100_000; +// 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`; @@ -84,14 +89,9 @@ function categoryColor(category: FetchRequestEntry["category"]): string { } function isLongLivedStream(entry: FetchRequestEntry): boolean { - // Matches the fetch tracker's `isLongLivedStream` rule. Only the GET + - // SSE / ndjson case is unbounded; bounded POST SSE responses now have - // their bodies captured, so they would not reach this placeholder. - if (entry.method !== "GET") return false; - const contentType = entry.responseHeaders?.["content-type"] ?? ""; - return ( - contentType.includes("text/event-stream") || - contentType.includes("application/x-ndjson") + return isLongLivedStreamResponse( + entry.method, + entry.responseHeaders?.["content-type"], ); } @@ -127,11 +127,11 @@ function HeadersTable({ headers }: { headers: Record }) { } function BodyPreview({ body }: { body: string }) { - const tooLarge = body.length > MAX_INLINE_BODY_BYTES; + const tooLarge = body.length > MAX_INLINE_BODY_CHARS; if (tooLarge) { return ( - Body too large to preview ({body.length} bytes) + Body too large to preview ({body.length} characters) ); } @@ -141,6 +141,12 @@ function BodyPreview({ body }: { body: string }) { 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]); diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx index 77d3a20bf..ea3014015 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx @@ -84,6 +84,16 @@ describe("NetworkStreamPanel", () => { expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); }); + it("does not match across field boundaries", () => { + // The method "POST" ends with "ST"; the URL begins with "https". A + // search for "sthttps" used to match because we joined fields with + // spaces and ran .includes on the joined string. Should not match. + renderWithMantine( + , + ); + expect(screen.getByText("No network requests")).toBeInTheDocument(); + }); + it("is case-insensitive", () => { renderWithMantine( , @@ -119,16 +129,7 @@ describe("NetworkStreamPanel", () => { renderWithMantine(); // List starts compact -> entry should show Expand button expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument(); - // The first icon button (ListToggle) is the toggle next to Clear/Export. - // Identify it by being the first button that is neither Clear, Export, - // nor an Expand/Collapse inside an entry. - const buttons = screen.getAllByRole("button"); - const toggle = buttons.find((b) => { - const text = b.textContent ?? ""; - return !/Clear|Export|Expand|Collapse/.test(text); - }); - expect(toggle).toBeDefined(); - await user.click(toggle!); + await user.click(screen.getByRole("button", { name: "Expand all" })); expect( screen.getByRole("button", { name: "Collapse" }), ).toBeInTheDocument(); diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index 7dae44b85..7c0d05640 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -57,7 +57,10 @@ function matchesFilters( const term = filterText.toLowerCase(); const status = entry.responseStatus !== undefined ? String(entry.responseStatus) : ""; - const searchable = [ + // Per-field match (rather than join + includes) so the search term + // can't span field boundaries — a search for "foo bar" where one + // field ends "foo" and the next begins "bar" should not match. + const fields: string[] = [ entry.method, entry.url, status, @@ -67,10 +70,8 @@ function matchesFilters( entry.requestBody ?? "", entry.responseBody ?? "", entry.error ?? "", - ] - .join(" ") - .toLowerCase(); - if (!searchable.includes(term)) return false; + ]; + if (!fields.some((f) => f.toLowerCase().includes(term))) return false; } return true; } diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 0747a2706..27eb62695 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -1,5 +1,26 @@ import type { FetchRequestEntryBase } from "./types.js"; +/** + * Whether a response represents an unbounded (long-lived) HTTP stream + * whose body cannot be cloned + read to completion. The streamable HTTP + * spec uses `GET` + `text/event-stream` for the long-lived server-push + * channel; `POST` SSE replies are bounded (server closes after the + * JSON-RPC response) and therefore safe to capture. Shared between the + * fetch tracker (where it decides whether to read the body) and the + * Network UI (where it decides which placeholder to show). + */ +export function isLongLivedStreamResponse( + method: string, + contentType: string | null | undefined, +): boolean { + if (method !== "GET") return false; + if (!contentType) return false; + return ( + contentType.includes("text/event-stream") || + contentType.includes("application/x-ndjson") + ); +} + export interface FetchTrackingCallbacks { trackRequest?: (entry: FetchRequestEntryBase) => void; /** @@ -114,11 +135,10 @@ export function createFetchTracker( // the JSON-RPC reply (sometimes preceded by progress events) and // closes the connection, so cloning + reading is safe and gives the // user the raw SSE payload they were missing. - const contentType = response.headers.get("content-type"); - const isLongLivedStream = - method === "GET" && - (contentType?.includes("text/event-stream") || - contentType?.includes("application/x-ndjson")); + const isLongLivedStream = isLongLivedStreamResponse( + method, + response.headers.get("content-type"), + ); const duration = Date.now() - startTime; diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index aa6b3c62a..47a82e95d 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -309,11 +309,16 @@ export class InspectorClient extends InspectorClientEventTarget { private buildEffectiveAuthFetch(): typeof fetch { const base = this.fetchFn ?? fetch; + // Note: we deliberately do NOT wire `updateResponseBody` for the auth + // fetcher. OAuth token-exchange responses contain `access_token` and + // `refresh_token`; capturing them into FetchRequestLogState would + // surface live credentials in the Network tab body preview, which is + // easy to leak during a screen-share. Headers + status are still + // tracked. If a future need calls for inspecting auth bodies, add + // explicit secret redaction first. return createFetchTracker(base, { trackRequest: (entry) => this.dispatchFetchRequest({ ...entry, category: "auth" }), - updateResponseBody: (id, body) => - this.dispatchFetchRequestBodyUpdate(id, body), }); } From 9185fdf943947d2d0af0ecad6182d884ee47de2c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 26 May 2026 21:04:54 -0400 Subject: [PATCH 7/8] 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) --- .../elements/ListToggle/ListToggle.test.tsx | 23 ++++++++++++++----- .../HistoryListPanel.test.tsx | 12 +++------- .../ServerSettingsModal.test.tsx | 10 ++++---- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/clients/web/src/components/elements/ListToggle/ListToggle.test.tsx b/clients/web/src/components/elements/ListToggle/ListToggle.test.tsx index fa7a479f2..b77c4418b 100644 --- a/clients/web/src/components/elements/ListToggle/ListToggle.test.tsx +++ b/clients/web/src/components/elements/ListToggle/ListToggle.test.tsx @@ -4,23 +4,34 @@ import userEvent from "@testing-library/user-event"; import { ListToggle } from "./ListToggle"; describe("ListToggle", () => { - it("renders a Button by default", () => { + it("renders a Button by default with 'Expand all' aria-label when compact", () => { renderWithMantine( {}} />); - expect(screen.getByRole("button")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Expand all" }), + ).toBeInTheDocument(); }); - it("renders an ActionIcon for the subtle variant", () => { + it("renders 'Collapse all' aria-label when not compact", () => { + renderWithMantine( {}} />); + expect( + screen.getByRole("button", { name: "Collapse all" }), + ).toBeInTheDocument(); + }); + + it("renders an ActionIcon for the subtle variant and labels it", () => { renderWithMantine( {}} />, ); - expect(screen.getByRole("button")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Expand all" }), + ).toBeInTheDocument(); }); it("invokes onToggle when clicked (default variant)", async () => { const user = userEvent.setup(); const onToggle = vi.fn(); renderWithMantine(); - await user.click(screen.getByRole("button")); + await user.click(screen.getByRole("button", { name: "Expand all" })); expect(onToggle).toHaveBeenCalledTimes(1); }); @@ -30,7 +41,7 @@ describe("ListToggle", () => { renderWithMantine( , ); - await user.click(screen.getByRole("button")); + await user.click(screen.getByRole("button", { name: "Collapse all" })); expect(onToggle).toHaveBeenCalledTimes(1); }); }); diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx index 2c98526c0..0b36ab8da 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx @@ -224,19 +224,13 @@ describe("HistoryListPanel", () => { renderWithMantine( , ); - // Initially expanded — Collapse buttons exist on each entry + // Initially expanded — Collapse buttons exist on each entry, and the + // ListToggle exposes its aria-label as "Collapse all". expect( screen.getAllByRole("button", { name: "Collapse" }).length, ).toBeGreaterThan(0); - // Find the ListToggle button (last subtle toolbar button at top — has no accessible name) - const buttons = screen.getAllByRole("button"); - const toggle = buttons.find( - (b) => - b.textContent === "" && b.classList.contains("mantine-Button-root"), - ); - expect(toggle).toBeDefined(); - await user.click(toggle!); + await user.click(screen.getByRole("button", { name: "Collapse all" })); // After toggle, entries collapsed — they show Expand expect( diff --git a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx index 0015f0b3b..b281d270f 100644 --- a/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx +++ b/clients/web/src/components/groups/ServerSettingsModal/ServerSettingsModal.test.tsx @@ -246,16 +246,16 @@ describe("ServerSettingsModal", () => { expect(headersControl.getAttribute("aria-expanded")).toBe("true"); expect(metadataControl.getAttribute("aria-expanded")).toBe("false"); - // ListToggle is the first button in the header row; click to expand all. - const allButtons = screen.getAllByRole("button"); - await user.click(allButtons[0]); + // Not every section is expanded → ListToggle starts in compact mode + // and exposes "Expand all" as its aria-label. + await user.click(screen.getByRole("button", { name: "Expand all" })); expect(metadataControl.getAttribute("aria-expanded")).toBe("true"); expect(timeoutsControl.getAttribute("aria-expanded")).toBe("true"); expect(oauthControl.getAttribute("aria-expanded")).toBe("true"); - // Clicking again should collapse all. - await user.click(allButtons[0]); + // After expanding every section the toggle flips to "Collapse all". + await user.click(screen.getByRole("button", { name: "Collapse all" })); expect(headersControl.getAttribute("aria-expanded")).toBe("false"); expect(metadataControl.getAttribute("aria-expanded")).toBe("false"); }); From ebea6ca267d67477ee47bc1d2c95b4c852676400 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 27 May 2026 08:30:45 -0400 Subject: [PATCH 8/8] 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) --- .../NetworkControls/NetworkControls.test.tsx | 17 +++++++++++++++++ .../groups/NetworkControls/NetworkControls.tsx | 1 + 2 files changed, 18 insertions(+) diff --git a/clients/web/src/components/groups/NetworkControls/NetworkControls.test.tsx b/clients/web/src/components/groups/NetworkControls/NetworkControls.test.tsx index e95958e81..71397a212 100644 --- a/clients/web/src/components/groups/NetworkControls/NetworkControls.test.tsx +++ b/clients/web/src/components/groups/NetworkControls/NetworkControls.test.tsx @@ -32,6 +32,23 @@ describe("NetworkControls", () => { expect(onFilterChange).toHaveBeenLastCalledWith("x"); }); + it("reflects category visibility via aria-pressed", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("button", { name: "auth" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: "transport" })).toHaveAttribute( + "aria-pressed", + "false", + ); + }); + it("fires onToggleCategory with inverted visibility when clicked", async () => { const user = userEvent.setup(); const onToggleCategory = vi.fn(); diff --git a/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx b/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx index e2a711a6b..b6a5f1c21 100644 --- a/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx +++ b/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx @@ -62,6 +62,7 @@ export function NetworkControls({ w="100%" p="sm" variant="listItem" + aria-pressed={active} bg={active ? "var(--mantine-primary-color-light)" : undefined} onClick={() => onToggleCategory(category, !active)} >