inspector/core/mcp/state/fetchRequestLogState.ts
Bob Dickinson 2488ca5e38
feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452)
Ports the CLI, TUI, and launcher from v1.5 into v2 behind a single
`mcp-inspector` entrypoint (launcher → web/cli/tui). Includes the
`mcp-inspector --web --dev` launcher fix (Vite alias resolution + prod-server
double-start), the root `postinstall` install cascade with simplified CI, and
spec/AGENTS docs (no-workspaces rationale, Development setup).

Closes #1246.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:27:49 -04:00

282 lines
11 KiB
TypeScript

/**
* FetchRequestLogState: holds the fetch request log, subscribes to the protocol
* "fetchRequest" event. Protocol emits per-entry; this manager owns the list and
* emits both `fetchRequest` (single entry) and `fetchRequestsChange` (full list)
* on append, and `fetchRequestsChange` on clear. Does not clear on connect or
* disconnect.
*
* When `sessionStorage` and `sessionId` are provided, restores fetch requests
* from storage on creation and listens for the client's `saveSession` event to
* persist before OAuth redirect.
*
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
* concrete `InspectorClient` since the runtime class is not yet ported.
*/
import { silentLogger, type InspectorLogger } from "../../logging/logger.js";
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
import type { FetchRequestEntry } from "../types.js";
import type {
InspectorClientStorage,
InspectorClientSessionState,
} from "../sessionStorage.js";
import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js";
import {
TypedEventTarget,
type TypedEventGeneric,
} from "../typedEventTarget.js";
/**
* Detail for `fetchRequestBodyDropped`: a deferred response body arrived after
* its log entry had already rotated out (>= `maxFetchRequests` newer requests
* appended in between), so the body was unrecoverable. Carries the dropped
* request id and the cap that was in force, so the UI can explain the drop and
* offer to raise the limit.
*/
export interface FetchRequestBodyDropped {
id: string;
maxFetchRequests: number;
}
export interface FetchRequestLogStateEventMap {
fetchRequest: FetchRequestEntry;
fetchRequestsChange: FetchRequestEntry[];
fetchRequestBodyDropped: FetchRequestBodyDropped;
}
export interface FetchRequestLogStateOptions {
/**
* Maximum number of fetch requests to store (0 = unlimited, not recommended).
* When exceeded, oldest entries are dropped. Default 1000.
*/
maxFetchRequests?: number;
/**
* When provided with sessionId, fetch requests are restored from storage on
* creation and saved when the client dispatches `saveSession` (e.g. before
* OAuth redirect).
*/
sessionStorage?: InspectorClientStorage;
/** Session ID for load/save; required for sessionStorage to have effect. */
sessionId?: string;
/**
* Logger for diagnostic traces (e.g. a deferred body update arriving after
* its entry rotated out of the log). Defaults to the silent logger.
*/
logger?: InspectorLogger;
}
export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateEventMap> {
private fetchRequests: FetchRequestEntry[] = [];
private client: InspectorClientProtocol | null = null;
private unsubscribe: (() => void) | null = null;
private maxFetchRequests: number;
private readonly logger: InspectorLogger;
constructor(
client: InspectorClientProtocol,
options: FetchRequestLogStateOptions = {},
) {
super();
this.maxFetchRequests = options.maxFetchRequests ?? 1000;
this.logger = options.logger ?? silentLogger;
this.client = client;
const onFetchRequest = (
event: TypedEventGeneric<InspectorClientEventMap, "fetchRequest">,
): void => {
const entry = event.detail;
if (
this.maxFetchRequests > 0 &&
this.fetchRequests.length >= this.maxFetchRequests
) {
this.fetchRequests.shift();
}
this.fetchRequests.push(entry);
this.dispatchTypedEvent("fetchRequest", entry);
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
};
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) {
// The deferred body read (fire-and-forget tee'd stream in
// fetchTracking) found no matching entry. Distinguish the two causes:
// a *rotation* drop — the entry was evicted because the log is at
// capacity (>= maxFetchRequests, with a finite cap) — versus a benign
// straggler whose entry was simply cleared (length below the cap, or
// an unlimited log where rotation can't happen). Only the rotation
// case is unexpected data loss worth surfacing; a post-clear straggler
// is expected and stays silent.
const rotatedOut =
this.maxFetchRequests > 0 &&
this.fetchRequests.length >= this.maxFetchRequests;
if (rotatedOut) {
// `warn` (not `debug`) so the trace clears the web remote logger's
// default `info` level and is observable without opt-in.
this.logger.warn(
{ fetchRequestId: id, maxFetchRequests: this.maxFetchRequests },
"fetchRequestBodyUpdate dropped: entry rotated out before body arrived",
);
// Let the UI surface the drop (toast) and offer to raise the cap.
this.dispatchTypedEvent("fetchRequestBodyDropped", {
id,
maxFetchRequests: this.maxFetchRequests,
});
}
return;
}
this.fetchRequests[idx] = {
...this.fetchRequests[idx]!,
responseBody,
};
// Body fill-in re-emits the list event only, not the per-entry
// `fetchRequest` event (that one fires once, on append). Consumers read
// the full list (`useFetchRequests`), so they pick up the body on the
// next list re-render. A future per-entry subscriber wanting incremental
// body updates would need its own event here.
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
};
this.client.addEventListener(
"fetchRequestBodyUpdate",
onFetchRequestBodyUpdate,
);
const sessionStorage = options.sessionStorage;
const sessionId = options.sessionId;
if (sessionStorage) {
// Backstop persistence on the client's `saveSession` event. For the
// OAuth full-page-redirect case the web client's `BrowserNavigation`
// `beforeNavigate` hook is the *primary* flush (it runs synchronously
// before navigation, so a keepalive request survives the unload); this
// listener fires from the same event but can lose the race with an
// already-scheduled navigation. It remains the save path for any
// non-redirect `saveSession` caller (e.g. a future token-refresh save
// point) and is harmless when it duplicates the primary flush —
// last-writer-wins on an identical payload under the same id.
//
// SECURITY TRIPWIRE: captured `auth`-category response bodies in this log
// are stored UNMASKED (masking is a Network-UI display concern only).
// Today the only `saveSession` trigger is the pre-redirect flush, which
// runs before the `/token` exchange — so no bearer token is ever
// persisted. Any NEW `saveSession` trigger added after token exchange
// (e.g. a periodic snapshot) would write `access_token` /
// `refresh_token` to disk. Redact response bodies here before persisting
// if that ever changes.
const onSaveSession = (
event: TypedEventGeneric<InspectorClientEventMap, "saveSession">,
): void => {
const { sessionId: id } = event.detail;
const state: InspectorClientSessionState = {
fetchRequests: this.getFetchRequests(),
createdAt: Date.now(),
updatedAt: Date.now(),
};
sessionStorage.saveSession(id, state).catch(() => {
// fire-and-forget; storage may log internally
});
};
this.client.addEventListener("saveSession", onSaveSession);
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("fetchRequest", onFetchRequest);
this.client.removeEventListener(
"fetchRequestBodyUpdate",
onFetchRequestBodyUpdate,
);
this.client.removeEventListener("saveSession", onSaveSession);
}
this.client = null;
};
if (sessionId) {
sessionStorage
.loadSession(sessionId)
.then((state) => {
if (this.client && state?.fetchRequests?.length) {
this.hydrateFetchRequests(state.fetchRequests);
}
})
.catch(() => {
// fire-and-forget; storage may log internally. Matches the
// saveSession swallow above so a corrupt or unreadable session
// doesn't surface as an unhandled rejection.
});
}
} else {
this.unsubscribe = () => {
if (this.client) {
this.client.removeEventListener("fetchRequest", onFetchRequest);
this.client.removeEventListener(
"fetchRequestBodyUpdate",
onFetchRequestBodyUpdate,
);
}
this.client = null;
};
}
}
// Restore persisted entries (e.g. the pre-redirect OAuth Network log loaded
// on the `/oauth/callback` page). Merges rather than replaces: the async
// load races against entries appended live by the resuming connect
// (`completeOAuthFlow` + transport handshake), so we must not clobber
// whichever arrived first. Restored entries are older, so they go in front;
// duplicates (by id) already present from a live append are skipped.
private hydrateFetchRequests(entries: FetchRequestEntry[]): void {
if (entries.length === 0) return;
const existingIds = new Set(this.fetchRequests.map((e) => e.id));
const restored = entries.filter((e) => !existingIds.has(e.id));
if (restored.length === 0) return;
const merged = [...restored, ...this.fetchRequests];
this.fetchRequests =
this.maxFetchRequests > 0
? merged.slice(-this.maxFetchRequests)
: merged;
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
}
getFetchRequests(): FetchRequestEntry[] {
return [...this.fetchRequests];
}
/**
* Adjust the retention cap live (e.g. when the user edits the per-server
* `maxFetchRequests` setting) without reconnecting. Shrinking the cap trims
* the oldest entries immediately and re-emits so the Network UI updates;
* growing it (or setting 0 = unlimited) just takes effect for future appends.
*/
setMaxFetchRequests(max: number): void {
if (max === this.maxFetchRequests) return;
this.maxFetchRequests = max;
if (max > 0 && this.fetchRequests.length > max) {
this.fetchRequests = this.fetchRequests.slice(-max);
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
}
}
/**
* Clear all fetch requests. Dispatches fetchRequestsChange only if the list
* was non-empty.
*/
clearFetchRequests(): void {
if (this.fetchRequests.length === 0) return;
this.fetchRequests = [];
this.dispatchTypedEvent("fetchRequestsChange", []);
}
destroy(): void {
this.unsubscribe?.();
this.unsubscribe = null;
this.fetchRequests = [];
}
}