* feat(network): add Network screen surfacing backend fetch log (#1355) Routes `FetchRequestLogState` to a new Network tab so users can inspect HTTP-layer request/response headers, status, duration, and category (auth vs transport) of every fetch the backend makes for an active session. Adds `NetworkControls` + `NetworkStreamPanel` + `NetworkEntry` groups, a `monoBreak` Text theme variant for long header values, and wires onClearNetwork / onExportNetwork (JSON download) from `App.tsx`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(network): include request / response bodies in search filter Headers were already covered but bodies were not, so searches for strings that live inside the JSON-RPC payload (e.g. "jsonrpc", "initialize", a tool name) silently returned no matches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(network): always render Response Body section, note streaming Streaming responses (text/event-stream, application/x-ndjson, POST to /mcp) are intentionally not body-captured by the fetch tracker, so the section was silently omitted. Now whenever a response was received we render the section, with either the body, a "(empty)" note, or a "Streaming response — body not captured" note keyed off content-type. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(network): capture response bodies via async body-update events Two related fixes: 1. The fetch tracker was marking every streamable-HTTP POST /mcp call as "not captured" because the response content-type is text/event-stream. In practice those streams are bounded — the server sends the JSON-RPC reply (sometimes preceded by progress events) and closes — so we can clone the body and read it. Only GET + SSE / ndjson is the unbounded long-lived channel that has to stay uncaptured. 2. Reading the body inline blocked the transport from processing progress notifications until the entire stream finished, breaking the `resetTimeoutOnProgress` integration test. The body is now read in the background and dispatched via a new `fetchRequestBodyUpdate` event that the FetchRequestLogState patches into the matching entry by id. Plumbing wires through node/transport, the remote SSE channel (RemoteSession, RemoteClientTransport), and the remote event types. Also hides the Network tab when the active server is stdio (no HTTP traffic to surface) and bumps the inline body preview limit to 100 KB so typical tools/list responses render rather than tripping the "too large" notice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(network): show orange SSE badge for long-lived stream entries Adds a small "SSE" badge to the right side of the NetworkEntry header (left of the HTTP status badge) when the response is a long-lived streaming channel (GET + text/event-stream or application/x-ndjson) — the only case where the body genuinely isn't captured. Makes the "not captured" placeholder less surprising at a glance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(network): address PR review findings - Stop capturing OAuth response bodies (drop updateResponseBody from the effectiveAuthFetch tracker). Restores pre-PR behavior for auth-category entries so live access_token / refresh_token values can't leak into the Network tab body preview during a screen-share. Headers + status are still tracked. - Extract `isLongLivedStreamResponse(method, contentType)` to core/mcp/fetchTracking so the rule lives in one place; consumed by the tracker and the NetworkEntry UI placeholder. - Document the intent of NetworkEntry's `useEffect` (the list-level toggle is authoritative; per-entry overrides are intentionally discarded on each list toggle). - Rename MAX_INLINE_BODY_BYTES → MAX_INLINE_BODY_CHARS (`.length` is UTF-16 code units, not bytes) and update the user-facing notice. - Switch NetworkStreamPanel's search from joined-string `.includes` to per-field `.some(.includes)` so a term can't span field boundaries. Regression test added. - Add aria-label to ListToggle ("Expand all" / "Collapse all") — both an a11y improvement and a stable selector that lets the panel test stop fishing the button out by text-content negation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(network): use ListToggle aria-label in panel + modal tests Cleanup spotted in PR review follow-up: - HistoryListPanel + ServerSettingsModal previously fished the ListToggle out by empty text content because the toggle had no accessible name. Now that ListToggle exposes "Expand all" / "Collapse all", both tests can use getByRole + name and the stale comments go away. - ListToggle's own tests now assert the aria-label contract so a future refactor can't silently drop it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(network): expose category toggle state via aria-pressed The category UnstyledButtons in NetworkControls previously communicated their toggled state only via background color. Adds aria-pressed={active} so assistive tech can discover the on/off state without visuals, with a test that asserts the attribute tracks the visibleCategories prop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
491a522e8d
commit
4641339cc3
36 changed files with 1891 additions and 67 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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(<ListToggle compact onToggle={() => {}} />);
|
||||
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(<ListToggle compact={false} onToggle={() => {}} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Collapse all" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an ActionIcon for the subtle variant and labels it", () => {
|
||||
renderWithMantine(
|
||||
<ListToggle compact variant="subtle" onToggle={() => {}} />,
|
||||
);
|
||||
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(<ListToggle compact onToggle={onToggle} />);
|
||||
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(
|
||||
<ListToggle compact={false} variant="subtle" onToggle={onToggle} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button"));
|
||||
await user.click(screen.getByRole("button", { name: "Collapse all" }));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ActionIcon variant="subtle" color="gray" size="md" onClick={onToggle}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="md"
|
||||
aria-label={label}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button size="sm" variant="subtle" onClick={onToggle}>
|
||||
<Button size="sm" variant="subtle" aria-label={label} onClick={onToggle}>
|
||||
<Icon size={20} />
|
||||
</Button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -224,19 +224,13 @@ describe("HistoryListPanel", () => {
|
|||
renderWithMantine(
|
||||
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
|
||||
);
|
||||
// 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(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { NetworkControls } from "./NetworkControls";
|
||||
|
||||
const meta: Meta<typeof NetworkControls> = {
|
||||
title: "Groups/NetworkControls",
|
||||
component: NetworkControls,
|
||||
args: {
|
||||
filterText: "",
|
||||
visibleCategories: { auth: true, transport: true },
|
||||
onFilterChange: fn(),
|
||||
onToggleCategory: fn(),
|
||||
onToggleAllCategories: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NetworkControls>;
|
||||
|
||||
export const AllVisible: Story = {};
|
||||
|
||||
export const NoneVisible: Story = {
|
||||
args: {
|
||||
visibleCategories: { auth: false, transport: false },
|
||||
},
|
||||
};
|
||||
|
||||
export const OnlyAuth: Story = {
|
||||
args: {
|
||||
visibleCategories: { auth: true, transport: false },
|
||||
},
|
||||
};
|
||||
|
|
@ -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(<NetworkControls {...baseProps} />);
|
||||
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(
|
||||
<NetworkControls {...baseProps} onFilterChange={onFilterChange} />,
|
||||
);
|
||||
await user.type(screen.getByPlaceholderText("Search..."), "x");
|
||||
expect(onFilterChange).toHaveBeenLastCalledWith("x");
|
||||
});
|
||||
|
||||
it("reflects category visibility via aria-pressed", () => {
|
||||
renderWithMantine(
|
||||
<NetworkControls
|
||||
{...baseProps}
|
||||
visibleCategories={{ auth: true, transport: false }}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<NetworkControls {...baseProps} onToggleCategory={onToggleCategory} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "auth" }));
|
||||
expect(onToggleCategory).toHaveBeenCalledWith("auth", false);
|
||||
});
|
||||
|
||||
it("toggles between Select All and Deselect All", () => {
|
||||
const { rerender } = renderWithMantine(<NetworkControls {...baseProps} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Deselect All" }),
|
||||
).toBeInTheDocument();
|
||||
rerender(
|
||||
<NetworkControls
|
||||
{...baseProps}
|
||||
visibleCategories={{ auth: false, transport: false }}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<NetworkControls
|
||||
{...baseProps}
|
||||
onToggleAllCategories={onToggleAllCategories}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Deselect All" }));
|
||||
expect(onToggleAllCategories).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<FetchRequestCategory, string> = {
|
||||
auth: "violet",
|
||||
transport: "blue",
|
||||
};
|
||||
|
||||
const SubtleButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "xs",
|
||||
});
|
||||
|
||||
export interface NetworkControlsProps {
|
||||
filterText: string;
|
||||
visibleCategories: Record<FetchRequestCategory, boolean>;
|
||||
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 (
|
||||
<Stack gap="md">
|
||||
<Title order={4}>Network</Title>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search..."
|
||||
value={filterText}
|
||||
onChange={(e) => onFilterChange(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Title order={5}>Filter by Category</Title>
|
||||
<SubtleButton onClick={onToggleAllCategories}>
|
||||
{allSelected ? "Deselect All" : "Select All"}
|
||||
</SubtleButton>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{NETWORK_CATEGORIES.map((category) => {
|
||||
const active = visibleCategories[category];
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={category}
|
||||
w="100%"
|
||||
p="sm"
|
||||
variant="listItem"
|
||||
aria-pressed={active}
|
||||
bg={active ? "var(--mantine-primary-color-light)" : undefined}
|
||||
onClick={() => onToggleCategory(category, !active)}
|
||||
>
|
||||
<Text c={CATEGORY_COLORS[category]} ta="center" fw={500}>
|
||||
{category}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { FetchRequestEntry } from "../../../../../../core/mcp/types.js";
|
||||
import { NetworkEntry } from "./NetworkEntry";
|
||||
|
||||
const meta: Meta<typeof NetworkEntry> = {
|
||||
title: "Groups/NetworkEntry",
|
||||
component: NetworkEntry,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NetworkEntry>;
|
||||
|
||||
const transportEntry: FetchRequestEntry = {
|
||||
id: "n-1",
|
||||
timestamp: new Date("2026-03-17T10:30:00Z"),
|
||||
method: "POST",
|
||||
url: "https://example.com/mcp",
|
||||
requestHeaders: {
|
||||
"content-type": "application/json",
|
||||
"x-test": "hello",
|
||||
},
|
||||
requestBody: '{"jsonrpc":"2.0","method":"initialize","id":1}',
|
||||
responseStatus: 200,
|
||||
responseStatusText: "OK",
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
responseBody: '{"jsonrpc":"2.0","id":1,"result":{}}',
|
||||
duration: 45,
|
||||
category: "transport",
|
||||
};
|
||||
|
||||
const authEntry: FetchRequestEntry = {
|
||||
id: "n-2",
|
||||
timestamp: new Date("2026-03-17T10:30:05Z"),
|
||||
method: "POST",
|
||||
url: "https://example.com/oauth/token",
|
||||
requestHeaders: { "content-type": "application/x-www-form-urlencoded" },
|
||||
requestBody: "grant_type=authorization_code&code=abc",
|
||||
responseStatus: 200,
|
||||
responseStatusText: "OK",
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
responseBody: '{"access_token":"x","token_type":"bearer"}',
|
||||
duration: 120,
|
||||
category: "auth",
|
||||
};
|
||||
|
||||
const errorEntry: FetchRequestEntry = {
|
||||
id: "n-3",
|
||||
timestamp: new Date("2026-03-17T10:30:10Z"),
|
||||
method: "POST",
|
||||
url: "https://example.com/mcp",
|
||||
requestHeaders: { "content-type": "application/json" },
|
||||
responseStatus: 500,
|
||||
responseStatusText: "Internal Server Error",
|
||||
responseHeaders: { "content-type": "text/plain" },
|
||||
responseBody: "Unhandled exception",
|
||||
duration: 1200,
|
||||
category: "transport",
|
||||
};
|
||||
|
||||
const streamingEntry: FetchRequestEntry = {
|
||||
id: "n-stream",
|
||||
timestamp: new Date("2026-03-17T10:30:12Z"),
|
||||
method: "POST",
|
||||
url: "http://localhost:3000/mcp",
|
||||
requestHeaders: {
|
||||
accept: "application/json, text/event-stream",
|
||||
"content-type": "application/json",
|
||||
"mcp-session-id": "0a0b0a5-fd27-4c95-a805-c0fba67e00fb",
|
||||
},
|
||||
requestBody: '{"method":"resources/templates/list","jsonrpc":"2.0","id":4}',
|
||||
responseStatus: 200,
|
||||
responseStatusText: "OK",
|
||||
responseHeaders: {
|
||||
"cache-control": "no-cache",
|
||||
"content-type": "text/event-stream",
|
||||
"mcp-session-id": "0a0b0a5-fd27-4c95-a805-c0fba67e00fb",
|
||||
},
|
||||
duration: 26,
|
||||
category: "transport",
|
||||
};
|
||||
|
||||
const transportError: FetchRequestEntry = {
|
||||
id: "n-4",
|
||||
timestamp: new Date("2026-03-17T10:30:15Z"),
|
||||
method: "POST",
|
||||
url: "https://example.com/mcp",
|
||||
requestHeaders: {},
|
||||
error: "fetch failed: ECONNREFUSED",
|
||||
category: "transport",
|
||||
};
|
||||
|
||||
export const TransportSuccessCollapsed: Story = {
|
||||
args: { entry: transportEntry, isListExpanded: false },
|
||||
};
|
||||
|
||||
export const TransportSuccessExpanded: Story = {
|
||||
args: { entry: transportEntry, isListExpanded: true },
|
||||
};
|
||||
|
||||
export const AuthSuccess: Story = {
|
||||
args: { entry: authEntry, isListExpanded: true },
|
||||
};
|
||||
|
||||
export const HttpError: Story = {
|
||||
args: { entry: errorEntry, isListExpanded: true },
|
||||
};
|
||||
|
||||
export const StreamingResponse: Story = {
|
||||
args: { entry: streamingEntry, isListExpanded: true },
|
||||
};
|
||||
|
||||
export const FetchError: Story = {
|
||||
args: { entry: transportError, isListExpanded: true },
|
||||
};
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { NetworkEntry } from "./NetworkEntry";
|
||||
|
||||
const baseEntry: FetchRequestEntry = {
|
||||
id: "n-1",
|
||||
timestamp: new Date("2026-03-17T10:00:00Z"),
|
||||
method: "POST",
|
||||
url: "https://example.com/mcp",
|
||||
requestHeaders: { "x-test": "hello" },
|
||||
requestBody: '{"foo":"bar"}',
|
||||
responseStatus: 200,
|
||||
responseStatusText: "OK",
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
responseBody: '{"ok":true}',
|
||||
duration: 45,
|
||||
category: "transport",
|
||||
};
|
||||
|
||||
describe("NetworkEntry", () => {
|
||||
it("renders timestamp, method, URL, status, duration, and category", () => {
|
||||
renderWithMantine(
|
||||
<NetworkEntry entry={baseEntry} isListExpanded={false} />,
|
||||
);
|
||||
expect(screen.getByText("POST")).toBeInTheDocument();
|
||||
expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
|
||||
expect(screen.getByText("200 OK")).toBeInTheDocument();
|
||||
expect(screen.getByText("45ms")).toBeInTheDocument();
|
||||
expect(screen.getByText("transport")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows body / header detail when expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<NetworkEntry entry={baseEntry} isListExpanded={false} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(screen.getByText("Request Headers")).toBeInTheDocument();
|
||||
expect(screen.getByText("x-test")).toBeInTheDocument();
|
||||
expect(screen.getByText("hello")).toBeInTheDocument();
|
||||
expect(screen.getByText("Request Body")).toBeInTheDocument();
|
||||
expect(screen.getByText("Response Headers")).toBeInTheDocument();
|
||||
expect(screen.getByText("Response Body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders without response when status is missing (pending)", () => {
|
||||
const pending: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
responseStatus: undefined,
|
||||
responseStatusText: undefined,
|
||||
responseHeaders: undefined,
|
||||
responseBody: undefined,
|
||||
duration: undefined,
|
||||
};
|
||||
renderWithMantine(<NetworkEntry entry={pending} isListExpanded={false} />);
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Error label and surfaces the error message when expanded", () => {
|
||||
const errored: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
responseStatus: undefined,
|
||||
responseStatusText: undefined,
|
||||
responseHeaders: undefined,
|
||||
responseBody: undefined,
|
||||
error: "ECONNRESET",
|
||||
};
|
||||
renderWithMantine(<NetworkEntry entry={errored} isListExpanded={true} />);
|
||||
expect(screen.getAllByText("Error").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("ECONNRESET")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders status labels across HTTP classes", () => {
|
||||
const cases: Array<[number, string]> = [
|
||||
[201, "201"],
|
||||
[301, "301"],
|
||||
[404, "404"],
|
||||
[500, "500"],
|
||||
];
|
||||
for (const [status, label] of cases) {
|
||||
const { unmount } = renderWithMantine(
|
||||
<NetworkEntry
|
||||
entry={{
|
||||
...baseEntry,
|
||||
id: `n-${status}`,
|
||||
responseStatus: status,
|
||||
responseStatusText: undefined,
|
||||
}}
|
||||
isListExpanded={false}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows headers placeholder when none are present", async () => {
|
||||
const user = userEvent.setup();
|
||||
const noHeaders: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
requestHeaders: {},
|
||||
responseHeaders: {},
|
||||
};
|
||||
renderWithMantine(
|
||||
<NetworkEntry entry={noHeaders} isListExpanded={false} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(screen.getAllByText("(none)").length).toBe(2);
|
||||
});
|
||||
|
||||
it("shows a 'long-lived stream' placeholder when a GET SSE response has no body", async () => {
|
||||
const user = userEvent.setup();
|
||||
const sse: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
method: "GET",
|
||||
responseHeaders: { "content-type": "text/event-stream" },
|
||||
responseBody: undefined,
|
||||
};
|
||||
renderWithMantine(<NetworkEntry entry={sse} isListExpanded={false} />);
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(screen.getByText("Response Body")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Long-lived stream — body not captured/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an SSE badge on long-lived GET event-stream entries", () => {
|
||||
const sse: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
method: "GET",
|
||||
responseHeaders: { "content-type": "text/event-stream" },
|
||||
responseBody: undefined,
|
||||
};
|
||||
renderWithMantine(<NetworkEntry entry={sse} isListExpanded={false} />);
|
||||
expect(screen.getByText("SSE")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show an SSE badge on bounded POST entries", () => {
|
||||
renderWithMantine(
|
||||
<NetworkEntry entry={baseEntry} isListExpanded={false} />,
|
||||
);
|
||||
expect(screen.queryByText("SSE")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows '(empty)' for a POST SSE response with no body (bounded stream where capture failed)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const sse: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
method: "POST",
|
||||
responseHeaders: { "content-type": "text/event-stream" },
|
||||
responseBody: undefined,
|
||||
};
|
||||
renderWithMantine(<NetworkEntry entry={sse} isListExpanded={false} />);
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(screen.getByText("(empty)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows '(empty)' for a non-streaming response with no body", async () => {
|
||||
const user = userEvent.setup();
|
||||
const empty: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
responseBody: undefined,
|
||||
};
|
||||
renderWithMantine(<NetworkEntry entry={empty} isListExpanded={false} />);
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(screen.getByText("Response Body")).toBeInTheDocument();
|
||||
expect(screen.getByText("(empty)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits the Response Body section entirely when no response was received", async () => {
|
||||
const user = userEvent.setup();
|
||||
const pending: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
responseStatus: undefined,
|
||||
responseStatusText: undefined,
|
||||
responseHeaders: undefined,
|
||||
responseBody: undefined,
|
||||
duration: undefined,
|
||||
};
|
||||
renderWithMantine(<NetworkEntry entry={pending} isListExpanded={false} />);
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(screen.queryByText("Response Body")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a 'too large' notice when a body exceeds the inline preview limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const huge = "x".repeat(150_000);
|
||||
const big: FetchRequestEntry = {
|
||||
...baseEntry,
|
||||
requestBody: huge,
|
||||
};
|
||||
renderWithMantine(<NetworkEntry entry={big} isListExpanded={false} />);
|
||||
await user.click(screen.getByRole("button", { name: "Expand" }));
|
||||
expect(screen.getByText(/Body too large to preview/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
237
clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx
Normal file
237
clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import type { FetchRequestEntry } from "@inspector/core/mcp/types.js";
|
||||
import { isLongLivedStreamResponse } from "@inspector/core/mcp/fetchTracking.js";
|
||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
|
||||
export interface NetworkEntryProps {
|
||||
entry: FetchRequestEntry;
|
||||
isListExpanded: boolean;
|
||||
}
|
||||
|
||||
const EntryContainer = Card.withProps({
|
||||
withBorder: true,
|
||||
padding: "md",
|
||||
});
|
||||
|
||||
const HeaderRow = Group.withProps({
|
||||
justify: "space-between",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const TimestampText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
ff: "monospace",
|
||||
});
|
||||
|
||||
const UrlText = Text.withProps({
|
||||
size: "sm",
|
||||
fw: 500,
|
||||
truncate: "end",
|
||||
});
|
||||
|
||||
const DurationText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
const SubtleButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "xs",
|
||||
});
|
||||
|
||||
// Cap is in JS string `.length` units (UTF-16 code units), not bytes — for
|
||||
// multi-byte content the wire size is larger, but the limit's purpose is
|
||||
// to keep the DOM from drowning in a single Code block so character count
|
||||
// is the right unit.
|
||||
const MAX_INLINE_BODY_CHARS = 100_000;
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function statusColor(entry: FetchRequestEntry): string {
|
||||
if (entry.error) return "red";
|
||||
const status = entry.responseStatus;
|
||||
if (status === undefined) return "gray";
|
||||
if (status >= 500) return "red";
|
||||
if (status >= 400) return "orange";
|
||||
if (status >= 300) return "yellow";
|
||||
if (status >= 200) return "green";
|
||||
return "gray";
|
||||
}
|
||||
|
||||
function statusLabel(entry: FetchRequestEntry): string {
|
||||
if (entry.error) return "Error";
|
||||
if (entry.responseStatus === undefined) return "Pending";
|
||||
return entry.responseStatusText
|
||||
? `${entry.responseStatus} ${entry.responseStatusText}`
|
||||
: `${entry.responseStatus}`;
|
||||
}
|
||||
|
||||
function categoryColor(category: FetchRequestEntry["category"]): string {
|
||||
return category === "auth" ? "violet" : "blue";
|
||||
}
|
||||
|
||||
function isLongLivedStream(entry: FetchRequestEntry): boolean {
|
||||
return isLongLivedStreamResponse(
|
||||
entry.method,
|
||||
entry.responseHeaders?.["content-type"],
|
||||
);
|
||||
}
|
||||
|
||||
function HeadersTable({ headers }: { headers: Record<string, string> }) {
|
||||
const rows = Object.entries(headers);
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
(none)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table striped withColumnBorders fz="xs">
|
||||
<Table.Tbody>
|
||||
{rows.map(([name, value]) => (
|
||||
<Table.Tr key={name}>
|
||||
<Table.Td>
|
||||
<Text size="xs" ff="monospace" fw={500}>
|
||||
{name}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" ff="monospace" variant="monoBreak">
|
||||
{value}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BodyPreview({ body }: { body: string }) {
|
||||
const tooLarge = body.length > MAX_INLINE_BODY_CHARS;
|
||||
if (tooLarge) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
Body too large to preview ({body.length} characters)
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return <ContentViewer block={{ type: "text", text: body }} copyable />;
|
||||
}
|
||||
|
||||
export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(isListExpanded);
|
||||
|
||||
// The list-level Expand/Collapse toggle is authoritative: each time the
|
||||
// parent changes `isListExpanded`, every entry snaps to that state and
|
||||
// any per-entry override is intentionally discarded. Mirrors
|
||||
// HistoryEntry; do not change without aligning both. This re-runs on
|
||||
// re-render when `isListExpanded` keeps its reference, but the setter
|
||||
// is a no-op when the next value equals the current one.
|
||||
useEffect(() => {
|
||||
setIsExpanded(isListExpanded);
|
||||
}, [isListExpanded]);
|
||||
|
||||
return (
|
||||
<EntryContainer>
|
||||
<Stack gap="sm">
|
||||
<HeaderRow>
|
||||
<Group gap="sm" wrap="nowrap" miw={0} flex={1}>
|
||||
<TimestampText>{formatTimestamp(entry.timestamp)}</TimestampText>
|
||||
<Badge color="dark">{entry.method}</Badge>
|
||||
<Badge color={categoryColor(entry.category)} variant="light">
|
||||
{entry.category}
|
||||
</Badge>
|
||||
<UrlText>{entry.url}</UrlText>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
)}
|
||||
{isLongLivedStream(entry) && <Badge color="orange">SSE</Badge>}
|
||||
<Badge color={statusColor(entry)}>{statusLabel(entry)}</Badge>
|
||||
</Group>
|
||||
</HeaderRow>
|
||||
|
||||
<Group gap="xs">
|
||||
<SubtleButton onClick={() => setIsExpanded((v) => !v)} ml="auto">
|
||||
{isExpanded ? "Collapse" : "Expand"}
|
||||
</SubtleButton>
|
||||
</Group>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Stack gap="sm">
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Request Headers
|
||||
</Text>
|
||||
<HeadersTable headers={entry.requestHeaders} />
|
||||
</Stack>
|
||||
{entry.requestBody && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Request Body
|
||||
</Text>
|
||||
<BodyPreview body={entry.requestBody} />
|
||||
</Stack>
|
||||
)}
|
||||
{entry.responseHeaders && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Response Headers
|
||||
</Text>
|
||||
<HeadersTable headers={entry.responseHeaders} />
|
||||
</Stack>
|
||||
)}
|
||||
{entry.responseStatus !== undefined && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
Response Body
|
||||
</Text>
|
||||
{entry.responseBody ? (
|
||||
<BodyPreview body={entry.responseBody} />
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{isLongLivedStream(entry)
|
||||
? "Long-lived stream — body not captured"
|
||||
: "(empty)"}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{entry.error && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500} c="red">
|
||||
Error
|
||||
</Text>
|
||||
<Text size="xs" ff="monospace" c="red">
|
||||
{entry.error}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</EntryContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof NetworkStreamPanel> = {
|
||||
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<typeof NetworkStreamPanel>;
|
||||
|
||||
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: [] },
|
||||
};
|
||||
|
|
@ -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(<NetworkStreamPanel {...baseProps} />);
|
||||
expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
|
||||
expect(screen.getByText("Requests (1)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the empty state when filters hide everything", () => {
|
||||
renderWithMantine(
|
||||
<NetworkStreamPanel
|
||||
{...baseProps}
|
||||
visibleCategories={{ auth: false, transport: false }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("No network requests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters entries by search text", () => {
|
||||
renderWithMantine(<NetworkStreamPanel {...baseProps} filterText="oauth" />);
|
||||
expect(screen.getByText("No network requests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters entries by URL match", () => {
|
||||
renderWithMantine(
|
||||
<NetworkStreamPanel {...baseProps} filterText="example.com" />,
|
||||
);
|
||||
expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches against header keys and values", () => {
|
||||
renderWithMantine(
|
||||
<NetworkStreamPanel {...baseProps} filterText="content-type" />,
|
||||
);
|
||||
expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches against the request body", () => {
|
||||
renderWithMantine(
|
||||
<NetworkStreamPanel {...baseProps} filterText="initialize" />,
|
||||
);
|
||||
expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches against the response body", () => {
|
||||
renderWithMantine(
|
||||
<NetworkStreamPanel {...baseProps} filterText="jsonrpc" />,
|
||||
);
|
||||
expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches against responseStatusText", () => {
|
||||
renderWithMantine(<NetworkStreamPanel {...baseProps} filterText="ok" />);
|
||||
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(
|
||||
<NetworkStreamPanel {...baseProps} filterText="sthttps" />,
|
||||
);
|
||||
expect(screen.getByText("No network requests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
renderWithMantine(
|
||||
<NetworkStreamPanel {...baseProps} filterText="CONTENT-TYPE" />,
|
||||
);
|
||||
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(
|
||||
<NetworkStreamPanel
|
||||
{...baseProps}
|
||||
onClear={onClear}
|
||||
onExport={onExport}
|
||||
/>,
|
||||
);
|
||||
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(<NetworkStreamPanel {...baseProps} entries={[]} />);
|
||||
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(<NetworkStreamPanel {...baseProps} />);
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<FetchRequestCategory, boolean>;
|
||||
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<string, string> | undefined): string {
|
||||
if (!headers) return "";
|
||||
return Object.entries(headers)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function matchesFilters(
|
||||
entry: FetchRequestEntry,
|
||||
filterText: string,
|
||||
visibleCategories: Record<FetchRequestCategory, boolean>,
|
||||
): 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 (
|
||||
<PanelContainer>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>{formatTitle(filteredEntries.length)}</Title>
|
||||
<Group gap="xs">
|
||||
{hasResults && (
|
||||
<ListToggle
|
||||
compact={compact}
|
||||
onToggle={() => setCompact((c) => !c)}
|
||||
/>
|
||||
)}
|
||||
<Button variant="default" onClick={onClear} disabled={!hasEntries}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button variant="default" onClick={onExport} disabled={!hasEntries}>
|
||||
Export
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!hasResults ? (
|
||||
<EmptyState>No network requests</EmptyState>
|
||||
) : (
|
||||
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)">
|
||||
<Stack gap="md">
|
||||
{filteredEntries.map((entry) => (
|
||||
<NetworkEntry
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isListExpanded={!compact}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
)}
|
||||
</PanelContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<typeof NetworkScreen> = {
|
||||
title: "Screens/NetworkScreen",
|
||||
component: NetworkScreen,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
onClear: fn(),
|
||||
onExport: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof NetworkScreen>;
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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(<NetworkScreen {...baseProps} />);
|
||||
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(<NetworkScreen {...baseProps} entries={[]} />);
|
||||
expect(screen.getByText("No network requests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows entry headers when expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<NetworkScreen {...baseProps} entries={[transportEntry]} />,
|
||||
);
|
||||
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(<NetworkScreen {...baseProps} />);
|
||||
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(<NetworkScreen {...baseProps} />);
|
||||
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(<NetworkScreen {...baseProps} />);
|
||||
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(<NetworkScreen {...baseProps} onClear={onClear} />);
|
||||
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(<NetworkScreen {...baseProps} onExport={onExport} />);
|
||||
await user.click(screen.getByRole("button", { name: "Export" }));
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("disables Clear / Export when there are no entries", () => {
|
||||
renderWithMantine(<NetworkScreen {...baseProps} entries={[]} />);
|
||||
expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Export" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<FetchRequestCategory, boolean> = {
|
||||
auth: true,
|
||||
transport: true,
|
||||
};
|
||||
|
||||
const NO_CATEGORIES_VISIBLE: Record<FetchRequestCategory, boolean> = {
|
||||
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<FetchRequestCategory, boolean>
|
||||
>(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 (
|
||||
<ScreenLayout>
|
||||
<Sidebar>
|
||||
<SidebarCard>
|
||||
<NetworkControls
|
||||
filterText={filterText}
|
||||
visibleCategories={visibleCategories}
|
||||
onFilterChange={setFilterText}
|
||||
onToggleCategory={handleToggleCategory}
|
||||
onToggleAllCategories={handleToggleAllCategories}
|
||||
/>
|
||||
</SidebarCard>
|
||||
</Sidebar>
|
||||
<NetworkStreamPanel
|
||||
entries={entries}
|
||||
filterText={filterText}
|
||||
visibleCategories={visibleCategories}
|
||||
onClear={onClear}
|
||||
onExport={onExport}
|
||||
/>
|
||||
</ScreenLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof InspectorView> = {
|
|||
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<typeof InspectorView> = {
|
|||
onExportHistory: fn(),
|
||||
onReplayHistory: fn(),
|
||||
onTogglePinHistory: fn(),
|
||||
onClearNetwork: fn(),
|
||||
onExportNetwork: fn(),
|
||||
onSelectApp: fn(),
|
||||
onOpenApp: fn(),
|
||||
onCloseApp: fn(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [sampleServer],
|
||||
activeServer: "alpha",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: connectedInit,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<InspectorView
|
||||
{...makeProps({
|
||||
servers: [httpServer],
|
||||
activeServer: "beta",
|
||||
connectionStatus: "connected",
|
||||
initializeResult: httpInit,
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -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<string, TaskProgress>;
|
||||
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<string, unknown>) => 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<boolean>(true);
|
||||
const appRendererRef = useRef<AppRendererHandle>(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<string[]>(
|
||||
() => (connectionStatus === "connected" ? ALL_TABS : [SERVERS_TAB]),
|
||||
[connectionStatus],
|
||||
);
|
||||
const availableTabs = useMemo<string[]>(() => {
|
||||
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}
|
||||
/>
|
||||
</ScreenStage>
|
||||
<ScreenStage active={activeTab === "Network"}>
|
||||
<NetworkScreen
|
||||
entries={network}
|
||||
onClear={onClearNetwork}
|
||||
onExport={onExportNetwork}
|
||||
/>
|
||||
</ScreenStage>
|
||||
</ScreenStageContainer>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue