Skip to content

Commit a25bd4d

Browse files
cliffhallclaude
andcommitted
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>
1 parent c2f7aa6 commit a25bd4d

17 files changed

Lines changed: 362 additions & 52 deletions

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,21 +110,35 @@ describe("NetworkEntry", () => {
110110
expect(screen.getAllByText("(none)").length).toBe(2);
111111
});
112112

113-
it("shows a 'streaming' placeholder when responseBody is missing but content-type is SSE", async () => {
113+
it("shows a 'long-lived stream' placeholder when a GET SSE response has no body", async () => {
114114
const user = userEvent.setup();
115115
const sse: FetchRequestEntry = {
116116
...baseEntry,
117+
method: "GET",
117118
responseHeaders: { "content-type": "text/event-stream" },
118119
responseBody: undefined,
119120
};
120121
renderWithMantine(<NetworkEntry entry={sse} isListExpanded={false} />);
121122
await user.click(screen.getByRole("button", { name: "Expand" }));
122123
expect(screen.getByText("Response Body")).toBeInTheDocument();
123124
expect(
124-
screen.getByText(/Streaming response body not captured/),
125+
screen.getByText(/Long-lived stream body not captured/),
125126
).toBeInTheDocument();
126127
});
127128

129+
it("shows '(empty)' for a POST SSE response with no body (bounded stream where capture failed)", async () => {
130+
const user = userEvent.setup();
131+
const sse: FetchRequestEntry = {
132+
...baseEntry,
133+
method: "POST",
134+
responseHeaders: { "content-type": "text/event-stream" },
135+
responseBody: undefined,
136+
};
137+
renderWithMantine(<NetworkEntry entry={sse} isListExpanded={false} />);
138+
await user.click(screen.getByRole("button", { name: "Expand" }));
139+
expect(screen.getByText("(empty)")).toBeInTheDocument();
140+
});
141+
128142
it("shows '(empty)' for a non-streaming response with no body", async () => {
129143
const user = userEvent.setup();
130144
const empty: FetchRequestEntry = {
@@ -155,7 +169,7 @@ describe("NetworkEntry", () => {
155169

156170
it("shows a 'too large' notice when a body exceeds the inline preview limit", async () => {
157171
const user = userEvent.setup();
158-
const huge = "x".repeat(5000);
172+
const huge = "x".repeat(150_000);
159173
const big: FetchRequestEntry = {
160174
...baseEntry,
161175
requestBody: huge,

clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const SubtleButton = Button.withProps({
5050
size: "xs",
5151
});
5252

53-
const MAX_INLINE_BODY_BYTES = 4096;
53+
const MAX_INLINE_BODY_BYTES = 100_000;
5454

5555
function formatDuration(ms: number): string {
5656
return `${ms}ms`;
@@ -83,7 +83,11 @@ function categoryColor(category: FetchRequestEntry["category"]): string {
8383
return category === "auth" ? "violet" : "blue";
8484
}
8585

86-
function isStreamingResponse(entry: FetchRequestEntry): boolean {
86+
function isLongLivedStream(entry: FetchRequestEntry): boolean {
87+
// Matches the fetch tracker's `isLongLivedStream` rule. Only the GET +
88+
// SSE / ndjson case is unbounded; bounded POST SSE responses now have
89+
// their bodies captured, so they would not reach this placeholder.
90+
if (entry.method !== "GET") return false;
8791
const contentType = entry.responseHeaders?.["content-type"] ?? "";
8892
return (
8993
contentType.includes("text/event-stream") ||
@@ -201,8 +205,8 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
201205
<BodyPreview body={entry.responseBody} />
202206
) : (
203207
<Text size="xs" c="dimmed">
204-
{isStreamingResponse(entry)
205-
? "Streaming response — body not captured"
208+
{isLongLivedStream(entry)
209+
? "Long-lived stream — body not captured"
206210
: "(empty)"}
207211
</Text>
208212
)}

clients/web/src/components/views/InspectorView/InspectorView.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,52 @@ describe("InspectorView", () => {
215215
).toBeInTheDocument();
216216
});
217217

218+
it("hides the Network tab when the active server is stdio", async () => {
219+
renderWithMantine(
220+
<InspectorView
221+
{...makeProps({
222+
servers: [sampleServer],
223+
activeServer: "alpha",
224+
connectionStatus: "connected",
225+
initializeResult: connectedInit,
226+
})}
227+
/>,
228+
);
229+
// ViewHeader renders the tab radiogroup as accessible radios; check the
230+
// radio list directly so the assertion isn't fooled by hidden options.
231+
const radios = await screen.findAllByRole("radio");
232+
const labels = radios.map((r) => r.getAttribute("value"));
233+
expect(labels).toContain("Tools");
234+
expect(labels).not.toContain("Network");
235+
});
236+
237+
it("shows the Network tab when the active server is streamable-http", async () => {
238+
const httpServer: ServerEntry = {
239+
id: "beta",
240+
name: "Beta",
241+
config: { type: "streamable-http", url: "http://localhost:3000/mcp" },
242+
connection: { status: "connected" },
243+
};
244+
const httpInit: InitializeResult = {
245+
protocolVersion: "2025-06-18",
246+
capabilities: {},
247+
serverInfo: { name: "Beta", version: "1.0.0" },
248+
};
249+
renderWithMantine(
250+
<InspectorView
251+
{...makeProps({
252+
servers: [httpServer],
253+
activeServer: "beta",
254+
connectionStatus: "connected",
255+
initializeResult: httpInit,
256+
})}
257+
/>,
258+
);
259+
const radios = await screen.findAllByRole("radio");
260+
const labels = radios.map((r) => r.getAttribute("value"));
261+
expect(labels).toContain("Network");
262+
});
263+
218264
it("filters tools to apps and auto-launches a no-fields app on the Apps tab", async () => {
219265
const user = userEvent.setup();
220266
const opsApp: Tool = {

clients/web/src/components/views/InspectorView/InspectorView.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ import { TasksScreen } from "../../screens/TasksScreen/TasksScreen";
4242
import type { TaskProgress } from "../../groups/TaskCard/TaskCard";
4343
import { HistoryScreen } from "../../screens/HistoryScreen/HistoryScreen";
4444
import { NetworkScreen } from "../../screens/NetworkScreen/NetworkScreen";
45+
import { getServerType } from "@inspector/core/mcp/config.js";
4546

4647
const SERVERS_TAB = "Servers";
48+
const NETWORK_TAB = "Network";
4749

4850
const ALL_TABS: string[] = [
4951
SERVERS_TAB,
@@ -54,7 +56,7 @@ const ALL_TABS: string[] = [
5456
"Tasks",
5557
"Logs",
5658
"History",
57-
"Network",
59+
NETWORK_TAB,
5860
];
5961

6062
const SCREEN_ENTER_MS = 350;
@@ -287,13 +289,17 @@ export function InspectorView({
287289
const [autoScroll, setAutoScroll] = useState<boolean>(true);
288290
const appRendererRef = useRef<AppRendererHandle>(null);
289291

290-
// Only show the non-Servers tabs when actually connected. Capability-aware
292+
// Only show the non-Servers tabs when actually connected. Network is
293+
// additionally hidden for stdio servers — there is no HTTP traffic to
294+
// surface there, so the tab would always be empty. Capability-aware
291295
// tab gating (hide Tools when the server doesn't advertise `tools`, etc.)
292296
// can layer in later once the parent passes capabilities through.
293-
const availableTabs = useMemo<string[]>(
294-
() => (connectionStatus === "connected" ? ALL_TABS : [SERVERS_TAB]),
295-
[connectionStatus],
296-
);
297+
const availableTabs = useMemo<string[]>(() => {
298+
if (connectionStatus !== "connected") return [SERVERS_TAB];
299+
const active = serversInput.find((s) => s.id === activeServer);
300+
const isStdio = active ? getServerType(active.config) === "stdio" : false;
301+
return isStdio ? ALL_TABS.filter((t) => t !== NETWORK_TAB) : ALL_TABS;
302+
}, [connectionStatus, serversInput, activeServer]);
297303

298304
// Clamp the rendered tab to whatever's currently available. If the user
299305
// had "Tools" selected and the connection drops, `availableTabs` becomes

clients/web/src/test/core/mcp/fetchTracking.test.ts

Lines changed: 107 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@ import { describe, it, expect, vi } from "vitest";
22
import { createFetchTracker } from "@inspector/core/mcp/fetchTracking.js";
33
import type { FetchRequestEntryBase } from "@inspector/core/mcp/types.js";
44

5+
// The tracker fires `trackRequest` synchronously with an entry whose
6+
// responseBody is always undefined, then reads the body in the background
7+
// and calls `updateResponseBody(id, body)` when done. This helper waits a
8+
// microtask so the background read can complete before assertions.
9+
const flush = () => new Promise((r) => setTimeout(r, 0));
10+
511
describe("createFetchTracker", () => {
6-
it("tracks a successful GET request with response body", async () => {
12+
it("tracks a successful GET request and emits the response body asynchronously", async () => {
713
const baseFetch = vi.fn(
814
async () =>
915
new Response("hello", {
@@ -13,17 +19,22 @@ describe("createFetchTracker", () => {
1319
}),
1420
);
1521
const tracked: FetchRequestEntryBase[] = [];
22+
const bodies: Array<{ id: string; body: string }> = [];
1623
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
1724
trackRequest: (entry) => tracked.push(entry),
25+
updateResponseBody: (id, body) => bodies.push({ id, body }),
1826
});
1927

2028
const res = await fetcher("https://example.com/data");
2129
expect(res.status).toBe(200);
2230
expect(tracked).toHaveLength(1);
2331
expect(tracked[0]?.method).toBe("GET");
2432
expect(tracked[0]?.url).toBe("https://example.com/data");
25-
expect(tracked[0]?.responseBody).toBe("hello");
33+
expect(tracked[0]?.responseBody).toBeUndefined();
2634
expect(tracked[0]?.responseStatus).toBe(200);
35+
36+
await flush();
37+
expect(bodies).toEqual([{ id: tracked[0]!.id, body: "hello" }]);
2738
});
2839

2940
it("accepts URL objects and Request instances as input", async () => {
@@ -99,29 +110,114 @@ describe("createFetchTracker", () => {
99110
expect(tracked[0]?.error).toBe("stringly-typed");
100111
});
101112

102-
it("skips body reading on event-stream responses", async () => {
113+
it("skips body reading on GET event-stream responses (long-lived stream)", async () => {
103114
const baseFetch = vi.fn(
104115
async () =>
105116
new Response("ignored", {
106117
headers: { "content-type": "text/event-stream" },
107118
}),
108119
);
109120
const tracked: FetchRequestEntryBase[] = [];
121+
const bodies: Array<{ id: string; body: string }> = [];
110122
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
111123
trackRequest: (entry) => tracked.push(entry),
124+
updateResponseBody: (id, body) => bodies.push({ id, body }),
112125
});
113-
await fetcher("https://example.com/events");
126+
await fetcher("https://example.com/events", { method: "GET" });
127+
await flush();
114128
expect(tracked[0]?.responseBody).toBeUndefined();
129+
expect(bodies).toHaveLength(0);
130+
});
131+
132+
it("skips body reading on GET application/x-ndjson responses", async () => {
133+
const baseFetch = vi.fn(
134+
async () =>
135+
new Response("ignored", {
136+
headers: { "content-type": "application/x-ndjson" },
137+
}),
138+
);
139+
const tracked: FetchRequestEntryBase[] = [];
140+
const bodies: Array<{ id: string; body: string }> = [];
141+
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
142+
trackRequest: (entry) => tracked.push(entry),
143+
updateResponseBody: (id, body) => bodies.push({ id, body }),
144+
});
145+
await fetcher("https://example.com/events", { method: "GET" });
146+
await flush();
147+
expect(bodies).toHaveLength(0);
115148
});
116149

117-
it("skips body reading for POST /mcp streamable responses", async () => {
118-
const baseFetch = vi.fn(async () => new Response("streamed"));
150+
it("emits the body for a POST event-stream response after the stream closes (bounded)", async () => {
151+
// Streamable HTTP POST /mcp answers with SSE that closes after the
152+
// reply. The tracker must NOT block on this read — the transport
153+
// needs to consume the stream first to drive progress notifications.
154+
// Body therefore arrives asynchronously via updateResponseBody.
155+
const sse =
156+
'event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{"tools":[]}}\n\n';
157+
const baseFetch = vi.fn(
158+
async () =>
159+
new Response(sse, {
160+
headers: { "content-type": "text/event-stream" },
161+
}),
162+
);
119163
const tracked: FetchRequestEntryBase[] = [];
164+
const bodies: Array<{ id: string; body: string }> = [];
120165
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
121166
trackRequest: (entry) => tracked.push(entry),
167+
updateResponseBody: (id, body) => bodies.push({ id, body }),
122168
});
123169
await fetcher("https://example.com/mcp", { method: "POST" });
124170
expect(tracked[0]?.responseBody).toBeUndefined();
171+
await flush();
172+
expect(bodies).toEqual([{ id: tracked[0]!.id, body: sse }]);
173+
});
174+
175+
it("emits the body for a POST /mcp JSON response asynchronously", async () => {
176+
const baseFetch = vi.fn(
177+
async () =>
178+
new Response('{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}', {
179+
status: 200,
180+
statusText: "OK",
181+
headers: { "content-type": "application/json" },
182+
}),
183+
);
184+
const tracked: FetchRequestEntryBase[] = [];
185+
const bodies: Array<{ id: string; body: string }> = [];
186+
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
187+
trackRequest: (entry) => tracked.push(entry),
188+
updateResponseBody: (id, body) => bodies.push({ id, body }),
189+
});
190+
await fetcher("https://example.com/mcp", { method: "POST" });
191+
expect(tracked[0]?.responseBody).toBeUndefined();
192+
await flush();
193+
expect(bodies).toEqual([
194+
{
195+
id: tracked[0]!.id,
196+
body: '{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}',
197+
},
198+
]);
199+
});
200+
201+
it("does not block the caller awaiting the response body", async () => {
202+
// If the body promise hangs forever (simulating a long-lived stream
203+
// mid-flight), the tracker still has to resolve the outer fetcher
204+
// promise immediately. Otherwise the transport blocks waiting on us.
205+
const neverEnding = new ReadableStream({
206+
start() {
207+
// Never enqueue, never close — `.text()` on a clone of this would hang.
208+
},
209+
});
210+
const baseFetch = vi.fn(
211+
async () => new Response(neverEnding, { status: 200 }),
212+
);
213+
const tracked: FetchRequestEntryBase[] = [];
214+
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
215+
trackRequest: (entry) => tracked.push(entry),
216+
});
217+
const res = await fetcher("https://example.com/slow", { method: "POST" });
218+
expect(res.status).toBe(200);
219+
expect(tracked).toHaveLength(1);
220+
expect(tracked[0]?.responseBody).toBeUndefined();
125221
});
126222

127223
it("survives a Request whose body cannot be cloned/read", async () => {
@@ -145,8 +241,9 @@ describe("createFetchTracker", () => {
145241
expect(tracked[0]?.requestBody).toBeUndefined();
146242
});
147243

148-
it("falls back to undefined when response.clone() throws", async () => {
244+
it("does not call updateResponseBody when response.clone() throws", async () => {
149245
const tracked: FetchRequestEntryBase[] = [];
246+
const bodies: Array<{ id: string; body: string }> = [];
150247
const baseFetch = vi.fn(async () => {
151248
const r = new Response("body");
152249
Object.defineProperty(r, "clone", {
@@ -158,8 +255,11 @@ describe("createFetchTracker", () => {
158255
});
159256
const fetcher = createFetchTracker(baseFetch as typeof fetch, {
160257
trackRequest: (entry) => tracked.push(entry),
258+
updateResponseBody: (id, body) => bodies.push({ id, body }),
161259
});
162260
await fetcher("https://example.com/data");
261+
await flush();
163262
expect(tracked[0]?.responseBody).toBeUndefined();
263+
expect(bodies).toHaveLength(0);
164264
});
165265
});

clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,35 @@ describe("FetchRequestLogState", () => {
102102
expect(dispatched).toBe(false);
103103
});
104104

105+
it("patches the matching entry's responseBody and re-emits on fetchRequestBodyUpdate", () => {
106+
client.dispatchTypedEvent("fetchRequest", entry("a"));
107+
client.dispatchTypedEvent("fetchRequest", entry("b"));
108+
const seen: FetchRequestEntry[][] = [];
109+
state.addEventListener("fetchRequestsChange", (e) => seen.push(e.detail));
110+
111+
client.dispatchTypedEvent("fetchRequestBodyUpdate", {
112+
id: "b",
113+
responseBody: "hello",
114+
});
115+
116+
const entries = state.getFetchRequests();
117+
expect(entries.map((e) => e.id)).toEqual(["a", "b"]);
118+
expect(entries[0]?.responseBody).toBeUndefined();
119+
expect(entries[1]?.responseBody).toBe("hello");
120+
expect(seen).toHaveLength(1);
121+
});
122+
123+
it("ignores fetchRequestBodyUpdate for unknown ids", () => {
124+
client.dispatchTypedEvent("fetchRequest", entry("a"));
125+
let changes = 0;
126+
state.addEventListener("fetchRequestsChange", () => changes++);
127+
client.dispatchTypedEvent("fetchRequestBodyUpdate", {
128+
id: "nonexistent",
129+
responseBody: "x",
130+
});
131+
expect(changes).toBe(0);
132+
});
133+
105134
it("does NOT clear on connect or disconnect", () => {
106135
client.dispatchTypedEvent("fetchRequest", entry("a"));
107136
client.dispatchTypedEvent("connect");

0 commit comments

Comments
 (0)