inspector/core/mcp/fetchTracking.ts
Cliff Hall d552564966
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Network: modern Mcp-* headers, new error codes, cancellation-as-abort (#1628) (#1722)
* Network: modern Mcp-* headers, new error codes, cancellation-as-abort (#1628)

Upgrade the Network tab for the modern Streamable HTTP transport
(SEP-2243 / SEP-2575):

- Recognise and highlight the mirrored headers (Mcp-Method, Mcp-Name,
  Mcp-Param-*, MCP-Protocol-Version), decode =?base64?…?= sentinel values
  inline, and cross-check each against the request body so a HeaderMismatch
  is visible before the server rejects it.
- Show the new spec error codes distinctly: -32020 HeaderMismatch,
  -32021 MissingRequiredClientCapability, -32022 UnsupportedProtocolVersion
  (with the server-advertised `supported` versions), and modern -32601
  MethodNotFound on HTTP 404 (distinguished from a legacy bare 404 by its
  JSON-RPC body).
- Reflect cancellation as a connection abort ("Cancelled") rather than a
  hard error, per SEP §7.5.
- Confirm and document browser-path header mirroring: the SDK builds the
  Mcp-* headers client-side and the /api/fetch proxy forwards them verbatim
  (new integration test).

New pure util `mcpNetworkHeaders.ts` holds the recognition/decode/classify
logic; new `McpErrorBadge` element renders the distinct error chips. The
optional raw-request controls (cacheMode bypass/refresh, single-page fetch)
are split to #1721 — they are cross-cutting core + feature-tab work beyond
the Network tab.

Also refreshes the AGENTS.md board Status option-ID table (the Building /
MCP Apps Extension columns were removed).

Closes #1628

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network smoke-test showcase + fix redaction clobbering JSON-RPC error codes

Adds a composable "modern-network" showcase server for exercising the #1628
Network-tab work end-to-end, and fixes a real bug the smoke test surfaced.

Bug fix (core): the fetch-tracking body redaction masked ANY field named
`code` (targeting the OAuth authorization code), including the numeric
JSON-RPC `error.code`. That destroyed the field the Network tab classifies
the modern spec errors on, so -32020/-32021/-32022/-32601 never rendered
against real traffic. Redaction now only masks STRING values of a sensitive
field (secrets are always strings; a numeric code is never one).

Test-server showcase (test-servers): `modern-network-http.json` serves a
`get_weather` tool with an `x-mcp-header: "City"` annotation plus four
`trigger_*` tools. A new `transport.modern.injectSpecErrors` flag installs a
middleware on the modern leg that answers the triggers with real HTTP status
+ JSON-RPC error bodies (400/-32020, 400/-32021, 400/-32022 w/ `supported`,
404/-32601), so the Inspector's error rendering can be driven live.

Mirroring finding (verified live, comment corrected in createRemoteFetch.ts):
the SDK builds the STANDARD mirrored headers (Mcp-Method / Mcp-Name /
MCP-Protocol-Version) unconditionally — they reach the server through the
web proxy — but gates `Mcp-Param-*` on a non-browser environment
(`detectProbeEnvironment() !== "browser"`), so the web client never emits
them. An x-mcp-header tool is therefore uncallable from the web client
against a strict modern server (it answers -32020); it works from the Node
CLI/TUI. The earlier "mirroring works through the proxy" note was corrected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: move the spec-error badge off the crowded compact top row

In the narrow monitoring sidebar (embedded layout) the McpErrorBadge shared
the top row with the timestamp/method/category/duration/status chips, which
truncated the method and category ("PO…", "TRANSP…") and clipped the error
name. Render the badge separately from `metaBadges` and place it on the
second row, to the right of the URL, so the top row stays readable and the
error name shows in full. The full-width layout keeps it in the top-right
cluster (where there's room).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: keep the spec error name's casing in McpErrorBadge (no uppercase)

The four spec/SDK error identifiers are PascalCase (`HeaderMismatch`,
`MissingRequiredClientCapability`, `UnsupportedProtocolVersion`,
`MethodNotFound`). Mantine's Badge uppercases label text by default, which
flattened them to a hard-to-read run-together string
("UNSUPPORTEDPROTOCOLVERSION"). Set `tt="none"` so the badge shows the
spec's own casing with word boundaries intact; short badges (POST, status)
keep the default uppercase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: in the full-width layout, move the spec-error badge to the second row

The full-width entry kept the McpErrorBadge in the top-right cluster, where a
long spec name (e.g. "MissingRequiredClientCapability") crowded the top row
and truncated the method chips and URL. Move it to the start of the second
row (the expand-toggle row) so the top row stays readable; the toggle stays
at the end (`space-between` only when the badge is present). The compact
sidebar already places it on its second row beside the URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: show Replay as the right-side icon in the wide layout too

The compact (sidebar) ProtocolEntry already renders Replay as an icon next to
the pin/expand icons; the wide layout used a left-aligned "Replay" text
button instead. Make the wide layout match — Replay as the ReplayButton icon
at the start of the right-side action cluster (Replay, Pin, Expand) — so the
two layouts are consistent. Drops the now-unused SubtleButton constant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Move the modern spec-error chip + alert from the Network tab to the Protocol tab

The Network tab is focused on the HTTP transaction; the distinct protocol-error
taxonomy (-32020/-32021/-32022/-32601) belongs with the protocol messages. So:

- ProtocolEntry now renders the McpErrorBadge chip on the top line (right after
  the direction badge, both layouts) plus an explanatory alert in the expanded
  detail, classified by JSON-RPC error code via `classifyProtocolSpecError`.
- NetworkEntry drops the chip + alert; it keeps the HTTP-level features (status
  badge, Mcp-* header highlight/sentinel-decode, header/body mismatch marker,
  cancellation-as-abort).
- The SDK delivers -32020/21/22 (HTTP 400) as JSON-RPC error responses, so they
  reach the Protocol log directly. -32601 (HTTP 404) and other non-enveloped
  errors are *thrown* — the request stays pending. `enrichProtocolEntries`
  (App) correlates the Protocol log to the Network log by JSON-RPC id (parsed
  from the transport fetch's requestBody) and folds the thrown error onto its
  pending request, so it too appears in Protocol. This is a derived view; the
  underlying log is not mutated.

Verified live against the modern-network showcase: both a -32020 error response
and a thrown -32601 render distinctly in the Protocol tab.

Follow-up: a "reveal in Network" link on the Protocol alert (cross-tab
scroll-to + expand of the correlated HTTP entry).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: show the spec-error chip only in the wide layout, after the method

Refine the placement of the spec-error chip added in the previous commit:
- Wide layout: move it from after the direction badge to right after the
  method chip (reads as "tools/call  -32020 HeaderMismatch").
- Compact sidebar row: drop the chip entirely — the two-line row stays
  uncluttered and relies on its ERROR status badge. The explanatory alert
  still appears in the expanded detail in both layouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: "reveal in Network" link — jump from a spec error to its HTTP entry

Adds the cross-tab navigation that was the follow-up to relocating the
spec-error rendering. The expanded Protocol alert now shows a "View the HTTP
request in the Network tab" link when the entry has a correlated Network
request; clicking it:

- correlates the Protocol message to its transport fetch by JSON-RPC id
  (`revealableMessageIds` gates the link; `correlateFetchEntry` finds the
  target on click),
- clears whatever filter would hide the target (network filterText +
  category, and the embedded monitorSearch),
- switches to the Network view in whichever mode is active — the pinned
  column (`pinMonitor`) when the sidebar is open, or the full-screen header
  tab (`onActiveTabChange`) when it's closed,
- scrolls the matching NetworkEntry into view and force-expands it.

The reveal is a one-shot: a new `revealId` threads NetworkScreen →
NetworkStreamPanel → NetworkEntry, where the targeted entry scrolls itself
into view (rAF, after `useScrollMemory`'s restore) + expands, then clears the
signal via `onRevealComplete`. Verified live in both the pinned-sidebar and
closed/full-screen layouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: only show the "reveal in Network" link when it adds value

Gate the link on a new `httpRelevant` flag: show it only when the error is
either thrown by the SDK (its real HTTP response lives only in the Network
log — the Protocol entry is a synthetic fold) OR tied to the HTTP request
headers. A delivered, protocol-only error gets no link, since the raw HTTP
entry adds nothing over the alert.

- -32020 HeaderMismatch  → link (the mirrored headers are the problem)
- -32601 MethodNotFound  → link (thrown; the 404 response is only in Network)
- -32021 MissingRequiredClientCapability → no link (delivered, protocol-only)
- -32022 UnsupportedProtocolVersion      → no link (supported list is in the alert)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: cite SEP-2575 for cancellation-as-abort, not the internal doc's §7.5

The cancellation note referenced "(SEP §7.5)", but §7.5 is a section of the
internal v2_new_spec_impact.md doc, not a SEP. The behavior — closing the
Streamable HTTP request stream is the cancellation signal; notifications/cancelled
is stdio-only — is defined in SEP-2575 (stateless-mcp). Cite SEP-2575 in the
user-facing alert and the two related comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: fix reveal one-shot canceling its own scroll; review doc/comment nits

Address @claude review of #1722:
- Reveal one-shot: move onRevealComplete() inside the rAF (after scrollIntoView)
  so clearing revealId (which flips revealed->false and runs the effect cleanup)
  can no longer race/cancel the pending scroll. Add a flip-back test.
- Comment the intentional double-mount of revealId (pinned monitor + full-screen
  ScreenStage both match; clear is idempotent).
- Doc drift after the Network->Protocol relocation: McpErrorBadge + the
  mcpNetworkHeaders module header now say the spec-error chip lives in Protocol.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Network: clarify redaction array-of-scalars edge + revealableIds superset

Address second @claude review of #1722 (both non-blocking polish notes):
- fetchTracking redaction: note that masking assumes a sensitive value is a
  scalar or object; an array-of-scalars under a sensitive key would slip through
  (no OAuth/token payload has that shape).
- revealableMessageIds: document it's a superset of the linked entries; the
  spec-error/httpRelevant narrowing is applied downstream at render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: gate modern -32601 framing to a genuine 404, not in-band 200

Address third @claude review of #1722. classifyProtocolSpecError recognised
-32601 from the code alone, so an ordinary in-band 'method not found' returned
on HTTP 200 was dressed up as the modern MethodNotFound taxonomy: the spec
badge, a 404-asserting alert, and a 'reveal in Network' link that jumped to a
plain 200 (claiming diagnostic value that wasn't there).

Gate the modern -32601 treatment on the correlated fetch actually being a 404
(mirroring classifyMcpSpecError). -32020/-32021/-32022 are SEP-reserved and
unaffected. The correlated HTTP status is threaded from InspectorView (new
correlatedFetchStatusById) through ProtocolScreen/ProtocolListPanel to
ProtocolEntry; when unknown, -32601 falls back to code-only recognition (the
thrown-404 path always carries a 404). Badge, alert copy, and link now resolve
together, so a plain in-band -32601 falls through to ordinary error rendering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* Protocol: require a genuine 404 for modern -32601 (drop unknown-status fallback)

Address fourth @claude review of #1722. The prior fix only rejected -32601 when
the status was *known* and not 404; an unknown status fell back to code-only
recognition. But the thrown-404 path always carries a 404 (folded in from a 404
fetch), so the unknown-status branch is only ever reached by an ordinary in-band
-32601 with no correlated 404 — most notably a stdio connection (no HTTP at all)
— which then wrongly got the modern MethodNotFound badge and 404-asserting copy.

Drop the escape hatch: -32601 is the modern taxonomy only on httpStatus === 404.
Nothing intended is lost (the genuine case always has its 404). Update the tests
(code-only/undefined -32601 now expects null; add a stdio no-correlated-fetch
ProtocolEntry case) and mark the MethodNotFound story with correlatedHttpStatus
404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:32:05 -04:00

411 lines
15 KiB
TypeScript

import type { FetchRequestEntryBase } from "./types.js";
/**
* Header names whose values are replaced with `REDACTED_HEADER_VALUE` before a
* fetch entry is recorded. The recorded entry flows to the in-memory log, the
* pino logger, and (via session storage) to disk — none of those sinks should
* ever see a live bearer token or session cookie.
*/
const SENSITIVE_HEADERS: ReadonlySet<string> = new Set([
"authorization",
"cookie",
"set-cookie",
"proxy-authorization",
"x-api-key",
// The inspector backend's own bearer (createRemoteFetch stamps this on every
// proxied request); same exposure as Authorization.
"x-mcp-remote-auth",
]);
/** Placeholder substituted for sensitive header values in recorded entries. */
export const REDACTED_HEADER_VALUE = "[REDACTED]";
/**
* Field / query-parameter names whose values are masked in a recorded fetch
* entry's request body, response body, and URL query string. These are the
* credentials that ride in OAuth token exchanges (and similar flows): the
* header slice masks `Authorization`, but the same secrets show up verbatim in
* the form/JSON body (`client_secret`, `code`, `refresh_token`, …) and are
* sometimes carried as URL query params. Matching is case-insensitive.
*/
const SENSITIVE_BODY_FIELDS: ReadonlySet<string> = new Set([
"client_secret",
"code",
"refresh_token",
"access_token",
"id_token",
"code_verifier",
"client_assertion",
"assertion",
"password",
"token",
]);
/**
* Placeholder substituted for sensitive body / URL values in recorded entries.
* Deliberately kept separate from {@link REDACTED_HEADER_VALUE} (even though both
* are `"[REDACTED]"` today) so the header and body/URL redaction paths can evolve
* their sentinels independently.
*/
export const REDACTED_VALUE = "[REDACTED]";
/** Whether `name` (any casing) is a known-sensitive field / query-param name. */
function isSensitiveField(name: string): boolean {
return SENSITIVE_BODY_FIELDS.has(name.toLowerCase());
}
/**
* Returns a copy of `headers` with every {@link SENSITIVE_HEADERS} value
* replaced by {@link REDACTED_HEADER_VALUE}. Comparison is case-insensitive
* (HTTP header names are case-insensitive); the original casing of every key is
* preserved so the recorded entry still shows what the client actually sent.
*/
export function redactSensitiveHeaders(
headers: Record<string, string>,
): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
out[key] = SENSITIVE_HEADERS.has(key.toLowerCase())
? REDACTED_HEADER_VALUE
: value;
}
return out;
}
/**
* Returns `url` with every {@link SENSITIVE_BODY_FIELDS} query-parameter value
* replaced by {@link REDACTED_VALUE}. The path and non-sensitive params stay
* readable. Best-effort: if the URL (or its query string) can't be parsed the
* original string is returned unchanged. Only the recorded copy is redacted —
* the live request still uses the original `input`/`init`.
*/
export function redactUrlQuery(url: string): string {
const queryStart = url.indexOf("?");
if (queryStart === -1) return url;
const base = url.slice(0, queryStart);
const afterQuery = url.slice(queryStart + 1);
// Preserve a trailing fragment (#…) untouched — it never carries query params.
const hashStart = afterQuery.indexOf("#");
const query = hashStart === -1 ? afterQuery : afterQuery.slice(0, hashStart);
const fragment = hashStart === -1 ? "" : afterQuery.slice(hashStart);
try {
const params = new URLSearchParams(query);
let changed = false;
for (const key of new Set(params.keys())) {
if (isSensitiveField(key)) {
changed = true;
// Collapse repeated occurrences to a single redacted value.
params.set(key, REDACTED_VALUE);
}
}
if (!changed) return url;
return `${base}?${params.toString()}${fragment}`;
} catch {
return url;
}
}
/** Recursively redact sensitive keys in a parsed JSON value (in place). */
function redactJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(redactJsonValue);
}
if (value !== null && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
// Only STRING values of a sensitive-named field are masked. The secrets
// this targets (OAuth `code`, `access_token`, `client_secret`, …) are
// always strings; a non-string is never one of them. This is important
// for JSON-RPC bodies, whose numeric `error.code` (e.g. -32020) collides
// with the OAuth authorization-`code` name — masking it would destroy the
// very field the Network tab classifies the modern spec errors on.
//
// Assumes a sensitive value is a scalar or an object (recursed, so an inner
// sensitive string key is still masked). A sensitive key whose value is an
// *array of scalars* (e.g. `{ password: ["a", "b"] }`) would recurse
// element-by-element with no key context and slip through — no OAuth/token
// payload has that shape, so it is not handled.
out[key] =
isSensitiveField(key) && typeof val === "string"
? REDACTED_VALUE
: redactJsonValue(val);
}
return out;
}
return value;
}
/**
* Returns `body` with every {@link SENSITIVE_BODY_FIELDS} value masked, for
* `application/x-www-form-urlencoded` and JSON payloads. The surrounding shape
* (field order, non-sensitive fields, JSON structure) is preserved — only the
* values change. Best-effort and never throws: an empty, non-string, or
* unparseable body is returned unchanged. Only the recorded copy is redacted;
* the live request body is never touched.
*
* Scope is deliberately limited to `application/x-www-form-urlencoded` and JSON:
* these cover the OAuth token flows this redaction targets. `multipart/form-data`
* (and other binary/opaque bodies) are passed through verbatim — OAuth never uses
* multipart, so the risk is low; revisit if a multipart secret path appears.
*/
export function redactBody(
body: string | undefined,
contentType: string | null | undefined,
): string | undefined {
if (!body) return body;
const type = (contentType ?? "").toLowerCase();
// Form-encoded bodies (the OAuth token endpoint's request format).
if (type.includes("application/x-www-form-urlencoded")) {
try {
const params = new URLSearchParams(body);
let changed = false;
for (const key of new Set(params.keys())) {
if (isSensitiveField(key)) {
changed = true;
params.set(key, REDACTED_VALUE);
}
}
return changed ? params.toString() : body;
} catch {
return body;
}
}
// JSON bodies — either explicitly typed, or (when the content-type is
// missing/other) any string that parses as a JSON object/array. A bare
// JSON scalar has no field names, so it can't carry a sensitive key.
try {
const parsed: unknown = JSON.parse(body);
if (parsed !== null && typeof parsed === "object") {
return JSON.stringify(redactJsonValue(parsed));
}
} catch {
// Not JSON — fall through and leave as-is.
}
return body;
}
/** Case-insensitive lookup of a header value from a plain header record. */
function findHeader(
headers: Record<string, string>,
name: string,
): string | undefined {
const target = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === target) return value;
}
return undefined;
}
/**
* 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;
}
/**
* Creates a fetch wrapper that tracks HTTP requests and responses
*/
export function createFetchTracker(
baseFetch: typeof fetch,
callbacks: FetchTrackingCallbacks,
): typeof fetch {
return async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const startTime = Date.now();
const timestamp = new Date();
const id = `${timestamp.getTime()}-${Math.random().toString(36).slice(2, 11)}`;
// Extract request information
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
const method = init?.method || "GET";
// Extract headers, redacting sensitive values BEFORE they reach any
// downstream sink (logger, in-memory list, persisted session storage).
const rawRequestHeaders: Record<string, string> = {};
if (input instanceof Request) {
input.headers.forEach((value, key) => {
rawRequestHeaders[key] = value;
});
}
if (init?.headers) {
const headers = new Headers(init.headers);
headers.forEach((value, key) => {
rawRequestHeaders[key] = value;
});
}
const requestHeaders = redactSensitiveHeaders(rawRequestHeaders);
const requestContentType = findHeader(rawRequestHeaders, "content-type");
// Redact sensitive query params in the recorded URL (live `input` is
// untouched — only this logged copy is masked).
const redactedUrl = redactUrlQuery(url);
// Extract body (if present and readable)
let requestBody: string | undefined;
if (init?.body) {
if (typeof init.body === "string") {
requestBody = init.body;
} else {
// Try to convert to string, but skip if it fails (e.g., ReadableStream)
try {
requestBody = String(init.body);
} catch {
requestBody = undefined;
}
}
} else if (input instanceof Request && input.body) {
// Try to clone and read the request body
// Clone protects the original body from being consumed
try {
const cloned = input.clone();
requestBody = await cloned.text();
} catch {
// Can't read body (might be consumed, not readable, or other issue)
requestBody = undefined;
}
}
// Redact sensitive fields in the recorded request body. The live request
// body (`init.body` / `input`) is never touched — only this logged string.
const redactedRequestBody = redactBody(requestBody, requestContentType);
// Make the actual fetch request
let response: Response;
let error: string | undefined;
try {
response = await baseFetch(input, init);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
// Create a minimal error entry
const entry: FetchRequestEntryBase = {
id,
timestamp,
method,
url: redactedUrl,
requestHeaders,
requestBody: redactedRequestBody,
error,
duration: Date.now() - startTime,
};
callbacks.trackRequest?.(entry);
throw err;
}
// Extract response information
const responseStatus = response.status;
const responseStatusText = response.statusText;
// Extract response headers (redacted — Set-Cookie etc. are credentials too)
const rawResponseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
rawResponseHeaders[key] = value;
});
const responseHeaders = redactSensitiveHeaders(rawResponseHeaders);
// 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"),
);
const duration = Date.now() - startTime;
// 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,
method,
url: redactedUrl,
requestHeaders,
requestBody: redactedRequestBody,
responseStatus,
responseStatusText,
responseHeaders,
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) {
const responseContentType = response.headers.get("content-type");
try {
const cloned = response.clone();
cloned
.text()
.then((body) => {
// Mask token-endpoint secrets (access_token, refresh_token, …)
// before the body reaches any sink.
callbacks.updateResponseBody?.(
id,
redactBody(body, responseContentType) ?? 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;
};
}