feat(network): add Network screen surfacing backend fetch log (#1355)#1363
feat(network): add Network screen surfacing backend fetch log (#1355)#1363cliffhall wants to merge 7 commits into
Conversation
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>
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>
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>
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>
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>
|
@claude review |
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
1 similar comment
|
Claude encountered an error after 0s —— View job I'll analyze this and get back to you. |
|
Claude finished @cliffhall's task in 3m 31s —— View job Code ReviewSolid PR overall — the new Network screen is well-structured, tests are thorough, and the async body-capture plumbing through the remote-transport layer is cleanly threaded. A few things worth considering before merge. High-signal findings1. OAuth response bodies are now captured and rendered
Pre-PR these were silently dropped (the body was undefined). Post-PR a developer screen-sharing the Inspector can leak live tokens by clicking Expand on an auth entry. Two options worth considering:
Probably acceptable for a local inspector tool, but worth a deliberate decision rather than a side-effect of the body-capture refactor. 2. useEffect(() => {
setIsExpanded(isListExpanded);
}, [isListExpanded]);This is the classic "derive state from prop via effect" anti-pattern. When the user manually expands a single entry, then the parent flips the list-level toggle to the same value the entry already had, the user's intent is silently discarded on the next list-level flip. It's likely the intended behavior (the list-level toggle is supposed to be authoritative), but the implementation also resets state in the no-op case where
3. Duplicated Two copies of the same rule that must stay in lockstep. The comment in Lower-priority observations4. 5. Long-lived stream rule is GET-only. The PR description correctly explains why bounded POST SSE is safe to clone, but if a buggy/malicious server returns SSE on POST without closing, 6. 7. Fragile test selector — 8. Plumbing reviewThe fetch-body-update plumbing is consistent end-to-end:
Tests / coverage
Looks good — tests cover the new event end-to-end and the new placeholder branches. SummaryNo blockers, but the OAuth-body-visibility consideration (finding 1) is worth a deliberate decision rather than letting it ride. Findings 2 + 3 are cleanup items that would make future refactors safer.
|
- 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>
|
Thanks for the review! Responses inline, addressed in 80a2bc1. #1 OAuth response bodies are captured — Fixed. Removed #2 NetworkEntry useEffect anti-pattern — Documented. Added a comment explaining the parent's list-level toggle is authoritative and per-entry overrides are intentionally discarded on each list toggle. Mirrors HistoryEntry (which has the same pattern). Didn't switch to #3 Duplicated isLongLivedStream — Fixed. Extracted #4 Cross-field substring match in search — Fixed. Switched from joined-string #5 POST SSE memory leak from buggy server — Noted, deferred. Local-only inspector + browser GC will eventually reclaim if the response goes out of scope; if real servers start mis-behaving we can add an #6 MAX_INLINE_BODY_BYTES naming — Fixed. Renamed to #7 Fragile ListToggle selector — Fixed. Added #8 Magic 150px in ScrollArea — Skipped. Matches the existing pattern in HistoryListPanel and LogStreamPanel; tweaking one without the others would be inconsistent and the value is a layout-chrome budget that doesn't really belong as a public token. Happy to extract across all three panels in a separate PR if you'd prefer. |
|
@claude review |
|
Claude finished @cliffhall's task in 1m 41s —— View job Follow-up review on
|
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>
|
Both follow-ups applied in 9185fdf. Stale "no accessible name" test selectors — Fixed in ListToggle tests don't assert the aria-label — Fixed. |
|
@claude review |
|
Claude finished @cliffhall's task in 2m 24s —— View job Follow-up review on
|
Summary
Closes #1355.
Adds a new Network tab to the v2 web client that routes the existing
FetchRequestLogStateto a screen. Each entry shows method, URL, status, duration, and category (auth vs transport), with an expandable detail view for request/response headers and body (or a placeholder when the body is unreadable).New surfaces
clients/web/src/components/screens/NetworkScreen/NetworkControls(search + category filter),NetworkStreamPanel(Clear / Export / list),NetworkEntry(per-entry display + expand)ThemeTextwith amonoBreakvariant so long header values wrap inside the table cell without inline stylesApp.tsxnow consumesuseFetchRequestLogand dispatchesonClearNetwork→FetchRequestLogState.clearFetchRequests()andonExportNetwork→ JSON download via the existingdownloadJsonFilehelperSearch filter
The sidebar search matches against method, URL, status code, status text, request/response headers, request body, response body, and error message — case-insensitive.
Response body capture
The fetch tracker used to silently drop the body for every streamable-HTTP POST because the response content-type is
text/event-stream. In practice those streams are bounded — the server sends the JSON-RPC reply (possibly preceded by progress events) and closes — so we can clone the response and read it. OnlyGET+ SSE / ndjson stays uncaptured (genuinely long-lived push channel).Reading the body inline would block the transport from delivering progress notifications in real time (it broke the
resetTimeoutOnProgressintegration test). The body is therefore read in the background and emitted via a newfetchRequestBodyUpdateevent thatFetchRequestLogStatepatches into the matching entry by id. The plumbing flows throughcore/mcp/node/transport,RemoteSession,RemoteClientTransport, and the remote-event type union so it works for both direct (in-browser) and remoted (dev backend) transports.When the body genuinely isn't captured (long-lived GET stream) the entry header gets an orange SSE badge to the left of the status badge, and the Response Body section renders
Long-lived stream — body not captured. Non-streaming responses with no body render(empty). Inline preview limit bumped to 100 KB so typical tools/list responses render rather than tripping the "too large" notice.Test plan
npm run validate(format, lint, build, unit + integration coverage) — 1786 tests passnpm run test:storybook— 320 stories passresetTimeoutOnProgressintegration test (which would have regressed if body reading blocked the transport) passesNetworkScreen/NetworkControls/NetworkStreamPanelat 100%;NetworkEntryat 97% lines / 100% functions