inspector/core/mcp/remote/sessionStorage.ts
cliffhall 1a5dd6820d fix: address Copilot review round 1 on #2318
- fetchTracking: count NDJSON streams line by line instead of leaving
  them at zero forever; justify the detached watcher promise; document
  that a partial SSE block at end-of-stream is dropped, as the SDK's own
  parser (no flush) and the SSE spec drop it.
- uncapturedBodyNote: case-insensitive content-type lookup, and a
  stream label (SSE / NDJSON) for the Network badge.
- InspectorClient: `openedAt` is when the response headers arrived, not
  when the GET went out; the snapshot copies its request records; a
  request the transport failed to send is rolled back out of the
  unanswered set via a new `trackSendFailure` callback; a raw-wire
  timeout is built as the SDK's own timeout shape and annotated the
  same way, so callers see one kind of timeout.
- Remote session storage revives the nested `stream.closedAt` Date.
- Hook test models a legacy double with `Object.defineProperty`, not a
  double cast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APKSYHGxXBUpnR7ErFiLJn
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-13 22:03:00 -04:00

162 lines
5.5 KiB
TypeScript

/**
* Remote HTTP storage implementation for InspectorClient session state.
* Uses the remote /api/storage/:storeId endpoint to persist session data
* across page navigations during OAuth flows.
*/
import type {
InspectorClientStorage,
InspectorClientSessionState,
} from "../sessionStorage.js";
export interface RemoteInspectorClientStorageOptions {
/** Base URL of the remote server (e.g. http://localhost:3000) */
baseUrl: string;
/** Optional auth token for x-mcp-remote-auth header */
authToken?: string;
/** Fetch function to use (default: globalThis.fetch) */
fetchFn?: typeof fetch;
}
/**
* Remote HTTP storage implementation for InspectorClient session state.
* Stores session data via HTTP API (GET/POST/DELETE /api/storage/:storeId).
* For web clients that need to persist session state across OAuth redirects.
*/
export class RemoteInspectorClientStorage implements InspectorClientStorage {
private baseUrl: string;
private authToken?: string;
private fetchFn: typeof fetch;
constructor(options: RemoteInspectorClientStorageOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.authToken = options.authToken;
// Default to a wrapper rather than `globalThis.fetch` directly: invoking
// the bare reference as `this.fetchFn(...)` re-binds `this` to this
// instance, which native `fetch` rejects with "Illegal invocation". The
// wrapper preserves the global receiver. (Same gotcha handled in
// `environmentFactory.ts` for the transport/auth fetchers.)
this.fetchFn = options.fetchFn ?? ((...args) => globalThis.fetch(...args));
}
private getStoreId(sessionId: string): string {
// Use a prefix to distinguish from OAuth storage
return `inspector-session-${sessionId}`;
}
async saveSession(
sessionId: string,
state: InspectorClientSessionState,
): Promise<void> {
const storeId = this.getStoreId(sessionId);
const url = `${this.baseUrl}/api/storage/${encodeURIComponent(storeId)}`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (this.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${this.authToken}`;
}
// Serialize state (convert Date objects to ISO strings for JSON)
const serializedState = {
...state,
fetchRequests: state.fetchRequests.map((req) => ({
...req,
timestamp:
req.timestamp instanceof Date
? req.timestamp.toISOString()
: req.timestamp,
})),
};
const res = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(serializedState),
// `saveSession` runs as the OAuth full-page redirect is being
// scheduled, so a normal fetch would be cancelled mid-flight and the
// pre-redirect network log (discovery + DCR) would never reach disk.
// `keepalive` lets the request outlive the unloading document.
// Caveat: keepalive caps the body at 64KB. The pre-redirect-log payload
// is small, but this method is also reachable from
// `FetchRequestLogState`'s `saveSession` listener with the full session
// log — a long session could exceed the cap and be dropped silently.
// Acceptable here: the persisted log is a best-effort convenience, not
// load-bearing state.
keepalive: true,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Failed to save session: ${res.status} ${text}`);
}
}
async loadSession(
sessionId: string,
): Promise<InspectorClientSessionState | undefined> {
const storeId = this.getStoreId(sessionId);
const url = `${this.baseUrl}/api/storage/${encodeURIComponent(storeId)}`;
const headers: Record<string, string> = {};
if (this.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${this.authToken}`;
}
const res = await this.fetchFn(url, {
method: "GET",
headers,
});
if (!res.ok) {
if (res.status === 404) {
return undefined;
}
const text = await res.text();
throw new Error(`Failed to load session: ${res.status} ${text}`);
}
const data = (await res.json()) as InspectorClientSessionState;
// Deserialize state (convert ISO strings back to Date objects). The
// nested `stream.closedAt` is a Date too, and `JSON.stringify` turned it
// into an ISO string on the way out; left as a string it would fail the
// `getTime()` every consumer of the type performs (#2318).
return {
...data,
fetchRequests: data.fetchRequests.map((req) => ({
...req,
timestamp:
typeof req.timestamp === "string"
? new Date(req.timestamp)
: req.timestamp instanceof Date
? req.timestamp
: new Date(req.timestamp),
...(req.stream?.closedAt !== undefined && {
stream: { ...req.stream, closedAt: new Date(req.stream.closedAt) },
}),
})),
};
}
async deleteSession(sessionId: string): Promise<void> {
const storeId = this.getStoreId(sessionId);
const url = `${this.baseUrl}/api/storage/${encodeURIComponent(storeId)}`;
const headers: Record<string, string> = {};
if (this.authToken) {
headers["x-mcp-remote-auth"] = `Bearer ${this.authToken}`;
}
const res = await this.fetchFn(url, {
method: "DELETE",
headers,
});
if (!res.ok && res.status !== 404) {
const text = await res.text();
throw new Error(`Failed to delete session: ${res.status} ${text}`);
}
}
}