feat(network): add Network screen surfacing backend fetch log (#1355) (#1363)

* 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:
Cliff Hall 2026-05-27 08:35:25 -04:00 • committed by GitHub
commit 4641339cc3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1891 additions and 67 deletions

View file

@ -1,7 +1,36 @@
import type { FetchRequestEntryBase } from "./types.js";
/**
* Whether a response represents an unbounded (long-lived) HTTP stream
* whose body cannot be cloned + read to completion. The streamable HTTP
* spec uses `GET` + `text/event-stream` for the long-lived server-push
* channel; `POST` SSE replies are bounded (server closes after the
* JSON-RPC response) and therefore safe to capture. Shared between the
* fetch tracker (where it decides whether to read the body) and the
* Network UI (where it decides which placeholder to show).
*/
export function isLongLivedStreamResponse(
method: string,
contentType: string | null | undefined,
): boolean {
if (method !== "GET") return false;
if (!contentType) return false;
return (
contentType.includes("text/event-stream") ||
contentType.includes("application/x-ndjson")
);
}
export interface FetchTrackingCallbacks {
trackRequest?: (entry: FetchRequestEntryBase) => void;
/**
* Called after the response body has been read asynchronously. Lets the
* consumer patch the already-dispatched entry with the body without
* blocking the transport on body reading. Fires only on success — if the
* body couldn't be read (long-lived stream, clone failure), this is
* never invoked and the entry's responseBody stays undefined.
*/
updateResponseBody?: (id: string, responseBody: string) => void;
}
/**
@ -99,37 +128,26 @@ export function createFetchTracker(
responseHeaders[key] = value;
});
// Check if this is a streaming response - if so, skip body reading entirely
// For streamable-http POST requests to /mcp, the response is always a stream
// that the transport needs to consume, so we should never try to read it
const contentType = response.headers.get("content-type");
const isStream =
contentType?.includes("text/event-stream") ||
contentType?.includes("application/x-ndjson") ||
(method === "POST" && url.includes("/mcp"));
// Skip body reading only for *long-lived* streams. On streamable HTTP,
// GET /mcp opens an unbounded SSE channel for server-to-client pushes
// — calling `.text()` on a clone of that would buffer forever. POST
// responses with the same content-type are bounded: the server emits
// the JSON-RPC reply (sometimes preceded by progress events) and
// closes the connection, so cloning + reading is safe and gives the
// user the raw SSE payload they were missing.
const isLongLivedStream = isLongLivedStreamResponse(
method,
response.headers.get("content-type"),
);
let responseBody: string | undefined;
let duration: number;
const duration = Date.now() - startTime;
if (isStream) {
// For streams, don't try to read the body - just record metadata and return immediately
// The transport needs to consume the stream, so we can't clone/read it
duration = Date.now() - startTime;
} else {
// For regular responses, try to read the body (clone so we don't consume it)
if (response.body && !response.bodyUsed) {
try {
const cloned = response.clone();
responseBody = await cloned.text();
} catch {
// Can't read body (might be consumed, not readable, or other issue)
responseBody = undefined;
}
}
duration = Date.now() - startTime;
}
// Create entry and track it
// Create entry and track it immediately. The body is read asynchronously
// below to avoid blocking the transport — for streaming responses (POST
// + SSE), the server keeps the connection open until it has delivered
// every progress notification plus the final reply, so awaiting
// `.text()` here would force the transport to wait for all events
// before it could process any of them.
const entry: FetchRequestEntryBase = {
id,
timestamp,
@ -140,12 +158,34 @@ export function createFetchTracker(
responseStatus,
responseStatusText,
responseHeaders,
responseBody,
responseBody: undefined,
duration,
};
callbacks.trackRequest?.(entry);
// Kick off a fire-and-forget read of the cloned body. The clone is an
// independent tee'd stream so the transport keeps consuming the
// original at its own pace. When the read resolves we patch the entry
// via `updateResponseBody`. Skipped for long-lived streams (GET +
// SSE / ndjson) because `.text()` would never resolve on those.
if (!isLongLivedStream && response.body && !response.bodyUsed) {
try {
const cloned = response.clone();
cloned
.text()
.then((body) => {
callbacks.updateResponseBody?.(id, body);
})
.catch(() => {
// Stream errored after clone — leave the body undefined.
});
} catch {
// Clone failed (consumed body, transport quirks). Leave body
// undefined; the entry is already dispatched.
}
}
return response;
};
}

View file

@ -309,6 +309,13 @@ export class InspectorClient extends InspectorClientEventTarget {
private buildEffectiveAuthFetch(): typeof fetch {
const base = this.fetchFn ?? fetch;
// Note: we deliberately do NOT wire `updateResponseBody` for the auth
// fetcher. OAuth token-exchange responses contain `access_token` and
// `refresh_token`; capturing them into FetchRequestLogState would
// surface live credentials in the Network tab body preview, which is
// easy to leak during a screen-share. Headers + status are still
// tracked. If a future need calls for inspecting auth bodies, add
// explicit secret redaction first.
return createFetchTracker(base, {
trackRequest: (entry) =>
this.dispatchFetchRequest({ ...entry, category: "auth" }),
@ -569,6 +576,9 @@ export class InspectorClient extends InspectorClientEventTarget {
onFetchRequest: (entry: FetchRequestEntryBase) => {
this.dispatchFetchRequest({ ...entry, category: "transport" });
},
onFetchResponseBody: (id: string, body: string) => {
this.dispatchFetchRequestBodyUpdate(id, body);
},
...(this.serverSettings && { settings: this.serverSettings }),
};
const oauthManager = this.oauthManager;
@ -1943,6 +1953,13 @@ export class InspectorClient extends InspectorClientEventTarget {
this.dispatchTypedEvent("fetchRequest", entry);
}
private dispatchFetchRequestBodyUpdate(
id: string,
responseBody: string,
): void {
this.dispatchTypedEvent("fetchRequestBodyUpdate", { id, responseBody });
}
/**
* Get current session ID (from OAuth state authId)
*/

View file

@ -58,6 +58,8 @@ export interface InspectorClientEventMap {
message: MessageEntry;
stderrLog: StderrLogEntry;
fetchRequest: FetchRequestEntry;
/** Fired when an in-flight fetch's response body is read asynchronously. */
fetchRequestBodyUpdate: { id: string; responseBody: string };
error: Error;
resourceUpdated: { uri: string };
progressNotification: Progress & { progressToken?: ProgressToken };

View file

@ -43,6 +43,7 @@ export function createTransportNode(
onStderr,
pipeStderr = false,
onFetchRequest,
onFetchResponseBody,
authProvider,
settings,
} = options;
@ -80,7 +81,10 @@ export function createTransportNode(
const sseFetch =
(sseConfig.eventSourceInit?.fetch as typeof fetch) || baseFetch;
const trackedFetch = onFetchRequest
? createFetchTracker(sseFetch, { trackRequest: onFetchRequest })
? createFetchTracker(sseFetch, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
})
: sseFetch;
const headers = headersFromSettings(settings);
@ -97,7 +101,10 @@ export function createTransportNode(
};
const postFetch = onFetchRequest
? createFetchTracker(baseFetch, { trackRequest: onFetchRequest })
? createFetchTracker(baseFetch, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
})
: baseFetch;
const transport = new SSEClientTransport(url, {
@ -121,7 +128,10 @@ export function createTransportNode(
};
const transportFetch = onFetchRequest
? createFetchTracker(baseFetch, { trackRequest: onFetchRequest })
? createFetchTracker(baseFetch, {
trackRequest: onFetchRequest,
updateResponseBody: onFetchResponseBody,
})
: baseFetch;
const transport = new StreamableHTTPClientTransport(url, {

View file

@ -57,6 +57,7 @@ export function createRemoteTransport(
fetchFn: options.fetchFn,
onStderr: transportOptions.onStderr,
onFetchRequest: transportOptions.onFetchRequest,
onFetchResponseBody: transportOptions.onFetchResponseBody,
authProvider: transportOptions.authProvider,
settings: transportOptions.settings,
},

View file

@ -97,6 +97,13 @@ export class RemoteSession {
});
}
onFetchResponseBody(id: string, responseBody: string): void {
this.pushEvent({
type: "fetch_request_body_update",
data: { id, responseBody },
});
}
onStderr(entry: { timestamp: Date; message: string }): void {
this.pushEvent({
type: "stdio_log",

View file

@ -477,6 +477,8 @@ export function createRemoteApp(
pipeStderr: true,
onStderr: (entry) => session.onStderr(entry),
onFetchRequest: (entry) => session.onFetchRequest(entry),
onFetchResponseBody: (id, body) =>
session.onFetchResponseBody(id, body),
authProvider,
settings: body.settings,
});

View file

@ -35,6 +35,9 @@ export interface RemoteTransportOptions {
/** Callback for fetch request tracking (forwarded via remote) */
onFetchRequest?: (entry: FetchRequestEntryBase) => void;
/** Callback for async response-body updates to a previously tracked fetch. */
onFetchResponseBody?: (id: string, responseBody: string) => void;
/** Optional OAuth client provider for Bearer authentication */
authProvider?: import("@modelcontextprotocol/sdk/client/auth.js").OAuthClientProvider;
@ -243,6 +246,14 @@ export class RemoteClientTransport implements Transport {
? new Date(entry.timestamp)
: entry.timestamp,
});
} else if (
parsed.type === "fetch_request_body_update" &&
this.options.onFetchResponseBody
) {
this.options.onFetchResponseBody(
parsed.data.id,
parsed.data.responseBody,
);
} else if (parsed.type === "stdio_log" && this.options.onStderr) {
this.options.onStderr({
timestamp: new Date(parsed.data.timestamp),

View file

@ -40,6 +40,7 @@ export interface RemoteSendRequest {
export type RemoteEventType =
| "message"
| "fetch_request"
| "fetch_request_body_update"
| "stdio_log"
| "transport_error";
@ -53,6 +54,11 @@ export interface RemoteEventFetchRequest {
data: FetchRequestEntryBase;
}
export interface RemoteEventFetchRequestBodyUpdate {
type: "fetch_request_body_update";
data: { id: string; responseBody: string };
}
export interface RemoteEventStdioLog {
type: "stdio_log";
data: { timestamp: string; message: string };
@ -69,5 +75,6 @@ export interface RemoteEventTransportError {
export type RemoteEvent =
| RemoteEventMessage
| RemoteEventFetchRequest
| RemoteEventFetchRequestBodyUpdate
| RemoteEventStdioLog
| RemoteEventTransportError;

View file

@ -76,6 +76,29 @@ export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateE
};
this.client.addEventListener("fetchRequest", onFetchRequest);
// Body is read asynchronously by fetchTracking and arrives via a
// separate event; patch the matching entry in place and re-emit so
// React subscribers re-render with the body filled in.
const onFetchRequestBodyUpdate = (
event: TypedEventGeneric<
InspectorClientEventMap,
"fetchRequestBodyUpdate"
>,
): void => {
const { id, responseBody } = event.detail;
const idx = this.fetchRequests.findIndex((e) => e.id === id);
if (idx === -1) return;
this.fetchRequests[idx] = {
...this.fetchRequests[idx]!,
responseBody,
};
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
};
this.client.addEventListener(
"fetchRequestBodyUpdate",
onFetchRequestBodyUpdate,
);
const sessionStorage = options.sessionStorage;
const sessionId = options.sessionId;
@ -97,6 +120,10 @@ export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateE
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("fetchRequest", onFetchRequest);
this.client.removeEventListener(
"fetchRequestBodyUpdate",
onFetchRequestBodyUpdate,
);
this.client.removeEventListener("saveSession", onSaveSession);
}
this.client = null;
@ -119,6 +146,10 @@ export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateE
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("fetchRequest", onFetchRequest);
this.client.removeEventListener(
"fetchRequestBodyUpdate",
onFetchRequestBodyUpdate,
);
}
this.client = null;
};

View file

@ -392,6 +392,14 @@ export interface CreateTransportOptions {
*/
onFetchRequest?: (entry: FetchRequestEntryBase) => void;
/**
* Optional callback fired asynchronously when a previously tracked
* fetch's response body has been read. Lets the consumer update the
* already-dispatched entry without blocking the transport on body
* reading (critical for SSE responses that include progress events).
*/
onFetchResponseBody?: (id: string, responseBody: string) => void;
/**
* Optional OAuth client provider for Bearer authentication (SSE, streamable-http).
* When set, the SDK injects tokens and handles 401 via the provider.