* 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
3 changed files with 1891 additions and 67 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue