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/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/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/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/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..71397a212 --- /dev/null +++ b/clients/web/src/components/groups/NetworkControls/NetworkControls.test.tsx @@ -0,0 +1,90 @@ +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("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(); + 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..b6a5f1c21 --- /dev/null +++ b/clients/web/src/components/groups/NetworkControls/NetworkControls.tsx @@ -0,0 +1,78 @@ +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..e056c23b2 --- /dev/null +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.stories.tsx @@ -0,0 +1,114 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js"; +import { NetworkEntry } from "./NetworkEntry"; + +const meta: Meta = { + 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 streamingEntry: FetchRequestEntry = { + id: "n-stream", + timestamp: new Date("2026-03-17T10:30:12Z"), + method: "POST", + url: "http://localhost:3000/mcp", + requestHeaders: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + "mcp-session-id": "0a0b0a5-fd27-4c95-a805-c0fba67e00fb", + }, + requestBody: '{"method":"resources/templates/list","jsonrpc":"2.0","id":4}', + responseStatus: 200, + responseStatusText: "OK", + responseHeaders: { + "cache-control": "no-cache", + "content-type": "text/event-stream", + "mcp-session-id": "0a0b0a5-fd27-4c95-a805-c0fba67e00fb", + }, + duration: 26, + category: "transport", +}; + +const transportError: FetchRequestEntry = { + id: "n-4", + timestamp: new Date("2026-03-17T10:30:15Z"), + method: "POST", + url: "https://example.com/mcp", + requestHeaders: {}, + error: "fetch failed: ECONNREFUSED", + category: "transport", +}; + +export const TransportSuccessCollapsed: Story = { + args: { entry: transportEntry, isListExpanded: false }, +}; + +export const TransportSuccessExpanded: Story = { + args: { entry: transportEntry, isListExpanded: true }, +}; + +export const AuthSuccess: Story = { + args: { entry: authEntry, isListExpanded: true }, +}; + +export const HttpError: Story = { + args: { entry: errorEntry, isListExpanded: true }, +}; + +export const StreamingResponse: Story = { + args: { entry: streamingEntry, isListExpanded: true }, +}; + +export const FetchError: Story = { + args: { entry: transportError, isListExpanded: true }, +}; 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..6dc1381a2 --- /dev/null +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -0,0 +1,199 @@ +import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; +import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { NetworkEntry } from "./NetworkEntry"; + +const baseEntry: FetchRequestEntry = { + id: "n-1", + timestamp: new Date("2026-03-17T10:00:00Z"), + method: "POST", + url: "https://example.com/mcp", + requestHeaders: { "x-test": "hello" }, + requestBody: '{"foo":"bar"}', + responseStatus: 200, + responseStatusText: "OK", + responseHeaders: { "content-type": "application/json" }, + responseBody: '{"ok":true}', + duration: 45, + category: "transport", +}; + +describe("NetworkEntry", () => { + it("renders timestamp, method, URL, status, duration, and category", () => { + renderWithMantine( + , + ); + 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 'long-lived stream' placeholder when a GET SSE response has no body", async () => { + const user = userEvent.setup(); + const sse: FetchRequestEntry = { + ...baseEntry, + method: "GET", + responseHeaders: { "content-type": "text/event-stream" }, + responseBody: undefined, + }; + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Expand" })); + expect(screen.getByText("Response Body")).toBeInTheDocument(); + expect( + screen.getByText(/Long-lived stream — body not captured/), + ).toBeInTheDocument(); + }); + + it("shows an SSE badge on long-lived GET event-stream entries", () => { + const sse: FetchRequestEntry = { + ...baseEntry, + method: "GET", + responseHeaders: { "content-type": "text/event-stream" }, + responseBody: undefined, + }; + renderWithMantine(); + 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 = { + ...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 = { + ...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(150_000); + 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..b7040832d --- /dev/null +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from "react"; +import { + Badge, + Button, + Card, + Collapse, + Divider, + Group, + Stack, + Table, + Text, +} from "@mantine/core"; +import type { FetchRequestEntry } from "@inspector/core/mcp/types.js"; +import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js"; +import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; + +export interface NetworkEntryProps { + entry: FetchRequestEntry; + isListExpanded: boolean; +} + +const EntryContainer = Card.withProps({ + withBorder: true, + padding: "md", +}); + +const HeaderRow = Group.withProps({ + justify: "space-between", + wrap: "nowrap", +}); + +const TimestampText = Text.withProps({ + size: "sm", + c: "dimmed", + ff: "monospace", +}); + +const UrlText = Text.withProps({ + size: "sm", + fw: 500, + truncate: "end", +}); + +const DurationText = Text.withProps({ + size: "sm", + c: "dimmed", +}); + +const SubtleButton = Button.withProps({ + variant: "subtle", + size: "xs", +}); + +// Cap is in JS string `.length` units (UTF-16 code units), not bytes — for +// multi-byte content the wire size is larger, but the limit's purpose is +// to keep the DOM from drowning in a single Code block so character count +// is the right unit. +const MAX_INLINE_BODY_CHARS = 100_000; + +function formatDuration(ms: number): string { + return `${ms}ms`; +} + +function formatTimestamp(date: Date): string { + return date.toISOString(); +} + +function statusColor(entry: FetchRequestEntry): string { + if (entry.error) return "red"; + const status = entry.responseStatus; + if (status === undefined) return "gray"; + if (status >= 500) return "red"; + if (status >= 400) return "orange"; + if (status >= 300) return "yellow"; + if (status >= 200) return "green"; + return "gray"; +} + +function statusLabel(entry: FetchRequestEntry): string { + if (entry.error) return "Error"; + if (entry.responseStatus === undefined) return "Pending"; + return entry.responseStatusText + ? `${entry.responseStatus} ${entry.responseStatusText}` + : `${entry.responseStatus}`; +} + +function categoryColor(category: FetchRequestEntry["category"]): string { + return category === "auth" ? "violet" : "blue"; +} + +function isLongLivedStream(entry: FetchRequestEntry): boolean { + return isLongLivedStreamResponse( + entry.method, + entry.responseHeaders?.["content-type"], + ); +} + +function HeadersTable({ headers }: { headers: Record }) { + 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_CHARS; + if (tooLarge) { + return ( + + Body too large to preview ({body.length} characters) + + ); + } + return ; +} + +export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { + const [isExpanded, setIsExpanded] = useState(isListExpanded); + + // The list-level Expand/Collapse toggle is authoritative: each time the + // parent changes `isListExpanded`, every entry snaps to that state and + // any per-entry override is intentionally discarded. Mirrors + // HistoryEntry; do not change without aligning both. This re-runs on + // re-render when `isListExpanded` keeps its reference, but the setter + // is a no-op when the next value equals the current one. + useEffect(() => { + setIsExpanded(isListExpanded); + }, [isListExpanded]); + + return ( + + + + + {formatTimestamp(entry.timestamp)} + {entry.method} + + {entry.category} + + {entry.url} + + + {entry.duration != null && ( + {formatDuration(entry.duration)} + )} + {isLongLivedStream(entry) && SSE} + {statusLabel(entry)} + + + + + setIsExpanded((v) => !v)} ml="auto"> + {isExpanded ? "Collapse" : "Expand"} + + + + + + + + + Request Headers + + + + {entry.requestBody && ( + + + Request Body + + + + )} + {entry.responseHeaders && ( + + + Response Headers + + + + )} + {entry.responseStatus !== undefined && ( + + + Response Body + + {entry.responseBody ? ( + + ) : ( + + {isLongLivedStream(entry) + ? "Long-lived stream — body not captured" + : "(empty)"} + + )} + + )} + {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..ea3014015 --- /dev/null +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.test.tsx @@ -0,0 +1,137 @@ +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", + "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", +}; + +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("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("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( + , + ); + 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(); + 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 new file mode 100644 index 000000000..7c0d05640 --- /dev/null +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -0,0 +1,134 @@ +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 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, + visibleCategories: Record, +): boolean { + if (!visibleCategories[entry.category]) return false; + if (filterText) { + const term = filterText.toLowerCase(); + const status = + entry.responseStatus !== undefined ? String(entry.responseStatus) : ""; + // 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, + entry.responseStatusText ?? "", + headersToString(entry.requestHeaders), + headersToString(entry.responseHeaders), + entry.requestBody ?? "", + entry.responseBody ?? "", + entry.error ?? "", + ]; + if (!fields.some((f) => f.toLowerCase().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/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"); }); 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..6eca8f4c7 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(), @@ -212,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 edfc1c1d7..ba98919f5 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,8 +41,11 @@ 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"; +import { getServerType } from "@inspector/core/mcp/config.js"; const SERVERS_TAB = "Servers"; +const NETWORK_TAB = "Network"; const ALL_TABS: string[] = [ SERVERS_TAB, @@ -52,6 +56,7 @@ const ALL_TABS: string[] = [ "Tasks", "Logs", "History", + NETWORK_TAB, ]; const SCREEN_ENTER_MS = 350; @@ -122,6 +127,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 +206,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 +231,7 @@ export function InspectorView({ tasks, progressByTaskId, history, + network, toolCallState, getPromptState, readResourceState, @@ -265,6 +275,8 @@ export function InspectorView({ onExportHistory, onReplayHistory, onTogglePinHistory, + onClearNetwork, + onExportNetwork, onSelectApp, onOpenApp, onCloseApp, @@ -277,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 @@ -450,6 +466,13 @@ export function InspectorView({ onTogglePin={onTogglePinHistory} /> + + + 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/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, }, diff --git a/core/mcp/fetchTracking.ts b/core/mcp/fetchTracking.ts index 349054ec4..27eb62695 100644 --- a/core/mcp/fetchTracking.ts +++ b/core/mcp/fetchTracking.ts @@ -1,7 +1,36 @@ 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; + /** + * 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 +128,26 @@ 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 - const contentType = response.headers.get("content-type"); - const isStream = - contentType?.includes("text/event-stream") || - contentType?.includes("application/x-ndjson") || - (method === "POST" && url.includes("/mcp")); - - let responseBody: string | undefined; - let duration: number; + // 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 isLongLivedStream = isLongLivedStreamResponse( + method, + response.headers.get("content-type"), + ); - 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; - } + const 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 +158,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..47a82e95d 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -309,6 +309,13 @@ 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" }), @@ -569,6 +576,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 +1953,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.