From 98192fd86e0b5918696950b7492e7407404a10e3 Mon Sep 17 00:00:00 2001 From: Cliff Hall Date: Sat, 30 May 2026 20:39:25 -0400 Subject: [PATCH] fix(auth): persist OAuth pre-redirect Network log across the redirect (#1384) (#1385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): inject MCP_INSPECTOR_API_TOKEN into served index.html (#1378) Reloading the web client at the bare URL (no `?MCP_INSPECTOR_API_TOKEN=…` query string) with empty sessionStorage made every `/api/*` request 401 — the browser had no way to recover the backend's auth token. Embed the token into `index.html` on every page load so the browser no longer depends on the query string surviving navigation: - New shared helper `clients/web/server/inject-auth-token.ts` embeds `` (escaped against `` injection; no-op when auth is dangerously omitted). - Dev: the Vite plugin injects via `transformIndexHtml`. - Prod: the Hono server injects on the `/` route. - `App.tsx` `getAuthToken()` now reads the injected global first, then the query string, then sessionStorage (both fallbacks preserved). - Shared global name lives in `INSPECTOR_API_TOKEN_GLOBAL` (`core/mcp/remote/constants.ts`). Tests: helper unit coverage + an integration test exercising the real prod server's `/` → `/api/*` flow (injected token authenticates; missing token 401s). AGENTS.md documents the token-recovery order. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(auth): wire OAuth authorization-code flow into App.tsx (#1379) OAuth-protected MCP servers could not be connected to from the v2 web client: the core OAuth pipeline exists, but App.tsx never invoked it, so a connect attempt 401'd and surfaced "Remote send failed (401): … Missing Authorization header" as a toast. Wire the two missing entry points (all core primitives already in place): - Auto-trigger on 401: in onToggleConnection's catch, detect an upstream 401 (isUnauthorizedError) and call client.authenticate(), which runs discovery + DCR (backend-proxied) and redirects the page to the auth server via BrowserNavigation. The initiating server id is persisted to sessionStorage first, since the OAuth `state` carries only mode+authId and the full-page redirect wipes React state. - /oauth/callback handler: a mount effect that, once `servers` hydrate, parses the callback params, recovers the pending server, rebuilds its InspectorClient, runs completeOAuthFlow(code) (PKCE verifier + DCR client info survive in BrowserOAuthStorage), replaceState("/") so a reload can't replay the single-use code, then connect(). An `error=` callback toasts instead of retrying. connect() already attaches the OAuth provider to the transport (inspectorClient.ts), so once tokens land in BrowserOAuthStorage the outbound request carries the bearer token. Extracted the pure pieces (constants + isUnauthorizedError) to src/utils/oauthFlow.ts with unit tests. Verified end-to-end in a real browser against the MCP SDK demo OAuth server: Connect -> redirect -> auto-approve -> callback -> Connected, with the access token shown in the Connection Info modal (#1377). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(auth): persist OAuth pre-redirect Network log across the redirect (#1384) After #1379, the Network tab showed only the post-redirect auth HTTP (discovery re-run + POST /token); the pre-redirect discovery and the DCR POST /register that run during authenticate() were lost when the page navigated to the auth server. Root causes: 1. Ordering — BrowserNavigation set `window.location.href` before the client's `saveSession` event fired (OAuthManager calls onBeforeOAuthRedirect *after* auth() already navigated), so the save raced the unload and was dropped. Fix: BrowserNavigation now runs a synchronous `beforeNavigate` hook immediately before assigning location.href; App wires it through createWebEnvironment to flush the active fetch log to RemoteInspectorClient Storage (keyed by the authId parsed from the auth URL) via a keepalive POST that outlives the unload. 2. Illegal invocation — RemoteInspectorClientStorage defaulted to `this.fetchFn = globalThis.fetch` and called `this.fetchFn(...)`, which re-binds `this` and makes native fetch throw "Illegal invocation" (swallowed by the catch). This silently broke *all* session save/load. Fix: default to a wrapper that preserves the global receiver. 3. Restore race — hydrateFetchRequests replaced the list, so a load that resolved after the resuming connect appended live entries would clobber them. Fix: merge restored (older) entries ahead of live ones, dedupe by id. saveSession also now uses keepalive: true. Verified end-to-end against the MCP SDK demo OAuth server: the connected page's Network tab shows the full handshake — pre-redirect discovery + DCR /register plus post-redirect discovery + /token as `auth`, alongside `transport`. Added unit tests for the beforeNavigate ordering and the hydrate merge/dedupe. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(auth): inject token into prod SPA fallback + prime sessionStorage + no-store (#1378) Addresses code-review feedback on the token-injection PR: - Prod `server.ts` SPA deep-link fallback (e.g. `/oauth/callback`) previously served the raw index.html off disk via serveStatic, bypassing injection — so a bookmark/reload at a non-`/` route with empty sessionStorage would 401. Route the SPA fallback through the same `serveIndexHtml` (inject) helper; real static assets (paths with a dot) still serve verbatim. Dev already injected on every HTML serve via Vite `transformIndexHtml`. - `getAuthToken()` now persists the injected `window.__INSPECTOR_API_TOKEN__` to sessionStorage (not just the URL-param branch), priming the backstop for any later navigation that loses the global. - Injected HTML responses now send `Cache-Control: no-store`, so a page carrying a token isn't cached and served stale after a restart regenerates the token. Integration tests added: SPA fallback (`/oauth/callback`) carries the token, `Cache-Control: no-store` on injected HTML, real assets served verbatim, and unknown `/api` routes 404 rather than falling through to the HTML shell. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(auth): address #1383 review — split OAuth/connect toasts, tighten 401 match (#1379) Code-review feedback on the OAuth-wiring PR: - Callback effect: split completeOAuthFlow vs connect() into separate try/catch blocks. A token-exchange failure now reads "OAuth token exchange failed … Please try connecting again." (the single-use code is spent and the URL was cleared, so a reload can't retry); a post-OAuth connect failure reads "Failed to connect" since OAuth actually succeeded and re-clicking Connect reuses the persisted tokens. - isUnauthorizedError: anchor the message fallback on the transport's `failed …(401)` wording instead of a bare `(401)`, so an unrelated `(401)` spliced into an error message can't trip the OAuth flow. Added a test. - Documented that clearing the pending id + URL before the server lookup is intentional (deleted/renamed server mid-flow → require a fresh Connect). Also merges the squash-merged #1382 base from v2/main. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(auth): address #1385 review — clarify double-save + keepalive cap + test fetch default (#1384) Code-review feedback on the OAuth Network-log persistence PR: - Documented the double-save: `FetchRequestLogState`'s `saveSession` listener is the backstop; `BrowserNavigation`'s `beforeNavigate` hook is the primary flush for the redirect case. Notes the listener may lose the navigation race and is harmless when it duplicates (last-writer-wins, identical payload). - Reworded the keepalive comment in `RemoteInspectorClientStorage.saveSession`: the 64KB cap is general (the method is also reachable from the listener with the full session log), so a long session could exceed it and drop silently — acceptable since the persisted log is best-effort, not load-bearing. - Added a regression test that constructs `RemoteInspectorClientStorage` without a `fetchFn`, stubs `globalThis.fetch`, and asserts the default wrapper calls it (locks in the "Illegal invocation" fix, which callers otherwise swallow). Optional items (logger.warn on swallowed save errors; setupClientForServer dep churn) acknowledged on the PR, not changed. Also merges the squash-merged #1383 base from v2/main. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- clients/web/src/App.tsx | 91 ++++++++++++++++++- clients/web/src/lib/environmentFactory.ts | 8 +- .../web/src/test/core/auth/providers.test.ts | 33 +++++++ .../core/mcp/remote/sessionStorage.test.ts | 24 +++++ .../mcp/state/fetchRequestLogState.test.ts | 67 ++++++++++++++ core/auth/browser/providers.ts | 20 +++- core/mcp/remote/sessionStorage.ts | 18 +++- core/mcp/state/fetchRequestLogState.ts | 27 +++++- 8 files changed, 276 insertions(+), 12 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 2903d606..41b88f95 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -34,8 +34,10 @@ import { StderrLogState } from "@inspector/core/mcp/state/stderrLogState.js"; import type { RedirectUrlProvider } from "@inspector/core/auth/index.js"; import { parseOAuthCallbackParams, + parseOAuthState, generateOAuthErrorDescription, } from "@inspector/core/auth/index.js"; +import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js"; import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js"; import { useServers } from "@inspector/core/react/useServers.js"; import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js"; @@ -450,11 +452,68 @@ function App() { [messages], ); + // Backend-backed session storage used to carry the fetch (Network) log + // across the OAuth full-page redirect. The auth handshake's first half — + // protected-resource + auth-server discovery and Dynamic Client + // Registration — happens on the pre-redirect page; without persisting it + // those `auth` entries would vanish when the browser navigates to the + // authorization server. `FetchRequestLogState` saves to this on the + // client's `saveSession` event (fired in `onBeforeOAuthRedirect`) keyed by + // the OAuth authId, and restores from it when rebuilt on `/oauth/callback`. + // Created once; `getAuthToken()` is stable for the page's lifetime. + const sessionStorageAdapter = useMemo( + () => + new RemoteInspectorClientStorage({ + baseUrl: + typeof window !== "undefined" + ? window.location.origin + : "http://localhost", + authToken: getAuthToken(), + }), + [], + ); + + // Always points at the live `FetchRequestLogState` so the synchronous + // pre-redirect hook below can read the current Network log without being + // rebound every time the active server (and its log state) changes. + const fetchLogRef = useRef(null); + + // Flush the pre-redirect Network log to backend storage, keyed by the OAuth + // authId carried in the authorization URL's `state`. Runs synchronously from + // `BrowserNavigation` right before `window.location.href`, so the keepalive + // POST it kicks off outlives the unloading page. The `/oauth/callback` + // rebuild restores these entries via `FetchRequestLogState`'s `sessionId`. + // Stable identity: it reads mutable refs, so it never needs to be rebuilt. + const onBeforeOAuthRedirect = useCallback( + (authorizationUrl: URL) => { + const stateParam = authorizationUrl.searchParams.get("state"); + const authId = stateParam + ? (parseOAuthState(stateParam)?.authId ?? undefined) + : undefined; + if (!authId) return; + const fetchRequests = fetchLogRef.current?.getFetchRequests() ?? []; + if (fetchRequests.length === 0) return; + const now = Date.now(); + // Fire-and-forget: the keepalive request inside `saveSession` is + // dispatched synchronously here, before navigation commits. + void sessionStorageAdapter + .saveSession(authId, { + fetchRequests, + createdAt: now, + updatedAt: now, + }) + .catch(() => { + // Best-effort; losing the pre-redirect log is non-fatal. + }); + }, + [sessionStorageAdapter], + ); + // Wire up + tear down per active server. Called by `onToggleConnection` // when the user switches targets. Returns the new client so the toggle // can call `connect()` against it before React re-renders. const setupClientForServer = useCallback( - (server: ServerEntry): InspectorClient => { + (server: ServerEntry, sessionId?: string): InspectorClient => { // Tear down the previous session's managers — each destroy() // unsubscribes from the old client's events. Skipped on the first // call (initial values are null). @@ -471,6 +530,7 @@ function App() { const { environment } = createWebEnvironment( getAuthToken(), redirectUrlProvider, + onBeforeOAuthRedirect, ); // The settings node persisted in mcp.json for this server — distinct // from the InspectorClient options we're about to derive from it. @@ -519,6 +579,10 @@ function App() { }), ...(oauth && { oauth }), ...(savedSettings && { serverSettings: savedSettings }), + // Set on the `/oauth/callback` rebuild so the client's `saveSession` + // events (and any later persistence) key off the same OAuth authId + // the pre-redirect page saved under. + ...(sessionId && { sessionId }), }); setInspectorClient(client); @@ -538,7 +602,18 @@ function App() { new ResourceSubscriptionsState(client, nextResourcesState), ); setMessageLogState(new MessageLogState(client)); - setFetchRequestLogState(new FetchRequestLogState(client)); + // Wire session storage so the fetch log survives the OAuth redirect. + // When `sessionId` is supplied (the `/oauth/callback` rebuild) the prior + // page's `auth` entries are restored on construction; the actual save is + // driven synchronously from `onBeforeOAuthRedirect` above (keyed by the + // same authId). Keep `fetchLogRef` pointed at this instance so that hook + // reads the current log. + const nextFetchLog = new FetchRequestLogState(client, { + sessionStorage: sessionStorageAdapter, + ...(sessionId && { sessionId }), + }); + fetchLogRef.current = nextFetchLog; + setFetchRequestLogState(nextFetchLog); setStderrLogState(new StderrLogState(client)); return client; @@ -553,6 +628,8 @@ function App() { messageLogState, fetchRequestLogState, stderrLogState, + sessionStorageAdapter, + onBeforeOAuthRedirect, ], ); @@ -574,6 +651,14 @@ function App() { oauthCallbackHandledRef.current = true; const params = parseOAuthCallbackParams(window.location.search); + // The OAuth `state` round-trips `{mode}:{authId}`; the authId is the + // session key the pre-redirect page saved the fetch log under, so the + // rebuilt client can restore those `auth` entries. Read it before the + // URL is cleared below. + const stateParam = new URLSearchParams(window.location.search).get("state"); + const sessionId = stateParam + ? (parseOAuthState(stateParam)?.authId ?? undefined) + : undefined; const pendingId = window.sessionStorage.getItem(OAUTH_PENDING_SERVER_KEY) ?? undefined; window.sessionStorage.removeItem(OAUTH_PENDING_SERVER_KEY); @@ -609,7 +694,7 @@ function App() { } void (async () => { - const client = setupClientForServer(server); + const client = setupClientForServer(server, sessionId); setActiveServerId(server.id); // Two distinct failure modes get distinct toasts. A token-exchange // failure means OAuth did NOT complete — and since the single-use code diff --git a/clients/web/src/lib/environmentFactory.ts b/clients/web/src/lib/environmentFactory.ts index 350a16f0..bba83fc3 100644 --- a/clients/web/src/lib/environmentFactory.ts +++ b/clients/web/src/lib/environmentFactory.ts @@ -30,10 +30,16 @@ export interface WebEnvironmentResult { * `authToken` is read from a higher level (currently unused in this app since * v2 has no auth-token UI yet, but kept in the signature so the wiring is * ready when token plumbing lands). + * + * `onBeforeOAuthRedirect` runs synchronously immediately before the OAuth + * full-page redirect (see `BrowserNavigation`). The app uses it to flush the + * pre-redirect Network log to backend storage so the auth handshake's first + * half (discovery + Dynamic Client Registration) survives the navigation. */ export function createWebEnvironment( authToken: string | undefined, redirectUrlProvider: RedirectUrlProvider, + onBeforeOAuthRedirect?: (authorizationUrl: URL) => void, ): WebEnvironmentResult { const baseUrl = `${window.location.protocol}//${window.location.host}`; @@ -62,7 +68,7 @@ export function createWebEnvironment( logger, oauth: { storage: new BrowserOAuthStorage(), - navigation: new BrowserNavigation(), + navigation: new BrowserNavigation(undefined, onBeforeOAuthRedirect), redirectUrlProvider, }, }; diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index 77734cf8..1aa53a86 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -79,6 +79,39 @@ describe("OAuthNavigation", () => { "BrowserNavigation requires browser environment", ); }); + + it("runs beforeNavigate synchronously BEFORE assigning location.href", () => { + // The pre-redirect persistence relies on this ordering: the hook must + // observe the still-current document (location.href not yet reassigned) + // so a keepalive request it fires outlives the navigation. + const order: string[] = []; + const authUrl = new URL("http://example.com/authorize?state=normal:abc"); + const navigation = new BrowserNavigation(undefined, (url) => { + order.push("before"); + // At hook time the redirect has not happened yet. + expect((global as GlobalWithWindow).window!.location.href).toBe( + "http://localhost:5173", + ); + expect(url.toString()).toBe(authUrl.toString()); + }); + + navigation.navigateToAuthorization(authUrl); + order.push("after"); + + expect(order).toEqual(["before", "after"]); + expect((global as GlobalWithWindow).window!.location.href).toBe( + authUrl.toString(), + ); + }); + + it("still navigates when no beforeNavigate hook is provided", () => { + const navigation = new BrowserNavigation(); + const authUrl = new URL("http://example.com/authorize"); + navigation.navigateToAuthorization(authUrl); + expect((global as GlobalWithWindow).window!.location.href).toBe( + authUrl.toString(), + ); + }); }); describe("BrowserOAuthClientProvider", () => { diff --git a/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts b/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts index e091c30d..0437f904 100644 --- a/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts +++ b/clients/web/src/test/core/mcp/remote/sessionStorage.test.ts @@ -160,4 +160,28 @@ describe("RemoteInspectorClientStorage", () => { ], ).toBeUndefined(); }); + + it("defaults to a wrapper around globalThis.fetch (not the bare reference)", async () => { + // Regression guard: the default must call `globalThis.fetch` with the + // global as receiver. Assigning the bare reference and invoking it as + // `this.fetchFn(...)` throws "Illegal invocation" — which callers swallow + // via `.catch(() => {})`, so it would silently break all save/load. + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 204 })); + try { + const storage = new RemoteInspectorClientStorage({ + baseUrl: "http://remote.example/", + }); + await expect( + storage.saveSession("sid", sampleState()), + ).resolves.toBeUndefined(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]?.[0]).toBe( + "http://remote.example/api/storage/inspector-session-sid", + ); + } finally { + spy.mockRestore(); + } + }); }); diff --git a/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts b/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts index e2cec036..6af743e8 100644 --- a/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts +++ b/clients/web/src/test/core/mcp/state/fetchRequestLogState.test.ts @@ -202,6 +202,73 @@ describe("FetchRequestLogState", () => { expect(hydrated.getFetchRequests().map((e) => e.id)).toEqual(["r2", "r3"]); }); + it("merges restored entries ahead of live ones, deduping by id, when load resolves after a live append", async () => { + // Mirrors the `/oauth/callback` race: the resuming connect appends live + // entries while the persisted pre-redirect log is still loading. The + // restored (older) entries must land in front without clobbering the live + // ones, and an id present in both must not duplicate. + let resolveLoad: + | ((s: InspectorClientSessionState | undefined) => void) + | undefined; + const storage: InspectorClientStorage = { + async saveSession() {}, + loadSession: () => + new Promise((resolve) => { + resolveLoad = resolve; + }), + async deleteSession() {}, + }; + const hydrated = new FetchRequestLogState(client, { + sessionStorage: storage, + sessionId: "sess-merge", + }); + // Live entries arrive before the load resolves. + client.dispatchTypedEvent("fetchRequest", entry("token")); + client.dispatchTypedEvent("fetchRequest", entry("transport")); + // Restored set includes the two pre-redirect entries plus a duplicate of + // a live one ("token"). + resolveLoad?.({ + fetchRequests: [entry("discovery"), entry("register"), entry("token")], + createdAt: 0, + updatedAt: 0, + }); + await Promise.resolve(); + await Promise.resolve(); + expect(hydrated.getFetchRequests().map((e) => e.id)).toEqual([ + "discovery", + "register", + "token", + "transport", + ]); + hydrated.destroy(); + }); + + it("hydrate is a no-op when restored entries are all already present", async () => { + const storage = makeStorage({ + "sess-dup": { + fetchRequests: [entry("a")], + createdAt: 0, + updatedAt: 0, + }, + }); + const hydrated = new FetchRequestLogState(client, { + sessionStorage: storage, + sessionId: "sess-dup", + }); + const changes: FetchRequestEntry[][] = []; + hydrated.addEventListener("fetchRequestsChange", (e) => + changes.push(e.detail), + ); + client.dispatchTypedEvent("fetchRequest", entry("a")); + changes.length = 0; // ignore the live-append dispatch + await Promise.resolve(); + await Promise.resolve(); + // The only restored entry ("a") is already present → no merge, no event. + expect(hydrated.getFetchRequests().map((e) => e.id)).toEqual(["a"]); + expect(changes).toEqual([]); + hydrated.destroy(); + }); + it("ignores hydration when destroy() happens first", async () => { let resolveLoad: | ((s: InspectorClientSessionState | undefined) => void) diff --git a/core/auth/browser/providers.ts b/core/auth/browser/providers.ts index bafc633b..7f30b2cf 100644 --- a/core/auth/browser/providers.ts +++ b/core/auth/browser/providers.ts @@ -9,15 +9,29 @@ export type { OAuthNavigationCallback } from "../providers.js"; /** * Browser navigation handler - * Redirects the browser window to the authorization URL, optionally invokes an - * extra callback. + * Redirects the browser window to the authorization URL. + * + * `beforeNavigate` runs **synchronously, immediately before** the + * `window.location.href` assignment. It is the last point at which code can + * run on the soon-to-unload document, so consumers that need to flush state + * across the OAuth redirect (e.g. persisting the Network log via a `keepalive` + * request) must do it here — a request dispatched synchronously before + * navigation survives the unload, whereas one fired from a later microtask + * (after `auth()` returns) is dropped when the document tears down. + * + * `callback` runs after navigation is requested; kept for backwards + * compatibility with callers that only need a post-redirect notification. */ export class BrowserNavigation extends CallbackNavigation { - constructor(callback?: OAuthNavigationCallback) { + constructor( + callback?: OAuthNavigationCallback, + beforeNavigate?: (authorizationUrl: URL) => void, + ) { super((url) => { if (typeof window === "undefined") { throw new Error("BrowserNavigation requires browser environment"); } + beforeNavigate?.(url); window.location.href = url.href; return callback?.(url); }); diff --git a/core/mcp/remote/sessionStorage.ts b/core/mcp/remote/sessionStorage.ts index 593baf41..9c24ee59 100644 --- a/core/mcp/remote/sessionStorage.ts +++ b/core/mcp/remote/sessionStorage.ts @@ -31,7 +31,12 @@ export class RemoteInspectorClientStorage implements InspectorClientStorage { constructor(options: RemoteInspectorClientStorageOptions) { this.baseUrl = options.baseUrl.replace(/\/$/, ""); this.authToken = options.authToken; - this.fetchFn = options.fetchFn ?? globalThis.fetch; + // 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 { @@ -69,6 +74,17 @@ export class RemoteInspectorClientStorage implements InspectorClientStorage { 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) { diff --git a/core/mcp/state/fetchRequestLogState.ts b/core/mcp/state/fetchRequestLogState.ts index cfa99936..35f8f4e6 100644 --- a/core/mcp/state/fetchRequestLogState.ts +++ b/core/mcp/state/fetchRequestLogState.ts @@ -103,6 +103,15 @@ export class FetchRequestLogState extends TypedEventTarget, ): void => { @@ -156,12 +165,22 @@ export class FetchRequestLogState extends TypedEventTarget 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 - ? entries.slice(-this.maxFetchRequests) - : entries; - this.fetchRequests = trimmed; + ? merged.slice(-this.maxFetchRequests) + : merged; this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests()); }