From 7bd26c938e04f3f23665384ab06cfa74b848e6c8 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 3 Jul 2026 13:11:13 -0700 Subject: [PATCH 01/26] refactor: migrate to RemoteOAuthStorage for web OAuth handling --- clients/web/src/App.tsx | 21 ++- .../web/src/lib/environmentFactory.test.ts | 124 ++++++++++++++++++ clients/web/src/lib/environmentFactory.ts | 19 ++- .../web/src/lib/remoteOAuthStorage.test.ts | 66 ++++++++++ clients/web/src/lib/remoteOAuthStorage.ts | 55 ++++++++ .../test/core/auth/storage-browser.test.ts | 13 +- .../src/utils/clearServerOAuthState.test.ts | 22 ++-- .../web/src/utils/clearServerOAuthState.ts | 8 +- clients/web/vite.config.ts | 1 + specification/v2_auth_ema.md | 28 ++-- specification/v2_auth_mid_session.md | 4 +- 11 files changed, 317 insertions(+), 44 deletions(-) create mode 100644 clients/web/src/lib/environmentFactory.test.ts create mode 100644 clients/web/src/lib/remoteOAuthStorage.test.ts create mode 100644 clients/web/src/lib/remoteOAuthStorage.ts diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 1999cd1c..4ce5ffe2 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -86,7 +86,7 @@ import { useServers } from "@inspector/core/react/useServers.js"; import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js"; import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js"; import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js"; -import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js"; +import { getWebRemoteOAuthStorage } from "./lib/remoteOAuthStorage"; import { useManagedTools } from "@inspector/core/react/useManagedTools.js"; import { useManagedPrompts } from "@inspector/core/react/useManagedPrompts.js"; import { useManagedResources } from "@inspector/core/react/useManagedResources.js"; @@ -1428,6 +1428,13 @@ function App() { [messages], ); + // Shared OAuth runtime store (oauth.json via /api/storage/oauth). Memoized so + // connect, EMA IdP session, and per-server clear share one in-memory view. + const webOAuthStorage = useMemo( + () => getWebRemoteOAuthStorage(getAuthToken()), + [], + ); + // 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 @@ -2098,9 +2105,10 @@ function App() { // `onToggleConnection` unloaded the previous one), so all React state is // reset and we recover the initiating server from sessionStorage. We wait for // `servers` to hydrate before acting; the ref guard keeps the exchange to a - // single run. The persisted PKCE verifier + DCR client info live in - // `BrowserOAuthStorage` and survive the redirect, so `completeOAuthFlow` - // exchanges the code without needing the original in-memory state machine. + // single run. The persisted PKCE verifier + DCR client info live in shared + // `RemoteOAuthStorage` (`oauth.json`) and survive the redirect, so + // `completeOAuthFlow` exchanges the code without needing the original + // in-memory state machine. useEffect(() => { if (typeof window === "undefined") return; if (window.location.pathname !== OAUTH_CALLBACK_PATH) return; @@ -3239,10 +3247,9 @@ function App() { const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS; - const emaOAuthStorage = useMemo(() => getBrowserOAuthStorage(), []); const { loginState: emaIdpLoginState, logout: logoutEmaIdp } = useEmaIdpLoginState( - emaOAuthStorage, + webOAuthStorage, clientSettingsModalValue.emaEnabled ? clientSettingsModalValue.issuer : undefined, @@ -3270,6 +3277,7 @@ function App() { config: server.config, inspectorClient: isActive ? inspectorClient : null, isActiveConnection: isActive, + oauthStorage: webOAuthStorage, }); if (!cleared) return; @@ -3295,6 +3303,7 @@ function App() { [ activeServerId, inspectorClient, + webOAuthStorage, finalizeExplicitDisconnect, clearOAuthResumeOnExplicitDisconnect, ], diff --git a/clients/web/src/lib/environmentFactory.test.ts b/clients/web/src/lib/environmentFactory.test.ts new file mode 100644 index 00000000..c3c4e70e --- /dev/null +++ b/clients/web/src/lib/environmentFactory.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserNavigation } from "@inspector/core/auth/browser/index.js"; +import { MutableRedirectUrlProvider } from "@inspector/core/auth/providers.js"; +import { createWebEnvironment } from "./environmentFactory"; +import { + getWebRemoteOAuthStorage, + resetWebRemoteOAuthStorageCacheForTests, +} from "./remoteOAuthStorage"; + +describe("createWebEnvironment", () => { + beforeEach(() => { + vi.stubGlobal("window", { + location: { + protocol: "http:", + host: "127.0.0.1:6299", + }, + }); + }); + + afterEach(() => { + resetWebRemoteOAuthStorageCacheForTests(); + vi.unstubAllGlobals(); + }); + + it("wires RemoteOAuthStorage shared with getWebRemoteOAuthStorage", () => { + const redirectUrlProvider = new MutableRedirectUrlProvider(); + const first = createWebEnvironment("unit-test-token", redirectUrlProvider); + const second = createWebEnvironment("unit-test-token", redirectUrlProvider); + + expect(first.environment.oauth).toBeDefined(); + expect(second.environment.oauth).toBeDefined(); + expect(second.environment.oauth!.storage).toBe( + first.environment.oauth!.storage, + ); + expect(first.environment.oauth!.storage).toBe( + getWebRemoteOAuthStorage("unit-test-token"), + ); + }); + + it("uses BrowserNavigation for oauth.navigation", () => { + const { environment } = createWebEnvironment( + undefined, + new MutableRedirectUrlProvider(), + ); + expect(environment.oauth).toBeDefined(); + expect(environment.oauth!.navigation).toBeInstanceOf(BrowserNavigation); + }); + + it("returns the same logger instance as environment.logger", () => { + const { environment, logger } = createWebEnvironment( + "tok", + new MutableRedirectUrlProvider(), + ); + expect(logger).toBe(environment.logger); + }); + + it("passes redirectUrlProvider into oauth config", () => { + const redirectUrlProvider = new MutableRedirectUrlProvider(); + redirectUrlProvider.redirectUrl = "http://127.0.0.1:6299/oauth/callback"; + const { environment } = createWebEnvironment("tok", redirectUrlProvider); + if (!environment.oauth) { + throw new Error("expected oauth config"); + } + const { redirectUrlProvider: oauthRedirect } = environment.oauth; + if (!oauthRedirect) { + throw new Error("expected redirectUrlProvider"); + } + expect(oauthRedirect.getRedirectUrl()).toBe( + "http://127.0.0.1:6299/oauth/callback", + ); + }); + + it("forwards onBeforeOAuthRedirect to BrowserNavigation", () => { + const onBeforeOAuthRedirect = vi.fn<(authorizationUrl: URL) => void>(); + const { environment } = createWebEnvironment( + "tok", + new MutableRedirectUrlProvider(), + onBeforeOAuthRedirect, + ); + if (!environment.oauth) { + throw new Error("expected oauth config"); + } + const { navigation } = environment.oauth; + if (!navigation) { + throw new Error("expected navigation"); + } + const authUrl = new URL("https://idp.example/authorize?state=abc"); + navigation.navigateToAuthorization(authUrl); + expect(onBeforeOAuthRedirect).toHaveBeenCalledWith(authUrl); + }); + + it("routes fetch through the wrapped global fetch at the window origin", async () => { + const remoteBody = { + ok: true, + status: 200, + statusText: "OK", + headers: { "content-type": "text/plain" }, + body: "echoed", + }; + const fetchMock = vi.fn().mockImplementation( + async () => + new Response(JSON.stringify(remoteBody), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const { environment } = createWebEnvironment( + "api-tok", + new MutableRedirectUrlProvider(), + ); + fetchMock.mockClear(); + + const res = await environment.fetch!("http://upstream.test/mcp"); + + expect(res.status).toBe(200); + expect(await res.text()).toBe("echoed"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + "http://127.0.0.1:6299/api/fetch", + ); + }); +}); diff --git a/clients/web/src/lib/environmentFactory.ts b/clients/web/src/lib/environmentFactory.ts index e64cadef..3b08460b 100644 --- a/clients/web/src/lib/environmentFactory.ts +++ b/clients/web/src/lib/environmentFactory.ts @@ -4,11 +4,9 @@ import { createRemoteFetch, createRemoteLogger, } from "@inspector/core/mcp/remote/index.js"; -import { - getBrowserOAuthStorage, - BrowserNavigation, -} from "@inspector/core/auth/browser/index.js"; +import { BrowserNavigation } from "@inspector/core/auth/browser/index.js"; import type { RedirectUrlProvider } from "@inspector/core/auth/index.js"; +import { getWebRemoteOAuthStorage } from "./remoteOAuthStorage.js"; export interface WebEnvironmentResult { environment: InspectorClientEnvironment; @@ -20,16 +18,17 @@ export interface WebEnvironmentResult { * - transport / fetch / logger all routed through the in-process Hono * backend at `window.location.origin` (the `clients/web/server` * dev-backend wires this in `/api/*`). - * - OAuth storage + navigation use the `BrowserOAuthStorage` (sessionStorage) - * and `BrowserNavigation` (full-page redirect) adapters. + * - OAuth storage uses `RemoteOAuthStorage` (shared `oauth.json` via + * `/api/storage/oauth`); navigation uses `BrowserNavigation`. * * Returns both the assembled environment and the logger so callers can share * the same pino instance for any direct logging they need to do, instead of * reaching back through the client. * - * `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). + * `authToken` is supplied by `App.tsx` (`getAuthToken()`) and forwarded to + * remote transport/fetch/logger and `getWebRemoteOAuthStorage` so every + * `/api/*` call (including `/api/storage/oauth`) carries `x-mcp-remote-auth` + * when the backend requires it. * * `onBeforeOAuthRedirect` runs synchronously immediately before the OAuth * full-page redirect (see `BrowserNavigation`). The app uses it to flush the @@ -67,7 +66,7 @@ export function createWebEnvironment( }), logger, oauth: { - storage: getBrowserOAuthStorage(), + storage: getWebRemoteOAuthStorage(authToken), navigation: new BrowserNavigation(undefined, onBeforeOAuthRedirect), redirectUrlProvider, }, diff --git a/clients/web/src/lib/remoteOAuthStorage.test.ts b/clients/web/src/lib/remoteOAuthStorage.test.ts new file mode 100644 index 00000000..0077480c --- /dev/null +++ b/clients/web/src/lib/remoteOAuthStorage.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getRemoteOAuthStorage, + getWebRemoteOAuthStorage, + resetWebRemoteOAuthStorageCacheForTests, +} from "./remoteOAuthStorage"; + +describe("remoteOAuthStorage", () => { + afterEach(() => { + resetWebRemoteOAuthStorageCacheForTests(); + }); + + it("returns one RemoteOAuthStorage instance per cache key", () => { + const a = getRemoteOAuthStorage({ + baseUrl: "http://127.0.0.1:6277", + authToken: "tok-a", + }); + const aAgain = getRemoteOAuthStorage({ + baseUrl: "http://127.0.0.1:6277", + authToken: "tok-a", + }); + const b = getRemoteOAuthStorage({ + baseUrl: "http://127.0.0.1:6277", + authToken: "tok-b", + }); + + expect(aAgain).toBe(a); + expect(b).not.toBe(a); + }); + + it("getWebRemoteOAuthStorage uses window.location origin", () => { + vi.stubGlobal("window", { + location: { + protocol: "http:", + host: "127.0.0.1:6299", + }, + }); + + const storage = getWebRemoteOAuthStorage("smoke-web-token"); + const again = getWebRemoteOAuthStorage("smoke-web-token"); + + expect(again).toBe(storage); + vi.unstubAllGlobals(); + }); + + it("throws when window is unavailable", () => { + vi.stubGlobal("window", undefined); + expect(() => getWebRemoteOAuthStorage()).toThrow( + "getWebRemoteOAuthStorage requires a browser environment", + ); + vi.unstubAllGlobals(); + }); + + it("creates a new instance after the test cache reset", () => { + const first = getRemoteOAuthStorage({ + baseUrl: "http://127.0.0.1:6277", + authToken: "reset-me", + }); + resetWebRemoteOAuthStorageCacheForTests(); + const second = getRemoteOAuthStorage({ + baseUrl: "http://127.0.0.1:6277", + authToken: "reset-me", + }); + expect(second).not.toBe(first); + }); +}); diff --git a/clients/web/src/lib/remoteOAuthStorage.ts b/clients/web/src/lib/remoteOAuthStorage.ts new file mode 100644 index 00000000..0d95806b --- /dev/null +++ b/clients/web/src/lib/remoteOAuthStorage.ts @@ -0,0 +1,55 @@ +import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js"; + +export interface WebRemoteOAuthStorageOptions { + baseUrl: string; + authToken?: string; +} + +let cached: { cacheKey: string; storage: RemoteOAuthStorage } | undefined; + +function buildCacheKey(options: WebRemoteOAuthStorageOptions): string { + return `${options.baseUrl}\0${options.authToken ?? ""}`; +} + +const defaultFetch: typeof fetch = (...args) => globalThis.fetch(...args); + +/** + * Shared web OAuth store: `RemoteOAuthStorage` → `GET/POST /api/storage/oauth` + * → `~/.mcp-inspector/storage/oauth.json` (same file as CLI/TUI). + * + * Memoized by `{ baseUrl, authToken }` so connect, EMA IdP session, and + * per-server clear all mutate the same in-memory view. + */ +export function getRemoteOAuthStorage( + options: WebRemoteOAuthStorageOptions, +): RemoteOAuthStorage { + const cacheKey = buildCacheKey(options); + if (cached?.cacheKey === cacheKey) { + return cached.storage; + } + cached = { + cacheKey, + storage: new RemoteOAuthStorage({ + baseUrl: options.baseUrl, + authToken: options.authToken, + fetchFn: defaultFetch, + }), + }; + return cached.storage; +} + +/** Current origin + optional API token (see `getAuthToken()` in App.tsx). */ +export function getWebRemoteOAuthStorage( + authToken?: string, +): RemoteOAuthStorage { + if (typeof window === "undefined") { + throw new Error("getWebRemoteOAuthStorage requires a browser environment"); + } + const baseUrl = `${window.location.protocol}//${window.location.host}`; + return getRemoteOAuthStorage({ baseUrl, authToken }); +} + +/** @internal Vitest isolation */ +export function resetWebRemoteOAuthStorageCacheForTests(): void { + cached = undefined; +} diff --git a/clients/web/src/test/core/auth/storage-browser.test.ts b/clients/web/src/test/core/auth/storage-browser.test.ts index f009b4bd..6429811c 100644 --- a/clients/web/src/test/core/auth/storage-browser.test.ts +++ b/clients/web/src/test/core/auth/storage-browser.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js"; +import { + BrowserOAuthStorage, + getBrowserOAuthStorage, +} from "@inspector/core/auth/browser/storage.js"; import type { OAuthClientInformation, OAuthTokens, @@ -41,6 +44,14 @@ const mockSessionStorage = new MockSessionStorage(); (global as typeof globalThis & { sessionStorage?: Storage }).sessionStorage = mockSessionStorage; +describe("getBrowserOAuthStorage", () => { + it("returns the same singleton on repeated calls", () => { + const first = getBrowserOAuthStorage(); + const second = getBrowserOAuthStorage(); + expect(second).toBe(first); + }); +}); + describe("BrowserOAuthStorage", () => { let storage: BrowserOAuthStorage; const testServerUrl = "http://localhost:3000"; diff --git a/clients/web/src/utils/clearServerOAuthState.test.ts b/clients/web/src/utils/clearServerOAuthState.test.ts index 6cdbfeec..47f44e5d 100644 --- a/clients/web/src/utils/clearServerOAuthState.test.ts +++ b/clients/web/src/utils/clearServerOAuthState.test.ts @@ -1,14 +1,16 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js"; import { clearServerOAuthState } from "./clearServerOAuthState"; describe("clearServerOAuthState", () => { + let storage: BrowserOAuthStorage; + beforeEach(() => { - getBrowserOAuthStorage().clear("https://mcp.example.com/mcp"); + storage = new BrowserOAuthStorage(); + storage.clear("https://mcp.example.com/mcp"); }); it("clears storage by server URL when not the active connection", async () => { - const storage = getBrowserOAuthStorage(); await storage.saveTokens("https://mcp.example.com/mcp", { access_token: "tok", token_type: "Bearer", @@ -17,6 +19,7 @@ describe("clearServerOAuthState", () => { const cleared = clearServerOAuthState({ config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, isActiveConnection: false, + oauthStorage: storage, }); expect(cleared).toBe(true); @@ -26,18 +29,18 @@ describe("clearServerOAuthState", () => { }); it("uses the live client when clearing the active connection", () => { - const inspectorClient = { - clearOAuthTokens: vi.fn(), - }; + const clearOAuthTokens = vi.fn<() => void>(); + const inspectorClient = { clearOAuthTokens }; const cleared = clearServerOAuthState({ config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, - inspectorClient: inspectorClient as never, + inspectorClient, isActiveConnection: true, + oauthStorage: storage, }); expect(cleared).toBe(true); - expect(inspectorClient.clearOAuthTokens).toHaveBeenCalledTimes(1); + expect(clearOAuthTokens).toHaveBeenCalledTimes(1); }); it("returns false for stdio servers", () => { @@ -45,6 +48,7 @@ describe("clearServerOAuthState", () => { clearServerOAuthState({ config: { type: "stdio", command: "node", args: [] }, isActiveConnection: false, + oauthStorage: storage, }), ).toBe(false); }); diff --git a/clients/web/src/utils/clearServerOAuthState.ts b/clients/web/src/utils/clearServerOAuthState.ts index a3fd9d7c..0e22dd5a 100644 --- a/clients/web/src/utils/clearServerOAuthState.ts +++ b/clients/web/src/utils/clearServerOAuthState.ts @@ -1,4 +1,4 @@ -import { getBrowserOAuthStorage } from "@inspector/core/auth/browser/index.js"; +import type { OAuthStorage } from "@inspector/core/auth/storage.js"; import { getOAuthServerUrl } from "@inspector/core/mcp/config.js"; import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; @@ -6,8 +6,10 @@ import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; export interface ClearServerOAuthStateParams { config: MCPServerConfig; /** When set and this server is the active connection, clear via the live client. */ - inspectorClient?: InspectorClient | null; + inspectorClient?: Pick | null; isActiveConnection: boolean; + /** Shared web OAuth store; required so clear hits the same blob as connect. */ + oauthStorage: OAuthStorage; } /** @@ -26,7 +28,7 @@ export function clearServerOAuthState( if (params.isActiveConnection && params.inspectorClient) { params.inspectorClient.clearOAuthTokens(); } else { - getBrowserOAuthStorage().clear(serverUrl); + params.oauthStorage.clear(serverUrl); } return true; } diff --git a/clients/web/vite.config.ts b/clients/web/vite.config.ts index d3b99f93..e59e1f7d 100644 --- a/clients/web/vite.config.ts +++ b/clients/web/vite.config.ts @@ -100,6 +100,7 @@ export default defineConfig(({ command }) => { reporter: ['text', 'html', 'json-summary'], include: [ 'src/components/**/*.{ts,tsx}', + 'src/lib/**/*.{ts,tsx}', 'src/utils/**/*.{ts,tsx}', 'clients/web/server/**/*.{ts,tsx}', path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'), diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index 2c38693f..da4d8f02 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -111,12 +111,11 @@ Until **client profiles** (see below) land, install-level client config — star **Runtime auth state** (standard OAuth tokens, PKCE, **and** EMA runtime state: cached IdP ID Token / refresh token, leg-1 in-flight PKCE under `ema-idp:{issuer}`, and per-server resource tokens tagged `enterpriseManaged: true` when minted via EMA legs 2–3) uses the existing **`OAuthStorage`** interface (`core/auth/storage.ts`) — whatever adapter each client already passes to `InspectorClient`. ID-JAG is **not** cached — legs 2–3 re-mint on each connect or 401 refresh. EMA does **not** mandate a specific backing store; it extends the serialized auth state that adapter already persists. -No EMA-specific migration of web OAuth persistence is required for the initial ship — sessionStorage-backed web sessions work for EMA today. **Follow-up:** shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` so web matches CLI/TUI (see §Follow-up work). +Web uses shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` (same `oauth.json` as CLI/TUI). Existing **sessionStorage** OAuth blobs from before that wiring are not migrated automatically — users start fresh or re-authorize once. | Client | Config (`client.json`) | Runtime auth state (`OAuthStorage`) | | ------ | ---------------------- | ----------------------------------- | -| **Web (today)** | `RemoteStorage` adapter → `/api/storage/client` | `BrowserOAuthStorage` → sessionStorage | -| **Web (target)** | same | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` | +| **Web** | `RemoteStorage` adapter → `/api/storage/client` | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` (via `getWebRemoteOAuthStorage()` in `clients/web/src/lib/remoteOAuthStorage.ts`) | | **CLI / TUI** | `NodeClientStorage` (file adapter) | `NodeOAuthStorage` → `oauth.json` | On-disk shape (initial): @@ -299,7 +298,7 @@ Install-level IdP credentials are edited in **Client Settings**, separate from p 2. Clears leg-1 in-flight state at `servers["ema-idp:{issuer}"]` 3. Calls `OAuthStorage.clearEnterpriseManagedResourceServers()` — scans the shared OAuth blob and removes `servers[url]` entries where `enterpriseManaged === true` (set when EMA legs 2–3 saved tokens via `saveTokens(url, tokens, { enterpriseManaged: true })` in `emaFlow.ts` / `EmaTransportOAuthProvider`). Standard OAuth entries in the same blob are **not** cleared. Untagged legacy EMA tokens (saved before tagging landed) are not removed until the next EMA connect re-saves with the tag. The next connect to a cleared EMA server has no cached access token, so a **401** triggers leg 1 (IdP login) via `authenticate()`. -- **Web OAuth store singleton:** web uses `getBrowserOAuthStorage()` (`core/auth/browser/storage.ts`) so Client Settings sign-out updates the same in-memory Zustand store as the active `InspectorClient` (not a separate `BrowserOAuthStorage` instance per consumer). +- **Web OAuth store singleton:** web uses `getWebRemoteOAuthStorage()` (`clients/web/src/lib/remoteOAuthStorage.ts`) — memoized `RemoteOAuthStorage` backed by `/api/storage/oauth` — so Client Settings sign-out, EMA IdP session, connect, and per-server clear all mutate the same in-memory Zustand view as the active `InspectorClient`. **Future (not implemented):** explicit **Sign out** may additionally invoke the IdP's OIDC **end-session** / logout endpoint (RP-initiated logout) so the IdP SSO cookie is cleared — not just inspector-local IdP/resource token state. Today sign-out is **local-only** (clear `idpSessions`, tagged EMA resource entries, and leg-1 PKCE); the IdP may still treat the browser as signed in and skip the login prompt on the next authorize redirect until the IdP session expires or the user signs out at the IdP. @@ -347,7 +346,7 @@ Leg 1 is **OIDC authorization-code login** against the enterprise IdP using cred | Client | Leg 1 mechanism (same stack as standard OAuth in that client) | | ------ | ------------------------------------------------------------- | -| **Web** | Browser redirect via `navigation`; callback at `/oauth/callback`; `InspectorClient.completeOAuthFlow(code)`; `BrowserOAuthStorage` | +| **Web** | Browser redirect via `navigation`; callback at `/oauth/callback`; `InspectorClient.completeOAuthFlow(code)`; `RemoteOAuthStorage` → shared `oauth.json` | | **TUI / CLI** | `OAuthCallbackServer` opens local callback; system browser for authorize URL; `completeOAuthFlow(code)`; `NodeOAuthStorage` | **Silent connect:** if a valid cached ID Token exists for the configured issuer, skip the interactive redirect and proceed to leg 2. @@ -422,7 +421,8 @@ Inspector touchpoints to extend: - `core/mcp/oauthManager.ts` — branch on `enterpriseManaged`; EMA connect via `emaFlow`; `getOAuthState()`; `createOAuthProvider()` returns `EmaTransportOAuthProvider` for EMA servers (401 re-auth); standard OAuth unchanged - `core/mcp/inspectorClient.ts` — declare EMA extension in `initialize` capabilities when `enterpriseManaged`; `getOAuthState()` - `core/react/useEmaIdpLoginState.ts` — Client Settings IdP session status; calls `clearEmaIdpSession` on sign-out (no catalog wiring) -- `core/auth/browser/storage.ts` — `getBrowserOAuthStorage()` singleton (web) +- `clients/web/src/lib/remoteOAuthStorage.ts` — `getWebRemoteOAuthStorage()` singleton (web OAuth runtime store) +- `core/auth/browser/storage.ts` — `BrowserOAuthStorage` / `getBrowserOAuthStorage()` (sessionStorage; reference provider only, not wired in v2 web app) - `clients/web` — **Client Settings** modal; **Connection Info** OAuth snapshot; friendly EMA connect toasts; load client config at startup; `ServerSettingsForm` OAuth section (HTTP/SSE only); web callback flow tagging for IdP vs resource OAuth - `clients/cli`, `clients/tui` — load `client.json` at startup; pass into `InspectorClient`; leg 1 via same `OAuthCallbackServer` + `NodeOAuthStorage` stack as TUI standard OAuth today @@ -515,7 +515,7 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che ### Later - [ ] **Remove Zustand from OAuth persistence** — direct read/write of OAuth blob; see §Follow-up work -- [ ] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity; see §Follow-up work +- [x] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity (#1548); see §Follow-up work - [ ] Client profile persistence (migrate from `client.json`; may extend or replace web Client Settings) - [ ] Optional: adopt `@modelcontextprotocol/client` v2 Layer-2 helpers for legs 2–3 (replace `wire.ts`) or full v2 transport for EMA - [ ] Optional: IdP **end-session** / RP-initiated logout on explicit **Sign out** (today sign-out clears inspector-local IdP session and tagged EMA resource tokens only; IdP browser SSO may remain active) @@ -558,24 +558,26 @@ These items came out of EMA staging and apply beyond EMA. They are **not** requi Today `OAuthStorage` is backed by a **Zustand** `persist` store (`core/auth/store.ts`) with adapters for sessionStorage (`BrowserOAuthStorage`), file (`NodeOAuthStorage` via `file-storage.ts`), and HTTP (`RemoteOAuthStorage` via `remote-storage.ts`). The Zustand layer wraps a simple `{ servers, idpSessions }` blob and adds: - `{ state, version }` envelope on file/remote adapters (see [Servers file](v2_servers_file.md) — intentionally avoided for `mcp.json`) -- A second in-memory copy and async persist semantics that complicate sign-out, callback rebuild, and multi-consumer sharing (`getBrowserOAuthStorage()` singleton exists partly to paper over this) +- A second in-memory copy and async persist semantics that complicate sign-out, callback rebuild, and multi-consumer sharing (web uses `getWebRemoteOAuthStorage()`; legacy `getBrowserOAuthStorage()` existed partly to paper over this for sessionStorage) **Direction:** refactor `OAuthStorage` implementations to read/write the serialized shape **directly** (file I/O, `/api/storage/oauth`, or in-memory for tests) without Zustand. Keep the `OAuthStorage` interface stable for `InspectorClient` / `OAuthManager`. ### Shared file-backed OAuth state (web + CLI + TUI) -Today: +**Status (July 2026):** Web is wired through `RemoteOAuthStorage` (`environmentFactory.ts`, `App.tsx`, `getWebRemoteOAuthStorage()`). CLI/TUI continue to use `NodeOAuthStorage` on the same on-disk file when using the default local backend. | Client | OAuth runtime store | | ------ | ------------------- | -| **Web** | `BrowserOAuthStorage` → **sessionStorage** (tab-local; lost on new tab / callback page load relies on same tab) | +| **Web** | `RemoteOAuthStorage` → `/api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json` | | **CLI / TUI** | `NodeOAuthStorage` → `~/.mcp-inspector/storage/oauth.json` | -`RemoteOAuthStorage` already exists (`core/auth/remote/storage-remote.ts`) and talks to **`GET/POST/DELETE /api/storage/oauth`** on the local Hono backend — the same generic storage API that persists to disk under `~/.mcp-inspector/storage/`. Integration tests use it (`inspectorClient-oauth-remote-storage-e2e.test.ts`). +`RemoteOAuthStorage` (`core/auth/remote/storage-remote.ts`) talks to **`GET/POST/DELETE /api/storage/oauth`** on the local Hono backend — the same generic storage API that persists to disk under `~/.mcp-inspector/storage/`. Integration tests use it (`inspectorClient-oauth-remote-storage-e2e.test.ts`). -**Direction:** wire the **web app** through `RemoteOAuthStorage` (via `environmentFactory.ts` / `App.tsx`) instead of `BrowserOAuthStorage`, so web, CLI, and TUI share one **`oauth.json`** when using the default local backend. Benefits: IdP session and EMA resource tokens survive browser refresh; sign-out in Client Settings and connect use the same store without a sessionStorage singleton; EMA testing matches how CLI/TUI will behave in Phase 4. +**Benefits:** IdP session and EMA resource tokens survive browser refresh; sign-out in Client Settings and connect use the same store; EMA testing matches CLI/TUI behavior. -**Migration:** one-time import or “start fresh” for existing sessionStorage OAuth blobs; document that enabling shared storage is the default for local dev. +**Migration:** no automatic import from old sessionStorage OAuth blobs; document that local dev uses shared `oauth.json` by default. + +**Optional follow-up:** `navigator.locks` for cross-tab single-flight on silent refresh (see [Mid-session auth](v2_auth_mid_session.md)). --- diff --git a/specification/v2_auth_mid_session.md b/specification/v2_auth_mid_session.md index 7b1cf3a9..0fdc5b56 100644 --- a/specification/v2_auth_mid_session.md +++ b/specification/v2_auth_mid_session.md @@ -338,7 +338,7 @@ Snapshots **per app tab UI**, not message logs, tool results, or network bodies. ### Multiple browser tabs -Each browser tab has its own `RemoteSession` and (today) `BrowserOAuthStorage` in `sessionStorage`. SSE `auth_challenge` is scoped to that session. +Each browser tab has its own `RemoteSession`. OAuth runtime state is shared file-backed (`RemoteOAuthStorage` → `oauth.json`) across tabs on the same backend; SSE `auth_challenge` is scoped to that tab's session. When a tab is **hidden**, **interactive** OAuth must not steal focus: @@ -413,7 +413,7 @@ Legacy **SSE** transport: **401 only** on mid-session `send()` (no 403 step-up). | ------- | --- | --- | --- | | Challenge detection | Inline send + SSE ambient | SDK transport + intercept | Same as TUI | | Auth execution | Browser `OAuthManager` | Node `OAuthManager` | Node `OAuthManager` | -| OAuth storage | `BrowserOAuthStorage` (sessionStorage) | `NodeOAuthStorage` (file) | Same file as TUI | +| OAuth storage | `RemoteOAuthStorage` → `oauth.json` (via `/api/storage/oauth`) | `NodeOAuthStorage` (file) | Same file as TUI | | Silent recovery | `auth-state` push + send retry | Reconnect / SDK retry (v2) | One-shot RPC retry | | Interactive recovery | Modal + full-page redirect + snapshot | Auth tab + callback | stderr **y/N** + callback | | Step-up confirm | `StepUpAuthModal` | Auth tab **A** / **C** | **y/N** | From a24091484cbf3b681da22a881a40e0b8d19b89bd Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 3 Jul 2026 14:18:00 -0700 Subject: [PATCH 02/26] fix(auth): await OAuth storage load() to fix remote OAuth races --- clients/tui/src/App.tsx | 2 +- clients/web/src/App.tsx | 5 +- .../test/core/auth/connection-state.test.ts | 1 + .../src/test/core/auth/ema/emaFlow.test.ts | 1 + .../src/test/core/auth/ema/idpOidc.test.ts | 4 ++ .../src/test/core/auth/ema/idpSession.test.ts | 9 +-- .../web/src/test/core/auth/providers.test.ts | 5 +- .../test/core/auth/storage-browser.test.ts | 68 +++++++++---------- .../src/test/core/auth/storage-remote.test.ts | 49 +++++++++++-- .../src/test/core/mcp/oauthManager.test.ts | 11 +-- .../core/react/useEmaIdpLoginState.test.tsx | 19 ++++-- .../integration/auth/node/storage.test.ts | 39 +++++------ .../mcp/inspectorClient-oauth-e2e.test.ts | 4 +- .../mcp/inspectorClient-oauth.test.ts | 4 +- .../integration/mcp/inspectorClient.test.ts | 2 +- .../src/utils/clearServerOAuthState.test.ts | 19 +++--- .../web/src/utils/clearServerOAuthState.ts | 8 +-- core/auth/ema/idpOidc.ts | 6 +- core/auth/ema/idpSession.ts | 11 +-- core/auth/oauth-storage.ts | 66 +++++++++++++++--- core/auth/providers.ts | 4 +- core/auth/storage.ts | 26 ++++--- core/mcp/inspectorClient.ts | 4 +- core/mcp/oauthManager.ts | 6 +- core/react/useEmaIdpLoginState.ts | 5 +- 25 files changed, 245 insertions(+), 133 deletions(-) diff --git a/clients/tui/src/App.tsx b/clients/tui/src/App.tsx index 01b05bc5..0d126414 100644 --- a/clients/tui/src/App.tsx +++ b/clients/tui/src/App.tsx @@ -896,7 +896,7 @@ function App({ const handleClearOAuth = useCallback(async () => { if (!selectedInspectorClient) return; - selectedInspectorClient.clearOAuthTokens(); + await selectedInspectorClient.clearOAuthTokens(); setOauthStatus("idle"); setOauthMessage(null); setConnectError(null); diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 4ce5ffe2..077a0cf1 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -2191,6 +2191,7 @@ function App() { } void (async () => { + await webOAuthStorage.load(); const client = setupClientForServer(server, sessionId); setActiveServerId(server.id); try { @@ -2246,7 +2247,7 @@ function App() { }); } })(); - }, [servers, setupClientForServer, showReAuthBanner]); + }, [servers, setupClientForServer, showReAuthBanner, webOAuthStorage]); const onToggleConnection = useCallback( async (id: string) => { @@ -3273,7 +3274,7 @@ function App() { const clearServerOAuthAndDisconnect = useCallback( async (server: { id: string; name: string; config: MCPServerConfig }) => { const isActive = server.id === activeServerId; - const cleared = clearServerOAuthState({ + const cleared = await clearServerOAuthState({ config: server.config, inspectorClient: isActive ? inspectorClient : null, isActiveConnection: isActive, diff --git a/clients/web/src/test/core/auth/connection-state.test.ts b/clients/web/src/test/core/auth/connection-state.test.ts index 62716e47..d060439b 100644 --- a/clients/web/src/test/core/auth/connection-state.test.ts +++ b/clients/web/src/test/core/auth/connection-state.test.ts @@ -22,6 +22,7 @@ function createStorage( }> = {}, ): OAuthStorage { return { + load: vi.fn().mockResolvedValue(undefined), getTokens: vi.fn().mockResolvedValue(overrides.tokens), getClientInformation: vi.fn(async (_url, isPreregistered) => isPreregistered ? overrides.preregistered : overrides.dynamic, diff --git a/clients/web/src/test/core/auth/ema/emaFlow.test.ts b/clients/web/src/test/core/auth/ema/emaFlow.test.ts index 37ef84a1..d88109cb 100644 --- a/clients/web/src/test/core/auth/ema/emaFlow.test.ts +++ b/clients/web/src/test/core/auth/ema/emaFlow.test.ts @@ -43,6 +43,7 @@ function createMemoryStorage( const clientInfoByKey: Record = {}; const codeVerifierByKey: Record = {}; return { + load: vi.fn().mockResolvedValue(undefined), getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]), saveIdpSession: vi.fn(async (issuer: string, updates) => { idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; diff --git a/clients/web/src/test/core/auth/ema/idpOidc.test.ts b/clients/web/src/test/core/auth/ema/idpOidc.test.ts index 0831b4db..baebb845 100644 --- a/clients/web/src/test/core/auth/ema/idpOidc.test.ts +++ b/clients/web/src/test/core/auth/ema/idpOidc.test.ts @@ -29,6 +29,7 @@ describe("idpOidc refresh", () => { beforeEach(() => { Object.keys(idpSessions).forEach((k) => delete idpSessions[k]); storage = { + load: vi.fn().mockResolvedValue(undefined), getIdpSession: vi.fn(async (issuer: string) => idpSessions[issuer]), saveIdpSession: vi.fn(async (issuer: string, updates) => { idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; @@ -264,6 +265,8 @@ describe("startIdpOidcAuthorization", () => { beforeEach(() => { Object.keys(saved).forEach((k) => delete saved[k]); storage = { + load: vi.fn().mockResolvedValue(undefined), + getServerMetadata: vi.fn(() => null), saveCodeVerifier: vi.fn(async (key: string, value: string) => { saved[`cv:${key}`] = value; }), @@ -331,6 +334,7 @@ describe("completeIdpOidcAuthorization", () => { function buildStorage(overrides: Partial = {}): OAuthStorage { return { + load: vi.fn().mockResolvedValue(undefined), getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)), getClientInformation: vi.fn(async () => ({ client_id: "idp-client", diff --git a/clients/web/src/test/core/auth/ema/idpSession.test.ts b/clients/web/src/test/core/auth/ema/idpSession.test.ts index df7eb616..11ef63e7 100644 --- a/clients/web/src/test/core/auth/ema/idpSession.test.ts +++ b/clients/web/src/test/core/auth/ema/idpSession.test.ts @@ -19,6 +19,7 @@ describe("idpSession", () => { beforeEach(() => { storage = { + load: vi.fn().mockResolvedValue(undefined), getIdpSession: vi.fn(), saveIdpSession: vi.fn(), clearIdpSession: vi.fn(), @@ -73,15 +74,15 @@ describe("idpSession", () => { ); }); - it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", () => { - clearEmaIdpSession(storage, "https://idp.test/"); + it("clearEmaIdpSession clears idp session, leg-1 key, and tagged resource servers", async () => { + await clearEmaIdpSession(storage, "https://idp.test/"); expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test"); expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test"); expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled(); }); - it("clearEmaIdpSession no-ops when issuer normalizes to empty", () => { - clearEmaIdpSession(storage, ""); + it("clearEmaIdpSession no-ops when issuer normalizes to empty", async () => { + await clearEmaIdpSession(storage, ""); expect(storage.clearIdpSession).not.toHaveBeenCalled(); expect(storage.clear).not.toHaveBeenCalled(); expect( diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index d20f1b54..d7270584 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -213,6 +213,7 @@ describe("OAuthNavigation", () => { function makeStorage(): OAuthStorage { return { + load: vi.fn().mockResolvedValue(undefined), getScope: vi.fn(() => undefined), getClientInformation: vi.fn(async () => undefined), saveClientInformation: vi.fn(async () => undefined), @@ -240,11 +241,11 @@ describe("OAuthNavigation", () => { return new BaseOAuthClientProvider(SERVER, config); } - it("clear() delegates to storage.clear with the server url", () => { + it("clear() delegates to storage.clear with the server url", async () => { const storage = makeStorage(); const provider = makeProvider(storage); - provider.clear(); + await provider.clear(); expect(storage.clear).toHaveBeenCalledWith(SERVER); }); diff --git a/clients/web/src/test/core/auth/storage-browser.test.ts b/clients/web/src/test/core/auth/storage-browser.test.ts index 6429811c..52afc034 100644 --- a/clients/web/src/test/core/auth/storage-browser.test.ts +++ b/clients/web/src/test/core/auth/storage-browser.test.ts @@ -77,7 +77,7 @@ describe("BrowserOAuthStorage", () => { client_secret: "test-secret", }; - storage.saveClientInformation(testServerUrl, clientInfo, { + await storage.saveClientInformation(testServerUrl, clientInfo, { registrationKind: "dcr", }); const result = await storage.getClientInformation(testServerUrl); @@ -93,7 +93,7 @@ describe("BrowserOAuthStorage", () => { // Use the storage API instead of manually setting sessionStorage // since BrowserOAuthStorage now uses Zustand with a different storage format - storage.savePreregisteredClientInformation( + await storage.savePreregisteredClientInformation( testServerUrl, preregisteredInfo, ); @@ -113,7 +113,7 @@ describe("BrowserOAuthStorage", () => { client_id: "test-client-id", }; - storage.saveClientInformation(testServerUrl, clientInfo, { + await storage.saveClientInformation(testServerUrl, clientInfo, { registrationKind: "dcr", }); const result = await storage.getClientInformation(testServerUrl); @@ -130,10 +130,10 @@ describe("BrowserOAuthStorage", () => { client_id: "second-id", }; - storage.saveClientInformation(testServerUrl, firstInfo, { + await storage.saveClientInformation(testServerUrl, firstInfo, { registrationKind: "dcr", }); - storage.saveClientInformation(testServerUrl, secondInfo, { + await storage.saveClientInformation(testServerUrl, secondInfo, { registrationKind: "cimd", }); const result = await storage.getClientInformation(testServerUrl); @@ -163,7 +163,7 @@ describe("BrowserOAuthStorage", () => { expires_in: 3600, }; - storage.saveTokens(testServerUrl, tokens); + await storage.saveTokens(testServerUrl, tokens); const result = await storage.getTokens(testServerUrl); expect(result).toEqual(tokens); @@ -177,7 +177,7 @@ describe("BrowserOAuthStorage", () => { token_type: "Bearer", }; - storage.saveTokens(testServerUrl, tokens); + await storage.saveTokens(testServerUrl, tokens); const result = await storage.getTokens(testServerUrl); expect(result).toEqual(tokens); @@ -194,7 +194,7 @@ describe("BrowserOAuthStorage", () => { await storage.saveTokens(emaUrl, tokens, { enterpriseManaged: true }); await storage.saveTokens(standardUrl, tokens); - storage.clearEnterpriseManagedResourceServers(); + await storage.clearEnterpriseManagedResourceServers(); expect(await storage.getTokens(emaUrl)).toBeUndefined(); expect(await storage.getTokens(standardUrl)).toEqual(tokens); @@ -210,7 +210,7 @@ describe("BrowserOAuthStorage", () => { it("should return stored code verifier", async () => { const codeVerifier = "test-code-verifier"; - storage.saveCodeVerifier(testServerUrl, codeVerifier); + await storage.saveCodeVerifier(testServerUrl, codeVerifier); const result = await storage.getCodeVerifier(testServerUrl); expect(result).toBe(codeVerifier); @@ -221,7 +221,7 @@ describe("BrowserOAuthStorage", () => { it("should save code verifier", async () => { const codeVerifier = "test-code-verifier"; - storage.saveCodeVerifier(testServerUrl, codeVerifier); + await storage.saveCodeVerifier(testServerUrl, codeVerifier); const result = await storage.getCodeVerifier(testServerUrl); expect(result).toBe(codeVerifier); @@ -237,7 +237,7 @@ describe("BrowserOAuthStorage", () => { it("should return stored scope", async () => { const scope = "read write"; - storage.saveScope(testServerUrl, scope); + await storage.saveScope(testServerUrl, scope); const result = await storage.getScope(testServerUrl); expect(result).toBe(scope); @@ -248,7 +248,7 @@ describe("BrowserOAuthStorage", () => { it("should save scope", async () => { const scope = "read write"; - storage.saveScope(testServerUrl, scope); + await storage.saveScope(testServerUrl, scope); const result = await storage.getScope(testServerUrl); expect(result).toBe(scope); @@ -269,7 +269,7 @@ describe("BrowserOAuthStorage", () => { response_types_supported: ["code"], }; - storage.saveServerMetadata(testServerUrl, metadata); + await storage.saveServerMetadata(testServerUrl, metadata); const result = await storage.getServerMetadata(testServerUrl); expect(result).toEqual(metadata); @@ -285,7 +285,7 @@ describe("BrowserOAuthStorage", () => { response_types_supported: ["code"], }; - storage.saveServerMetadata(testServerUrl, metadata); + await storage.saveServerMetadata(testServerUrl, metadata); const result = await storage.getServerMetadata(testServerUrl); expect(result).toEqual(metadata); @@ -294,7 +294,7 @@ describe("BrowserOAuthStorage", () => { describe("clearClientInformation", () => { it("removes the dynamically-registered client info by default", async () => { - storage.saveClientInformation( + await storage.saveClientInformation( testServerUrl, { client_id: "dyn" }, { registrationKind: "dcr" }, @@ -302,18 +302,18 @@ describe("BrowserOAuthStorage", () => { expect(await storage.getClientInformation(testServerUrl)).toEqual({ client_id: "dyn", }); - storage.clearClientInformation(testServerUrl); + await storage.clearClientInformation(testServerUrl); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); }); it("removes the preregistered client info when isPreregistered=true", async () => { - storage.savePreregisteredClientInformation(testServerUrl, { + await storage.savePreregisteredClientInformation(testServerUrl, { client_id: "pre", }); expect(await storage.getClientInformation(testServerUrl, true)).toEqual({ client_id: "pre", }); - storage.clearClientInformation(testServerUrl, true); + await storage.clearClientInformation(testServerUrl, true); expect( await storage.getClientInformation(testServerUrl, true), ).toBeUndefined(); @@ -322,26 +322,26 @@ describe("BrowserOAuthStorage", () => { describe("individual clear methods", () => { it("clearTokens removes only tokens", async () => { - storage.saveTokens(testServerUrl, { + await storage.saveTokens(testServerUrl, { access_token: "t", token_type: "Bearer", }); expect(await storage.getTokens(testServerUrl)).toBeDefined(); - storage.clearTokens(testServerUrl); + await storage.clearTokens(testServerUrl); expect(await storage.getTokens(testServerUrl)).toBeUndefined(); }); it("clearCodeVerifier removes only the PKCE verifier", async () => { - storage.saveCodeVerifier(testServerUrl, "verifier"); + await storage.saveCodeVerifier(testServerUrl, "verifier"); expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier"); - storage.clearCodeVerifier(testServerUrl); + await storage.clearCodeVerifier(testServerUrl); expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined(); }); it("clearScope removes only the scope", async () => { - storage.saveScope(testServerUrl, "read"); + await storage.saveScope(testServerUrl, "read"); expect(storage.getScope(testServerUrl)).toBe("read"); - storage.clearScope(testServerUrl); + await storage.clearScope(testServerUrl); expect(storage.getScope(testServerUrl)).toBeUndefined(); }); @@ -352,9 +352,9 @@ describe("BrowserOAuthStorage", () => { token_endpoint: "http://localhost:3000/token", response_types_supported: ["code"], }; - storage.saveServerMetadata(testServerUrl, metadata); + await storage.saveServerMetadata(testServerUrl, metadata); expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata); - storage.clearServerMetadata(testServerUrl); + await storage.clearServerMetadata(testServerUrl); expect(storage.getServerMetadata(testServerUrl)).toBeNull(); }); }); @@ -369,12 +369,12 @@ describe("BrowserOAuthStorage", () => { token_type: "Bearer", }; - storage.saveClientInformation(testServerUrl, clientInfo, { + await storage.saveClientInformation(testServerUrl, clientInfo, { registrationKind: "dcr", }); - storage.saveTokens(testServerUrl, tokens); + await storage.saveTokens(testServerUrl, tokens); - storage.clear(testServerUrl); + await storage.clear(testServerUrl); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getTokens(testServerUrl)).toBeUndefined(); @@ -386,14 +386,14 @@ describe("BrowserOAuthStorage", () => { client_id: "test-client-id", }; - storage.saveClientInformation(testServerUrl, clientInfo, { + await storage.saveClientInformation(testServerUrl, clientInfo, { registrationKind: "dcr", }); - storage.saveClientInformation(otherServerUrl, clientInfo, { + await storage.saveClientInformation(otherServerUrl, clientInfo, { registrationKind: "dcr", }); - storage.clear(testServerUrl); + await storage.clear(testServerUrl); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getClientInformation(otherServerUrl)).toEqual( @@ -415,10 +415,10 @@ describe("BrowserOAuthStorage", () => { client_id: "client-2", }; - storage.saveClientInformation(server1Url, clientInfo1, { + await storage.saveClientInformation(server1Url, clientInfo1, { registrationKind: "dcr", }); - storage.saveClientInformation(server2Url, clientInfo2, { + await storage.saveClientInformation(server2Url, clientInfo2, { registrationKind: "dcr", }); diff --git a/clients/web/src/test/core/auth/storage-remote.test.ts b/clients/web/src/test/core/auth/storage-remote.test.ts index c20617ef..04175845 100644 --- a/clients/web/src/test/core/auth/storage-remote.test.ts +++ b/clients/web/src/test/core/auth/storage-remote.test.ts @@ -50,7 +50,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { { client_id: "dyn" }, { registrationKind: "dcr" }, ); - storage.clearClientInformation(serverUrl); + await storage.clearClientInformation(serverUrl); expect(await storage.getClientInformation(serverUrl)).toBeUndefined(); }); @@ -58,7 +58,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { await storage.savePreregisteredClientInformation(serverUrl, { client_id: "pre", }); - storage.clearClientInformation(serverUrl, true); + await storage.clearClientInformation(serverUrl, true); expect(await storage.getClientInformation(serverUrl, true)).toBeUndefined(); }); @@ -66,21 +66,21 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { const tokens = { access_token: "t", token_type: "Bearer" }; await storage.saveTokens(serverUrl, tokens); expect(await storage.getTokens(serverUrl)).toEqual(tokens); - storage.clearTokens(serverUrl); + await storage.clearTokens(serverUrl); expect(await storage.getTokens(serverUrl)).toBeUndefined(); }); it("codeVerifier round-trip and clearCodeVerifier", async () => { await storage.saveCodeVerifier(serverUrl, "verifier"); expect(storage.getCodeVerifier(serverUrl)).toBe("verifier"); - storage.clearCodeVerifier(serverUrl); + await storage.clearCodeVerifier(serverUrl); expect(storage.getCodeVerifier(serverUrl)).toBeUndefined(); }); it("scope round-trip and clearScope", async () => { await storage.saveScope(serverUrl, "read write"); expect(storage.getScope(serverUrl)).toBe("read write"); - storage.clearScope(serverUrl); + await storage.clearScope(serverUrl); expect(storage.getScope(serverUrl)).toBeUndefined(); }); @@ -93,7 +93,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { }; await storage.saveServerMetadata(serverUrl, md); expect(storage.getServerMetadata(serverUrl)).toEqual(md); - storage.clearServerMetadata(serverUrl); + await storage.clearServerMetadata(serverUrl); expect(storage.getServerMetadata(serverUrl)).toBeNull(); }); @@ -107,7 +107,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { access_token: "t", token_type: "Bearer", }); - storage.clear(serverUrl); + await storage.clear(serverUrl); expect(await storage.getClientInformation(serverUrl)).toBeUndefined(); expect(await storage.getTokens(serverUrl)).toBeUndefined(); }); @@ -120,4 +120,39 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { // No public accessor; constructing without throwing covers the default-branch. expect(s).toBeInstanceOf(RemoteOAuthStorage); }); + + it("load() waits for remote GET before sync reads see persisted state", async () => { + const persisted = { + state: { + servers: { + [serverUrl]: { codeVerifier: "persisted-verifier" }, + }, + idpSessions: {}, + }, + version: 0, + }; + let resolveFetch!: (value: Response) => void; + const delayedFetch = vi.fn( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ) as unknown as typeof fetch; + + const delayedStorage = new RemoteOAuthStorage({ + baseUrl: "http://remote.example", + storeId: `delayed-${Math.random().toString(36).slice(2)}`, + fetchFn: delayedFetch, + }); + + const loadPromise = delayedStorage.load(); + expect(delayedStorage.getCodeVerifier(serverUrl)).toBeUndefined(); + + resolveFetch(new Response(JSON.stringify(persisted), { status: 200 })); + await loadPromise; + + expect(delayedStorage.getCodeVerifier(serverUrl)).toBe( + "persisted-verifier", + ); + }); }); diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index 02d764f2..b4bfa7bb 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -35,6 +35,7 @@ function createMockParams( const dispatchOAuthError = vi.fn(); const storage = { + load: vi.fn().mockResolvedValue(undefined), getScope: vi.fn().mockReturnValue(undefined), getClientInformation: vi.fn().mockResolvedValue(undefined), getClientRegistrationKind: vi.fn().mockReturnValue(undefined), @@ -45,7 +46,7 @@ function createMockParams( saveTokens: vi.fn().mockResolvedValue(undefined), getCodeVerifier: vi.fn().mockReturnValue("verifier"), saveCodeVerifier: vi.fn().mockResolvedValue(undefined), - clear: vi.fn(), + clear: vi.fn().mockResolvedValue(undefined), clearClientInformation: vi.fn(), clearTokens: vi.fn(), clearCodeVerifier: vi.fn(), @@ -126,10 +127,10 @@ describe("OAuthManager", () => { }); describe("clearOAuthTokens", () => { - it("calls storage.clear(serverUrl) when storage is configured", () => { + it("calls storage.clear(serverUrl) when storage is configured", async () => { const params = createMockParams(); const manager = new OAuthManager(params); - manager.clearOAuthTokens(); + await manager.clearOAuthTokens(); expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith( SERVER_URL, ); @@ -137,7 +138,7 @@ describe("OAuthManager", () => { expect(manager.getOAuthFlowStep()).toBeUndefined(); }); - it("no-ops when storage is not configured", () => { + it("no-ops when storage is not configured", async () => { const params = createMockParams({ initialConfig: { redirectUrlProvider: { @@ -147,7 +148,7 @@ describe("OAuthManager", () => { } as OAuthManagerConfig, }); const manager = new OAuthManager(params); - manager.clearOAuthTokens(); + await manager.clearOAuthTokens(); expect(params.getServerUrl).not.toHaveBeenCalled(); }); }); diff --git a/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx b/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx index f8b9c0e6..bc7f1d6b 100644 --- a/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx +++ b/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx @@ -8,10 +8,13 @@ describe("useEmaIdpLoginState", () => { beforeEach(() => { storage = { + load: vi.fn().mockResolvedValue(undefined), getIdpSession: vi.fn().mockResolvedValue(undefined), - clearIdpSession: vi.fn(), - clear: vi.fn(), - clearEnterpriseManagedResourceServers: vi.fn(), + clearIdpSession: vi.fn().mockResolvedValue(undefined), + clear: vi.fn().mockResolvedValue(undefined), + clearEnterpriseManagedResourceServers: vi + .fn() + .mockResolvedValue(undefined), } as unknown as OAuthStorage; }); @@ -56,10 +59,12 @@ describe("useEmaIdpLoginState", () => { result.current.logout(); }); - expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test"); - expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test"); - expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled(); - expect(result.current.loginState).toBe("none"); + await waitFor(() => { + expect(storage.clearIdpSession).toHaveBeenCalledWith("https://idp.test"); + expect(storage.clear).toHaveBeenCalledWith("ema-idp:https://idp.test"); + expect(storage.clearEnterpriseManagedResourceServers).toHaveBeenCalled(); + expect(result.current.loginState).toBe("none"); + }); }); it("reports 'expired' for an expired token with no refresh token", async () => { diff --git a/clients/web/src/test/integration/auth/node/storage.test.ts b/clients/web/src/test/integration/auth/node/storage.test.ts index 2c72f8aa..eb43eab3 100644 --- a/clients/web/src/test/integration/auth/node/storage.test.ts +++ b/clients/web/src/test/integration/auth/node/storage.test.ts @@ -126,10 +126,10 @@ describe("NodeOAuthStorage", () => { client_id: "second-id", }; - storage.saveClientInformation(testServerUrl, firstInfo, { + await storage.saveClientInformation(testServerUrl, firstInfo, { registrationKind: "dcr", }); - storage.saveClientInformation(testServerUrl, secondInfo, { + await storage.saveClientInformation(testServerUrl, secondInfo, { registrationKind: "dcr", }); const result = await storage.getClientInformation(testServerUrl); @@ -310,7 +310,7 @@ describe("NodeOAuthStorage", () => { expect(await storage.getClientInformation(testServerUrl)).toEqual({ client_id: "dyn", }); - storage.clearClientInformation(testServerUrl); + await storage.clearClientInformation(testServerUrl); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); }); @@ -321,7 +321,7 @@ describe("NodeOAuthStorage", () => { expect(await storage.getClientInformation(testServerUrl, true)).toEqual({ client_id: "pre", }); - storage.clearClientInformation(testServerUrl, true); + await storage.clearClientInformation(testServerUrl, true); expect( await storage.getClientInformation(testServerUrl, true), ).toBeUndefined(); @@ -335,21 +335,21 @@ describe("NodeOAuthStorage", () => { token_type: "Bearer", }); expect(await storage.getTokens(testServerUrl)).toBeDefined(); - storage.clearTokens(testServerUrl); + await storage.clearTokens(testServerUrl); expect(await storage.getTokens(testServerUrl)).toBeUndefined(); }); it("clearCodeVerifier removes only the PKCE verifier", async () => { await storage.saveCodeVerifier(testServerUrl, "verifier"); expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier"); - storage.clearCodeVerifier(testServerUrl); + await storage.clearCodeVerifier(testServerUrl); expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined(); }); it("clearScope removes only the scope", async () => { await storage.saveScope(testServerUrl, "read write"); expect(storage.getScope(testServerUrl)).toBe("read write"); - storage.clearScope(testServerUrl); + await storage.clearScope(testServerUrl); expect(storage.getScope(testServerUrl)).toBeUndefined(); }); @@ -362,7 +362,7 @@ describe("NodeOAuthStorage", () => { }; await storage.saveServerMetadata(testServerUrl, metadata); expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata); - storage.clearServerMetadata(testServerUrl); + await storage.clearServerMetadata(testServerUrl); expect(storage.getServerMetadata(testServerUrl)).toBeNull(); }); }); @@ -382,7 +382,7 @@ describe("NodeOAuthStorage", () => { }); await storage.saveTokens(testServerUrl, tokens); - storage.clear(testServerUrl); + await storage.clear(testServerUrl); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getTokens(testServerUrl)).toBeUndefined(); @@ -401,7 +401,7 @@ describe("NodeOAuthStorage", () => { registrationKind: "dcr", }); - storage.clear(testServerUrl); + await storage.clear(testServerUrl); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); const otherResult = await storage.getClientInformation(otherServerUrl); @@ -424,10 +424,10 @@ describe("NodeOAuthStorage", () => { client_id: "client-2", }; - storage.saveClientInformation(server1Url, clientInfo1, { + await storage.saveClientInformation(server1Url, clientInfo1, { registrationKind: "dcr", }); - storage.saveClientInformation(server2Url, clientInfo2, { + await storage.saveClientInformation(server2Url, clientInfo2, { registrationKind: "dcr", }); @@ -547,7 +547,7 @@ describe("NodeOAuthStorage idpSessions (EMA)", () => { expect(session?.idToken).toBe("eyJ.id.token"); expect(session?.refreshToken).toBe("rt-1"); - storage.clearIdpSession(issuer); + await storage.clearIdpSession(issuer); await flushStoreFileWrites(testStatePath); expect(await storage.getIdpSession(issuer)).toBeUndefined(); }); @@ -635,12 +635,10 @@ describe("NodeOAuthStorage with custom storagePath", () => { ); try { - const defaultStore = getOAuthStore(); - defaultStore.getState().setServerState(testServerUrl, { - tokens: { - access_token: "default-token", - token_type: "Bearer", - }, + const defaultStorage = new NodeOAuthStorage(); + await defaultStorage.saveTokens(testServerUrl, { + access_token: "default-token", + token_type: "Bearer", }); const customStorage = new NodeOAuthStorage(customPath); @@ -652,11 +650,10 @@ describe("NodeOAuthStorage with custom storagePath", () => { const fromCustom = await customStorage.getTokens(testServerUrl); expect(fromCustom?.access_token).toBe("custom-token"); - const defaultStorage = new NodeOAuthStorage(); const fromDefault = await defaultStorage.getTokens(testServerUrl); expect(fromDefault?.access_token).toBe("default-token"); - defaultStore.getState().clearServerState(testServerUrl); + getOAuthStore().getState().clearServerState(testServerUrl); } finally { try { await fs.unlink(customPath); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts index b2ed94b0..f0aa91ce 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts @@ -630,7 +630,7 @@ describe("InspectorClient OAuth E2E", () => { expect(client.getStatus()).toBe("connected"); await client.disconnect(); - client.clearOAuthTokens(); + await client.clearOAuthTokens(); const authUrlSecond = await client.authenticate(); if (!authUrlSecond) throw new Error("Expected authorization URL"); @@ -995,7 +995,7 @@ describe("InspectorClient OAuth E2E", () => { expect(tokens?.access_token).toBeDefined(); expect(await client.isOAuthAuthorized()).toBe(true); - client.clearOAuthTokens(); + await client.clearOAuthTokens(); expect(await client.isOAuthAuthorized()).toBe(false); expect(await client.getOAuthTokens()).toBeUndefined(); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts index 86d439fc..edab9e77 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth.test.ts @@ -173,8 +173,8 @@ describe("InspectorClient OAuth", () => { expect(tokens).toBeUndefined(); }); - it("should clear OAuth tokens", () => { - client.clearOAuthTokens(); + it("should clear OAuth tokens", async () => { + await client.clearOAuthTokens(); // Should not throw expect(client).toBeDefined(); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 6b23727c..c75d258f 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -4949,7 +4949,7 @@ describe("InspectorClient", () => { expect(c.getOAuthFlowState()).toBeUndefined(); await expect(c.getOAuthState()).resolves.toBeUndefined(); // clearOAuthTokens is a no-op when there is no manager - expect(() => c.clearOAuthTokens()).not.toThrow(); + await expect(c.clearOAuthTokens()).resolves.toBeUndefined(); }); it("setOAuthConfig throws when oauthManager is unset", () => { diff --git a/clients/web/src/utils/clearServerOAuthState.test.ts b/clients/web/src/utils/clearServerOAuthState.test.ts index 47f44e5d..137bc9c3 100644 --- a/clients/web/src/utils/clearServerOAuthState.test.ts +++ b/clients/web/src/utils/clearServerOAuthState.test.ts @@ -1,13 +1,14 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js"; +import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import { clearServerOAuthState } from "./clearServerOAuthState"; describe("clearServerOAuthState", () => { let storage: BrowserOAuthStorage; - beforeEach(() => { + beforeEach(async () => { storage = new BrowserOAuthStorage(); - storage.clear("https://mcp.example.com/mcp"); + await storage.clear("https://mcp.example.com/mcp"); }); it("clears storage by server URL when not the active connection", async () => { @@ -16,7 +17,7 @@ describe("clearServerOAuthState", () => { token_type: "Bearer", }); - const cleared = clearServerOAuthState({ + const cleared = await clearServerOAuthState({ config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, isActiveConnection: false, oauthStorage: storage, @@ -28,11 +29,11 @@ describe("clearServerOAuthState", () => { ).toBeUndefined(); }); - it("uses the live client when clearing the active connection", () => { - const clearOAuthTokens = vi.fn<() => void>(); + it("uses the live client when clearing the active connection", async () => { + const clearOAuthTokens = vi.fn(); const inspectorClient = { clearOAuthTokens }; - const cleared = clearServerOAuthState({ + const cleared = await clearServerOAuthState({ config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, inspectorClient, isActiveConnection: true, @@ -43,13 +44,13 @@ describe("clearServerOAuthState", () => { expect(clearOAuthTokens).toHaveBeenCalledTimes(1); }); - it("returns false for stdio servers", () => { - expect( + it("returns false for stdio servers", async () => { + await expect( clearServerOAuthState({ config: { type: "stdio", command: "node", args: [] }, isActiveConnection: false, oauthStorage: storage, }), - ).toBe(false); + ).resolves.toBe(false); }); }); diff --git a/clients/web/src/utils/clearServerOAuthState.ts b/clients/web/src/utils/clearServerOAuthState.ts index 0e22dd5a..f7eb5233 100644 --- a/clients/web/src/utils/clearServerOAuthState.ts +++ b/clients/web/src/utils/clearServerOAuthState.ts @@ -17,18 +17,18 @@ export interface ClearServerOAuthStateParams { * HTTP MCP server. When clearing the active connection, uses the live client so * in-memory flow state is reset too. */ -export function clearServerOAuthState( +export async function clearServerOAuthState( params: ClearServerOAuthStateParams, -): boolean { +): Promise { const serverUrl = getOAuthServerUrl(params.config); if (!serverUrl) { return false; } if (params.isActiveConnection && params.inspectorClient) { - params.inspectorClient.clearOAuthTokens(); + await params.inspectorClient.clearOAuthTokens(); } else { - params.oauthStorage.clear(serverUrl); + await params.oauthStorage.clear(serverUrl); } return true; } diff --git a/core/auth/ema/idpOidc.ts b/core/auth/ema/idpOidc.ts index 8dff2ae6..e860d7e0 100644 --- a/core/auth/ema/idpOidc.ts +++ b/core/auth/ema/idpOidc.ts @@ -34,6 +34,7 @@ async function resolveIdpMetadata( storage: OAuthStorage, fetchFn?: typeof fetch, ): Promise { + await storage.load(); const storageKey = idpOAuthStorageKey(issuer); const cached = storage.getServerMetadata(storageKey); if (cached?.token_endpoint) { @@ -63,7 +64,7 @@ export async function startIdpOidcAuthorization(params: { fetchFn?: typeof fetch; }): Promise<{ authorizationUrl: URL }> { const issuer = normalizeIdpIssuer(params.idp.issuer); - const metadata = await discoverIdpMetadata(issuer, params.fetchFn); + const metadata = await resolveIdpMetadata(issuer, params.storage, params.fetchFn); const clientInformation = idpClientInformation(params.idp); const storageKey = idpOAuthStorageKey(issuer); const state = generateOAuthState(); @@ -101,6 +102,7 @@ export async function completeIdpOidcAuthorization(params: { }> { const issuer = normalizeIdpIssuer(params.idp.issuer); const storageKey = idpOAuthStorageKey(issuer); + await params.storage.load(); const metadata = params.storage.getServerMetadata(storageKey); if (!metadata) { throw new Error("IdP OAuth metadata not found — restart EMA IdP login"); @@ -131,7 +133,7 @@ export async function completeIdpOidcAuthorization(params: { throw new Error("IdP token response did not include an ID Token"); } - params.storage.clearCodeVerifier(storageKey); + await params.storage.clearCodeVerifier(storageKey); const idTokenExpiresAt = jwtExpiresAtMs(idToken); await params.storage.saveIdpSession(issuer, { idToken, diff --git a/core/auth/ema/idpSession.ts b/core/auth/ema/idpSession.ts index 21b47578..d736549a 100644 --- a/core/auth/ema/idpSession.ts +++ b/core/auth/ema/idpSession.ts @@ -21,10 +21,13 @@ export async function getEmaIdpLoginState( return "expired"; } -export function clearEmaIdpSession(storage: OAuthStorage, issuer: string): void { +export async function clearEmaIdpSession( + storage: OAuthStorage, + issuer: string, +): Promise { const normalized = normalizeIdpIssuer(issuer); if (!normalized) return; - storage.clearIdpSession(normalized); - storage.clear(idpOAuthStorageKey(normalized)); - storage.clearEnterpriseManagedResourceServers(); + await storage.clearIdpSession(normalized); + await storage.clear(idpOAuthStorageKey(normalized)); + await storage.clearEnterpriseManagedResourceServers(); } diff --git a/core/auth/oauth-storage.ts b/core/auth/oauth-storage.ts index f2246112..0332adf7 100644 --- a/core/auth/oauth-storage.ts +++ b/core/auth/oauth-storage.ts @@ -16,22 +16,48 @@ import type { SaveTokensOptions, } from "./storage.js"; +type OAuthStoreWithPersist = ReturnType & { + persist: { + hasHydrated: () => boolean; + onFinishHydration: (fn: () => void) => void; + }; +}; + /** * Concrete OAuthStorage implementation parameterized on a Zustand store. * The store carries the storage adapter (sessionStorage, file, remote HTTP, …), * so the same body works for browser, Node, and remote environments. */ export class OAuthStorageBase implements OAuthStorage { - private readonly store: ReturnType; + private readonly store: OAuthStoreWithPersist; + private loadedPromise: Promise | undefined; constructor(store: ReturnType) { - this.store = store; + this.store = store as OAuthStoreWithPersist; + } + + load(): Promise { + if (!this.loadedPromise) { + this.loadedPromise = new Promise((resolve) => { + if (this.store.persist.hasHydrated()) { + resolve(); + return; + } + this.store.persist.onFinishHydration(() => resolve()); + }); + } + return this.loadedPromise; + } + + private async ensureLoaded(): Promise { + await this.load(); } async getClientInformation( serverUrl: string, isPreregistered?: boolean, ): Promise { + await this.ensureLoaded(); const state = this.store.getState().getServerState(serverUrl); const clientInfo = isPreregistered ? state.preregisteredClientInformation @@ -56,6 +82,7 @@ export class OAuthStorageBase implements OAuthStorage { clientInformation: OAuthClientInformation, options: SaveClientInformationOptions, ): Promise { + await this.ensureLoaded(); this.store.getState().setServerState(serverUrl, { clientInformation, clientRegistrationKind: options.registrationKind, @@ -66,13 +93,18 @@ export class OAuthStorageBase implements OAuthStorage { serverUrl: string, clientInformation: OAuthClientInformation, ): Promise { + await this.ensureLoaded(); this.store.getState().setServerState(serverUrl, { preregisteredClientInformation: clientInformation, clientRegistrationKind: "static", }); } - clearClientInformation(serverUrl: string, isPreregistered?: boolean): void { + async clearClientInformation( + serverUrl: string, + isPreregistered?: boolean, + ): Promise { + await this.ensureLoaded(); const updates: Partial = {}; if (isPreregistered) { @@ -86,6 +118,7 @@ export class OAuthStorageBase implements OAuthStorage { } async getTokens(serverUrl: string): Promise { + await this.ensureLoaded(); const state = this.store.getState().getServerState(serverUrl); if (!state.tokens) { return undefined; @@ -99,13 +132,15 @@ export class OAuthStorageBase implements OAuthStorage { tokens: OAuthTokens, options?: SaveTokensOptions, ): Promise { + await this.ensureLoaded(); this.store.getState().setServerState(serverUrl, { tokens, ...(options?.enterpriseManaged === true && { enterpriseManaged: true }), }); } - clearTokens(serverUrl: string): void { + async clearTokens(serverUrl: string): Promise { + await this.ensureLoaded(); this.store.getState().setServerState(serverUrl, { tokens: undefined }); } @@ -118,10 +153,12 @@ export class OAuthStorageBase implements OAuthStorage { serverUrl: string, codeVerifier: string, ): Promise { + await this.ensureLoaded(); this.store.getState().setServerState(serverUrl, { codeVerifier }); } - clearCodeVerifier(serverUrl: string): void { + async clearCodeVerifier(serverUrl: string): Promise { + await this.ensureLoaded(); this.store .getState() .setServerState(serverUrl, { codeVerifier: undefined }); @@ -133,10 +170,12 @@ export class OAuthStorageBase implements OAuthStorage { } async saveScope(serverUrl: string, scope: string | undefined): Promise { + await this.ensureLoaded(); this.store.getState().setServerState(serverUrl, { scope }); } - clearScope(serverUrl: string): void { + async clearScope(serverUrl: string): Promise { + await this.ensureLoaded(); this.store.getState().setServerState(serverUrl, { scope: undefined }); } @@ -149,22 +188,26 @@ export class OAuthStorageBase implements OAuthStorage { serverUrl: string, metadata: OAuthMetadata, ): Promise { + await this.ensureLoaded(); this.store .getState() .setServerState(serverUrl, { serverMetadata: metadata }); } - clearServerMetadata(serverUrl: string): void { + async clearServerMetadata(serverUrl: string): Promise { + await this.ensureLoaded(); this.store .getState() .setServerState(serverUrl, { serverMetadata: undefined }); } - clear(serverUrl: string): void { + async clear(serverUrl: string): Promise { + await this.ensureLoaded(); this.store.getState().clearServerState(serverUrl); } async getIdpSession(issuer: string): Promise { + await this.ensureLoaded(); const session = this.store.getState().getIdpSession(issuer); if ( !session.idToken && @@ -180,14 +223,17 @@ export class OAuthStorageBase implements OAuthStorage { issuer: string, session: Partial, ): Promise { + await this.ensureLoaded(); this.store.getState().setIdpSession(issuer, session); } - clearIdpSession(issuer: string): void { + async clearIdpSession(issuer: string): Promise { + await this.ensureLoaded(); this.store.getState().clearIdpSession(issuer); } - clearEnterpriseManagedResourceServers(): void { + async clearEnterpriseManagedResourceServers(): Promise { + await this.ensureLoaded(); this.store.getState().clearEnterpriseManagedResourceServers(); } } diff --git a/core/auth/providers.ts b/core/auth/providers.ts index 4b300728..6e4c46ad 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -247,8 +247,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { return verifier; } - clear(): void { - this.storage.clear(this.serverUrl); + async clear(): Promise { + await this.storage.clear(this.serverUrl); } getServerMetadata(): OAuthMetadata | null { diff --git a/core/auth/storage.ts b/core/auth/storage.ts index f8b46874..441821e8 100644 --- a/core/auth/storage.ts +++ b/core/auth/storage.ts @@ -21,6 +21,13 @@ export interface SaveClientInformationOptions { } export interface OAuthStorage { + /** + * Load persisted OAuth state into memory. Resolves when the backing store + * (file, remote API, sessionStorage, …) has been read. Call before relying on + * sync reads, or await mutating methods which call this internally. + */ + load(): Promise; + /** * Get client information (preregistered or dynamically registered) */ @@ -56,7 +63,10 @@ export interface OAuthStorage { /** * Clear client information */ - clearClientInformation(serverUrl: string, isPreregistered?: boolean): void; + clearClientInformation( + serverUrl: string, + isPreregistered?: boolean, + ): Promise; /** * Get OAuth tokens @@ -75,7 +85,7 @@ export interface OAuthStorage { /** * Clear OAuth tokens */ - clearTokens(serverUrl: string): void; + clearTokens(serverUrl: string): Promise; /** * Get code verifier (for PKCE) @@ -90,7 +100,7 @@ export interface OAuthStorage { /** * Clear code verifier */ - clearCodeVerifier(serverUrl: string): void; + clearCodeVerifier(serverUrl: string): Promise; /** * Get scope @@ -105,7 +115,7 @@ export interface OAuthStorage { /** * Clear scope */ - clearScope(serverUrl: string): void; + clearScope(serverUrl: string): Promise; /** * Get server metadata discovered during OAuth @@ -120,12 +130,12 @@ export interface OAuthStorage { /** * Clear server metadata */ - clearServerMetadata(serverUrl: string): void; + clearServerMetadata(serverUrl: string): Promise; /** * Clear all OAuth data for a server */ - clear(serverUrl: string): void; + clear(serverUrl: string): Promise; /** * Get cached IdP OIDC session for EMA (keyed by issuer). @@ -143,12 +153,12 @@ export interface OAuthStorage { /** * Clear cached IdP session for an issuer. */ - clearIdpSession(issuer: string): void; + clearIdpSession(issuer: string): Promise; /** * Remove per-server OAuth state for MCP servers whose tokens were minted via EMA. */ - clearEnterpriseManagedResourceServers(): void; + clearEnterpriseManagedResourceServers(): Promise; } /** diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 601fc35b..e51d9887 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -3064,8 +3064,8 @@ export class InspectorClient extends InspectorClientEventTarget { /** * Clears OAuth tokens and client information */ - clearOAuthTokens(): void { - this.oauthManager?.clearOAuthTokens(); + async clearOAuthTokens(): Promise { + await this.oauthManager?.clearOAuthTokens(); } /** diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index 878495fa..ef8da8bb 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -282,6 +282,8 @@ export class OAuthManager { iss?: string, ): Promise { try { + await this.requireStorage().load(); + if (this.isEnterpriseManaged()) { const scopeForMint = this.pendingAuthorizationScope ?? this.getEmaFlowConfig().scope; @@ -387,13 +389,13 @@ export class OAuthManager { } } - clearOAuthTokens(): void { + async clearOAuthTokens(): Promise { if (!this.oauthConfig?.storage) { return; } const serverUrl = this.getServerUrl(); - this.oauthConfig.storage.clear(serverUrl); + await this.oauthConfig.storage.clear(serverUrl); this.oauthFlowState = null; this.pendingAuthorizationScope = undefined; diff --git a/core/react/useEmaIdpLoginState.ts b/core/react/useEmaIdpLoginState.ts index a9a65393..19f69827 100644 --- a/core/react/useEmaIdpLoginState.ts +++ b/core/react/useEmaIdpLoginState.ts @@ -40,8 +40,9 @@ export function useEmaIdpLoginState( const logout = useCallback(() => { if (!normalizedIssuer) return; - clearEmaIdpSession(storage, normalizedIssuer); - setLoginState("none"); + void clearEmaIdpSession(storage, normalizedIssuer).then(() => { + setLoginState("none"); + }); }, [storage, normalizedIssuer]); return { loginState, refresh, logout }; From 512f45d357bcc7aac2f1313b160a99d206c3e26d Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Fri, 3 Jul 2026 14:50:04 -0700 Subject: [PATCH 03/26] fix(auth): reject load() when persist read fails --- clients/web/src/App.tsx | 10 +++- .../src/test/core/auth/storage-remote.test.ts | 26 ++++++++++ core/auth/oauth-storage.ts | 18 +++---- core/auth/store.ts | 47 ++++++++++++++++++- 4 files changed, 91 insertions(+), 10 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 077a0cf1..c02dc763 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -2191,7 +2191,15 @@ function App() { } void (async () => { - await webOAuthStorage.load(); + try { + await webOAuthStorage.load(); + } catch (err) { + connectStartRef.current = undefined; + queueMicrotask(() => { + showReAuthBanner(server.id, err instanceof Error ? err : String(err)); + }); + return; + } const client = setupClientForServer(server, sessionId); setActiveServerId(server.id); try { diff --git a/clients/web/src/test/core/auth/storage-remote.test.ts b/clients/web/src/test/core/auth/storage-remote.test.ts index 04175845..be8536a5 100644 --- a/clients/web/src/test/core/auth/storage-remote.test.ts +++ b/clients/web/src/test/core/auth/storage-remote.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js"; +import { + type OAuthStore, + waitForOAuthStorePersistLoad, +} from "@inspector/core/auth/store.js"; const NOOP_FETCH = vi.fn( async () => @@ -121,6 +125,12 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { expect(s).toBeInstanceOf(RemoteOAuthStorage); }); + it("waitForOAuthStorePersistLoad resolves for an unregistered store handle", async () => { + await expect( + waitForOAuthStorePersistLoad({} as OAuthStore), + ).resolves.toBeUndefined(); + }); + it("load() waits for remote GET before sync reads see persisted state", async () => { const persisted = { state: { @@ -155,4 +165,20 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { "persisted-verifier", ); }); + + it("load() rejects when remote GET fails instead of hanging", async () => { + const failingFetch = vi.fn( + async () => new Response("server error", { status: 500 }), + ) as unknown as typeof fetch; + + const failingStorage = new RemoteOAuthStorage({ + baseUrl: "http://remote.example", + storeId: `fail-${Math.random().toString(36).slice(2)}`, + fetchFn: failingFetch, + }); + + await expect(failingStorage.load()).rejects.toThrow( + "Failed to read store: 500", + ); + }); }); diff --git a/core/auth/oauth-storage.ts b/core/auth/oauth-storage.ts index 0332adf7..64c29534 100644 --- a/core/auth/oauth-storage.ts +++ b/core/auth/oauth-storage.ts @@ -8,7 +8,11 @@ import { OAuthTokensSchema, } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthStorage } from "./storage.js"; -import { type createOAuthStore, type ServerOAuthState } from "./store.js"; +import { + type OAuthStore, + type ServerOAuthState, + waitForOAuthStorePersistLoad, +} from "./store.js"; import type { IdpSessionState, OAuthClientRegistrationKind, @@ -16,10 +20,9 @@ import type { SaveTokensOptions, } from "./storage.js"; -type OAuthStoreWithPersist = ReturnType & { +type OAuthStoreWithPersist = OAuthStore & { persist: { hasHydrated: () => boolean; - onFinishHydration: (fn: () => void) => void; }; }; @@ -32,19 +35,18 @@ export class OAuthStorageBase implements OAuthStorage { private readonly store: OAuthStoreWithPersist; private loadedPromise: Promise | undefined; - constructor(store: ReturnType) { + constructor(store: OAuthStore) { this.store = store as OAuthStoreWithPersist; } load(): Promise { if (!this.loadedPromise) { - this.loadedPromise = new Promise((resolve) => { + this.loadedPromise = (async () => { if (this.store.persist.hasHydrated()) { - resolve(); return; } - this.store.persist.onFinishHydration(() => resolve()); - }); + await waitForOAuthStorePersistLoad(this.store); + })(); } return this.loadedPromise; } diff --git a/core/auth/store.ts b/core/auth/store.ts index a54bfb30..52d966c3 100644 --- a/core/auth/store.ts +++ b/core/auth/store.ts @@ -43,6 +43,23 @@ export interface OAuthStoreState { clearEnterpriseManagedResourceServers: () => void; } +export type OAuthStore = ReturnType; + +const persistLoadByStore = new WeakMap>(); + +/** + * Wait until the store's initial persist read has finished (success or failure). + * Used by {@link OAuthStorageBase.load}; resolves on success, rejects when the + * storage adapter's getItem fails (Zustand does not fire onFinishHydration on error). + */ +export function waitForOAuthStorePersistLoad(store: OAuthStore): Promise { + const promise = persistLoadByStore.get(store); + if (!promise) { + return Promise.resolve(); + } + return promise; +} + /** * Creates a Zustand store for OAuth state with the given storage adapter. * The storage adapter handles persistence (file, remote HTTP, sessionStorage, etc.). @@ -53,7 +70,29 @@ export interface OAuthStoreState { export function createOAuthStore( storage: ReturnType, ) { - return createStore()( + let resolvePersistLoad!: () => void; + let rejectPersistLoad!: (error: unknown) => void; + let persistLoadSettled = false; + + const persistLoadPromise = new Promise((resolve, reject) => { + resolvePersistLoad = resolve; + rejectPersistLoad = reject; + }); + + const settlePersistLoad = (error?: unknown) => { + if (persistLoadSettled) { + /* v8 ignore next -- idempotent if persist signals completion more than once */ + return; + } + persistLoadSettled = true; + if (error) { + rejectPersistLoad(error); + return; + } + resolvePersistLoad(); + }; + + const store = createStore()( persist( (set, get) => ({ servers: {}, @@ -118,7 +157,13 @@ export function createOAuthStore( { name: "mcp-inspector-oauth", storage, + onRehydrateStorage: () => (_state, error) => { + settlePersistLoad(error); + }, }, ), ); + + persistLoadByStore.set(store, persistLoadPromise); + return store; } From 01cc79c61b2c669021f33cb0dc6f73dc66921b8d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 4 Jul 2026 16:47:48 -0400 Subject: [PATCH 04/26] =?UTF-8?q?test(cli):=20cover=20cliOAuth.ts=20step-u?= =?UTF-8?q?p/recovery=20paths=20to=20the=20=E2=89=A590%=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring clients/cli/src/cliOAuth.ts from 75% to ≥90% on all four coverage dimensions (now 100/96.42/100/100) by exercising the previously-untested interactive step-up and auth-recovery paths: - confirmStepUpFromStdin (the default stdin confirmer): drive it via the default `confirmStepUp` parameter of handleCliAuthRecoveryRequired with node:readline/promises mocked — covers the y / "yes" (trimmed, case-insensitive) accept paths and the n decline path (which throws). - connectInspectorWithOAuth's AuthRecoveryRequiredError branch: both the storage-already-satisfies resume and the interactive-recovery sub-branches, plus the isUnauthorizedError branch (with a rejecting disconnect to hit the `.catch(() => {})` guard), the non-OAuth-capable-config rethrow, and the non-OAuth-error rethrow. Part of #1610 — v2/main mid-session-auth code below the per-file coverage gate that v2/main's CI does not run but the Wave-1 rollup (#1550) does. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- clients/cli/__tests__/cliOAuth.test.ts | 227 +++++++++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/clients/cli/__tests__/cliOAuth.test.ts b/clients/cli/__tests__/cliOAuth.test.ts index 42178e29..f9d19153 100644 --- a/clients/cli/__tests__/cliOAuth.test.ts +++ b/clients/cli/__tests__/cliOAuth.test.ts @@ -3,15 +3,39 @@ import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js"; import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js"; import * as runnerInteractive from "@inspector/core/auth/node/runner-interactive-oauth.js"; import { + connectInspectorWithOAuth, handleCliAuthRecoveryRequired, isStandardOAuthStepUp, runCliInteractiveOAuth, withCliAuthRecoveryRetry, } from "../src/cliOAuth.js"; +import type { MCPServerConfig } from "@inspector/core/mcp/types.js"; + +// `confirmStepUpFromStdin` (the default step-up confirmer) reads a line from +// stdin via node:readline/promises. Mock the module so the default path can be +// exercised deterministically without real TTY input. +const { mockQuestion, mockClose } = vi.hoisted(() => ({ + mockQuestion: vi.fn(), + mockClose: vi.fn(), +})); +vi.mock("node:readline/promises", () => ({ + createInterface: vi.fn(() => ({ + question: mockQuestion, + close: mockClose, + })), +})); + +const CALLBACK_URL_CONFIG = { + hostname: "127.0.0.1", + port: 6276, + pathname: "/oauth/callback", +}; describe("cliOAuth", () => { afterEach(() => { vi.restoreAllMocks(); + mockQuestion.mockReset(); + mockClose.mockReset(); }); describe("isStandardOAuthStepUp", () => { @@ -227,4 +251,207 @@ describe("cliOAuth", () => { expect(result).toBe("ok"); expect(fn).toHaveBeenCalledTimes(2); }); + + describe("confirmStepUpFromStdin (default stdin confirmer)", () => { + const standardStepUpError = () => + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "insufficient_scope", + requiredScopes: ["weather:read"], + }); + + const clientNeedingStepUp = () => ({ + authenticate: vi.fn(), + beginInteractiveAuthorization: vi.fn(), + completeOAuthFlow: vi.fn(), + checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false), + }); + + it("proceeds with OAuth when the user answers y (no confirmStepUp arg)", async () => { + mockQuestion.mockResolvedValue("y"); + const runSpy = vi + .spyOn(runnerInteractive, "runRunnerInteractiveOAuth") + .mockResolvedValue({ kind: "success" }); + + // Omitting the confirmStepUp argument exercises the default + // confirmStepUpFromStdin, which reads from the mocked readline interface. + await handleCliAuthRecoveryRequired( + clientNeedingStepUp(), + standardStepUpError(), + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + {}, + ); + + expect(mockQuestion).toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); + expect(runSpy).toHaveBeenCalled(); + }); + + it("accepts a whitespace-padded, upper-case 'YES'", async () => { + mockQuestion.mockResolvedValue(" YES "); + const runSpy = vi + .spyOn(runnerInteractive, "runRunnerInteractiveOAuth") + .mockResolvedValue({ kind: "success" }); + + await handleCliAuthRecoveryRequired( + clientNeedingStepUp(), + standardStepUpError(), + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + {}, + ); + + expect(runSpy).toHaveBeenCalled(); + }); + + it("declines (throws) when the user answers n", async () => { + mockQuestion.mockResolvedValue("n"); + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + + await expect( + handleCliAuthRecoveryRequired( + clientNeedingStepUp(), + standardStepUpError(), + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + {}, + ), + ).rejects.toThrow("Step-up authorization declined."); + + expect(mockClose).toHaveBeenCalled(); + expect(runSpy).not.toHaveBeenCalled(); + }); + }); + + describe("connectInspectorWithOAuth recovery branch", () => { + const oauthServerConfig = { + type: "streamable-http", + url: "https://as.example/mcp", + } as MCPServerConfig; + + it("resumes without re-auth when storage already satisfies the challenge", async () => { + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + const connect = vi + .fn() + .mockRejectedValueOnce( + new AuthRecoveryRequiredError( + new URL("https://as.example/authorize"), + { reason: "insufficient_scope", requiredScopes: ["weather:read"] }, + ), + ) + .mockResolvedValueOnce(undefined); + const client = { + connect, + disconnect: vi.fn(), + checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true), + }; + + await connectInspectorWithOAuth( + client, + oauthServerConfig, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + ); + + expect(connect).toHaveBeenCalledTimes(2); + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("runs interactive recovery when storage does not satisfy the challenge", async () => { + const runSpy = vi + .spyOn(runnerInteractive, "runRunnerInteractiveOAuth") + .mockResolvedValue({ kind: "success" }); + const connect = vi + .fn() + .mockRejectedValueOnce( + new AuthRecoveryRequiredError( + new URL("https://as.example/authorize"), + { reason: "token_expired" }, + ), + ) + .mockResolvedValueOnce(undefined); + const client = { + connect, + disconnect: vi.fn(), + checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(false), + }; + + await connectInspectorWithOAuth( + client, + oauthServerConfig, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + ); + + expect(connect).toHaveBeenCalledTimes(2); + expect(runSpy).toHaveBeenCalled(); + }); + + it("runs interactive OAuth on a plain unauthorized error (disconnect failure is swallowed)", async () => { + const runSpy = vi + .spyOn(runnerInteractive, "runRunnerInteractiveOAuth") + .mockResolvedValue({ kind: "success" }); + const connect = vi + .fn() + .mockRejectedValueOnce(new Error("Connection failed for server (401)")) + .mockResolvedValueOnce(undefined); + // A rejecting disconnect exercises the `.catch(() => {})` guard. + const client = { + connect, + disconnect: vi.fn().mockRejectedValue(new Error("disconnect failed")), + checkAuthChallengeSatisfied: vi.fn(), + }; + + await connectInspectorWithOAuth( + client, + oauthServerConfig, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + ); + + expect(client.disconnect).toHaveBeenCalled(); + expect(runSpy).toHaveBeenCalled(); + expect(connect).toHaveBeenCalledTimes(2); + }); + + it("rethrows a non-OAuth error unchanged", async () => { + const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth"); + const connect = vi + .fn() + .mockRejectedValue(new Error("some unrelated failure")); + const client = { + connect, + disconnect: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + + await expect( + connectInspectorWithOAuth( + client, + oauthServerConfig, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + ), + ).rejects.toThrow("some unrelated failure"); + expect(runSpy).not.toHaveBeenCalled(); + }); + + it("rethrows when the server config is not OAuth-capable", async () => { + const connect = vi.fn().mockRejectedValue(new Error("nope (401)")); + const client = { + connect, + disconnect: vi.fn(), + checkAuthChallengeSatisfied: vi.fn(), + }; + + await expect( + connectInspectorWithOAuth( + client, + { type: "stdio", command: "x" } as MCPServerConfig, + new MutableRedirectUrlProvider(), + CALLBACK_URL_CONFIG, + ), + ).rejects.toThrow("nope (401)"); + }); + }); }); From 9a3604d01d453f719f76addba307cc30688a74d8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 4 Jul 2026 16:59:51 -0400 Subject: [PATCH 05/26] test(tui): cover AuthTab step-up up-arrow and a/c shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring clients/tui/src/components/AuthTab.tsx to ≥90 on all four dimensions (now 97.43/90.81/100/100) by exercising the previously-untested step-up keypress paths in the `useInput` handler: up-arrow navigation back to the "authorize" choice, the `a` (authorize) and `c` (cancel) shortcuts, and the fall-through for an unrelated key while the prompt is pending. Part of #1610. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- clients/tui/__tests__/AuthTab.test.tsx | 60 ++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/clients/tui/__tests__/AuthTab.test.tsx b/clients/tui/__tests__/AuthTab.test.tsx index fbefcf2e..d12f16f3 100644 --- a/clients/tui/__tests__/AuthTab.test.tsx +++ b/clients/tui/__tests__/AuthTab.test.tsx @@ -309,6 +309,66 @@ describe("AuthTab", () => { expect(lastFrame() ?? "").toContain("OAuth Details"); }); + it("moves the step-up selection back up with the up arrow", async () => { + const onAuthorizeStepUp = vi.fn(); + const onCancelStepUp = vi.fn(); + const { client } = makeClient(sampleOAuthState); + const { stdin } = render( + , + ); + await tick(); + // Move down to "cancel" (index 1), then back up to "authorize" (index 0). + stdin.write(DOWN); + await tick(); + stdin.write(UP); + await tick(); + stdin.write("\r"); + await tick(); + expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1); + expect(onCancelStepUp).not.toHaveBeenCalled(); + }); + + it("authorizes step-up with 'a', cancels with 'c', and ignores other keys", async () => { + const onAuthorizeStepUp = vi.fn(); + const onCancelStepUp = vi.fn(); + const { client } = makeClient(sampleOAuthState); + const { stdin } = render( + , + ); + await tick(); + stdin.write("a"); + await tick(); + expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1); + + stdin.write("c"); + await tick(); + expect(onCancelStepUp).toHaveBeenCalledTimes(1); + + // An unrelated key is swallowed while the step-up prompt is pending. + stdin.write("x"); + await tick(); + expect(onAuthorizeStepUp).toHaveBeenCalledTimes(1); + expect(onCancelStepUp).toHaveBeenCalledTimes(1); + }); + it("refreshes OAuth state when oauthComplete fires", async () => { const { client, getOAuthState, fire, listeners } = makeClient(sampleOAuthState); From f7ab287f6a3edd2568ef92e9df822c1de2d8eeda Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 4 Jul 2026 17:24:10 -0400 Subject: [PATCH 06/26] =?UTF-8?q?test(tui):=20cover=20App.tsx=20mid-sessio?= =?UTF-8?q?n=20auth=20flows=20to=20the=20=E2=89=A590%=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring clients/tui/src/App.tsx from 79% to ≥90 on all four dimensions (92.23 / 90.20 / 92.85 / 92.68) by exercising the mid-session / step-up auth machinery that the existing suite didn't reach: - runOAuthAuthentication result branches (success / already_authorized / insufficient_scope / unsupported), driven deterministically via a new opt-in runRunnerInteractiveOAuth override (defaults to the real runner so the existing callback-driven OAuth tests are unchanged). - The per-client auth-lifecycle listeners (authChallengeAmbient / Recovered / Interactive / oauthError), fired through a new client-event registry on the FakeClient, including the non-selected-server early-return guards. - handleAuthRecoveryRequired step-up-confirm and reauth paths, handleConnect's OAuth-on-401 branches (AuthRecoveryRequired + EmaClientNotConfigured thrown mid-flow), handleClearOAuth's connected/disconnect path, and the AuthTab onAuthorizeStepUp handler (EMA satisfied/interactive/failed/throw + standard non-EMA authorize, cancel). Part of #1610. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- clients/tui/__tests__/App.test.tsx | 383 ++++++++++++++++++++++++++++- 1 file changed, 381 insertions(+), 2 deletions(-) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 7ea98657..dda5f894 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -75,6 +75,20 @@ const h = vi.hoisted(() => { start: callbackStart, stop: callbackStop, })); + // Registry of the auth-lifecycle listeners App registers per client, so a test + // can fire authChallengeAmbient / authChallengeRecovered / authChallengeInteractive + // / oauthError against whichever FakeClient instance App built. + const clientEvents = new Map void>>(); + const fireClientEvent = (event: string, detail?: unknown) => { + clientEvents.get(event)?.forEach((fn) => fn({ detail })); + }; + // Optional per-test override for runRunnerInteractiveOAuth. Left null, the real + // runner runs (existing tests drive it via the captured callback opts); set it + // to return a specific { kind } to deterministically exercise the result + // branches without steering the real callback flow. + const runner: { + override: null | ((opts: unknown) => Promise); + } = { override: null }; class FakeManager { destroy = vi.fn(); } @@ -106,8 +120,15 @@ const h = vi.hoisted(() => { readResource = vi.fn(async () => ({ result: { contents: [{ uri: "file://x", text: "hello" }] }, })); - addEventListener = vi.fn(); - removeEventListener = vi.fn(); + addEventListener = vi.fn((event: string, fn: (event: unknown) => void) => { + if (!clientEvents.has(event)) clientEvents.set(event, new Set()); + clientEvents.get(event)!.add(fn); + }); + removeEventListener = vi.fn( + (event: string, fn: (event: unknown) => void) => { + clientEvents.get(event)?.delete(fn); + }, + ); // Reject so the unmount cleanup's `.catch(() => {})` arrow is exercised. disconnect = vi.fn().mockRejectedValue(new Error("cleanup disconnect")); } @@ -121,6 +142,9 @@ const h = vi.hoisted(() => { createOAuthCallbackServer, callbackStart, callbackStop, + clientEvents, + fireClientEvent, + runner, FakeManager, FakeClient, useInspectorClient: vi.fn(() => ({ @@ -200,6 +224,12 @@ vi.mock("@inspector/core/auth/node/index.js", async (importOriginal) => { ...actual, createOAuthCallbackServer: h.createOAuthCallbackServer, NodeOAuthStorage: class {}, + runRunnerInteractiveOAuth: (opts: unknown) => + h.runner.override + ? h.runner.override(opts) + : actual.runRunnerInteractiveOAuth( + opts as Parameters[0], + ), }; }); vi.mock("../src/utils/openUrl.js", () => ({ @@ -212,6 +242,7 @@ import { AuthRecoveryRequiredError, EMA_STEP_UP_PENDING_URL, } from "@inspector/core/auth/challenge.js"; +import { EmaClientNotConfiguredError } from "@inspector/core/auth/ema/clientConfigError.js"; const tick = () => new Promise((r) => setTimeout(r, 25)); const callbackUrlConfig = { hostname: "127.0.0.1", port: 0, pathname: "/cb" }; @@ -272,6 +303,16 @@ function oneEmaHttp(): Record { }; } +// Two OAuth-capable http servers (first auto-selected). Drives the per-server +// auth-event guards (events from the non-selected server return early) and the +// "a step-up is already pending for another server" branch. +function twoHttp(): Record { + return { + web: { config: { type: "streamable-http", url: "http://a" } } as never, + api: { config: { type: "streamable-http", url: "http://b" } } as never, + }; +} + // Mixed catalog: an OAuth-capable http server first (auto-selected) followed by // a stdio server � drives per-server tab gating + the tab-switch-away effects. function httpThenStdio(): Record { @@ -497,6 +538,8 @@ beforeEach(() => { h.cb.opts = null; h.callbackStart.mockClear(); h.callbackStop.mockClear(); + h.clientEvents.clear(); + h.runner.override = null; h.clientSpies.authenticate.mockReset(); h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start"); h.clientSpies.clearOAuthTokens.mockReset(); @@ -1041,3 +1084,339 @@ describe("App (OAuth flows)", () => { await expectFrame(r, "Step-up authorization succeeded"); }); }); + +describe("App (mid-session auth lifecycle events)", () => { + it("shows the ambient-refresh message then clears it on recovery", async () => { + const r = await mount(oneHttp()); + await press(r, ["a"]); // Auth tab so oauthMessage is visible + h.fireClientEvent("authChallengeAmbient"); + await expectFrame(r, "Refreshing authorization"); + h.fireClientEvent("authChallengeRecovered"); + await waitUntil( + () => !(r.lastFrame() ?? "").includes("Refreshing authorization"), + ); + expect(r.lastFrame() ?? "").not.toContain("Refreshing authorization"); + }); + + it("surfaces an oauthError event for both Error and non-Error payloads", async () => { + const r = await mount(oneHttp()); + await press(r, ["a"]); + h.fireClientEvent("oauthError", { error: new Error("oauth boom") }); + await expectFrame(r, "oauth boom"); + h.fireClientEvent("oauthError", { error: "plain oauth string" }); + await expectFrame(r, "plain oauth string"); + }); + + it("clears OAuth tokens and disconnects when connected", async () => { + h.ctrl.status = "connected"; + const r = await mount(oneHttp()); + await press(r, ["a", "s"]); + await waitUntil(() => h.clientSpies.clearOAuthTokens.mock.calls.length > 0); + expect(h.clientSpies.clearOAuthTokens).toHaveBeenCalled(); + expect(h.disconnect).toHaveBeenCalled(); + }); + + const stepUpChallenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["env:read"], + authorizationScopes: ["tools:read", "env:read"], + context: { toolName: "get-env" }, + }; + + it("presents a standard step-up on an interactive auth-challenge event", async () => { + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + const r = await mount(oneHttp()); + await press(r, ["a"]); + h.fireClientEvent("authChallengeInteractive", { + authorizationUrl: new URL("https://as.example/authorize"), + challenge: stepUpChallenge, + }); + await expectFrame(r, "needs additional OAuth scopes"); + }); + + it("skips step-up when the challenge is already satisfied (interactive event)", async () => { + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(true); + const r = await mount(oneHttp()); + await press(r, ["a"]); + h.fireClientEvent("authChallengeInteractive", { + authorizationUrl: new URL("https://as.example/authorize"), + challenge: stepUpChallenge, + }); + await expectFrame(r, "Authorization updated"); + }); + + it("auto-runs OAuth on an interactive reauth event (no step-up confirm)", async () => { + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + const r = await mount(oneHttp()); + await press(r, ["a"]); + h.fireClientEvent("authChallengeInteractive", { + authorizationUrl: new URL("https://as.example/authorize"), + challenge: { reason: "unauthorized" as const }, + }); + await waitUntil(() => h.callbackStart.mock.calls.length > 0); + expect(h.callbackStart).toHaveBeenCalled(); + }); + + it("routes a connect AuthRecoveryRequiredError into recovery", async () => { + h.connect + .mockRejectedValueOnce( + new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), { + reason: "unauthorized", + }), + ) + .mockResolvedValue(undefined); + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await waitUntil(() => h.callbackStart.mock.calls.length > 0); + expect(h.callbackStart).toHaveBeenCalled(); + }); + + it("surfaces an EMA-client-not-configured error on connect", async () => { + h.connect.mockRejectedValue( + new EmaClientNotConfiguredError("not_configured"), + ); + const r = await mount(oneEmaHttp()); + await press(r, ["a", "c"]); + await waitUntil(() => (r.lastFrame() ?? "").length > 0); + await expectFrame(r, "enterprise"); + }); +}); + +describe("App (step-up authorize outcomes)", () => { + const challenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["env:read"], + authorizationScopes: ["tools:read", "env:read"], + context: { toolName: "get-env" }, + }; + + async function presentEmaStepUp() { + h.clientSpies.callTool.mockRejectedValue( + new AuthRecoveryRequiredError(EMA_STEP_UP_PENDING_URL, challenge, { + emaStepUpConfirm: true, + }), + ); + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.ctrl.status = "connected"; + h.ctrl.tools = [sampleTool]; + const r = await mount(oneEmaHttp()); + await press(r, ["t", TAB, ENTER]); + await expectFrame(r, "MOCK_FORM"); + await press(r, [ENTER]); + await waitUntil(() => h.clientSpies.callTool.mock.calls.length > 0); + await expectFrame(r, "organization before it can continue"); + return r; + } + + it("reports a failed EMA step-up outcome", async () => { + const r = await presentEmaStepUp(); + h.clientSpies.handleAuthChallenge.mockResolvedValue({ + kind: "failed", + error: new Error("mint failed"), + }); + await press(r, ["a"]); + await expectFrame(r, "mint failed"); + }); + + it("runs interactive OAuth when EMA step-up returns interactive", async () => { + const r = await presentEmaStepUp(); + h.clientSpies.handleAuthChallenge.mockResolvedValue({ + kind: "interactive", + challenge, + authorizationUrl: new URL("https://as.example/authorize"), + }); + await press(r, ["a"]); + await waitUntil(() => h.callbackStart.mock.calls.length > 0); + expect(h.callbackStart).toHaveBeenCalled(); + }); + + it("surfaces an error when EMA handleAuthChallenge throws", async () => { + const r = await presentEmaStepUp(); + h.clientSpies.handleAuthChallenge.mockRejectedValue( + new Error("challenge boom"), + ); + await press(r, ["a"]); + await expectFrame(r, "challenge boom"); + }); + + it("cancels a pending step-up with 'c'", async () => { + const r = await presentEmaStepUp(); + await press(r, ["c"]); + await expectFrame(r, "Authorization cancelled"); + }); + + it("completes an EMA interactive step-up when OAuth succeeds", async () => { + const r = await presentEmaStepUp(); + h.clientSpies.handleAuthChallenge.mockResolvedValue({ + kind: "interactive", + challenge, + authorizationUrl: new URL("https://as.example/authorize"), + }); + h.runner.override = async () => ({ kind: "success" }); + await press(r, ["a"]); + await expectFrame(r, "Step-up authorization succeeded"); + }); + + it("completes an EMA interactive step-up when OAuth returns already_authorized", async () => { + const r = await presentEmaStepUp(); + h.clientSpies.handleAuthChallenge.mockResolvedValue({ + kind: "interactive", + challenge, + authorizationUrl: new URL("https://as.example/authorize"), + }); + h.runner.override = async () => ({ kind: "already_authorized" }); + await press(r, ["a"]); + await expectFrame(r, "Step-up authorization succeeded"); + }); +}); + +describe("App (OAuth result branches)", () => { + const unauthorized = Object.assign(new Error("request failed (401)"), { + status: 401, + }); + const stepUpChallenge = { + reason: "insufficient_scope" as const, + requiredScopes: ["env:read"], + authorizationScopes: ["tools:read", "env:read"], + context: { toolName: "get-env" }, + }; + const authUrl = () => new URL("https://as.example/authorize"); + + it("re-connects after OAuth returns already_authorized on a 401", async () => { + h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); + h.runner.override = async () => ({ kind: "already_authorized" }); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await waitUntil(() => h.connect.mock.calls.length >= 2); + expect(h.connect).toHaveBeenCalledTimes(2); + }); + + it("handles an unsupported OAuth result on a 401 without reconnecting", async () => { + h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); + h.runner.override = async () => ({ kind: "failed" }); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await tick(); + await tick(); + expect(h.connect).toHaveBeenCalledTimes(1); + }); + + it("routes an AuthRecoveryRequiredError thrown during 401 OAuth to recovery", async () => { + h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); + h.runner.override = async () => { + throw new AuthRecoveryRequiredError(authUrl(), { + reason: "unauthorized", + }); + }; + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(true); + const r = await mount(oneHttp()); + await press(r, ["c"]); + await waitUntil(() => h.disconnect.mock.calls.length > 0); + await press(r, ["a"]); // view the Auth tab where oauthMessage renders + await expectFrame(r, "Authorization updated"); + }); + + it("surfaces an EMA-not-configured error thrown during 401 OAuth", async () => { + h.connect.mockRejectedValueOnce(unauthorized).mockResolvedValue(undefined); + h.runner.override = async () => { + throw new EmaClientNotConfiguredError("disabled"); + }; + const r = await mount(oneHttp()); + await press(r, ["a", "c"]); + await expectFrame(r, "enterprise"); + }); + + it("completes a standard step-up authorize when OAuth succeeds", async () => { + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.runner.override = async () => ({ kind: "success" }); + const r = await mount(oneHttp()); + await press(r, ["a"]); + h.fireClientEvent("authChallengeInteractive", { + authorizationUrl: authUrl(), + challenge: stepUpChallenge, + }); + await expectFrame(r, "needs additional OAuth scopes"); + await press(r, ["a"]); + await expectFrame(r, "Step-up authorization succeeded"); + }); + + it("completes a standard step-up authorize when OAuth returns already_authorized", async () => { + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.runner.override = async () => ({ kind: "already_authorized" }); + const r = await mount(oneHttp()); + await press(r, ["a"]); + h.fireClientEvent("authChallengeInteractive", { + authorizationUrl: authUrl(), + challenge: stepUpChallenge, + }); + await expectFrame(r, "needs additional OAuth scopes"); + await press(r, ["a"]); + await expectFrame(r, "Step-up authorization succeeded"); + }); + + it("shows insufficient-scope message when step-up OAuth stays insufficient", async () => { + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.runner.override = async () => ({ + kind: "insufficient_scope", + challenge: stepUpChallenge, + }); + const r = await mount(oneHttp()); + await press(r, ["a"]); + h.fireClientEvent("authChallengeInteractive", { + authorizationUrl: authUrl(), + challenge: stepUpChallenge, + }); + await expectFrame(r, "needs additional OAuth scopes"); + await press(r, ["a"]); + await expectFrame(r, "were not granted"); + }); + + it("skips reauth when already satisfied (interactive event)", async () => { + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(true); + const r = await mount(oneHttp()); + await press(r, ["a"]); + h.fireClientEvent("authChallengeInteractive", { + authorizationUrl: authUrl(), + challenge: { reason: "unauthorized" }, + }); + await expectFrame(r, "Authorization updated"); + }); + + it("completes reauth via OAuth on an interactive event", async () => { + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.runner.override = async () => ({ kind: "success" }); + const r = await mount(oneHttp()); + await press(r, ["a"]); + h.fireClientEvent("authChallengeInteractive", { + authorizationUrl: authUrl(), + challenge: { reason: "unauthorized" }, + }); + await expectFrame(r, "Authorization updated. Retry your action"); + }); + + it("completes reauth when OAuth returns already_authorized", async () => { + h.clientSpies.checkAuthChallengeSatisfied.mockResolvedValue(false); + h.runner.override = async () => ({ kind: "already_authorized" }); + const r = await mount(oneHttp()); + await press(r, ["a"]); + h.fireClientEvent("authChallengeInteractive", { + authorizationUrl: authUrl(), + challenge: { reason: "unauthorized" }, + }); + await expectFrame(r, "Authorization updated. Retry your action"); + }); + + it("ignores auth lifecycle events from a non-selected server", async () => { + const r = await mount(twoHttp()); // web is selected; api is not + await press(r, ["a"]); + // Fired for every registered client; the non-selected `api` handlers hit + // their early-return guards while the selected `web` handlers proceed. + h.fireClientEvent("authChallengeAmbient"); + h.fireClientEvent("authChallengeRecovered"); + h.fireClientEvent("oauthError", { + error: new Error("selected-only error"), + }); + await expectFrame(r, "selected-only error"); + }); +}); From 1f7ef9184c3f50f8628b9ba7afa92d89d8b2d94a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 4 Jul 2026 19:08:44 -0400 Subject: [PATCH 07/26] test(tui): truly assert the non-selected-server auth guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the @claude review of #1611: the event registry is now keyed by (client, fn) rather than event name alone, and a new `fireClientEventFor(client, …)` helper fires a single client's listeners. The "ignores auth lifecycle events from a non-selected server" test now fires ambient/recovered/oauthError for ONLY the non-selected client and asserts no message appears — so removing the `selectedServerRef.current !== serverName` guard would fail the test — then fires for the selected client to confirm it does act. Coverage of App.tsx is unchanged (92.23 / 90.20 / 92.85 / 92.68); existing fire-all `fireClientEvent` behavior is preserved. Part of #1610. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01S3fTN8H3R8YV4yUGvZjYnX --- clients/tui/__tests__/App.test.tsx | 64 ++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/clients/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index dda5f894..b8efdb7c 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -78,9 +78,25 @@ const h = vi.hoisted(() => { // Registry of the auth-lifecycle listeners App registers per client, so a test // can fire authChallengeAmbient / authChallengeRecovered / authChallengeInteractive // / oauthError against whichever FakeClient instance App built. - const clientEvents = new Map void>>(); + // Each entry records which FakeClient registered the handler so a test can + // fire an event for a single client (`fireClientEventFor`) — needed to truly + // assert the per-server `selectedServerRef.current !== serverName` guards, + // not merely execute them. `fireClientEvent` still fires every client's + // handler for the event (the common single-server case). + type EventEntry = { client: unknown; fn: (event: unknown) => void }; + const clientEvents = new Map>(); + const clientInstances: Array<{ cfg?: { type?: string; url?: string } }> = []; const fireClientEvent = (event: string, detail?: unknown) => { - clientEvents.get(event)?.forEach((fn) => fn({ detail })); + clientEvents.get(event)?.forEach((e) => e.fn({ detail })); + }; + const fireClientEventFor = ( + client: unknown, + event: string, + detail?: unknown, + ) => { + clientEvents.get(event)?.forEach((e) => { + if (e.client === client) e.fn({ detail }); + }); }; // Optional per-test override for runRunnerInteractiveOAuth. Left null, the real // runner runs (existing tests drive it via the captured callback opts); set it @@ -93,9 +109,10 @@ const h = vi.hoisted(() => { destroy = vi.fn(); } class FakeClient { - cfg: { type?: string } | undefined; - constructor(config?: { type?: string }) { + cfg: { type?: string; url?: string } | undefined; + constructor(config?: { type?: string; url?: string }) { this.cfg = config; + clientInstances.push(this); } // Derive the transport type from the server config the client was built // with (config.type aligns with the serverType union) so per-server gating @@ -122,11 +139,18 @@ const h = vi.hoisted(() => { })); addEventListener = vi.fn((event: string, fn: (event: unknown) => void) => { if (!clientEvents.has(event)) clientEvents.set(event, new Set()); - clientEvents.get(event)!.add(fn); + clientEvents.get(event)!.add({ client: this, fn }); }); removeEventListener = vi.fn( (event: string, fn: (event: unknown) => void) => { - clientEvents.get(event)?.delete(fn); + const set = clientEvents.get(event); + if (!set) return; + for (const entry of set) { + if (entry.fn === fn) { + set.delete(entry); + break; + } + } }, ); // Reject so the unmount cleanup's `.catch(() => {})` arrow is exercised. @@ -143,7 +167,9 @@ const h = vi.hoisted(() => { callbackStart, callbackStop, clientEvents, + clientInstances, fireClientEvent, + fireClientEventFor, runner, FakeManager, FakeClient, @@ -539,6 +565,7 @@ beforeEach(() => { h.callbackStart.mockClear(); h.callbackStop.mockClear(); h.clientEvents.clear(); + h.clientInstances.length = 0; h.runner.override = null; h.clientSpies.authenticate.mockReset(); h.clientSpies.authenticate.mockResolvedValue("https://auth.example/start"); @@ -1410,13 +1437,24 @@ describe("App (OAuth result branches)", () => { it("ignores auth lifecycle events from a non-selected server", async () => { const r = await mount(twoHttp()); // web is selected; api is not await press(r, ["a"]); - // Fired for every registered client; the non-selected `api` handlers hit - // their early-return guards while the selected `web` handlers proceed. - h.fireClientEvent("authChallengeAmbient"); - h.fireClientEvent("authChallengeRecovered"); - h.fireClientEvent("oauthError", { - error: new Error("selected-only error"), + const api = h.clientInstances.find((c) => c.cfg?.url === "http://b"); + const web = h.clientInstances.find((c) => c.cfg?.url === "http://a"); + // Firing ONLY the non-selected server's handlers must hit the + // `selectedServerRef.current !== serverName` guard on each listener and set + // no message — if any guard were removed, a negative assertion would fail. + h.fireClientEventFor(api, "authChallengeAmbient"); + h.fireClientEventFor(api, "authChallengeRecovered"); + h.fireClientEventFor(api, "oauthError", { + error: new Error("api-only error"), }); - await expectFrame(r, "selected-only error"); + await tick(); + expect(r.lastFrame() ?? "").not.toContain("Refreshing authorization"); + expect(r.lastFrame() ?? "").not.toContain("api-only error"); + // The selected server's handlers do act (guard false): ambient refreshes, + // then an error surfaces. + h.fireClientEventFor(web, "authChallengeAmbient"); + await expectFrame(r, "Refreshing authorization"); + h.fireClientEventFor(web, "oauthError", { error: new Error("web error") }); + await expectFrame(r, "web error"); }); }); From 3c65bdb4c6d78b4ac22cdd6bc0850dd684cf1ab6 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sat, 4 Jul 2026 21:56:45 -0700 Subject: [PATCH 08/26] Removed Zustand storage, cleanup up auth storage IO interfaces, implementations, and usage. --- AGENTS.md | 6 +- clients/cli/package-lock.json | 32 +-- clients/cli/package.json | 3 +- clients/cli/tsup.config.ts | 2 +- clients/tui/package-lock.json | 32 +-- clients/tui/package.json | 3 +- clients/tui/tsup.config.ts | 1 - clients/web/package-lock.json | 34 +-- clients/web/package.json | 3 +- clients/web/src/test/core/auth/cimd.test.ts | 2 +- .../test/core/auth/connection-state.test.ts | 16 +- .../src/test/core/auth/ema/emaFlow.test.ts | 20 +- .../src/test/core/auth/ema/idpOidc.test.ts | 10 +- .../src/test/core/auth/oauth-persist.test.ts | 54 ++++ .../web/src/test/core/auth/providers.test.ts | 23 +- .../test/core/auth/storage-browser.test.ts | 25 +- .../src/test/core/auth/storage-remote.test.ts | 33 +-- .../src/test/core/mcp/oauthManager.test.ts | 54 ++-- .../integration/auth/node/storage.test.ts | 187 +++++------- .../mcp/inspectorClient-ema-e2e.test.ts | 14 +- .../mcp/inspectorClient-oauth-e2e.test.ts | 10 +- ...lient-oauth-remote-mid-session-e2e.test.ts | 4 +- ...torClient-oauth-remote-storage-e2e.test.ts | 13 +- .../test/integration/storage/adapters.test.ts | 268 +++++++----------- clients/web/tsconfig.app.json | 2 - clients/web/tsconfig.test.json | 2 - clients/web/tsup.runner.config.ts | 1 - core/auth/browser/storage.ts | 10 +- core/auth/connection-state.ts | 8 +- core/auth/ema/idpOidc.ts | 8 +- core/auth/node/index.ts | 2 - core/auth/node/storage-node.ts | 65 +++-- core/auth/oauth-persist.ts | 194 +++++++++++++ core/auth/oauth-storage.ts | 132 +++++---- core/auth/providers.ts | 19 +- core/auth/remote/storage-remote.ts | 22 +- core/auth/storage.ts | 16 +- core/auth/store.ts | 197 +++++-------- core/mcp/oauthManager.ts | 46 +-- core/storage/adapters/file-storage.ts | 32 --- core/storage/adapters/index.ts | 10 - core/storage/adapters/remote-storage.ts | 94 ------ core/storage/store-io.ts | 17 +- package-lock.json | 32 +-- package.json | 3 +- specification/v2_auth_ema.md | 35 ++- specification/v2_auth_hardening.md | 2 +- specification/v2_auth_mid_session.md | 4 +- specification/v2_auth_smoke_testing.md | 4 +- specification/v2_cli_tui_launcher.md | 2 +- specification/v2_new_spec_impact.md | 4 +- specification/v2_scope.md | 3 +- specification/v2_servers_file.md | 6 +- specification/v2_storage.md | 29 +- specification/v2_ux_components.md | 12 +- test-servers/src/test-helpers.ts | 2 +- vitest.shared.mts | 9 - 57 files changed, 864 insertions(+), 1009 deletions(-) create mode 100644 clients/web/src/test/core/auth/oauth-persist.test.ts create mode 100644 core/auth/oauth-persist.ts delete mode 100644 core/storage/adapters/file-storage.ts delete mode 100644 core/storage/adapters/index.ts delete mode 100644 core/storage/adapters/remote-storage.ts diff --git a/AGENTS.md b/AGENTS.md index 12500c43..3e9aea49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ inspector/ │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs) ├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias) -│ ├── auth/ # OAuth: state machine, providers, discovery, storage; +│ ├── auth/ # OAuth: providers, discovery, OAuthStorage + persist backends; │ │ # mid-session recovery (challenge.ts WWW-Authenticate │ │ # parsing, scopes.ts SEP-2350 scope union, oauthUx.ts │ │ # shared copy, mcpAuth.ts force-reauthorization) @@ -41,9 +41,9 @@ inspector/ │ │ ├── node/ # Node stdio transport factory │ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch │ │ │ └── node/ # Hono-based remote server backend (used by remote/ above) -│ │ └── state/ # Zustand-style state stores consumed by core/react/ +│ │ └── state/ # InspectorClient state stores consumed by core/react/ │ ├── react/ # React hooks over the state stores -│ └── storage/ # File and remote storage adapters (Zustand middleware) +│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests. │ ├── src/ # TypeScript sources. │ ├── build/ # Built JS (gitignored). Produced by `npm run test-servers:build` diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 940cfb55..7be38fe9 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -15,8 +15,7 @@ "atomically": "^2.1.1", "commander": "^13.1.0", "pino": "^9.14.0", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "bin": { "mcp-inspector-cli": "build/index.js" @@ -5675,35 +5674,6 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } - }, - "node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } } } } diff --git a/clients/cli/package.json b/clients/cli/package.json index d76b3b45..b025ab8b 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -38,8 +38,7 @@ "atomically": "^2.1.1", "commander": "^13.1.0", "pino": "^9.14.0", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts index 0091499c..63278f07 100644 --- a/clients/cli/tsup.config.ts +++ b/clients/cli/tsup.config.ts @@ -15,7 +15,7 @@ export default defineConfig({ platform: 'node', // Bundle core source; leave npm deps external. noExternal: [/^@inspector\/core/], - external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino', 'zustand'], + external: ['@napi-rs/keyring', '@modelcontextprotocol/sdk', 'commander', 'pino'], esbuildOptions(options) { options.alias = { '@inspector/core': path.join(repoRoot, 'core'), diff --git a/clients/tui/package-lock.json b/clients/tui/package-lock.json index 0eb6b0a4..91c3e9a1 100644 --- a/clients/tui/package-lock.json +++ b/clients/tui/package-lock.json @@ -20,8 +20,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "bin": { "mcp-inspector-tui": "build/index.js" @@ -7510,35 +7509,6 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } - }, - "node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } } } } diff --git a/clients/tui/package.json b/clients/tui/package.json index cfd4ea3e..73070e2f 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -38,8 +38,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 039a245f..568d97d7 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -22,7 +22,6 @@ export default defineConfig({ 'open', 'commander', 'pino', - 'zustand', '@modelcontextprotocol/sdk', '@napi-rs/keyring', ], diff --git a/clients/web/package-lock.json b/clients/web/package-lock.json index 8553cda8..c4506904 100644 --- a/clients/web/package-lock.json +++ b/clients/web/package-lock.json @@ -34,8 +34,7 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "bin": { "mcp-inspector-web": "build/index.js" @@ -9952,7 +9951,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -10359,35 +10358,6 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/zustand": { - "version": "5.0.13", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", - "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/clients/web/package.json b/clients/web/package.json index b51cb69b..3c666856 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -59,8 +59,7 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "devDependencies": { "@chromatic-com/storybook": "^5.0.1", diff --git a/clients/web/src/test/core/auth/cimd.test.ts b/clients/web/src/test/core/auth/cimd.test.ts index 7f3bc38b..afb7ac3a 100644 --- a/clients/web/src/test/core/auth/cimd.test.ts +++ b/clients/web/src/test/core/auth/cimd.test.ts @@ -24,7 +24,7 @@ describe("ensureCimdClientRegistration", () => { storage = { getClientInformation: vi.fn(async () => undefined), saveClientInformation: vi.fn(async () => {}), - getScope: vi.fn(() => undefined), + getScope: vi.fn().mockResolvedValue(undefined), getTokens: vi.fn(async () => undefined), saveTokens: vi.fn(async () => {}), clear: vi.fn(), diff --git a/clients/web/src/test/core/auth/connection-state.test.ts b/clients/web/src/test/core/auth/connection-state.test.ts index d060439b..0e70ef07 100644 --- a/clients/web/src/test/core/auth/connection-state.test.ts +++ b/clients/web/src/test/core/auth/connection-state.test.ts @@ -14,11 +14,13 @@ function createStorage( tokens: Awaited>; preregistered: Awaited>; dynamic: Awaited>; - registrationKind: ReturnType; + registrationKind: Awaited< + ReturnType + >; scope: string | undefined; - serverMetadata: ReturnType; + serverMetadata: Awaited>; idpSession: Awaited>; - idpMetadata: ReturnType; + idpMetadata: Awaited>; }> = {}, ): OAuthStorage { return { @@ -27,15 +29,17 @@ function createStorage( getClientInformation: vi.fn(async (_url, isPreregistered) => isPreregistered ? overrides.preregistered : overrides.dynamic, ), - getScope: vi.fn().mockReturnValue(overrides.scope), - getServerMetadata: vi.fn((url: string) => { + getScope: vi.fn().mockResolvedValue(overrides.scope), + getServerMetadata: vi.fn(async (url: string) => { if (url.startsWith("ema-idp:")) { return overrides.idpMetadata ?? null; } return overrides.serverMetadata ?? null; }), getIdpSession: vi.fn().mockResolvedValue(overrides.idpSession), - getClientRegistrationKind: vi.fn(() => overrides.registrationKind), + getClientRegistrationKind: vi + .fn() + .mockResolvedValue(overrides.registrationKind), saveClientInformation: vi.fn(), savePreregisteredClientInformation: vi.fn(), saveTokens: vi.fn(), diff --git a/clients/web/src/test/core/auth/ema/emaFlow.test.ts b/clients/web/src/test/core/auth/ema/emaFlow.test.ts index d88109cb..25ff3b78 100644 --- a/clients/web/src/test/core/auth/ema/emaFlow.test.ts +++ b/clients/web/src/test/core/auth/ema/emaFlow.test.ts @@ -49,19 +49,21 @@ function createMemoryStorage( idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; }), clearIdpSession: vi.fn(), - getServerMetadata: vi.fn((key: string) => metadataByKey[key]), - saveServerMetadata: vi.fn((key: string, metadata: unknown) => { + getServerMetadata: vi.fn(async (key: string) => metadataByKey[key] ?? null), + saveServerMetadata: vi.fn(async (key: string, metadata: unknown) => { metadataByKey[key] = metadata; }), - getClientInformation: vi.fn((key: string) => clientInfoByKey[key]), - savePreregisteredClientInformation: vi.fn((key: string, info: unknown) => { - clientInfoByKey[key] = info; - }), - getCodeVerifier: vi.fn((key: string) => codeVerifierByKey[key]), - saveCodeVerifier: vi.fn((key: string, verifier: string) => { + getClientInformation: vi.fn(async (key: string) => clientInfoByKey[key]), + savePreregisteredClientInformation: vi.fn( + async (key: string, info: unknown) => { + clientInfoByKey[key] = info; + }, + ), + getCodeVerifier: vi.fn(async (key: string) => codeVerifierByKey[key]), + saveCodeVerifier: vi.fn(async (key: string, verifier: string) => { codeVerifierByKey[key] = verifier; }), - clearCodeVerifier: vi.fn((key: string) => { + clearCodeVerifier: vi.fn(async (key: string) => { delete codeVerifierByKey[key]; }), getTokens: vi.fn(async (url: string) => savedTokens[url] as never), diff --git a/clients/web/src/test/core/auth/ema/idpOidc.test.ts b/clients/web/src/test/core/auth/ema/idpOidc.test.ts index baebb845..1c6d73f3 100644 --- a/clients/web/src/test/core/auth/ema/idpOidc.test.ts +++ b/clients/web/src/test/core/auth/ema/idpOidc.test.ts @@ -34,7 +34,7 @@ describe("idpOidc refresh", () => { saveIdpSession: vi.fn(async (issuer: string, updates) => { idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; }), - getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)), + getServerMetadata: vi.fn(async () => minimalOAuthAsMetadata(IDP_ISSUER)), clearIdpSession: vi.fn(), } as unknown as OAuthStorage; }); @@ -266,7 +266,7 @@ describe("startIdpOidcAuthorization", () => { Object.keys(saved).forEach((k) => delete saved[k]); storage = { load: vi.fn().mockResolvedValue(undefined), - getServerMetadata: vi.fn(() => null), + getServerMetadata: vi.fn(async () => null), saveCodeVerifier: vi.fn(async (key: string, value: string) => { saved[`cv:${key}`] = value; }), @@ -335,13 +335,13 @@ describe("completeIdpOidcAuthorization", () => { function buildStorage(overrides: Partial = {}): OAuthStorage { return { load: vi.fn().mockResolvedValue(undefined), - getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)), + getServerMetadata: vi.fn(async () => minimalOAuthAsMetadata(IDP_ISSUER)), getClientInformation: vi.fn(async () => ({ client_id: "idp-client", client_secret: "idp-secret", token_endpoint_auth_method: "client_secret_post", })), - getCodeVerifier: vi.fn(() => "verifier-123"), + getCodeVerifier: vi.fn(async () => "verifier-123"), clearCodeVerifier: vi.fn((key: string) => { clearedVerifiers.push(key); }), @@ -474,7 +474,7 @@ describe("completeIdpOidcAuthorization", () => { }); it("throws when the PKCE verifier is missing", async () => { - storage = buildStorage({ getCodeVerifier: vi.fn(() => undefined) }); + storage = buildStorage({ getCodeVerifier: vi.fn(async () => undefined) }); await expect( completeIdpOidcAuthorization({ idp, diff --git a/clients/web/src/test/core/auth/oauth-persist.test.ts b/clients/web/src/test/core/auth/oauth-persist.test.ts new file mode 100644 index 00000000..ccb7b4d1 --- /dev/null +++ b/clients/web/src/test/core/auth/oauth-persist.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { + parseOAuthPersistBlob, + serializeOAuthPersistBlob, +} from "@inspector/core/auth/oauth-persist.js"; + +describe("parseOAuthPersistBlob", () => { + it("returns null for empty input", () => { + expect(parseOAuthPersistBlob(null)).toBeNull(); + }); + + it("reads plain JSON with servers and idpSessions", () => { + const snapshot = { + servers: { + "http://example.com": { codeVerifier: "v1" }, + }, + idpSessions: { + "https://idp.example": { idToken: "token" }, + }, + }; + expect(parseOAuthPersistBlob(JSON.stringify(snapshot))).toEqual(snapshot); + }); + + it("promotes legacy persist envelope state to the top level", () => { + const legacy = { + state: { + servers: { + "http://example.com": { + tokens: { access_token: "t", token_type: "Bearer" }, + }, + }, + idpSessions: {}, + }, + version: 0, + }; + expect(parseOAuthPersistBlob(JSON.stringify(legacy))).toEqual({ + servers: legacy.state.servers, + idpSessions: {}, + }); + }); +}); + +describe("serializeOAuthPersistBlob", () => { + it("writes plain JSON without a state/version envelope", () => { + const snapshot = { + servers: { "http://example.com": { scope: "read" } }, + idpSessions: {}, + }; + const raw = serializeOAuthPersistBlob(snapshot); + expect(JSON.parse(raw)).toEqual(snapshot); + expect(raw).not.toContain('"version"'); + expect(raw).not.toMatch(/"state"\s*:/); + }); +}); diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index d7270584..fc066539 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -214,7 +214,7 @@ describe("OAuthNavigation", () => { function makeStorage(): OAuthStorage { return { load: vi.fn().mockResolvedValue(undefined), - getScope: vi.fn(() => undefined), + getScope: vi.fn().mockResolvedValue(undefined), getClientInformation: vi.fn(async () => undefined), saveClientInformation: vi.fn(async () => undefined), savePreregisteredClientInformation: vi.fn(async () => undefined), @@ -222,9 +222,9 @@ describe("OAuthNavigation", () => { getTokens: vi.fn(async () => undefined), saveTokens: vi.fn(async () => undefined), saveCodeVerifier: vi.fn(async () => undefined), - getCodeVerifier: vi.fn(() => undefined), + getCodeVerifier: vi.fn().mockResolvedValue(undefined), clear: vi.fn(), - getServerMetadata: vi.fn(() => null), + getServerMetadata: vi.fn().mockResolvedValue(null), saveServerMetadata: vi.fn(async () => undefined), } as unknown as OAuthStorage; } @@ -272,27 +272,28 @@ describe("OAuthNavigation", () => { expect(await provider.clientInformation()).toEqual({ client_id: "dyn" }); }); - it("codeVerifier() throws when none is saved", () => { + it("codeVerifier() throws when none is saved", async () => { const storage = makeStorage(); const provider = makeProvider(storage); - expect(() => provider.codeVerifier()).toThrow( + await expect(provider.codeVerifier()).rejects.toThrow( /No code verifier saved for session/, ); }); - it("codeVerifier() returns the saved verifier", () => { + it("codeVerifier() returns the saved verifier", async () => { const storage = makeStorage(); - vi.mocked(storage.getCodeVerifier).mockReturnValue("cv-1"); + vi.mocked(storage.getCodeVerifier).mockResolvedValue("cv-1"); const provider = makeProvider(storage); - expect(provider.codeVerifier()).toBe("cv-1"); + expect(await provider.codeVerifier()).toBe("cv-1"); }); - it("clientMetadata reflects the stored scope when present", () => { + it("clientMetadata reflects the stored scope when present", async () => { const storage = makeStorage(); - vi.mocked(storage.getScope).mockReturnValue("read write"); + vi.mocked(storage.getScope).mockResolvedValue("read write"); const provider = makeProvider(storage); + await provider.prepareForAuth(); expect(provider.clientMetadata.scope).toBe("read write"); }); @@ -380,7 +381,7 @@ describe("OAuthNavigation", () => { expect(storage.saveCodeVerifier).toHaveBeenCalledWith(SERVER, "cv"); expect(storage.saveServerMetadata).toHaveBeenCalled(); expect(await provider.tokens()).toBeUndefined(); - expect(provider.getServerMetadata()).toBeNull(); + expect(await provider.getServerMetadata()).toBeNull(); const state = await provider.state(); expect(typeof state).toBe("string"); expect(state.length).toBeGreaterThan(0); diff --git a/clients/web/src/test/core/auth/storage-browser.test.ts b/clients/web/src/test/core/auth/storage-browser.test.ts index 52afc034..bb855584 100644 --- a/clients/web/src/test/core/auth/storage-browser.test.ts +++ b/clients/web/src/test/core/auth/storage-browser.test.ts @@ -91,16 +91,11 @@ describe("BrowserOAuthStorage", () => { client_secret: "preregistered-secret", }; - // Use the storage API instead of manually setting sessionStorage - // since BrowserOAuthStorage now uses Zustand with a different storage format await storage.savePreregisteredClientInformation( testServerUrl, preregisteredInfo, ); - // Wait for Zustand to persist - await new Promise((resolve) => setTimeout(resolve, 100)); - const result = await storage.getClientInformation(testServerUrl, true); expect(result).toEqual(preregisteredInfo); @@ -139,14 +134,18 @@ describe("BrowserOAuthStorage", () => { const result = await storage.getClientInformation(testServerUrl); expect(result).toEqual(secondInfo); - expect(storage.getClientRegistrationKind(testServerUrl)).toBe("cimd"); + expect(await storage.getClientRegistrationKind(testServerUrl)).toBe( + "cimd", + ); }); it("savePreregisteredClientInformation sets static registration kind", async () => { await storage.savePreregisteredClientInformation(testServerUrl, { client_id: "static-id", }); - expect(storage.getClientRegistrationKind(testServerUrl)).toBe("static"); + expect(await storage.getClientRegistrationKind(testServerUrl)).toBe( + "static", + ); }); }); @@ -333,16 +332,16 @@ describe("BrowserOAuthStorage", () => { it("clearCodeVerifier removes only the PKCE verifier", async () => { await storage.saveCodeVerifier(testServerUrl, "verifier"); - expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier"); + expect(await storage.getCodeVerifier(testServerUrl)).toBe("verifier"); await storage.clearCodeVerifier(testServerUrl); - expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined(); + expect(await storage.getCodeVerifier(testServerUrl)).toBeUndefined(); }); it("clearScope removes only the scope", async () => { await storage.saveScope(testServerUrl, "read"); - expect(storage.getScope(testServerUrl)).toBe("read"); + expect(await storage.getScope(testServerUrl)).toBe("read"); await storage.clearScope(testServerUrl); - expect(storage.getScope(testServerUrl)).toBeUndefined(); + expect(await storage.getScope(testServerUrl)).toBeUndefined(); }); it("clearServerMetadata removes only the cached metadata", async () => { @@ -353,9 +352,9 @@ describe("BrowserOAuthStorage", () => { response_types_supported: ["code"], }; await storage.saveServerMetadata(testServerUrl, metadata); - expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata); + expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata); await storage.clearServerMetadata(testServerUrl); - expect(storage.getServerMetadata(testServerUrl)).toBeNull(); + expect(await storage.getServerMetadata(testServerUrl)).toBeNull(); }); }); diff --git a/clients/web/src/test/core/auth/storage-remote.test.ts b/clients/web/src/test/core/auth/storage-remote.test.ts index be8536a5..198ac757 100644 --- a/clients/web/src/test/core/auth/storage-remote.test.ts +++ b/clients/web/src/test/core/auth/storage-remote.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js"; -import { - type OAuthStore, - waitForOAuthStorePersistLoad, -} from "@inspector/core/auth/store.js"; const NOOP_FETCH = vi.fn( async () => @@ -76,16 +72,16 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { it("codeVerifier round-trip and clearCodeVerifier", async () => { await storage.saveCodeVerifier(serverUrl, "verifier"); - expect(storage.getCodeVerifier(serverUrl)).toBe("verifier"); + expect(await storage.getCodeVerifier(serverUrl)).toBe("verifier"); await storage.clearCodeVerifier(serverUrl); - expect(storage.getCodeVerifier(serverUrl)).toBeUndefined(); + expect(await storage.getCodeVerifier(serverUrl)).toBeUndefined(); }); it("scope round-trip and clearScope", async () => { await storage.saveScope(serverUrl, "read write"); - expect(storage.getScope(serverUrl)).toBe("read write"); + expect(await storage.getScope(serverUrl)).toBe("read write"); await storage.clearScope(serverUrl); - expect(storage.getScope(serverUrl)).toBeUndefined(); + expect(await storage.getScope(serverUrl)).toBeUndefined(); }); it("serverMetadata round-trip and clearServerMetadata", async () => { @@ -96,9 +92,9 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { response_types_supported: ["code"], }; await storage.saveServerMetadata(serverUrl, md); - expect(storage.getServerMetadata(serverUrl)).toEqual(md); + expect(await storage.getServerMetadata(serverUrl)).toEqual(md); await storage.clearServerMetadata(serverUrl); - expect(storage.getServerMetadata(serverUrl)).toBeNull(); + expect(await storage.getServerMetadata(serverUrl)).toBeNull(); }); it("clear() wipes all state for a server", async () => { @@ -125,13 +121,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { expect(s).toBeInstanceOf(RemoteOAuthStorage); }); - it("waitForOAuthStorePersistLoad resolves for an unregistered store handle", async () => { - await expect( - waitForOAuthStorePersistLoad({} as OAuthStore), - ).resolves.toBeUndefined(); - }); - - it("load() waits for remote GET before sync reads see persisted state", async () => { + it("getCodeVerifier loads remote state automatically when not preloaded", async () => { const persisted = { state: { servers: { @@ -155,15 +145,10 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { fetchFn: delayedFetch, }); - const loadPromise = delayedStorage.load(); - expect(delayedStorage.getCodeVerifier(serverUrl)).toBeUndefined(); - + const getPromise = delayedStorage.getCodeVerifier(serverUrl); resolveFetch(new Response(JSON.stringify(persisted), { status: 200 })); - await loadPromise; - expect(delayedStorage.getCodeVerifier(serverUrl)).toBe( - "persisted-verifier", - ); + expect(await getPromise).toBe("persisted-verifier"); }); it("load() rejects when remote GET fails instead of hanging", async () => { diff --git a/clients/web/src/test/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index b4bfa7bb..064c9301 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -36,15 +36,15 @@ function createMockParams( const storage = { load: vi.fn().mockResolvedValue(undefined), - getScope: vi.fn().mockReturnValue(undefined), + getScope: vi.fn().mockResolvedValue(undefined), getClientInformation: vi.fn().mockResolvedValue(undefined), - getClientRegistrationKind: vi.fn().mockReturnValue(undefined), + getClientRegistrationKind: vi.fn().mockResolvedValue(undefined), saveClientInformation: vi.fn().mockResolvedValue(undefined), savePreregisteredClientInformation: vi.fn().mockResolvedValue(undefined), saveScope: vi.fn().mockResolvedValue(undefined), getTokens: vi.fn().mockResolvedValue(undefined), saveTokens: vi.fn().mockResolvedValue(undefined), - getCodeVerifier: vi.fn().mockReturnValue("verifier"), + getCodeVerifier: vi.fn().mockResolvedValue("verifier"), saveCodeVerifier: vi.fn().mockResolvedValue(undefined), clear: vi.fn().mockResolvedValue(undefined), clearClientInformation: vi.fn(), @@ -52,7 +52,7 @@ function createMockParams( clearCodeVerifier: vi.fn(), clearScope: vi.fn(), clearServerMetadata: vi.fn(), - getServerMetadata: vi.fn().mockReturnValue(null), + getServerMetadata: vi.fn().mockResolvedValue(null), saveServerMetadata: vi.fn().mockResolvedValue(undefined), getIdpSession: vi.fn().mockResolvedValue(undefined), saveIdpSession: vi.fn().mockResolvedValue(undefined), @@ -428,7 +428,7 @@ describe("OAuthManager", () => { const params = createMockParams({ onBeforeOAuthRedirect }); // A configured scope exercises the saveScope branch in createOAuthProvider. params.initialConfig.scope = "read write"; - storageOf(params).getScope.mockReturnValue(undefined); + storageOf(params).getScope.mockResolvedValue(undefined); storageOf(params).getClientInformation.mockResolvedValue({ client_id: "cid", }); @@ -466,7 +466,9 @@ describe("OAuthManager", () => { mockedMcpAuth.mockResolvedValue("REDIRECT"); const params = createMockParams(); params.initialConfig.scope = "mcp tools:read"; - storageOf(params).getScope.mockReturnValue("mcp tools:read weather:read"); + storageOf(params).getScope.mockResolvedValue( + "mcp tools:read weather:read", + ); storageOf(params).getClientInformation.mockResolvedValue({ client_id: "cid", }); @@ -586,7 +588,7 @@ describe("OAuthManager", () => { .mockResolvedValueOnce("REDIRECT") .mockResolvedValueOnce("AUTHORIZED"); const params = createMockParams(); - storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getScope.mockResolvedValue("mcp"); storageOf(params).getTokens.mockResolvedValue({ access_token: "access", refresh_token: "refresh", @@ -629,7 +631,7 @@ describe("OAuthManager", () => { it("persists granted scope when AS down-scopes the token response", async () => { mockedMcpAuth.mockResolvedValue("AUTHORIZED"); const params = createMockParams(); - storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getScope.mockResolvedValue("mcp"); storageOf(params).getTokens.mockResolvedValue({ access_token: "access", token_type: "Bearer", @@ -984,7 +986,9 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read tools:write", }); - storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write"); + storageOf(params).getScope.mockResolvedValue( + "mcp tools:read tools:write", + ); const manager = new OAuthManager(params); expect( @@ -1002,7 +1006,7 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read", }); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); const manager = new OAuthManager(params); expect( @@ -1020,7 +1024,7 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp", }); - storageOf(params).getScope.mockReturnValue("mcp weather:read"); + storageOf(params).getScope.mockResolvedValue("mcp weather:read"); const manager = new OAuthManager(params); expect( @@ -1037,7 +1041,7 @@ describe("OAuthManager", () => { access_token: "tok", token_type: "Bearer", }); - storageOf(params).getScope.mockReturnValue(undefined); + storageOf(params).getScope.mockResolvedValue(undefined); const manager = new OAuthManager(params); expect( @@ -1054,7 +1058,9 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read tools:write", }); - storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write"); + storageOf(params).getScope.mockResolvedValue( + "mcp tools:read tools:write", + ); const manager = new OAuthManager(params); const outcome = await manager.handleAuthChallenge({ @@ -1136,7 +1142,7 @@ describe("OAuthManager", () => { const params = createMockParams(); const manager = new OAuthManager(params); manager.setOAuthConfig({ scope: "catalog:scope" }); - storageOf(params).getScope.mockReturnValue("stored union scope"); + storageOf(params).getScope.mockResolvedValue("stored union scope"); const captureSpy = vi .spyOn( (await import("@inspector/core/auth/providers.js")) @@ -1164,7 +1170,7 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read", }); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); const manager = new OAuthManager(params); const outcome = await manager.handleAuthChallenge({ @@ -1201,7 +1207,7 @@ describe("OAuthManager", () => { ); mockedMcpAuth.mockResolvedValue("REDIRECT"); const params = createMockParams(); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); const manager = new OAuthManager(params); const captureSpy = vi .spyOn( @@ -1234,7 +1240,7 @@ describe("OAuthManager", () => { ); mockedMcpAuth.mockResolvedValue("REDIRECT"); const params = createMockParams(); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params).getTokens.mockResolvedValue({ access_token: "access", refresh_token: "refresh", @@ -1322,7 +1328,7 @@ describe("OAuthManager", () => { }, }, }); - storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getScope.mockResolvedValue("mcp"); storageOf(params).getTokens.mockResolvedValue({ access_token: "tok", token_type: "Bearer", @@ -1360,7 +1366,7 @@ describe("OAuthManager", () => { }, }, }); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params).getTokens.mockResolvedValue({ access_token: "tok", token_type: "Bearer", @@ -1453,7 +1459,7 @@ describe("OAuthManager", () => { }, }, }); - storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getScope.mockResolvedValue("mcp"); storageOf(params).getTokens.mockResolvedValue({ access_token: "tok", token_type: "Bearer", @@ -1501,7 +1507,7 @@ describe("OAuthManager", () => { }, }, }); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params).getTokens.mockResolvedValue({ access_token: "old", token_type: "Bearer", @@ -1635,7 +1641,7 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read weather:read", }; - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params) .getTokens.mockResolvedValueOnce(insufficientTokens) .mockResolvedValue(sufficientTokens); @@ -1665,7 +1671,7 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read weather:read", }; - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params) .getTokens.mockResolvedValueOnce(insufficientTokens) .mockResolvedValueOnce(insufficientTokens) @@ -1801,7 +1807,7 @@ describe("OAuthManager", () => { .mockResolvedValueOnce("AUTHORIZED") .mockResolvedValueOnce("REDIRECT"); const params = createMockParams(); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params).getTokens.mockResolvedValue({ access_token: "access", token_type: "Bearer", diff --git a/clients/web/src/test/integration/auth/node/storage.test.ts b/clients/web/src/test/integration/auth/node/storage.test.ts index eb43eab3..9eea463d 100644 --- a/clients/web/src/test/integration/auth/node/storage.test.ts +++ b/clients/web/src/test/integration/auth/node/storage.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { NodeOAuthStorage, - getOAuthStore, + getStateFilePath, + resetNodeOAuthStorageCache, clearAllOAuthClientState, } from "@inspector/core/auth/node/storage-node.js"; import type { @@ -20,44 +21,27 @@ const testStatePath = path.join( `mcp-inspector-oauth-${process.pid}-storage-node.json`, ); +async function resetTestStorage(stateFilePath: string): Promise { + try { + await fs.unlink(stateFilePath); + } catch { + // Ignore if file doesn't exist + } + resetNodeOAuthStorageCache(stateFilePath); +} + describe("NodeOAuthStorage", () => { let storage: NodeOAuthStorage; const testServerUrl = "http://localhost:3000"; const stateFilePath = testStatePath; beforeEach(async () => { - // Clean up any existing state file - try { - await fs.unlink(stateFilePath); - } catch { - // Ignore if file doesn't exist - } - - // Reset store state by clearing all servers - const store = getOAuthStore(testStatePath); - const state = store.getState(); - // Clear all server states - Object.keys(state.servers).forEach((url) => { - state.clearServerState(url); - }); - + await resetTestStorage(stateFilePath); storage = new NodeOAuthStorage(testStatePath); }); afterEach(async () => { - // Clean up state file after each test - try { - await fs.unlink(stateFilePath); - } catch { - // Ignore if file doesn't exist - } - - // Reset store state - const store = getOAuthStore(testStatePath); - const state = store.getState(); - Object.keys(state.servers).forEach((url) => { - state.clearServerState(url); - }); + await resetTestStorage(stateFilePath); }); describe("getClientInformation", () => { @@ -88,11 +72,10 @@ describe("NodeOAuthStorage", () => { client_secret: "preregistered-secret", }; - // Store as preregistered by directly setting it in the store - const store = getOAuthStore(testStatePath); - store.getState().setServerState(testServerUrl, { - preregisteredClientInformation: preregisteredInfo, - }); + await storage.savePreregisteredClientInformation( + testServerUrl, + preregisteredInfo, + ); const result = await storage.getClientInformation(testServerUrl, true); @@ -341,16 +324,16 @@ describe("NodeOAuthStorage", () => { it("clearCodeVerifier removes only the PKCE verifier", async () => { await storage.saveCodeVerifier(testServerUrl, "verifier"); - expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier"); + expect(await storage.getCodeVerifier(testServerUrl)).toBe("verifier"); await storage.clearCodeVerifier(testServerUrl); - expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined(); + expect(await storage.getCodeVerifier(testServerUrl)).toBeUndefined(); }); it("clearScope removes only the scope", async () => { await storage.saveScope(testServerUrl, "read write"); - expect(storage.getScope(testServerUrl)).toBe("read write"); + expect(await storage.getScope(testServerUrl)).toBe("read write"); await storage.clearScope(testServerUrl); - expect(storage.getScope(testServerUrl)).toBeUndefined(); + expect(await storage.getScope(testServerUrl)).toBeUndefined(); }); it("clearServerMetadata removes only the cached metadata", async () => { @@ -361,9 +344,9 @@ describe("NodeOAuthStorage", () => { response_types_supported: ["code"], }; await storage.saveServerMetadata(testServerUrl, metadata); - expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata); + expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata); await storage.clearServerMetadata(testServerUrl); - expect(storage.getServerMetadata(testServerUrl)).toBeNull(); + expect(await storage.getServerMetadata(testServerUrl)).toBeNull(); }); }); @@ -437,83 +420,52 @@ describe("NodeOAuthStorage", () => { expect(result2).toEqual(clientInfo2); }); }); -}); -describe("OAuth Store (Zustand)", () => { - const stateFilePath = testStatePath; + it("reuses in-memory state for the same file path", async () => { + const serverUrl = "http://localhost:3000"; + const tokens: OAuthTokens = { + access_token: "shared-memory-token", + token_type: "Bearer", + }; - beforeEach(async () => { - try { - await fs.unlink(stateFilePath); - } catch { - // Ignore if file doesn't exist - } + await storage.saveTokens(serverUrl, tokens); + + const otherView = new NodeOAuthStorage(testStatePath); + expect(await otherView.getTokens(serverUrl)).toEqual(tokens); }); - afterEach(async () => { - try { - await fs.unlink(stateFilePath); - } catch { - // Ignore if file doesn't exist - } - }); - - it("should create a new store", () => { - const store = getOAuthStore(testStatePath); - expect(store).toBeDefined(); - expect(store.getState).toBeDefined(); - expect(store.setState).toBeDefined(); - }); - - it("should return the same store instance via getOAuthStore", () => { - const store1 = getOAuthStore(testStatePath); - const store2 = getOAuthStore(testStatePath); - expect(store1).toBe(store2); - }); - - it("should persist state to file", async () => { - // Use a unique path so no other test (e.g. "should overwrite..." with second-id) can - // write to the same file; Zustand persist is async and can race with shared paths. + it("persists state to file on save", async () => { const persistTestPath = path.join( os.tmpdir(), `mcp-inspector-oauth-persist-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, ); try { - if (process.env.DEBUG_PERSIST_PATH === "1") { - console.error("[storage-node.test] state file path:", persistTestPath); - } - const store = getOAuthStore(persistTestPath); + resetNodeOAuthStorageCache(persistTestPath); + const fileStorage = new NodeOAuthStorage(persistTestPath); const serverUrl = "http://localhost:3000"; const clientInfo: OAuthClientInformation = { client_id: "test-client-id", }; - store.getState().setServerState(serverUrl, { - clientInformation: clientInfo, + await fileStorage.saveClientInformation(serverUrl, clientInfo, { + registrationKind: "dcr", }); type StateShape = { - state: { - servers: Record< - string, - { clientInformation?: OAuthClientInformation } - >; - }; + servers: Record; }; - // Persistence is fire-and-forget; await the write rather than polling. await flushStoreFileWrites(persistTestPath); const parsed = JSON.parse( await fs.readFile(persistTestPath, "utf-8"), ) as StateShape; - expect(parsed.state.servers[serverUrl]?.clientInformation).toEqual( - clientInfo, - ); + expect(parsed.servers[serverUrl]?.clientInformation).toEqual(clientInfo); } finally { try { await fs.unlink(persistTestPath); } catch { /* ignore */ } + resetNodeOAuthStorageCache(persistTestPath); } }); }); @@ -572,9 +524,7 @@ describe("NodeOAuthStorage with custom storagePath", () => { await storage.saveTokens(testServerUrl, tokens); type StateShape = { - state: { - servers: Record; - }; + servers: Record; }; // Persistence is fire-and-forget; await the write rather than polling. await flushStoreFileWrites(customPath); @@ -582,7 +532,7 @@ describe("NodeOAuthStorage with custom storagePath", () => { await fs.readFile(customPath, "utf-8"), ) as StateShape; - expect(parsed.state.servers[testServerUrl]?.tokens?.access_token).toBe( + expect(parsed.servers[testServerUrl]?.tokens?.access_token).toBe( tokens.access_token, ); @@ -599,33 +549,34 @@ describe("NodeOAuthStorage with custom storagePath", () => { }); it("clearAllOAuthClientState clears every server in the default store", async () => { - const defaultStore = getOAuthStore(); - defaultStore.getState().setServerState("http://server-a.test", { - tokens: { access_token: "a", token_type: "Bearer" }, + resetNodeOAuthStorageCache(); + const storage = new NodeOAuthStorage(); + await storage.saveTokens("http://server-a.test", { + access_token: "a", + token_type: "Bearer", }); - defaultStore.getState().setServerState("http://server-b.test", { - tokens: { access_token: "b", token_type: "Bearer" }, + await storage.saveTokens("http://server-b.test", { + access_token: "b", + token_type: "Bearer", }); - clearAllOAuthClientState(); - const all = defaultStore.getState(); - expect(all.getServerState("http://server-a.test").tokens).toBeUndefined(); - expect(all.getServerState("http://server-b.test").tokens).toBeUndefined(); + await flushStoreFileWrites(getStateFilePath()); + + await clearAllOAuthClientState(); + + expect(await storage.getTokens("http://server-a.test")).toBeUndefined(); + expect(await storage.getTokens("http://server-b.test")).toBeUndefined(); + + resetNodeOAuthStorageCache(); }); - it("clearAllOAuthClientState tolerates a store whose servers map is absent", () => { - // Exercises the `state.servers ?? {}` nullish fallback: when the persisted - // state lacks a `servers` map, the iteration must default to {} (no throw). - const defaultStore = getOAuthStore(); - const clearServerState = vi.fn(); - vi.spyOn(defaultStore, "getState").mockReturnValue({ - servers: undefined, - clearServerState, - } as unknown as ReturnType); + it("clearAllOAuthClientState tolerates persisted state without a servers map", async () => { + const filePath = getStateFilePath(); + resetNodeOAuthStorageCache(); + await fs.writeFile(filePath, JSON.stringify({ idpSessions: {} }), "utf-8"); - expect(() => clearAllOAuthClientState()).not.toThrow(); - expect(clearServerState).not.toHaveBeenCalled(); + await expect(clearAllOAuthClientState()).resolves.toBeUndefined(); - vi.mocked(defaultStore.getState).mockRestore(); + resetNodeOAuthStorageCache(); }); it("should isolate state from default store", async () => { @@ -635,12 +586,14 @@ describe("NodeOAuthStorage with custom storagePath", () => { ); try { + resetNodeOAuthStorageCache(); const defaultStorage = new NodeOAuthStorage(); await defaultStorage.saveTokens(testServerUrl, { access_token: "default-token", token_type: "Bearer", }); + resetNodeOAuthStorageCache(customPath); const customStorage = new NodeOAuthStorage(customPath); await customStorage.saveTokens(testServerUrl, { access_token: "custom-token", @@ -653,13 +606,15 @@ describe("NodeOAuthStorage with custom storagePath", () => { const fromDefault = await defaultStorage.getTokens(testServerUrl); expect(fromDefault?.access_token).toBe("default-token"); - getOAuthStore().getState().clearServerState(testServerUrl); + await defaultStorage.clear(testServerUrl); } finally { try { await fs.unlink(customPath); } catch { /* ignore */ } + resetNodeOAuthStorageCache(customPath); + resetNodeOAuthStorageCache(); } }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts index 8ce12e79..adc1f606 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts @@ -83,7 +83,7 @@ describe("InspectorClient EMA E2E", () => { }); beforeEach(async () => { - clearAllOAuthClientState(); + await clearAllOAuthClientState(); vi.spyOn(console, "log").mockImplementation(() => {}); const keys = await createEmaMockKeyMaterial(); @@ -199,14 +199,12 @@ describe("InspectorClient EMA E2E", () => { await flushStoreFileWrites(oauthTestStatePath); const raw = JSON.parse(await fs.readFile(oauthTestStatePath, "utf-8")) as { - state: { - servers: Record< - string, - { tokens?: { access_token?: string }; enterpriseManaged?: boolean } - >; - }; + servers: Record< + string, + { tokens?: { access_token?: string }; enterpriseManaged?: boolean } + >; }; - const entry = raw.state.servers[mcpUrl]; + const entry = raw.servers[mcpUrl]; expect(entry?.tokens?.access_token).toBeDefined(); expect(entry?.enterpriseManaged).toBe(true); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts index f0aa91ce..65cb7a8a 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts @@ -91,9 +91,9 @@ describe("InspectorClient OAuth E2E", () => { } }); - beforeEach(() => { + beforeEach(async () => { clearOAuthTestData(); - clearAllOAuthClientState(); + await clearAllOAuthClientState(); // Capture console.log output instead of printing to stdout during tests vi.spyOn(console, "log").mockImplementation(() => {}); }); @@ -1072,16 +1072,14 @@ describe("InspectorClient OAuth E2E", () => { expect(client.getStatus()).toBe("connected"); type StateShape = { - state?: { - servers?: Record; - }; + servers?: Record; }; // Persistence is fire-and-forget; await the write rather than polling. await flushStoreFileWrites(customPath); const parsed = JSON.parse( await fs.readFile(customPath, "utf-8"), ) as StateShape; - const servers = parsed.state?.servers ?? {}; + const servers = parsed.servers ?? {}; expect(Object.keys(servers).length).toBeGreaterThan(0); expect( Object.values(servers).some((s) => !!s?.tokens?.access_token), diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts index f2e0bb66..a825be95 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts @@ -388,7 +388,7 @@ describe("InspectorClient remote mid-session OAuth", () => { expect(recovery?.authChallenge.reason).toBe("insufficient_scope"); const mcpServerUrl = `${serverUrl}/mcp`; - const scopeBeforeStepUp = oauthConfig.storage.getScope(mcpServerUrl); + const scopeBeforeStepUp = await oauthConfig.storage.getScope(mcpServerUrl); expect(scopeBeforeStepUp).toContain("mcp"); expect(scopeBeforeStepUp).not.toContain("weather:read"); @@ -413,7 +413,7 @@ describe("InspectorClient remote mid-session OAuth", () => { expect(callbackClient.getStatus()).toBe("connected"); - const scopeAfterStepUp = oauthConfig.storage.getScope(mcpServerUrl); + const scopeAfterStepUp = await oauthConfig.storage.getScope(mcpServerUrl); expect(scopeAfterStepUp).toContain("weather:read"); expect(scopeAfterStepUp).toContain("mcp"); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts index 37c2d47f..10bb8271 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts @@ -352,18 +352,11 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => { remoteAuthToken!, (body) => { const b = body as { - state?: { - servers?: Record< - string, - { tokens?: { access_token?: string } } - >; - }; + servers?: Record; }; return !!( - b?.state?.servers && - Object.values(b.state.servers).some( - (s) => s?.tokens?.access_token, - ) + b?.servers && + Object.values(b.servers).some((s) => s?.tokens?.access_token) ); }, ); diff --git a/clients/web/src/test/integration/storage/adapters.test.ts b/clients/web/src/test/integration/storage/adapters.test.ts index 8c248a3f..a48663a9 100644 --- a/clients/web/src/test/integration/storage/adapters.test.ts +++ b/clients/web/src/test/integration/storage/adapters.test.ts @@ -1,17 +1,18 @@ /** - * Tests for storage adapters (file, remote). + * Tests for OAuth persistence (file + remote) and store-io flush helpers. */ -import { describe, it, expect, afterEach, vi } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { waitForRemoteStore } from "@modelcontextprotocol/inspector-test-server"; import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { serve } from "@hono/node-server"; import type { ServerType } from "@hono/node-server"; -import { createFileStorageAdapter } from "@inspector/core/storage/adapters/file-storage.js"; -import { createRemoteStorageAdapter } from "@inspector/core/storage/adapters/remote-storage.js"; -import { createOAuthStore } from "@inspector/core/auth/store.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js"; +import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js"; +import { OAuthMemoryStore } from "@inspector/core/auth/store.js"; +import { createFileOAuthPersistBackend } from "@inspector/core/auth/oauth-persist.js"; import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; import { writeStoreFile, @@ -53,80 +54,8 @@ async function startRemoteServer( }); } -describe("Storage adapters", () => { - describe("createRemoteStorageAdapter (unit, mocked fetch)", () => { - function makeAdapter(fetchFn: typeof fetch, authToken?: string) { - return createRemoteStorageAdapter({ - baseUrl: "http://remote.example/", - storeId: "test", - fetchFn, - authToken, - })!; - } - - it("getItem returns null on 404 and parses stored blob otherwise", async () => { - const fetchFn = vi - .fn() - .mockResolvedValueOnce(new Response("not found", { status: 404 })) - .mockResolvedValueOnce( - new Response(JSON.stringify({ state: { hi: 1 }, version: 0 })), - ) - .mockResolvedValueOnce(new Response("{}")); - - const adapter = makeAdapter(fetchFn as typeof fetch); - expect(await adapter.getItem("anything")).toBeNull(); - const blob = await adapter.getItem("anything"); - expect(blob).toBeTruthy(); - // Empty object response → treated as "not initialized" → null. - expect(await adapter.getItem("anything")).toBeNull(); - }); - - it("getItem throws on non-404 error responses", async () => { - const fetchFn = vi - .fn() - .mockResolvedValueOnce(new Response("boom", { status: 500 })); - const adapter = makeAdapter(fetchFn as typeof fetch); - await expect(adapter.getItem("x")).rejects.toThrow(/500/); - }); - - it("setItem throws when the POST fails", async () => { - const fetchFn = vi - .fn() - .mockResolvedValueOnce(new Response("nope", { status: 500 })); - const adapter = makeAdapter(fetchFn as typeof fetch); - await expect( - adapter.setItem("name", { state: { a: 1 }, version: 0 }), - ).rejects.toThrow(/500/); - }); - - it("removeItem tolerates 404 but rethrows on other failures", async () => { - const fetchFn = vi - .fn() - .mockResolvedValueOnce(new Response(null, { status: 204 })) - .mockResolvedValueOnce(new Response(null, { status: 404 })) - .mockResolvedValueOnce(new Response("boom", { status: 500 })); - const adapter = makeAdapter(fetchFn as typeof fetch); - await expect(adapter.removeItem("x")).resolves.toBeUndefined(); - await expect(adapter.removeItem("x")).resolves.toBeUndefined(); - await expect(adapter.removeItem("x")).rejects.toThrow(/500/); - }); - - it("sends the x-mcp-remote-auth header on all three verbs when authToken is set", async () => { - const fetchFn = vi - .fn() - .mockResolvedValue(new Response("{}")); - const adapter = makeAdapter(fetchFn as typeof fetch, "secret"); - await adapter.getItem("x"); - await adapter.setItem("x", { state: {}, version: 0 }); - await adapter.removeItem("x"); - for (const call of fetchFn.mock.calls) { - const headers = call[1]?.headers as Record | undefined; - expect(headers?.["x-mcp-remote-auth"]).toBe("Bearer secret"); - } - }); - }); - - describe("FileStorageAdapter", () => { +describe("OAuth persistence", () => { + describe("OAuth file persistence", () => { let tempDir: string | null = null; afterEach(() => { @@ -143,19 +72,17 @@ describe("Storage adapters", () => { it("creates store and persists state", async () => { tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); const filePath = join(tempDir!, "test-store.json"); - const storage = createFileStorageAdapter({ filePath }); - const store = createOAuthStore(storage); + const storage = new NodeOAuthStorage(filePath); - // Set some state - store.getState().setServerState("https://example.com", { - tokens: { access_token: "test-token", token_type: "Bearer" }, + await storage.saveTokens("https://example.com", { + access_token: "test-token", + token_type: "Bearer", }); - // Persistence is fire-and-forget; await the write rather than polling. await flushStoreFileWrites(filePath); const fileContent = readFileSync(filePath, "utf-8"); const parsed = JSON.parse(fileContent); - expect(parsed.state.servers["https://example.com"].tokens).toEqual({ + expect(parsed.servers["https://example.com"].tokens).toEqual({ access_token: "test-token", token_type: "Bearer", }); @@ -165,67 +92,91 @@ describe("Storage adapters", () => { tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); const filePath = join(tempDir!, "test-store.json"); - // Create initial store and persist - const storage1 = createFileStorageAdapter({ filePath }); - const store1 = createOAuthStore(storage1); - store1.getState().setServerState("https://example.com", { - tokens: { access_token: "initial-token", token_type: "Bearer" }, + const storage1 = new NodeOAuthStorage(filePath); + await storage1.saveTokens("https://example.com", { + access_token: "initial-token", + token_type: "Bearer", }); await flushStoreFileWrites(filePath); - // Create new store instance (should load persisted state) - const storage2 = createFileStorageAdapter({ filePath }); - const store2 = createOAuthStore(storage2); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const state = store2.getState().getServerState("https://example.com"); + const backend = createFileOAuthPersistBackend({ filePath }); + const snapshot = await backend.read(); + const freshMemory = new OAuthMemoryStore(snapshot ?? undefined); + const state = freshMemory + .getState() + .getServerState("https://example.com"); expect(state.tokens).toEqual({ access_token: "initial-token", token_type: "Bearer", }); }); + it("reads legacy persist envelope and rewrites as plain JSON on save", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const filePath = join(tempDir!, "test-store.json"); + await writeStoreFile( + filePath, + JSON.stringify({ + state: { + servers: { + "https://example.com": { + tokens: { access_token: "legacy", token_type: "Bearer" }, + }, + }, + idpSessions: {}, + }, + version: 0, + }), + ); + + const storage = new NodeOAuthStorage(filePath); + expect(await storage.getTokens("https://example.com")).toEqual({ + access_token: "legacy", + token_type: "Bearer", + }); + + await storage.saveScope("https://example.com", "read"); + await flushStoreFileWrites(filePath); + + const parsed = JSON.parse(readFileSync(filePath, "utf-8")); + expect(parsed.servers["https://example.com"].scope).toBe("read"); + expect(parsed.version).toBeUndefined(); + expect(parsed.state).toBeUndefined(); + }); + it("handles empty state after clear", async () => { tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); const filePath = join(tempDir!, "test-store.json"); - const storage = createFileStorageAdapter({ filePath }); - const store = createOAuthStore(storage); + const storage = new NodeOAuthStorage(filePath); - // Set state and persist - store.getState().setServerState("https://example.com", { - tokens: { access_token: "test-token", token_type: "Bearer" }, + await storage.saveTokens("https://example.com", { + access_token: "test-token", + token_type: "Bearer", }); await flushStoreFileWrites(filePath); expect(existsSync(filePath)).toBe(true); - // Clear all servers (this will persist empty state) - const state = store.getState(); - const urls = Object.keys(state.servers); - for (const url of urls) { - state.clearServerState(url); - } + await storage.clear("https://example.com"); await flushStoreFileWrites(filePath); - // Verify file still exists but with empty servers expect(existsSync(filePath)).toBe(true); const fileContent = readFileSync(filePath, "utf-8"); const parsed = JSON.parse(fileContent); - expect(Object.keys(parsed.state.servers).length).toBe(0); + expect(Object.keys(parsed.servers).length).toBe(0); }); - it("removeItem deletes the underlying file", async () => { + it("remove deletes the underlying file", async () => { tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); const filePath = join(tempDir!, "test-store.json"); - const storage = createFileStorageAdapter({ filePath }); - // createJSONStorage returns a PersistStorage wrapper that delegates to - // our adapter's removeItem callback (line 25 of file-storage.ts). - const store = createOAuthStore(storage); - store.getState().setServerState("https://example.com", { - tokens: { access_token: "t", token_type: "Bearer" }, + const storage = new NodeOAuthStorage(filePath); + await storage.saveTokens("https://example.com", { + access_token: "t", + token_type: "Bearer", }); await flushStoreFileWrites(filePath); expect(existsSync(filePath)).toBe(true); - await storage!.removeItem("inspector-oauth-store"); + const backend = createFileOAuthPersistBackend({ filePath }); + await backend.remove!(); expect(existsSync(filePath)).toBe(false); }); }); @@ -255,7 +206,6 @@ describe("Storage adapters", () => { tempDir = mkdtempSync(join(tmpdir(), "inspector-flush-test-")); const filePath = join(tempDir, "store.json"); - // Kick off the write without awaiting it, then flush. const write = writeStoreFile(filePath, '{"hello":"world"}'); await flushStoreFileWrites(filePath); expect(existsSync(filePath)).toBe(true); @@ -292,7 +242,7 @@ describe("Storage adapters", () => { }); }); - describe("RemoteStorageAdapter", () => { + describe("Remote OAuth persistence", () => { let remoteServer: ServerType | null = null; let tempDir: string | null = null; @@ -320,31 +270,27 @@ describe("Storage adapters", () => { }); remoteServer = server; - const storage = createRemoteStorageAdapter({ + const storage = new RemoteOAuthStorage({ baseUrl, storeId: "test-store", authToken, }); - const store = createOAuthStore(storage); - // Set some state - store.getState().setServerState("https://example.com", { - tokens: { access_token: "test-token", token_type: "Bearer" }, + await storage.saveTokens("https://example.com", { + access_token: "test-token", + token_type: "Bearer", }); await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { const d = body as { - state?: { - servers?: Record; - }; + servers?: Record; }; return ( - d?.state?.servers?.["https://example.com"]?.tokens?.access_token === + d?.servers?.["https://example.com"]?.tokens?.access_token === "test-token" ); }); - // Verify via API const res = await fetch(`${baseUrl}/api/storage/test-store`, { method: "GET", headers: { @@ -353,7 +299,7 @@ describe("Storage adapters", () => { }); expect(res.status).toBe(200); const storeData = await res.json(); - expect(storeData.state.servers["https://example.com"].tokens).toEqual({ + expect(storeData.servers["https://example.com"].tokens).toEqual({ access_token: "test-token", token_type: "Bearer", }); @@ -366,46 +312,32 @@ describe("Storage adapters", () => { }); remoteServer = server; - // Create initial store and persist - const storage1 = createRemoteStorageAdapter({ + const storage1 = new RemoteOAuthStorage({ baseUrl, storeId: "test-store", authToken, }); - const store1 = createOAuthStore(storage1); - store1.getState().setServerState("https://example.com", { - tokens: { access_token: "initial-token", token_type: "Bearer" }, + await storage1.saveTokens("https://example.com", { + access_token: "initial-token", + token_type: "Bearer", }); await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { const d = body as { - state?: { - servers?: Record; - }; + servers?: Record; }; return ( - d?.state?.servers?.["https://example.com"]?.tokens?.access_token === + d?.servers?.["https://example.com"]?.tokens?.access_token === "initial-token" ); }); - // Create new store instance (should load persisted state) - const storage2 = createRemoteStorageAdapter({ + const storage2 = new RemoteOAuthStorage({ baseUrl, storeId: "test-store", authToken, }); - const store2 = createOAuthStore(storage2); - await vi.waitFor( - () => { - const state = store2.getState().getServerState("https://example.com"); - if (!state.tokens) throw new Error("Store not yet hydrated"); - return state; - }, - { timeout: 2000, interval: 50 }, - ); - const state = store2.getState().getServerState("https://example.com"); - expect(state.tokens).toEqual({ + expect(await storage2.getTokens("https://example.com")).toEqual({ access_token: "initial-token", token_type: "Bearer", }); @@ -418,23 +350,21 @@ describe("Storage adapters", () => { }); remoteServer = server; - const storage = createRemoteStorageAdapter({ + const storage = new RemoteOAuthStorage({ baseUrl, storeId: "test-store", authToken, }); - const store = createOAuthStore(storage); - // Set state and persist - store.getState().setServerState("https://example.com", { - tokens: { access_token: "test-token", token_type: "Bearer" }, + await storage.saveTokens("https://example.com", { + access_token: "test-token", + token_type: "Bearer", }); await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { - const d = body as { state?: { servers?: Record } }; - return !!d?.state?.servers && Object.keys(d.state.servers).length > 0; + const d = body as { servers?: Record }; + return !!d?.servers && Object.keys(d.servers).length > 0; }); - // Verify it exists let res = await fetch(`${baseUrl}/api/storage/test-store`, { method: "GET", headers: { @@ -443,20 +373,14 @@ describe("Storage adapters", () => { }); expect(res.status).toBe(200); const storeData = await res.json(); - expect(Object.keys(storeData.state.servers).length).toBeGreaterThan(0); + expect(Object.keys(storeData.servers).length).toBeGreaterThan(0); - // Clear all servers (this will persist empty state) - const state = store.getState(); - const urls = Object.keys(state.servers); - for (const url of urls) { - state.clearServerState(url); - } + await storage.clear("https://example.com"); await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { - const d = body as { state?: { servers?: Record } }; - return !d?.state?.servers || Object.keys(d.state.servers).length === 0; + const d = body as { servers?: Record }; + return !d?.servers || Object.keys(d.servers).length === 0; }); - // Verify it's empty res = await fetch(`${baseUrl}/api/storage/test-store`, { method: "GET", headers: { @@ -465,7 +389,7 @@ describe("Storage adapters", () => { }); expect(res.status).toBe(200); const emptyStore = await res.json(); - expect(Object.keys(emptyStore.state.servers).length).toBe(0); + expect(Object.keys(emptyStore.servers).length).toBe(0); }); }); }); diff --git a/clients/web/tsconfig.app.json b/clients/web/tsconfig.app.json index 167e4ba8..62da4bd0 100644 --- a/clients/web/tsconfig.app.json +++ b/clients/web/tsconfig.app.json @@ -37,8 +37,6 @@ "react-dom": ["./node_modules/@types/react-dom"], "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"], "pino": ["./node_modules/pino"], - "zustand": ["./node_modules/zustand"], - "zustand/*": ["./node_modules/zustand/*"], "hono": ["./node_modules/hono"], "hono/streaming": [ "./node_modules/hono/dist/types/helper/streaming/index" diff --git a/clients/web/tsconfig.test.json b/clients/web/tsconfig.test.json index d9757fd2..23dd9625 100644 --- a/clients/web/tsconfig.test.json +++ b/clients/web/tsconfig.test.json @@ -11,8 +11,6 @@ "react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"], "vitest": ["./node_modules/vitest"], "pino": ["./node_modules/pino"], - "zustand": ["./node_modules/zustand"], - "zustand/*": ["./node_modules/zustand/*"], "hono": ["./node_modules/hono"], "hono/streaming": [ "./node_modules/hono/dist/types/helper/streaming/index" diff --git a/clients/web/tsup.runner.config.ts b/clients/web/tsup.runner.config.ts index afbc9f91..6841de44 100644 --- a/clients/web/tsup.runner.config.ts +++ b/clients/web/tsup.runner.config.ts @@ -22,7 +22,6 @@ export default defineConfig({ '@hono/node-server', 'vite', '@vitejs/plugin-react', - 'zustand', 'atomically', 'chokidar', '@napi-rs/keyring', diff --git a/core/auth/browser/storage.ts b/core/auth/browser/storage.ts index 09ccee92..217e0b5d 100644 --- a/core/auth/browser/storage.ts +++ b/core/auth/browser/storage.ts @@ -1,16 +1,14 @@ -import { createJSONStorage } from "zustand/middleware"; import { OAuthStorageBase } from "../oauth-storage.js"; -import { createOAuthStore } from "../store.js"; +import { OAuthMemoryStore } from "../store.js"; +import { createSessionOAuthPersistBackend } from "../oauth-persist.js"; /** - * Browser storage implementation using Zustand with sessionStorage. + * Browser storage implementation using sessionStorage. * For web client (can be used by InspectorClient in browser). */ export class BrowserOAuthStorage extends OAuthStorageBase { constructor() { - // Use Zustand's built-in sessionStorage adapter - // The `name` option in persist() ("mcp-inspector-oauth") becomes the sessionStorage key - super(createOAuthStore(createJSONStorage(() => sessionStorage))); + super(new OAuthMemoryStore(), createSessionOAuthPersistBackend()); } } diff --git a/core/auth/connection-state.ts b/core/auth/connection-state.ts index cf517622..f0642019 100644 --- a/core/auth/connection-state.ts +++ b/core/auth/connection-state.ts @@ -88,13 +88,13 @@ export async function buildOAuthConnectionState( const preregistered = await storage.getClientInformation(serverUrl, true); const dynamic = await storage.getClientInformation(serverUrl); - const storageScope = storage.getScope(serverUrl); - const serverMetadata = storage.getServerMetadata(serverUrl); + const storageScope = await storage.getScope(serverUrl); + const serverMetadata = await storage.getServerMetadata(serverUrl); const authorized = isAccessTokenUsable(tokens); const grantedScope = resolveGrantedScope(tokens, storageScope); - const registrationKind = storage.getClientRegistrationKind(serverUrl); + const registrationKind = await storage.getClientRegistrationKind(serverUrl); const dynamicRegistrationKind = registrationKind === "dcr" || registrationKind === "cimd" ? registrationKind @@ -123,7 +123,7 @@ export async function buildOAuthConnectionState( const idp = params.enterpriseManagedAuth.idp; const issuer = normalizeIdpIssuer(idp.issuer); const idpSession = await getEmaIdpLoginState(storage, issuer); - const idpMetadata = storage.getServerMetadata(idpOAuthStorageKey(issuer)); + const idpMetadata = await storage.getServerMetadata(idpOAuthStorageKey(issuer)); state.ema = { idpIssuer: issuer, idpClientId: idp.clientId, diff --git a/core/auth/ema/idpOidc.ts b/core/auth/ema/idpOidc.ts index e860d7e0..0f0237c8 100644 --- a/core/auth/ema/idpOidc.ts +++ b/core/auth/ema/idpOidc.ts @@ -34,9 +34,8 @@ async function resolveIdpMetadata( storage: OAuthStorage, fetchFn?: typeof fetch, ): Promise { - await storage.load(); const storageKey = idpOAuthStorageKey(issuer); - const cached = storage.getServerMetadata(storageKey); + const cached = await storage.getServerMetadata(storageKey); if (cached?.token_endpoint) { return cached; } @@ -102,8 +101,7 @@ export async function completeIdpOidcAuthorization(params: { }> { const issuer = normalizeIdpIssuer(params.idp.issuer); const storageKey = idpOAuthStorageKey(issuer); - await params.storage.load(); - const metadata = params.storage.getServerMetadata(storageKey); + const metadata = await params.storage.getServerMetadata(storageKey); if (!metadata) { throw new Error("IdP OAuth metadata not found — restart EMA IdP login"); } @@ -113,7 +111,7 @@ export async function completeIdpOidcAuthorization(params: { if (!clientInformation) { throw new Error("IdP client information not found — restart EMA IdP login"); } - const codeVerifier = params.storage.getCodeVerifier(storageKey); + const codeVerifier = await params.storage.getCodeVerifier(storageKey); if (!codeVerifier) { throw new Error("IdP PKCE verifier not found — restart EMA IdP login"); } diff --git a/core/auth/node/index.ts b/core/auth/node/index.ts index b4fdab89..b7526f20 100644 --- a/core/auth/node/index.ts +++ b/core/auth/node/index.ts @@ -1,7 +1,5 @@ export { NodeOAuthStorage, - getOAuthStore, - getStateFilePath, clearAllOAuthClientState, } from "./storage-node.js"; export { diff --git a/core/auth/node/storage-node.ts b/core/auth/node/storage-node.ts index 952cb13b..49bb1f34 100644 --- a/core/auth/node/storage-node.ts +++ b/core/auth/node/storage-node.ts @@ -1,6 +1,6 @@ import { OAuthStorageBase } from "../oauth-storage.js"; -import { createOAuthStore } from "../store.js"; -import { createFileStorageAdapter } from "../../storage/adapters/file-storage.js"; +import { OAuthMemoryStore } from "../store.js"; +import { createFileOAuthPersistBackend } from "../oauth-persist.js"; import { getDefaultStorageDir, getStoreFilePath, @@ -17,22 +17,27 @@ export function getStateFilePath(customPath?: string): string { return customPath ?? DEFAULT_STATE_PATH; } -const storeCache = new Map>(); +const memoryCache = new Map(); +const storageCache = new Map(); + +function getSharedMemory(stateFilePath?: string): OAuthMemoryStore { + const key = getStateFilePath(stateFilePath); + let memory = memoryCache.get(key); + if (!memory) { + memory = new OAuthMemoryStore(); + memoryCache.set(key, memory); + } + return memory; +} /** - * Get or create the OAuth store instance for the given path. - * @param stateFilePath - Optional custom path to state file. Default: ~/.mcp-inspector/storage/oauth.json + * Drop cached in-memory and {@link NodeOAuthStorage} instances for a path. + * @internal Test isolation only. */ -export function getOAuthStore(stateFilePath?: string) { +export function resetNodeOAuthStorageCache(stateFilePath?: string): void { const key = getStateFilePath(stateFilePath); - let store = storeCache.get(key); - if (!store) { - const filePath = getStateFilePath(stateFilePath); - const storage = createFileStorageAdapter({ filePath }); - store = createOAuthStore(storage); - storeCache.set(key, store); - } - return store; + memoryCache.delete(key); + storageCache.delete(key); } /** @@ -40,24 +45,40 @@ export function getOAuthStore(stateFilePath?: string) { * Useful for test isolation in E2E OAuth tests. * Use a custom-path store and clear per serverUrl if you need to clear non-default storage. */ -export function clearAllOAuthClientState(): void { - const store = getOAuthStore(); - const state = store.getState(); - const urls = Object.keys(state.servers ?? {}); +export async function clearAllOAuthClientState(): Promise { + const storage = getNodeOAuthStorage(); + const filePath = getStateFilePath(); + const snapshot = await createFileOAuthPersistBackend({ filePath }).read(); + const urls = Object.keys(snapshot?.servers ?? {}); for (const url of urls) { - state.clearServerState(url); + await storage.clear(url); } } /** - * Node.js storage implementation using Zustand with file-based persistence. + * Node.js storage implementation with file-based persistence. * For InspectorClient, CLI, and TUI. */ export class NodeOAuthStorage extends OAuthStorageBase { /** - * @param storagePath - Optional path to state file. Default: ~/.mcp-inspector/oauth/state.json + * @param storagePath - Optional path to state file. Default: ~/.mcp-inspector/storage/oauth.json */ constructor(storagePath?: string) { - super(getOAuthStore(storagePath)); + const filePath = getStateFilePath(storagePath); + super( + getSharedMemory(storagePath), + createFileOAuthPersistBackend({ filePath }), + ); } } + +/** Cached NodeOAuthStorage instances keyed by resolved file path. */ +function getNodeOAuthStorage(storagePath?: string): NodeOAuthStorage { + const key = getStateFilePath(storagePath); + let storage = storageCache.get(key); + if (!storage) { + storage = new NodeOAuthStorage(storagePath); + storageCache.set(key, storage); + } + return storage; +} diff --git a/core/auth/oauth-persist.ts b/core/auth/oauth-persist.ts new file mode 100644 index 00000000..e6af75c6 --- /dev/null +++ b/core/auth/oauth-persist.ts @@ -0,0 +1,194 @@ +/** + * OAuth persistence format and backends (file, remote HTTP, sessionStorage). + * Writes plain JSON `{ servers, idpSessions }`. On read, accepts legacy + * persist envelopes `{ state: { servers, idpSessions }, version }` and + * promotes the inner payload. + */ + +import { + readStoreFile, + writeStoreFile, + deleteStoreFile, + serializeStore, + parseStore, +} from "../storage/store-io.js"; +import type { IdpSessionState } from "./storage.js"; +import type { ServerOAuthState } from "./store.js"; + +export const OAUTH_PERSIST_STORAGE_KEY = "mcp-inspector-oauth"; + +export interface OAuthPersistSnapshot { + servers: Record; + idpSessions: Record; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function snapshotFromPayload(payload: Partial): OAuthPersistSnapshot { + return { + servers: payload.servers ?? {}, + idpSessions: payload.idpSessions ?? {}, + }; +} + +/** + * Parse OAuth store JSON from disk, remote API, or sessionStorage. + * Accepts plain `{ servers, idpSessions }` or legacy `{ state, version }`. + */ +export function parseOAuthPersistBlob( + raw: string | null, +): OAuthPersistSnapshot | null { + if (!raw) { + return null; + } + const parsed = parseStore(raw); + if (!isRecord(parsed)) { + return null; + } + + if (isRecord(parsed.state) && "version" in parsed) { + return snapshotFromPayload(parsed.state as Partial); + } + + if ("servers" in parsed || "idpSessions" in parsed) { + return snapshotFromPayload(parsed as Partial); + } + + return null; +} + +export function serializeOAuthPersistBlob( + snapshot: OAuthPersistSnapshot, +): string { + return serializeStore(snapshot); +} + +export interface OAuthPersistBackend { + read(): Promise; + write(snapshot: OAuthPersistSnapshot): Promise; + remove?(): Promise; +} + +export interface FileOAuthPersistBackendOptions { + filePath: string; +} + +export function createFileOAuthPersistBackend( + options: FileOAuthPersistBackendOptions, +): OAuthPersistBackend { + return { + async read() { + const raw = await readStoreFile(options.filePath); + return parseOAuthPersistBlob(raw); + }, + async write(snapshot) { + await writeStoreFile( + options.filePath, + serializeOAuthPersistBlob(snapshot), + ); + }, + async remove() { + await deleteStoreFile(options.filePath); + }, + }; +} + +export interface RemoteOAuthPersistBackendOptions { + baseUrl: string; + storeId: string; + authToken?: string; + fetchFn?: typeof fetch; +} + +export function createRemoteOAuthPersistBackend( + options: RemoteOAuthPersistBackendOptions, +): OAuthPersistBackend { + const baseUrl = options.baseUrl.replace(/\/$/, ""); + const fetchFn = options.fetchFn ?? globalThis.fetch; + + return { + async read() { + const headers: Record = {}; + if (options.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; + } + + const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { + method: "GET", + headers, + }); + + if (!res.ok) { + if (res.status === 404) { + return null; + } + throw new Error(`Failed to read store: ${res.status}`); + } + + const store = await res.json(); + if (Object.keys(store as object).length === 0) { + return null; + } + return parseOAuthPersistBlob(JSON.stringify(store)); + }, + async write(snapshot) { + const headers: Record = { + "Content-Type": "application/json", + }; + if (options.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; + } + + const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { + method: "POST", + headers, + body: serializeOAuthPersistBlob(snapshot), + }); + + if (!res.ok) { + throw new Error(`Failed to write store: ${res.status}`); + } + }, + async remove() { + const headers: Record = {}; + if (options.authToken) { + headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; + } + + const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { + method: "DELETE", + headers, + }); + + if (!res.ok && res.status !== 404) { + throw new Error(`Failed to delete store: ${res.status}`); + } + }, + }; +} + +export interface SessionOAuthPersistBackendOptions { + storageKey?: string; + getStorage?: () => Storage; +} + +export function createSessionOAuthPersistBackend( + options: SessionOAuthPersistBackendOptions = {}, +): OAuthPersistBackend { + const storageKey = options.storageKey ?? OAUTH_PERSIST_STORAGE_KEY; + const getStorage = options.getStorage ?? (() => sessionStorage); + + return { + async read() { + return parseOAuthPersistBlob(getStorage().getItem(storageKey)); + }, + async write(snapshot) { + getStorage().setItem(storageKey, serializeOAuthPersistBlob(snapshot)); + }, + async remove() { + getStorage().removeItem(storageKey); + }, + }; +} diff --git a/core/auth/oauth-storage.ts b/core/auth/oauth-storage.ts index 64c29534..3b9fe1de 100644 --- a/core/auth/oauth-storage.ts +++ b/core/auth/oauth-storage.ts @@ -8,11 +8,8 @@ import { OAuthTokensSchema, } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthStorage } from "./storage.js"; -import { - type OAuthStore, - type ServerOAuthState, - waitForOAuthStorePersistLoad, -} from "./store.js"; +import { type OAuthMemoryStore, type ServerOAuthState } from "./store.js"; +import type { OAuthPersistBackend } from "./oauth-persist.js"; import type { IdpSessionState, OAuthClientRegistrationKind, @@ -20,47 +17,53 @@ import type { SaveTokensOptions, } from "./storage.js"; -type OAuthStoreWithPersist = OAuthStore & { - persist: { - hasHydrated: () => boolean; - }; -}; - /** - * Concrete OAuthStorage implementation parameterized on a Zustand store. - * The store carries the storage adapter (sessionStorage, file, remote HTTP, …), - * so the same body works for browser, Node, and remote environments. + * Concrete OAuthStorage implementation backed by in-memory state and an explicit + * persist backend (file, remote HTTP, sessionStorage, …). */ export class OAuthStorageBase implements OAuthStorage { - private readonly store: OAuthStoreWithPersist; - private loadedPromise: Promise | undefined; + private loaded = false; + private loadPromise: Promise | undefined; + private readonly memory: OAuthMemoryStore; + private readonly backend: OAuthPersistBackend; - constructor(store: OAuthStore) { - this.store = store as OAuthStoreWithPersist; + constructor(memory: OAuthMemoryStore, backend: OAuthPersistBackend) { + this.memory = memory; + this.backend = backend; } load(): Promise { - if (!this.loadedPromise) { - this.loadedPromise = (async () => { - if (this.store.persist.hasHydrated()) { - return; - } - await waitForOAuthStorePersistLoad(this.store); - })(); + if (!this.loadPromise) { + this.loadPromise = this.doLoad(); } - return this.loadedPromise; + return this.loadPromise; + } + + private async doLoad(): Promise { + if (this.loaded) { + return; + } + const snapshot = await this.backend.read(); + if (snapshot) { + this.memory.replace(snapshot); + } + this.loaded = true; } private async ensureLoaded(): Promise { await this.load(); } + private async persist(): Promise { + await this.backend.write(this.memory.snapshot()); + } + async getClientInformation( serverUrl: string, isPreregistered?: boolean, ): Promise { await this.ensureLoaded(); - const state = this.store.getState().getServerState(serverUrl); + const state = this.memory.getState().getServerState(serverUrl); const clientInfo = isPreregistered ? state.preregisteredClientInformation : state.clientInformation; @@ -72,10 +75,11 @@ export class OAuthStorageBase implements OAuthStorage { return await OAuthClientInformationSchema.parseAsync(clientInfo); } - getClientRegistrationKind( + async getClientRegistrationKind( serverUrl: string, - ): OAuthClientRegistrationKind | undefined { - return this.store.getState().getServerState(serverUrl) + ): Promise { + await this.ensureLoaded(); + return this.memory.getState().getServerState(serverUrl) .clientRegistrationKind; } @@ -85,10 +89,11 @@ export class OAuthStorageBase implements OAuthStorage { options: SaveClientInformationOptions, ): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { + this.memory.getState().setServerState(serverUrl, { clientInformation, clientRegistrationKind: options.registrationKind, }); + await this.persist(); } async savePreregisteredClientInformation( @@ -96,10 +101,11 @@ export class OAuthStorageBase implements OAuthStorage { clientInformation: OAuthClientInformation, ): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { + this.memory.getState().setServerState(serverUrl, { preregisteredClientInformation: clientInformation, clientRegistrationKind: "static", }); + await this.persist(); } async clearClientInformation( @@ -116,12 +122,13 @@ export class OAuthStorageBase implements OAuthStorage { updates.clientRegistrationKind = undefined; } - this.store.getState().setServerState(serverUrl, updates); + this.memory.getState().setServerState(serverUrl, updates); + await this.persist(); } async getTokens(serverUrl: string): Promise { await this.ensureLoaded(); - const state = this.store.getState().getServerState(serverUrl); + const state = this.memory.getState().getServerState(serverUrl); if (!state.tokens) { return undefined; } @@ -135,19 +142,22 @@ export class OAuthStorageBase implements OAuthStorage { options?: SaveTokensOptions, ): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { + this.memory.getState().setServerState(serverUrl, { tokens, ...(options?.enterpriseManaged === true && { enterpriseManaged: true }), }); + await this.persist(); } async clearTokens(serverUrl: string): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { tokens: undefined }); + this.memory.getState().setServerState(serverUrl, { tokens: undefined }); + await this.persist(); } - getCodeVerifier(serverUrl: string): string | undefined { - const state = this.store.getState().getServerState(serverUrl); + async getCodeVerifier(serverUrl: string): Promise { + await this.ensureLoaded(); + const state = this.memory.getState().getServerState(serverUrl); return state.codeVerifier; } @@ -156,33 +166,37 @@ export class OAuthStorageBase implements OAuthStorage { codeVerifier: string, ): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { codeVerifier }); + this.memory.getState().setServerState(serverUrl, { codeVerifier }); + await this.persist(); } async clearCodeVerifier(serverUrl: string): Promise { await this.ensureLoaded(); - this.store - .getState() - .setServerState(serverUrl, { codeVerifier: undefined }); + this.memory.getState().setServerState(serverUrl, { codeVerifier: undefined }); + await this.persist(); } - getScope(serverUrl: string): string | undefined { - const state = this.store.getState().getServerState(serverUrl); + async getScope(serverUrl: string): Promise { + await this.ensureLoaded(); + const state = this.memory.getState().getServerState(serverUrl); return state.scope; } async saveScope(serverUrl: string, scope: string | undefined): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { scope }); + this.memory.getState().setServerState(serverUrl, { scope }); + await this.persist(); } async clearScope(serverUrl: string): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { scope: undefined }); + this.memory.getState().setServerState(serverUrl, { scope: undefined }); + await this.persist(); } - getServerMetadata(serverUrl: string): OAuthMetadata | null { - const state = this.store.getState().getServerState(serverUrl); + async getServerMetadata(serverUrl: string): Promise { + await this.ensureLoaded(); + const state = this.memory.getState().getServerState(serverUrl); return state.serverMetadata || null; } @@ -191,26 +205,27 @@ export class OAuthStorageBase implements OAuthStorage { metadata: OAuthMetadata, ): Promise { await this.ensureLoaded(); - this.store - .getState() - .setServerState(serverUrl, { serverMetadata: metadata }); + this.memory.getState().setServerState(serverUrl, { serverMetadata: metadata }); + await this.persist(); } async clearServerMetadata(serverUrl: string): Promise { await this.ensureLoaded(); - this.store + this.memory .getState() .setServerState(serverUrl, { serverMetadata: undefined }); + await this.persist(); } async clear(serverUrl: string): Promise { await this.ensureLoaded(); - this.store.getState().clearServerState(serverUrl); + this.memory.getState().clearServerState(serverUrl); + await this.persist(); } async getIdpSession(issuer: string): Promise { await this.ensureLoaded(); - const session = this.store.getState().getIdpSession(issuer); + const session = this.memory.getState().getIdpSession(issuer); if ( !session.idToken && !session.refreshToken && @@ -226,16 +241,19 @@ export class OAuthStorageBase implements OAuthStorage { session: Partial, ): Promise { await this.ensureLoaded(); - this.store.getState().setIdpSession(issuer, session); + this.memory.getState().setIdpSession(issuer, session); + await this.persist(); } async clearIdpSession(issuer: string): Promise { await this.ensureLoaded(); - this.store.getState().clearIdpSession(issuer); + this.memory.getState().clearIdpSession(issuer); + await this.persist(); } async clearEnterpriseManagedResourceServers(): Promise { await this.ensureLoaded(); - this.store.getState().clearEnterpriseManagedResourceServers(); + this.memory.getState().clearEnterpriseManagedResourceServers(); + await this.persist(); } } diff --git a/core/auth/providers.ts b/core/auth/providers.ts index 6e4c46ad..b9cbdf30 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -103,6 +103,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { private capturedAuthUrl: URL | null = null; private eventTarget: EventTarget | null = null; private suppressAuthorizationNavigation = false; + /** Cached after {@link prepareForAuth} for sync SDK `clientMetadata.scope`. */ + private cachedScope: string | undefined; protected serverUrl: string; protected storage: OAuthStorage; @@ -118,6 +120,14 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { this.clientMetadataUrl = oauthConfig.clientMetadataUrl; } + /** + * Load persisted scope into {@link cachedScope} before SDK `auth()` (which + * reads {@link clientMetadata.scope} synchronously). + */ + async prepareForAuth(): Promise { + this.cachedScope = await this.storage.getScope(this.serverUrl); + } + /** * Set the event target for dispatching oauthAuthorizationRequired events */ @@ -145,7 +155,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { } get scope(): string | undefined { - return this.storage.getScope(this.serverUrl); + return this.cachedScope; } get redirectUrl(): string { @@ -200,6 +210,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { async saveScope(scope: string | undefined): Promise { await this.storage.saveScope(this.serverUrl, scope); + this.cachedScope = scope; } async savePreregisteredClientInformation( @@ -239,8 +250,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { await this.storage.saveCodeVerifier(this.serverUrl, codeVerifier); } - codeVerifier(): string { - const verifier = this.storage.getCodeVerifier(this.serverUrl); + async codeVerifier(): Promise { + const verifier = await this.storage.getCodeVerifier(this.serverUrl); if (!verifier) { throw new Error("No code verifier saved for session"); } @@ -251,7 +262,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { await this.storage.clear(this.serverUrl); } - getServerMetadata(): OAuthMetadata | null { + async getServerMetadata(): Promise { return this.storage.getServerMetadata(this.serverUrl); } diff --git a/core/auth/remote/storage-remote.ts b/core/auth/remote/storage-remote.ts index 12fc78cf..c2e23df3 100644 --- a/core/auth/remote/storage-remote.ts +++ b/core/auth/remote/storage-remote.ts @@ -1,12 +1,11 @@ /** * Remote HTTP storage implementation for OAuth state. - * Uses Zustand with remote storage adapter (HTTP API). * For web clients that need to share state with Node apps. */ import { OAuthStorageBase } from "../oauth-storage.js"; -import { createOAuthStore } from "../store.js"; -import { createRemoteStorageAdapter } from "../../storage/adapters/remote-storage.js"; +import { OAuthMemoryStore } from "../store.js"; +import { createRemoteOAuthPersistBackend } from "../oauth-persist.js"; export interface RemoteOAuthStorageOptions { /** Base URL of the remote server (e.g. http://localhost:3000) */ @@ -20,21 +19,20 @@ export interface RemoteOAuthStorageOptions { } /** - * Remote HTTP storage implementation using Zustand with remote storage adapter. + * Remote HTTP storage implementation. * Stores OAuth state via HTTP API (GET/POST/DELETE /api/storage/:storeId). * For web clients that need to share state with Node apps (TUI, CLI). */ export class RemoteOAuthStorage extends OAuthStorageBase { constructor(options: RemoteOAuthStorageOptions) { super( - createOAuthStore( - createRemoteStorageAdapter({ - baseUrl: options.baseUrl, - storeId: options.storeId ?? "oauth", - authToken: options.authToken, - fetchFn: options.fetchFn, - }), - ), + new OAuthMemoryStore(), + createRemoteOAuthPersistBackend({ + baseUrl: options.baseUrl, + storeId: options.storeId ?? "oauth", + authToken: options.authToken, + fetchFn: options.fetchFn, + }), ); } } diff --git a/core/auth/storage.ts b/core/auth/storage.ts index 441821e8..7ac9bf1e 100644 --- a/core/auth/storage.ts +++ b/core/auth/storage.ts @@ -7,7 +7,7 @@ import type { OAuthClientRegistrationKind } from "./types.js"; /** * Abstract storage interface for OAuth state - * Supports both browser (sessionStorage) and Node.js (Zustand) environments + * Supports browser (sessionStorage), Node.js (file), and remote HTTP backends. */ export interface SaveTokensOptions { /** Marks resource tokens minted via EMA (legs 2–3) for sign-out cleanup. */ @@ -22,9 +22,9 @@ export interface SaveClientInformationOptions { export interface OAuthStorage { /** - * Load persisted OAuth state into memory. Resolves when the backing store - * (file, remote API, sessionStorage, …) has been read. Call before relying on - * sync reads, or await mutating methods which call this internally. + * Optional preload of persisted state into memory. Getters and setters load + * automatically when needed; use this only for fail-fast at known boundaries + * (e.g. OAuth callback resume after a full-page navigation). */ load(): Promise; @@ -41,7 +41,7 @@ export interface OAuthStorage { */ getClientRegistrationKind( serverUrl: string, - ): OAuthClientRegistrationKind | undefined; + ): Promise; /** * Save client information (dynamically registered) @@ -90,7 +90,7 @@ export interface OAuthStorage { /** * Get code verifier (for PKCE) */ - getCodeVerifier(serverUrl: string): string | undefined; + getCodeVerifier(serverUrl: string): Promise; /** * Save code verifier (for PKCE) @@ -105,7 +105,7 @@ export interface OAuthStorage { /** * Get scope */ - getScope(serverUrl: string): string | undefined; + getScope(serverUrl: string): Promise; /** * Save scope @@ -120,7 +120,7 @@ export interface OAuthStorage { /** * Get server metadata discovered during OAuth */ - getServerMetadata(serverUrl: string): OAuthMetadata | null; + getServerMetadata(serverUrl: string): Promise; /** * Save server metadata discovered during OAuth diff --git a/core/auth/store.ts b/core/auth/store.ts index 52d966c3..30206254 100644 --- a/core/auth/store.ts +++ b/core/auth/store.ts @@ -1,16 +1,15 @@ /** - * OAuth store factory using Zustand. - * Creates a store with any storage adapter (file, remote, sessionStorage). + * In-memory OAuth state store (servers + IdP sessions). + * Persisted via `OAuthStorageBase` + `OAuthPersistBackend` backends. */ -import { createStore } from "zustand/vanilla"; -import { persist, createJSONStorage } from "zustand/middleware"; import type { OAuthClientInformation, OAuthTokens, OAuthMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { IdpSessionState, OAuthClientRegistrationKind } from "./storage.js"; +import type { OAuthPersistSnapshot } from "./oauth-persist.js"; /** * OAuth state for a single server @@ -29,7 +28,7 @@ export interface ServerOAuthState { } /** - * Zustand store state (all servers) + * OAuth store state (all servers) plus mutation helpers. */ export interface OAuthStoreState { servers: Record; @@ -43,127 +42,81 @@ export interface OAuthStoreState { clearEnterpriseManagedResourceServers: () => void; } -export type OAuthStore = ReturnType; - -const persistLoadByStore = new WeakMap>(); - /** - * Wait until the store's initial persist read has finished (success or failure). - * Used by {@link OAuthStorageBase.load}; resolves on success, rejects when the - * storage adapter's getItem fails (Zustand does not fire onFinishHydration on error). + * Mutable in-memory OAuth state keyed by server URL and IdP issuer. */ -export function waitForOAuthStorePersistLoad(store: OAuthStore): Promise { - const promise = persistLoadByStore.get(store); - if (!promise) { - return Promise.resolve(); +export class OAuthMemoryStore { + private servers: Record = {}; + private idpSessions: Record = {}; + + constructor(initial?: OAuthPersistSnapshot) { + if (initial) { + this.replace(initial); + } } - return promise; -} -/** - * Creates a Zustand store for OAuth state with the given storage adapter. - * The storage adapter handles persistence (file, remote HTTP, sessionStorage, etc.). - * - * @param storage - Zustand storage adapter (from createJSONStorage) - * @returns Zustand store instance - */ -export function createOAuthStore( - storage: ReturnType, -) { - let resolvePersistLoad!: () => void; - let rejectPersistLoad!: (error: unknown) => void; - let persistLoadSettled = false; - - const persistLoadPromise = new Promise((resolve, reject) => { - resolvePersistLoad = resolve; - rejectPersistLoad = reject; - }); - - const settlePersistLoad = (error?: unknown) => { - if (persistLoadSettled) { - /* v8 ignore next -- idempotent if persist signals completion more than once */ - return; - } - persistLoadSettled = true; - if (error) { - rejectPersistLoad(error); - return; - } - resolvePersistLoad(); - }; - - const store = createStore()( - persist( - (set, get) => ({ - servers: {}, - idpSessions: {}, - getServerState: (serverUrl: string) => { - return get().servers[serverUrl] || {}; - }, - setServerState: ( - serverUrl: string, - updates: Partial, - ) => { - set((state) => ({ - servers: { - ...state.servers, - [serverUrl]: { - ...state.servers[serverUrl], - ...updates, - }, - }, - })); - }, - clearServerState: (serverUrl: string) => { - set((state) => { - const rest = { ...state.servers }; - delete rest[serverUrl]; - return { servers: rest }; - }); - }, - getIdpSession: (issuer: string) => { - return get().idpSessions[issuer] || {}; - }, - setIdpSession: (issuer: string, updates: Partial) => { - set((state) => ({ - idpSessions: { - ...state.idpSessions, - [issuer]: { - ...state.idpSessions[issuer], - ...updates, - }, - }, - })); - }, - clearIdpSession: (issuer: string) => { - set((state) => { - const rest = { ...state.idpSessions }; - delete rest[issuer]; - return { idpSessions: rest }; - }); - }, - clearEnterpriseManagedResourceServers: () => { - set((state) => { - const rest = { ...state.servers }; - for (const [url, serverState] of Object.entries(state.servers)) { - if (serverState.enterpriseManaged === true) { - delete rest[url]; - } - } - return { servers: rest }; - }); - }, - }), - { - name: "mcp-inspector-oauth", - storage, - onRehydrateStorage: () => (_state, error) => { - settlePersistLoad(error); - }, + getState(): OAuthStoreState { + return { + servers: this.servers, + idpSessions: this.idpSessions, + getServerState: (serverUrl: string) => { + return this.servers[serverUrl] || {}; }, - ), - ); + setServerState: ( + serverUrl: string, + updates: Partial, + ) => { + this.servers = { + ...this.servers, + [serverUrl]: { + ...this.servers[serverUrl], + ...updates, + }, + }; + }, + clearServerState: (serverUrl: string) => { + const rest = { ...this.servers }; + delete rest[serverUrl]; + this.servers = rest; + }, + getIdpSession: (issuer: string) => { + return this.idpSessions[issuer] || {}; + }, + setIdpSession: (issuer: string, updates: Partial) => { + this.idpSessions = { + ...this.idpSessions, + [issuer]: { + ...this.idpSessions[issuer], + ...updates, + }, + }; + }, + clearIdpSession: (issuer: string) => { + const rest = { ...this.idpSessions }; + delete rest[issuer]; + this.idpSessions = rest; + }, + clearEnterpriseManagedResourceServers: () => { + const rest = { ...this.servers }; + for (const [url, serverState] of Object.entries(this.servers)) { + if (serverState.enterpriseManaged === true) { + delete rest[url]; + } + } + this.servers = rest; + }, + }; + } - persistLoadByStore.set(store, persistLoadPromise); - return store; + snapshot(): OAuthPersistSnapshot { + return { + servers: { ...this.servers }, + idpSessions: { ...this.idpSessions }, + }; + } + + replace(snapshot: OAuthPersistSnapshot): void { + this.servers = { ...(snapshot.servers ?? {}) }; + this.idpSessions = { ...(snapshot.idpSessions ?? {}) }; + } } diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index ef8da8bb..f413e5a4 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -139,7 +139,8 @@ export class OAuthManager { provider.setEventTarget(this.params.getEventTarget()); - const storedScope = this.oauthConfig.storage.getScope(serverUrl); + await provider.prepareForAuth(); + const storedScope = provider.scope; if (storedScope === undefined && this.oauthConfig.scope) { await provider.saveScope(this.oauthConfig.scope); } @@ -162,7 +163,7 @@ export class OAuthManager { return this.oauthConfig.enterpriseManaged === true; } - private getEmaFlowConfig(): EmaFlowConfig { + private async getEmaFlowConfig(): Promise { if (!this.oauthConfig.storage || !this.oauthConfig.redirectUrlProvider) { throw new Error( "OAuth environment components (storage, redirectUrlProvider) are required.", @@ -177,16 +178,17 @@ export class OAuthManager { : "not_configured", ); } + const storedScope = await this.oauthConfig.storage.getScope( + this.getServerUrl(), + ); return { serverUrl: this.getServerUrl(), idp, resourceClientId: this.oauthConfig.clientId, resourceClientSecret: this.oauthConfig.clientSecret, scope: - computeScopeUnion( - this.oauthConfig.scope, - this.oauthConfig.storage.getScope(this.getServerUrl()), - ) || this.oauthConfig.scope, + computeScopeUnion(this.oauthConfig.scope, storedScope) || + this.oauthConfig.scope, redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl(), storage: this.oauthConfig.storage, fetchFn: this.params.effectiveAuthFetch, @@ -196,14 +198,14 @@ export class OAuthManager { /** Attempt silent EMA (cached IdP session + legs 2–3). */ async trySilentEnterpriseManagedAuth(): Promise { if (!this.isEnterpriseManaged()) return false; - const result = await trySilentEmaAuth(this.getEmaFlowConfig()); + const result = await trySilentEmaAuth(await this.getEmaFlowConfig()); if (result.status === "success") return true; if (result.status === "mint_failed") throw result.error; return false; } private async authenticateEnterpriseManaged(): Promise { - const config = this.getEmaFlowConfig(); + const config = await this.getEmaFlowConfig(); const silent = await trySilentEmaAuth(config); if (silent.status === "success") { return undefined; @@ -282,14 +284,13 @@ export class OAuthManager { iss?: string, ): Promise { try { - await this.requireStorage().load(); - if (this.isEnterpriseManaged()) { + const emaConfig = await this.getEmaFlowConfig(); const scopeForMint = - this.pendingAuthorizationScope ?? this.getEmaFlowConfig().scope; + this.pendingAuthorizationScope ?? emaConfig.scope; const config = scopeForMint - ? { ...this.getEmaFlowConfig(), scope: scopeForMint } - : this.getEmaFlowConfig(); + ? { ...emaConfig, scope: scopeForMint } + : emaConfig; const tokens = await completeEmaIdpAuthorizationAndMint( config, authorizationCode, @@ -448,7 +449,7 @@ export class OAuthManager { */ async refreshEnterpriseManagedTokens(): Promise { if (!this.isEnterpriseManaged()) return false; - const tokens = await refreshEmaResourceTokens(this.getEmaFlowConfig()); + const tokens = await refreshEmaResourceTokens(await this.getEmaFlowConfig()); return tokens !== undefined; } @@ -494,7 +495,7 @@ export class OAuthManager { } const effectiveScope = resolveEffectiveGrantedScope( - storage.getScope(serverUrl), + await storage.getScope(serverUrl), tokens.scope, ); return !isStrictScopeSuperset(scopeForAuth, effectiveScope); @@ -570,7 +571,7 @@ export class OAuthManager { } const previousScope = computeScopeUnion( - storage?.getScope(serverUrl), + storage ? await storage.getScope(serverUrl) : undefined, grantedTokenScope, ); const requiredFromChallenge = @@ -607,8 +608,10 @@ export class OAuthManager { return this.oauthConfig.scope?.trim() || undefined; } - private emaFlowConfigForChallenge(challenge: AuthChallenge): EmaFlowConfig { - const base = this.getEmaFlowConfig(); + private async emaFlowConfigForChallenge( + challenge: AuthChallenge, + ): Promise { + const base = await this.getEmaFlowConfig(); const enriched = challenge.authorizationScopes; if (enriched && enriched.length > 0) { return { ...base, scope: enriched.join(" ") }; @@ -628,7 +631,7 @@ export class OAuthManager { return { kind: "step_up_confirm", challenge: enriched }; } - const config = this.emaFlowConfigForChallenge(enriched); + const config = await this.emaFlowConfigForChallenge(enriched); if (enriched.reason === "insufficient_scope") { const silent = await trySilentEmaAuth(config); @@ -863,7 +866,10 @@ export class OAuthManager { > { const provider = await this.createOAuthProvider(); if (this.isEnterpriseManaged()) { - return new EmaTransportOAuthProvider(provider, this.getEmaFlowConfig()); + return new EmaTransportOAuthProvider( + provider, + await this.getEmaFlowConfig(), + ); } return provider; } diff --git a/core/storage/adapters/file-storage.ts b/core/storage/adapters/file-storage.ts deleted file mode 100644 index 079b2548..00000000 --- a/core/storage/adapters/file-storage.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * File-based storage adapter for Zustand persist middleware. - * Stores entire store state as JSON in a single file using atomic I/O. - */ - -import { createJSONStorage } from "zustand/middleware"; -import { readStoreFile, writeStoreFile, deleteStoreFile } from "../store-io.js"; - -export interface FileStorageAdapterOptions { - /** Full path to the storage file */ - filePath: string; -} - -/** - * Creates a Zustand storage adapter that reads/writes from a file. - * Conforms to Zustand's StateStorage interface. - */ -export function createFileStorageAdapter( - options: FileStorageAdapterOptions, -): ReturnType { - return createJSONStorage(() => ({ - getItem: async () => readStoreFile(options.filePath), - // Do not introduce an `await` before writeStoreFile() here: it registers - // the write in pendingWrites synchronously, which is load-bearing for - // flushStoreFileWrites() (a microtask hop before registration would make a - // flush called right after setItem find an empty map and return early, - // silently regressing tests to non-deterministic). - setItem: async (_name: string, value: string) => - writeStoreFile(options.filePath, value), - removeItem: async () => deleteStoreFile(options.filePath), - })); -} diff --git a/core/storage/adapters/index.ts b/core/storage/adapters/index.ts deleted file mode 100644 index 214ba563..00000000 --- a/core/storage/adapters/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Storage adapters for Zustand persist middleware. - * Provides adapters for file, remote HTTP, and browser storage. - */ - -export { createFileStorageAdapter } from "./file-storage.js"; -export type { FileStorageAdapterOptions } from "./file-storage.js"; - -export { createRemoteStorageAdapter } from "./remote-storage.js"; -export type { RemoteStorageAdapterOptions } from "./remote-storage.js"; diff --git a/core/storage/adapters/remote-storage.ts b/core/storage/adapters/remote-storage.ts deleted file mode 100644 index d143584a..00000000 --- a/core/storage/adapters/remote-storage.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Remote HTTP storage adapter for Zustand persist middleware. - * Stores entire store state via HTTP API (GET/POST/DELETE /api/storage/:storeId). - */ - -import { createJSONStorage } from "zustand/middleware"; - -export interface RemoteStorageAdapterOptions { - /** Base URL of the remote server (e.g. http://localhost:3000) */ - baseUrl: string; - /** Store ID (e.g. "oauth", "inspector-settings") */ - storeId: string; - /** Optional auth token for x-mcp-remote-auth header */ - authToken?: string; - /** Fetch function to use (default: globalThis.fetch) */ - fetchFn?: typeof fetch; -} - -/** - * Creates a Zustand storage adapter that reads/writes via HTTP API. - * Conforms to Zustand's StateStorage interface. - */ -export function createRemoteStorageAdapter( - options: RemoteStorageAdapterOptions, -): ReturnType { - const baseUrl = options.baseUrl.replace(/\/$/, ""); - const fetchFn = options.fetchFn ?? globalThis.fetch; - - return createJSONStorage(() => ({ - getItem: async (_name: string) => { - const headers: Record = {}; - if (options.authToken) { - headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; - } - - const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { - method: "GET", - headers, - }); - - if (!res.ok) { - if (res.status === 404) { - return null; - } - throw new Error(`Failed to read store: ${res.status}`); - } - - const store = await res.json(); - // Zustand stores: { state: {...}, version: number } - // API returns the stored blob. If empty, Zustand hasn't initialized yet. - if (Object.keys(store).length === 0) { - return null; // Empty store means not initialized yet - } - // Return the stored Zustand format as string - return JSON.stringify(store); - }, - setItem: async (_name: string, value: string) => { - const headers: Record = { - "Content-Type": "application/json", - }; - if (options.authToken) { - headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; - } - - // Zustand gives us the full persisted format as a string - // Store it as-is (the API treats it as an opaque blob) - const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { - method: "POST", - headers, - body: value, // Already a JSON string from Zustand - }); - - if (!res.ok) { - throw new Error(`Failed to write store: ${res.status}`); - } - }, - removeItem: async (_name: string) => { - const headers: Record = {}; - if (options.authToken) { - headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; - } - - const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { - method: "DELETE", - headers, - }); - - // 404 is fine (already deleted), but other errors should propagate - if (!res.ok && res.status !== 404) { - throw new Error(`Failed to delete store: ${res.status}`); - } - }, - })); -} diff --git a/core/storage/store-io.ts b/core/storage/store-io.ts index 8b8fa3d9..5e0273b4 100644 --- a/core/storage/store-io.ts +++ b/core/storage/store-io.ts @@ -1,6 +1,6 @@ /** * Shared storage path resolution, validation, and atomic file I/O. - * Used by the file storage adapter and the remote server's /api/storage routes. + * Used by OAuth file persistence and the remote server's /api/storage routes. */ import * as path from "node:path"; @@ -57,15 +57,7 @@ export async function readStoreFile(filePath: string): Promise { /** * In-flight writeStoreFile() promises, keyed by resolved path. Lets callers * await persistence completion via flushStoreFileWrites() instead of polling - * the file — Zustand's persist middleware invokes writeStoreFile() fire-and- - * forget, so the in-memory store updates synchronously while the file write - * lags. Entries are removed once their write settles. - * - * Load-bearing: pendingWrites.set() below runs synchronously before the first - * await in writeStoreFile(), so a flushStoreFileWrites() called right after a - * persist sees the in-flight entry. Callers (e.g. the storage adapter's - * setItem) must not introduce an await before writeStoreFile() — doing so would - * let a flush run before registration and return early. + * the file. Entries are removed once their write settles. */ const pendingWrites = new Map>(); @@ -104,9 +96,8 @@ export async function writeStoreFile( /** * Await pending writeStoreFile() writes — those for `filePath` if given, else - * all of them. Use in tests after triggering persistence (Zustand persist - * writes fire-and-forget) instead of polling the file, and for graceful - * shutdown. Resolves immediately when nothing is in flight. + * all of them. Use in tests after triggering persistence instead of polling + * the file, and for graceful shutdown. Resolves immediately when nothing is in flight. */ export async function flushStoreFileWrites(filePath?: string): Promise { if (filePath !== undefined) { diff --git a/package-lock.json b/package-lock.json index fc02e30d..e43c923b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,7 @@ "react": "^19.2.4", "vite": "^8.0.0", "yaml": "^2.9.0", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "bin": { "mcp-inspector": "clients/launcher/build/index.js" @@ -3257,35 +3256,6 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } - }, - "node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index dd56fe20..3adbbc62 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,7 @@ "react": "^19.2.4", "vite": "^8.0.0", "yaml": "^2.9.0", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index da4d8f02..8e46d57d 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -109,7 +109,7 @@ Until **client profiles** (see below) land, install-level client config — star **Do not** put IdP credentials in the OAuth store or per-server `mcp.json`. **Do not** use environment variables for IdP config — all clients read/write the same `client.json` file. -**Runtime auth state** (standard OAuth tokens, PKCE, **and** EMA runtime state: cached IdP ID Token / refresh token, leg-1 in-flight PKCE under `ema-idp:{issuer}`, and per-server resource tokens tagged `enterpriseManaged: true` when minted via EMA legs 2–3) uses the existing **`OAuthStorage`** interface (`core/auth/storage.ts`) — whatever adapter each client already passes to `InspectorClient`. ID-JAG is **not** cached — legs 2–3 re-mint on each connect or 401 refresh. EMA does **not** mandate a specific backing store; it extends the serialized auth state that adapter already persists. +**Runtime auth state** (standard OAuth tokens, PKCE, **and** EMA runtime state: cached IdP ID Token / refresh token, leg-1 in-flight PKCE under `ema-idp:{issuer}`, and per-server resource tokens tagged `enterpriseManaged: true` when minted via EMA legs 2–3) uses the **`OAuthStorage`** interface (`core/auth/storage.ts`) — whichever implementation each client passes to `InspectorClient` (`NodeOAuthStorage`, `RemoteOAuthStorage`, etc.). ID-JAG is **not** cached — legs 2–3 re-mint on each connect or 401 refresh. Web uses shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` (same `oauth.json` as CLI/TUI). Existing **sessionStorage** OAuth blobs from before that wiring are not migrated automatically — users start fresh or re-authorize once. @@ -215,7 +215,7 @@ interface OAuthStoreState { } ``` -Extend `OAuthStorage` / store methods accordingly. IdP **credentials** stay in `client.json`; only runtime tokens belong in `idpSessions`. Per-server `ServerOAuthState` may include `enterpriseManaged: true` when EMA legs 2–3 persist resource tokens (`saveTokens(..., { enterpriseManaged: true })`). **Sign out** clears `idpSessions` for the configured issuer and removes tagged EMA resource entries from the shared OAuth blob — standard OAuth server entries are not cleared. All adapters (`BrowserOAuthStorage`, `NodeOAuthStorage`, `RemoteOAuthStorage`) serialize the same extended shape through their existing backends. +Extend `OAuthStorage` / store methods accordingly. IdP **credentials** stay in `client.json`; only runtime tokens belong in `idpSessions`. Per-server `ServerOAuthState` may include `enterpriseManaged: true` when EMA legs 2–3 persist resource tokens (`saveTokens(..., { enterpriseManaged: true })`). **Sign out** clears `idpSessions` for the configured issuer and removes tagged EMA resource entries from the shared OAuth blob — standard OAuth server entries are not cleared. All implementations (`BrowserOAuthStorage`, `NodeOAuthStorage`, `RemoteOAuthStorage`) extend `OAuthStorageBase` and serialize the same shape through their persist backends (`core/auth/oauth-persist.ts`). #### Later: client profiles @@ -298,7 +298,7 @@ Install-level IdP credentials are edited in **Client Settings**, separate from p 2. Clears leg-1 in-flight state at `servers["ema-idp:{issuer}"]` 3. Calls `OAuthStorage.clearEnterpriseManagedResourceServers()` — scans the shared OAuth blob and removes `servers[url]` entries where `enterpriseManaged === true` (set when EMA legs 2–3 saved tokens via `saveTokens(url, tokens, { enterpriseManaged: true })` in `emaFlow.ts` / `EmaTransportOAuthProvider`). Standard OAuth entries in the same blob are **not** cleared. Untagged legacy EMA tokens (saved before tagging landed) are not removed until the next EMA connect re-saves with the tag. The next connect to a cleared EMA server has no cached access token, so a **401** triggers leg 1 (IdP login) via `authenticate()`. -- **Web OAuth store singleton:** web uses `getWebRemoteOAuthStorage()` (`clients/web/src/lib/remoteOAuthStorage.ts`) — memoized `RemoteOAuthStorage` backed by `/api/storage/oauth` — so Client Settings sign-out, EMA IdP session, connect, and per-server clear all mutate the same in-memory Zustand view as the active `InspectorClient`. +- **Web OAuth store singleton:** web uses `getWebRemoteOAuthStorage()` (`clients/web/src/lib/remoteOAuthStorage.ts`) — memoized `RemoteOAuthStorage` backed by `/api/storage/oauth` — so Client Settings sign-out, EMA IdP session, connect, and per-server clear all mutate the same in-memory `OAuthStorageBase` view as the active `InspectorClient`. **Future (not implemented):** explicit **Sign out** may additionally invoke the IdP's OIDC **end-session** / logout endpoint (RP-initiated logout) so the IdP SSO cookie is cleared — not just inspector-local IdP/resource token state. Today sign-out is **local-only** (clear `idpSessions`, tagged EMA resource entries, and leg-1 PKCE); the IdP may still treat the browser as signed in and skip the login prompt on the next authorize redirect until the IdP session expires or the user signs out at the IdP. @@ -416,7 +416,7 @@ Inspector touchpoints to extend: - `core/client/` (or `core/storage/`) — `loadClientConfig` / `saveClientConfig`; `NodeClientStorage`; remote adapter for `/api/storage/client` - `core/auth/utils.ts` — `parseHttpUrl` (issuer and EMA URL validation) - `core/auth/connection-state.ts` — `buildOAuthConnectionState`, `OAuthConnectionState` -- `core/auth/storage.ts` + `core/auth/store.ts` — `OAuthStorage` / `OAuthStoreState`: store-root `idpSessions`; `ServerOAuthState.enterpriseManaged` tag; `SaveTokensOptions`; `clearEnterpriseManagedResourceServers()`; all adapters pick up the extended shape automatically +- `core/auth/storage.ts` + `core/auth/store.ts` + `core/auth/oauth-storage.ts` + `core/auth/oauth-persist.ts` — `OAuthStorage` / `OAuthMemoryStore`: store-root `idpSessions`; `ServerOAuthState.enterpriseManaged` tag; async getters with auto-load; setters auto-persist; legacy `{ state, version }` envelope promoted on read, plain JSON on write - `core/auth/ema/` — `idpOidc.ts` (leg 1), `wire.ts` (legs 2–3), `emaFlow.ts` (orchestration + tagged `saveTokens`), `transportProvider.ts` (401 re-auth + tagged `saveTokens`), `resourceContext.ts`, `idpSession.ts` (`getEmaIdpLoginState`, `clearEmaIdpSession`), `jwt.ts`, `storage.ts`, `constants.ts`, `clientConfigError.ts` (`EmaClientNotConfiguredError`) - `core/mcp/oauthManager.ts` — branch on `enterpriseManaged`; EMA connect via `emaFlow`; `getOAuthState()`; `createOAuthProvider()` returns `EmaTransportOAuthProvider` for EMA servers (401 re-auth); standard OAuth unchanged - `core/mcp/inspectorClient.ts` — declare EMA extension in `initialize` capabilities when `enterpriseManaged`; `getOAuthState()` @@ -503,7 +503,7 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che - [x] **Manual staging validation** — full EMA connect against live xaa.dev (see §Staging validation). Confirms legs 1–3 and web UX outside CI. - [x] **Automated integration tests** — mock IdP + mock resource AS + composable protected-resource server (see §Phase 3b test plan). Live xaa.dev is **not** required for CI. -- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (shared-storage follow-up). +- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (standard OAuth remote E2E exists; EMA-specific variant optional). - [ ] *(optional)* 401 re-auth stretch — invalidate resource token, assert legs 2–3 re-run. ### Phase 4 — Other clients @@ -514,8 +514,8 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che ### Later -- [ ] **Remove Zustand from OAuth persistence** — direct read/write of OAuth blob; see §Follow-up work -- [x] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity (#1548); see §Follow-up work +- [x] **Remove Zustand from OAuth persistence** — `OAuthStorageBase` + persist backends (#1549); see §OAuth persistence +- [x] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity (#1548); see §Shared file-backed OAuth state - [ ] Client profile persistence (migrate from `client.json`; may extend or replace web Client Settings) - [ ] Optional: adopt `@modelcontextprotocol/client` v2 Layer-2 helpers for legs 2–3 (replace `wire.ts`) or full v2 transport for EMA - [ ] Optional: IdP **end-session** / RP-initiated logout on explicit **Sign out** (today sign-out clears inspector-local IdP session and tagged EMA resource tokens only; IdP browser SSO may remain active) @@ -553,14 +553,21 @@ Registration on xaa.dev: composable test server registered as a **resource serve These items came out of EMA staging and apply beyond EMA. They are **not** required to ship EMA on web but should be tracked. -### Remove Zustand from OAuth persistence +### OAuth persistence (#1549 — done) -Today `OAuthStorage` is backed by a **Zustand** `persist` store (`core/auth/store.ts`) with adapters for sessionStorage (`BrowserOAuthStorage`), file (`NodeOAuthStorage` via `file-storage.ts`), and HTTP (`RemoteOAuthStorage` via `remote-storage.ts`). The Zustand layer wraps a simple `{ servers, idpSessions }` blob and adds: +`OAuthStorage` is implemented by **`OAuthStorageBase`** (`core/auth/oauth-storage.ts`) backed by **`OAuthMemoryStore`** (`core/auth/store.ts`) and an explicit **`OAuthPersistBackend`** (`core/auth/oauth-persist.ts`): -- `{ state, version }` envelope on file/remote adapters (see [Servers file](v2_servers_file.md) — intentionally avoided for `mcp.json`) -- A second in-memory copy and async persist semantics that complicate sign-out, callback rebuild, and multi-consumer sharing (web uses `getWebRemoteOAuthStorage()`; legacy `getBrowserOAuthStorage()` existed partly to paper over this for sessionStorage) +| Backend | Class | Where | +| ------- | ----- | ----- | +| File | `NodeOAuthStorage` | CLI, TUI, default `~/.mcp-inspector/storage/oauth.json` | +| Remote HTTP | `RemoteOAuthStorage` | Web → `GET/POST/DELETE /api/storage/oauth` on the Hono backend | +| Session | `BrowserOAuthStorage` | Reference/tests only; v2 web uses shared file-backed remote store | -**Direction:** refactor `OAuthStorage` implementations to read/write the serialized shape **directly** (file I/O, `/api/storage/oauth`, or in-memory for tests) without Zustand. Keep the `OAuthStorage` interface stable for `InspectorClient` / `OAuthManager`. +**On disk / wire:** writes plain JSON `{ servers, idpSessions }`. **Reads** still accept legacy blobs wrapped as `{ state: { servers, idpSessions }, version }` (produced by the old Zustand `persist` middleware) and promote the inner payload — migrate-on-write on the next save. + +**API shape:** all getters are async (`ensureLoaded()` internally); setters `await` persist. `load()` is optional preload (OAuth callback fail-fast on web), not required before reads. `BaseOAuthClientProvider.prepareForAuth()` caches scope for the SDK’s sync `clientMetadata.scope`. + +**Removed:** Zustand dependency, `core/storage/adapters/*`, public `getOAuthStore()`. ### Shared file-backed OAuth state (web + CLI + TUI) @@ -639,7 +646,7 @@ Shared helper `minimalOAuthAsMetadata()` satisfies SDK OAuth metadata schema. `c Runs under `integration` project; `test-servers:build` prerequisite (existing). Transport: `streamable-http` only (EMA is HTTP-only). -**Storage variant (optional — not implemented):** same flow with `RemoteOAuthStorage` + `createRemoteApp` tmp dir — proves EMA tokens persist via `/api/storage/oauth` (feeds shared-storage follow-up). +**Storage variant (optional — not implemented):** same flow with `RemoteOAuthStorage` + `createRemoteApp` tmp dir — proves EMA tokens persist via `/api/storage/oauth` (standard remote OAuth E2E already covers the storage API). #### Layer 4 — Protected-resource discovery — **partial** @@ -658,7 +665,7 @@ Existing coverage: `xaa-ema-http.json` config load, `ExternalAccessTokenValidato 1. ~~Layer 1 (`wire` / `emaFlow` unit tests)~~ — **done** 2. ~~Layer 2 mock servers helper~~ — **done** 3. ~~Layer 3 single happy-path E2E (`streamable-http`)~~ — **done** -4. Layer 3 storage variant with `RemoteOAuthStorage` — optional, aligns with shared-storage refactor. +4. Layer 3 storage variant with `RemoteOAuthStorage` — optional EMA-specific EMA E2E (shared storage already covered by standard remote OAuth tests). 5. 401 re-auth case — connect, invalidate resource token, assert legs 2–3 re-run (stretch). ### Done criteria for Phase 3b checklist diff --git a/specification/v2_auth_hardening.md b/specification/v2_auth_hardening.md index 95e02793..1affdfc4 100644 --- a/specification/v2_auth_hardening.md +++ b/specification/v2_auth_hardening.md @@ -199,7 +199,7 @@ Document manual scenarios in [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md | Upgrade | Root and client `package.json`; import paths v1 → `@modelcontextprotocol/client` | | Callback | `clients/web/src/App.tsx`, `core/auth/node/oauth-callback-server.ts`, `core/mcp/oauthManager.ts`, `core/mcp/inspectorClient.ts` | | Provider | `core/auth/providers.ts`, `core/auth/browser/providers.ts` — v2 hooks: `invalidateCredentials`, issuer-keyed storage, `application_type` | -| Storage | `core/auth/store.ts`, `core/auth/oauth-storage.ts`, `core/mcp/remote/node/remoteOAuthStorage.ts` (if landed) | +| Storage | `core/auth/store.ts`, `core/auth/oauth-storage.ts`, `core/auth/oauth-persist.ts`, `core/auth/remote/storage-remote.ts`, `clients/web/src/lib/remoteOAuthStorage.ts` | | Web remote (unchanged on v2) | `core/mcp/remote/remoteClientTransport.ts`, `core/mcp/node/authChallengeFetch.ts`, `core/mcp/remote/node/server.ts` | | TUI/CLI | [Mid-session § TUI and CLI](v2_auth_mid_session.md#tui-and-cli-implementation): `clients/tui/src/App.tsx`; `clients/cli/src/cliOAuth.ts`; provider wiring in `core/mcp/inspectorClient.ts` | | Mid-session overlap | `core/auth/challenge.ts`, `core/mcp/oauthManager.ts` (`handleAuthChallenge`, `saveScope`) | diff --git a/specification/v2_auth_mid_session.md b/specification/v2_auth_mid_session.md index 0fdc5b56..601eac0d 100644 --- a/specification/v2_auth_mid_session.md +++ b/specification/v2_auth_mid_session.md @@ -536,13 +536,13 @@ On each MCP request, after bearer validation: | [v2_auth_hardening.md](v2_auth_hardening.md) | Connect-time SEPs; v2 SDK upgrade; direct transport silent retry delegation | | [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 2–3 re-mint; scope resolution; no resource-OAuth fallback | | [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual OAuth and mid-session validation procedures | -| [v2_storage.md](v2_storage.md) | Target: shared `oauth.json` via `RemoteOAuthStorage` on web | +| [v2_storage.md](v2_storage.md) | Shared `oauth.json` via `RemoteOAuthStorage` on web (implemented) | --- ## Future work -- **Web default `RemoteOAuthStorage`** — shared `oauth.json` with TUI/CLI; optional `navigator.locks` for silent refresh single-flight across browser tabs. +- **Optional `navigator.locks`** on shared `RemoteOAuthStorage` — single-flight silent refresh across browser tabs (see [EMA §Shared storage](v2_auth_ema.md)). - **v2 SDK transport upgrade** — delegate direct streamable HTTP silent 401/403 + SEP-2350 union to SDK; remove `mcpAuth` / client intercept shims where redundant. - **Server-side token refresh** on the node using `RemoteAuthState.oauthClient` + `refresh_token` (browser owns refresh today). - **Connection Info** — display effective vs pending scopes. diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index d9fbe474..b37c7692 100644 --- a/specification/v2_auth_smoke_testing.md +++ b/specification/v2_auth_smoke_testing.md @@ -136,7 +136,7 @@ Per-server OAuth fields live on the same entry (lifted to `InspectorServerSettin ### Notes -- First connect performs discovery + DCR + authorization code + PKCE. Tokens persist in `~/.mcp-inspector/storage/oauth.json` (TUI/CLI path) or browser session storage (web). +- First connect performs discovery + DCR + authorization code + PKCE. Tokens persist in `~/.mcp-inspector/storage/oauth.json` (CLI/TUI direct file, web via `RemoteOAuthStorage` → same file on the local backend). - Reconnect should reuse stored DCR `client_id` unless storage was cleared. --- @@ -847,7 +847,7 @@ Session from C1 must have **`mcp tools:read` only**. If step-up does not prompt, ## Known gaps (Inspector) -**Mid-session auth** is implemented for web (remote transport), TUI, and CLI — see [Mid-session authorization](v2_auth_mid_session.md). **Remaining:** shared `RemoteOAuthStorage` on web, optional idle SSE E2E, v2 SDK transport upgrade for direct silent retry. +**Mid-session auth** is implemented for web (remote transport), TUI, and CLI — see [Mid-session authorization](v2_auth_mid_session.md). **Remaining:** optional idle SSE E2E, v2 SDK transport upgrade for direct silent retry. See **[Auth hardening (MCP 2026-07-28)](v2_auth_hardening.md)** for connect-time OAuth hardening (SEP-2468, SEP-837, SEP-2352, SEP-2207, SEP-2350, SEP-2351) and the v2 SDK upgrade strategy. diff --git a/specification/v2_cli_tui_launcher.md b/specification/v2_cli_tui_launcher.md index 47c9dbcf..93836f68 100644 --- a/specification/v2_cli_tui_launcher.md +++ b/specification/v2_cli_tui_launcher.md @@ -22,7 +22,7 @@ This document describes how those clients are built, wired, and tested today, an - **CLI v2 sessions** (connect once, many subcommands) — tracked separately in [#1432](https://github.com/modelcontextprotocol/inspector/issues/1432). - **npm workspaces** — v2 uses a fat root package plus per-client `package.json` for dev dependencies; the launcher resolves sibling `build/` outputs via relative paths, not workspace hoisting. - - _Why not workspaces:_ `core/` is consumed by **bundling** — a Vite alias for the browser, tsup inlining for the Node clients — not by symlinked package resolution, so workspaces' main benefit (cross-package linking) does not apply. Each client also pins `react` / `zustand` / `@modelcontextprotocol/sdk` to its own `node_modules` (see `vitest.shared.mts`) to avoid dual-package-instance hazards, which hoisting works against. And the published `@modelcontextprotocol/inspector` is a single flat fat package that workspaces would complicate rather than simplify. + - _Why not workspaces:_ `core/` is consumed by **bundling** — a Vite alias for the browser, tsup inlining for the Node clients — not by symlinked package resolution, so workspaces' main benefit (cross-package linking) does not apply. Each client also pins `react` / `@modelcontextprotocol/sdk` to its own `node_modules` (see `vitest.shared.mts`) to avoid dual-package-instance hazards, which hoisting works against. And the published `@modelcontextprotocol/inspector` is a single flat fat package that workspaces would complicate rather than simplify. - _Cost (from-source dev only):_ there is no hoisting, so each client keeps its own `node_modules`. A root `postinstall` (`scripts/install-clients.mjs`) cascades `npm install` into every client, so a single `npm install` at the repo root populates them all — re-run it after a pull that changes a client's dependencies. The cascade no-ops outside a source checkout (it exits early when running from `node_modules`, and the published tarball ships only each client's `build/`, no client `package.json`), so end users of the published package are unaffected. Set `INSPECTOR_SKIP_CLIENT_INSTALL=1` to skip the cascade (e.g. CI that installs each client itself). - **Per-client coverage gates** — `core/` coverage stays on the web suite; CLI/TUI source gates are follow-up work (see [Known gaps](#known-gaps)). - **Catalog CRUD in TUI** — TUI loads and connects; persistent catalog editing remains web-first today. diff --git a/specification/v2_new_spec_impact.md b/specification/v2_new_spec_impact.md index 26b2f4c4..5910255e 100644 --- a/specification/v2_new_spec_impact.md +++ b/specification/v2_new_spec_impact.md @@ -302,7 +302,7 @@ The Inspector v2 currently depends on `@modelcontextprotocol/sdk@1.29.0`. SDK v2 ## 9. Impact on MCP Inspector V2 (`v2/main`) -Grounded in the current branch architecture: `InspectorClient` (core/mcp/inspectorClient.ts) wrapping SDK 1.29 with typed events → state stores → React hooks; browser transport remoted through the Hono backend (`core/mcp/remote/`); zustand-persisted OAuth store; capability-gated tabs. +Grounded in the current branch architecture: `InspectorClient` (core/mcp/inspectorClient.ts) wrapping SDK 1.29 with typed events → `core/mcp/state` stores → React hooks; browser transport remoted through the Hono backend (`core/mcp/remote/`); file-backed OAuth via `OAuthStorageBase` (`oauth.json`); capability-gated tabs. ### 9.1 Connection model and state management @@ -331,7 +331,7 @@ This is the deepest change. Today `InspectorClient` and the UI assume: connect ### 9.4 Auth and the OAuth store -- **Re-key the zustand OAuth store** (`core/auth/store.ts`) from per-server to per-`(server, issuer)`: stamp `issuer` on stored `clientInformation`/`tokens`, detect issuer changes against freshly fetched PRM/AS metadata on every flow, invalidate + re-register (DCR) / error (static) / continue (CIMD) accordingly. SDK v2 handles the flow logic but expects providers to round-trip issuer-stamped objects and optionally implement `discoveryState()`/`saveDiscoveryState()`. +- **Re-key OAuth storage** (`core/auth/store.ts` / `OAuthStorage`) from per-server to per-`(server, issuer)`: stamp `issuer` on stored `clientInformation`/`tokens`, detect issuer changes against freshly fetched PRM/AS metadata on every flow, invalidate + re-register (DCR) / error (static) / continue (CIMD) accordingly. SDK v2 handles the flow logic but expects providers to round-trip issuer-stamped objects and optionally implement `discoveryState()`/`saveDiscoveryState()`. - **Persist the requested-scope set** per (server, issuer) and show the step-up union computation in the auth visualizations — this is exactly the kind of thing Inspector users will want to see when debugging 403 loops. Surface the `onInsufficientScope` policy as a setting. - **DCR panel:** show the `application_type` sent (Inspector = `"native"`), render registration rejections meaningfully, and reflect DCR's deprecated-in-favor-of-CIMD status (Inspector v2 already supports CIMD registration kind — good position). - **401 handling under negotiation:** unwrap `EraNegotiationFailed` → `UnauthorizedError` in `oauthManager.ts`; switch to `finishAuth(callbackParams)` whole-params form (RFC 9207 iss validation happens inside; mismatches throw `IssuerMismatchError` — a new error state to display). diff --git a/specification/v2_scope.md b/specification/v2_scope.md index 3d012d72..9358d3e4 100644 --- a/specification/v2_scope.md +++ b/specification/v2_scope.md @@ -45,9 +45,8 @@ * **Enterprise-managed authorization (EMA / XAA)** — see [EMA / XAA](v2_auth_ema.md) * **Mid-session authorization** (401 after connect: token refresh, step-up scopes, web remote reconnect) — see [Mid-session auth](v2_auth_mid_session.md) * **Authorization hardening** (MCP 2026-07-28 SEPs: `iss`, DCR client type, issuer-bound credentials, step-up scope union) — see [Auth hardening](v2_auth_hardening.md) + * **OAuth runtime persistence** — `OAuthStorageBase` + file/remote backends; shared `oauth.json` across web/CLI/TUI (#1548, #1549) * **OAuth smoke testing** (manual procedures against hosted servers) — see [Smoke testing](v2_auth_smoke_testing.md) - -## Transport Types * STDIO * SSE * SHTTP diff --git a/specification/v2_servers_file.md b/specification/v2_servers_file.md index 2a895502..6e019c8f 100644 --- a/specification/v2_servers_file.md +++ b/specification/v2_servers_file.md @@ -26,7 +26,7 @@ Replaces the hardcoded `SEED_SERVERS` in `clients/web/src/App.tsx:47` with a fil ## File location - **Path**: `~/.mcp-inspector/mcp.json` (Windows: `%USERPROFILE%\.mcp-inspector\mcp.json`). -- **Why this dir**: `~/.mcp-inspector/storage/` already exists for the Zustand-persist stores (OAuth, settings); one Inspector dir under `$HOME` is friendlier than two. Resolution uses the same `process.env.HOME || process.env.USERPROFILE` fallback as `getDefaultStorageDir()` in `core/storage/store-io.ts:13`. +- **Why this dir**: `~/.mcp-inspector/storage/` already holds runtime persistence files (OAuth tokens, install `client.json`, etc.); one Inspector dir under `$HOME` is friendlier than two. Resolution uses the same `process.env.HOME || process.env.USERPROFILE` fallback as `getDefaultStorageDir()` in `core/storage/store-io.ts:13`. - **Why canonical filename**: lets users symlink to/from Claude Desktop and similar tools. - **Permissions**: `0o600`, matching `writeStoreFile` in `core/storage/store-io.ts:55`. @@ -88,9 +88,9 @@ If the file does not exist when the backend boots, write a file containing the t | Hono backend + auth + storage routes pattern | `core/mcp/remote/node/server.ts` | `/api/storage/:storeId` is the template for the new `/api/servers` routes | | Auth'd fetch from browser | wired via `getAuthToken()` in `clients/web/src/App.tsx:84` | `useServers` will call the backend with `x-mcp-remote-auth: Bearer ` | -### Why not `createFileStorageAdapter` directly +### Why not a `{ state, version }` envelope for `mcp.json` -`core/storage/adapters/file-storage.ts` is a Zustand `persist` adapter — it wraps the payload as `{ state, version }` so the file ends up looking like `{"state":{...},"version":0}`. That breaks the "human-editable canonical `mcp.json`" goal. We use the underlying `store-io.ts` primitives instead. +Older OAuth persistence used a middleware-style envelope `{ state, version }` around the payload. That shape is fine for opaque runtime blobs like `oauth.json` (still accepted on **read** for migration — see [OAuth persistence](v2_auth_ema.md#oauth-persistence-1549--done)) but breaks the goal of a **human-editable canonical `mcp.json`**. Server list I/O uses the underlying `store-io.ts` primitives and writes plain JSON. ### New code diff --git a/specification/v2_storage.md b/specification/v2_storage.md index 12da6073..33e8687a 100644 --- a/specification/v2_storage.md +++ b/specification/v2_storage.md @@ -22,13 +22,14 @@ This specification defines the storage and state management architecture for Ins | Category | Examples | Persistence | Location | |----------|----------|-------------|----------| +| **OAuth runtime state** | Tokens, PKCE, scopes, IdP sessions, AS metadata | File (`oauth.json`) | `OAuthStorageBase` + persist backends | | **Server Configurations** | URL, transport, headers | File (mcp.json) | Proxy server | | **User Preferences** | Theme, log level, panel sizes | localStorage | Browser | | **Connection State** | Status, server info, errors | Memory | React Context | | **Execution State** | Current request, pending queue | Memory | React Context | -| **Logs Display** | Filtered view, pause state | Memory | Zustand store | -| **Execution Form State** | Selected tool, form values | Memory | Zustand store | -| **Testing Profiles** | Custom profiles, active selection | localStorage | Zustand store | +| **Logs Display** | Filtered view, pause state | Memory | `core/mcp/state` + React hooks | +| **Execution Form State** | Selected tool, form values | Memory | React component state / hooks | +| **Testing Profiles** | Custom profiles, active selection | localStorage | Browser (planned) | | **History Data** | Request/response records | NDJSON file (Pino) | Proxy server | ### Server-Side vs Client-Side Storage @@ -38,9 +39,9 @@ This spec focuses on **client-side** state management. Server-side persistence u | Layer | Technology | Purpose | |-------|------------|---------| | **Proxy (Pino)** | NDJSON files | Raw history persistence (`history.ndjson`), parsed by History API | -| **Client (Zustand)** | localStorage + memory | UI state, display buffers, user preferences | +| **Client (browser UI)** | localStorage + memory | UI preferences, display buffers | -The Pino logger on the proxy writes MCP request/response records to NDJSON format. The History API endpoint parses this file and returns filtered JSON. Client-side Zustand stores handle: +The Pino logger on the proxy writes MCP request/response records to NDJSON format. The History API endpoint parses this file and returns filtered JSON. Client-side `core/mcp/state` stores and React hooks handle: - How logs are **displayed** (filters, pause, auto-scroll) - Caching fetched history for UI performance - User preferences that don't belong on the server @@ -49,6 +50,22 @@ See [v2_server.md](./v2_server.md#pino-rationale) for Pino configuration details --- +## OAuth runtime persistence + +OAuth and EMA **runtime state** (access/refresh tokens, PKCE verifiers, granted scopes, cached authorization-server metadata, IdP sessions) is stored separately from `mcp.json` in **`~/.mcp-inspector/storage/oauth.json`** by default. + +| Client | Implementation | Path | +| ------ | -------------- | ---- | +| **Web** | `RemoteOAuthStorage` → `/api/storage/oauth` | Same on-disk file via Hono backend | +| **CLI / TUI** | `NodeOAuthStorage` | Direct file I/O | +| **Tests / reference** | `BrowserOAuthStorage` | sessionStorage (not wired in v2 web app) | + +**Stack (#1549):** `OAuthStorage` interface → `OAuthStorageBase` + `OAuthMemoryStore` + `OAuthPersistBackend` (`core/auth/oauth-storage.ts`, `core/auth/store.ts`, `core/auth/oauth-persist.ts`). Writes plain JSON `{ servers, idpSessions }`. Reads still promote legacy `{ state, version }` envelopes (migrate-on-write). All getters are async; setters auto-persist. Web shares one store via `getWebRemoteOAuthStorage()`. + +Details: [EMA auth](v2_auth_ema.md#oauth-persistence-1549--done), [Mid-session auth](v2_auth_mid_session.md). + +--- + ## State Management Options Analysis ### Comparison Matrix @@ -197,6 +214,8 @@ This pattern is already implemented in `McpContext.tsx` and working well. No mig ## Zustand Store Specifications +> **Historical note:** This section captured an early plan to use [Zustand](https://docs.pmnd.rs/zustand) for browser UI state. The **shipped** web client uses **`core/mcp/state`** event-driven stores and **`core/react`** hooks instead. **OAuth runtime persistence** uses **`OAuthStorageBase`** (see [OAuth runtime persistence](#oauth-runtime-persistence) above), not Zustand. The comparison matrix below remains useful for evaluating future UI-state libraries. + ### 1. Preferences Store **Purpose:** Persist user preferences across sessions diff --git a/specification/v2_ux_components.md b/specification/v2_ux_components.md index bc58aa59..0d32d264 100644 --- a/specification/v2_ux_components.md +++ b/specification/v2_ux_components.md @@ -3,13 +3,13 @@ ### [Brief](README.md) | [V1 Problems](v1_problems.md) | [V2 Scope](v2_scope.md) | [V2 Tech Stack](v2_web_client.md) | V2 UX | [V2 Auth](v2_auth.md) | [V2 New Spec Impact](v2_new_spec_impact.md) #### [Overview](v2_ux.md) | [Features](v2_ux_features.md) | [Handlers](v2_ux_handlers.md) | [Screenshots](v2_screenshots.md) | Components | [Interfaces](v2_ux_interfaces.md) -We are developing React components with Mantine, Zustand, and Storybook using a pattern called "Presentational Components" that emphasizes separation of concerns and testability. +We are developing React components with Mantine, **`core/mcp/state` stores + `core/react` hooks**, and Storybook using a pattern called "Presentational Components" that emphasizes separation of concerns and testability. ## The Presentational / Container Split The idea is that a component receives everything it needs through props — the data it renders *and* the callbacks it fires when the user interacts. The component itself has no idea where the data came from or what happens when a button is clicked. It just calls `onSave(formData)` and trusts that someone upstream is handling it. -This creates clean layering: Zustand stores own the state and logic, container components (or hooks) wire stores to presentational components, and the presentational components themselves are pure rendering machines. Storybook targets that bottom layer exclusively. +This creates clean layering: **`core/mcp/state` stores** own InspectorClient-derived data; container components or hooks wire stores to presentational components; presentational components are pure rendering machines. Storybook targets that bottom layer exclusively. ## What Storybook Does @@ -47,11 +47,11 @@ This is where the real challenge lives, because React makes it *very* easy to le **Props as the complete contract.** If a component needs to know whether a user has permission to delete something, don't pass in the user's role and let the component figure it out. Pass `canDelete: boolean`. The decision logic belongs in the store or a hook, not in the rendering layer. This also makes stories trivial to write — you just set `canDelete` to `true` or `false`. -**Callbacks over store access.** If a presentational component imports a Zustand store directly, it's no longer testable in isolation without mocking the store. Instead, have a thin wrapper hook or container that does `const { items, addItem } = useStore(...)` and passes those down. The presentational component just sees `items: Item[]` and `onAdd: (item: Item) => void`. +**Callbacks over store access.** If a presentational component imports a global store directly, it's no longer testable in isolation without mocking. Instead, use a thin wrapper hook or container that reads from `useXxx()` hooks and passes props down. The presentational component just sees `items: Item[]` and `onAdd: (item: Item) => void`. -**Derived state lives outside.** Filtering, sorting, computed totals — all of that should happen in Zustand selectors or custom hooks, not inside the component. The component receives the *already filtered* list. +**Derived state lives outside.** Filtering, sorting, computed totals — all of that should happen in store selectors or custom hooks, not inside the component. The component receives the *already filtered* list. -**Local UI state is the exception.** Things like "is this dropdown open" or "which tab is active" are legitimately component-internal. That's fine — that's UI state, not application state. The heuristic is: if the application would need to know about it (say, to restore state on navigation), it belongs in Zustand. If only the component cares, `useState` is appropriate. +**Local UI state is the exception.** Things like "is this dropdown open" or "which tab is active" are legitimately component-internal. That's fine — that's UI state, not application state. The heuristic is: if the application would need to know about it (say, to restore state on navigation), it belongs in shared state (stores/hooks). If only the component cares, `useState` is appropriate. ## Mantine-Specific Considerations @@ -63,4 +63,4 @@ It's the most mature choice, but it's not the only one. Ladle is a lighter alter For your stack, Storybook is the most well-trodden path and has the best TypeScript support for auto-generating controls from your prop types, which is genuinely useful. It will infer knobs/controls from your interfaces so you can tweak props interactively in the browser. -The bottom line: your instinct is right. Design your components around a typed model (the props interface), keep decision-making in Zustand, and Storybook becomes the natural place to develop and verify the visual layer independently. The discipline isn't really about Storybook — it's about prop interface design. Storybook just makes it obvious when you've violated the boundary, because a component that reaches outside its props becomes painful to write stories for. +The bottom line: your instinct is right. Design your components around a typed model (the props interface), keep decision-making in hooks/stores, and Storybook becomes the natural place to develop and verify the visual layer independently. The discipline isn't really about Storybook — it's about prop interface design. Storybook just makes it obvious when you've violated the boundary, because a component that reaches outside its props becomes painful to write stories for. diff --git a/test-servers/src/test-helpers.ts b/test-servers/src/test-helpers.ts index 244d4fe2..da73f6f2 100644 --- a/test-servers/src/test-helpers.ts +++ b/test-servers/src/test-helpers.ts @@ -143,7 +143,7 @@ export interface WaitForRemoteStoreOptions { * Poll GET /api/storage/:storeId until the response body satisfies `predicate`. * Use after persisting state (e.g. setServerState or client disconnect) and before * creating a second client/store or asserting on the API, so the test doesn't race - * with async persist (Zustand setItem). + * with async persist before asserting on the API. * * Uses x-mcp-remote-auth: Bearer for the request. * diff --git a/vitest.shared.mts b/vitest.shared.mts index 1c7e6cd2..0326e3c2 100644 --- a/vitest.shared.mts +++ b/vitest.shared.mts @@ -44,15 +44,6 @@ export function vitestSharedPaths(clientDir: string) { find: /^pino\/browser\.js$/, replacement: path.resolve(dirname, 'node_modules/pino/browser.js'), }, - { find: /^zustand$/, replacement: path.resolve(dirname, 'node_modules/zustand') }, - { - find: /^zustand\/middleware$/, - replacement: path.resolve(dirname, 'node_modules/zustand/middleware.js'), - }, - { - find: /^zustand\/vanilla$/, - replacement: path.resolve(dirname, 'node_modules/zustand/vanilla.js'), - }, { find: /^hono$/, replacement: path.resolve(dirname, 'node_modules/hono/dist/index.js'), From af174666d65c5566500ac79da3bac5d58cbbdd75 Mon Sep 17 00:00:00 2001 From: Bob Dickinson Date: Sun, 5 Jul 2026 18:38:13 -0700 Subject: [PATCH 09/26] fix(auth): address Claude review on OAuth persist serialization Serialize persist() in OAuthStorageBase so overlapping remote POSTs cannot reorder and clobber state. Regenerate launcher package-lock.json to drop stale zustand entries. Let parseOAuthPersistBlob accept parsed objects from res.json() without a redundant JSON round-trip. --- clients/launcher/package-lock.json | 988 ------------------ .../src/test/core/auth/oauth-persist.test.ts | 10 + core/auth/oauth-persist.ts | 16 +- core/auth/oauth-storage.ts | 10 +- 4 files changed, 31 insertions(+), 993 deletions(-) diff --git a/clients/launcher/package-lock.json b/clients/launcher/package-lock.json index ece3eed9..d23208e5 100644 --- a/clients/launcher/package-lock.json +++ b/clients/launcher/package-lock.json @@ -26,135 +26,8 @@ "vitest": "^4.1.0" } }, - "../cli": { - "name": "@modelcontextprotocol/inspector-cli", - "version": "2.0.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "ajv": "^8.17.1", - "atomically": "^2.1.1", - "commander": "^13.1.0", - "pino": "^9.14.0", - "zod": "^4.3.6", - "zustand": "^5.0.13" - }, - "bin": { - "mcp-inspector-cli": "build/index.js" - }, - "devDependencies": { - "@types/node": "^24.12.4", - "tsup": "^8.5.0", - "typescript": "~5.9.3", - "vitest": "^4.1.0" - } - }, - "../tui": { - "name": "@modelcontextprotocol/inspector-tui", - "version": "2.0.0", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", - "ajv": "^8.17.1", - "atomically": "^2.1.1", - "commander": "^13.1.0", - "ink": "^6.0.0", - "ink-form": "^2.0.1", - "ink-scroll-view": "^0.3.6", - "open": "^10.2.0", - "pino": "^9.14.0", - "react": "^19.2.4", - "zod": "^4.3.6", - "zustand": "^5.0.13" - }, - "bin": { - "mcp-inspector-tui": "build/index.js" - }, - "devDependencies": { - "@types/node": "^24.12.4", - "@types/react": "^19.2.14", - "tsup": "^8.5.0", - "tsx": "^4.21.0", - "typescript": "~5.9.3", - "vite": "^8.0.16", - "vite-node": "^6.0.0", - "vitest": "^4.1.0" - } - }, - "../web": { - "name": "@modelcontextprotocol/inspector-web", - "version": "2.0.0", - "extraneous": true, - "dependencies": { - "@emotion/react": "^11.14.0", - "@hono/node-server": "^1.19.14", - "@mantine/core": "^8.3.17", - "@mantine/form": "^8.3.17", - "@mantine/hooks": "^8.3.17", - "@mantine/notifications": "^8.3.17", - "@modelcontextprotocol/ext-apps": "^1.7.1", - "@modelcontextprotocol/sdk": "^1.29.0", - "@napi-rs/keyring": "^1.3.0", - "ajv": "^8.17.1", - "atomically": "^2.1.1", - "chokidar": "^4.0.3", - "commander": "^13.1.0", - "hono": "^4.12.18", - "open": "^10.2.0", - "pino": "^9.14.0", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "react-icons": "^5.6.0", - "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1", - "zod": "^4.3.6", - "zustand": "^5.0.13" - }, - "bin": { - "mcp-inspector-web": "build/index.js" - }, - "devDependencies": { - "@chromatic-com/storybook": "^5.0.1", - "@eslint/js": "^9.39.4", - "@storybook/addon-a11y": "^10.2.19", - "@storybook/addon-docs": "^10.2.19", - "@storybook/addon-onboarding": "^10.2.19", - "@storybook/addon-vitest": "^10.2.19", - "@storybook/react-vite": "^10.2.19", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", - "@types/express": "^5.0.6", - "@types/node": "^24.12.4", - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.0", - "@vitest/browser-playwright": "^4.1.0", - "@vitest/coverage-v8": "^4.1.0", - "eslint": "^9.39.4", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "eslint-plugin-storybook": "^10.2.19", - "express": "^5.2.1", - "globals": "^17.4.0", - "happy-dom": "^20.9.0", - "playwright": "^1.58.2", - "prettier": "^3.8.1", - "storybook": "^10.2.19", - "tsup": "^8.5.1", - "typescript": "~5.9.3", - "typescript-eslint": "^8.56.1", - "vite": "^8.0.0", - "vitest": "^4.1.0", - "yaml": "^2.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -163,8 +36,6 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -173,8 +44,6 @@ }, "node_modules/@babel/parser": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { @@ -189,8 +58,6 @@ }, "node_modules/@babel/types": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { @@ -203,52 +70,14 @@ }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -266,8 +95,6 @@ }, "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -279,8 +106,6 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -289,8 +114,6 @@ }, "node_modules/@eslint/config-array": { "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -304,8 +127,6 @@ }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -317,8 +138,6 @@ }, "node_modules/@eslint/core": { "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -330,8 +149,6 @@ }, "node_modules/@eslint/eslintrc": { "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { @@ -354,8 +171,6 @@ }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", "engines": { @@ -367,8 +182,6 @@ }, "node_modules/@eslint/js": { "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -380,8 +193,6 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -390,8 +201,6 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -404,8 +213,6 @@ }, "node_modules/@humanfs/core": { "version": "0.19.2", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", - "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -417,8 +224,6 @@ }, "node_modules/@humanfs/node": { "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", - "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -432,8 +237,6 @@ }, "node_modules/@humanfs/types": { "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", - "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", "dev": true, "license": "Apache-2.0", "engines": { @@ -442,8 +245,6 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -456,8 +257,6 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -470,8 +269,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { @@ -480,15 +277,11 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -496,56 +289,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-darwin-arm64": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -559,258 +312,18 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -820,29 +333,21 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { "version": "24.13.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.1.tgz", - "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", "dev": true, "license": "MIT", "dependencies": { @@ -851,8 +356,6 @@ }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", - "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -880,8 +383,6 @@ }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -890,8 +391,6 @@ }, "node_modules/@typescript-eslint/parser": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", - "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -915,8 +414,6 @@ }, "node_modules/@typescript-eslint/project-service": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", - "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", "dev": true, "license": "MIT", "dependencies": { @@ -937,8 +434,6 @@ }, "node_modules/@typescript-eslint/scope-manager": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", - "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", "dev": true, "license": "MIT", "dependencies": { @@ -955,8 +450,6 @@ }, "node_modules/@typescript-eslint/tsconfig-utils": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", - "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", "dev": true, "license": "MIT", "engines": { @@ -972,8 +465,6 @@ }, "node_modules/@typescript-eslint/type-utils": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", - "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "dev": true, "license": "MIT", "dependencies": { @@ -997,8 +488,6 @@ }, "node_modules/@typescript-eslint/types": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", - "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", "dev": true, "license": "MIT", "engines": { @@ -1011,8 +500,6 @@ }, "node_modules/@typescript-eslint/typescript-estree": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", - "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "dev": true, "license": "MIT", "dependencies": { @@ -1039,8 +526,6 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { @@ -1049,8 +534,6 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1062,8 +545,6 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -1078,8 +559,6 @@ }, "node_modules/@typescript-eslint/utils": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", - "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "dev": true, "license": "MIT", "dependencies": { @@ -1102,8 +581,6 @@ }, "node_modules/@typescript-eslint/visitor-keys": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", - "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", "dev": true, "license": "MIT", "dependencies": { @@ -1120,8 +597,6 @@ }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1133,8 +608,6 @@ }, "node_modules/@vitest/coverage-v8": { "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", - "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { @@ -1164,8 +637,6 @@ }, "node_modules/@vitest/expect": { "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { @@ -1182,8 +653,6 @@ }, "node_modules/@vitest/mocker": { "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { @@ -1209,8 +678,6 @@ }, "node_modules/@vitest/pretty-format": { "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -1222,8 +689,6 @@ }, "node_modules/@vitest/runner": { "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { @@ -1236,8 +701,6 @@ }, "node_modules/@vitest/snapshot": { "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { @@ -1252,8 +715,6 @@ }, "node_modules/@vitest/spy": { "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -1262,8 +723,6 @@ }, "node_modules/@vitest/utils": { "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { @@ -1277,8 +736,6 @@ }, "node_modules/acorn": { "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -1290,8 +747,6 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1300,8 +755,6 @@ }, "node_modules/ajv": { "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1317,8 +770,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -1333,15 +784,11 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/assertion-error": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -1350,8 +797,6 @@ }, "node_modules/ast-v8-to-istanbul": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", - "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", "dev": true, "license": "MIT", "dependencies": { @@ -1362,15 +807,11 @@ }, "node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/brace-expansion": { "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -1380,8 +821,6 @@ }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -1390,8 +829,6 @@ }, "node_modules/chai": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -1400,8 +837,6 @@ }, "node_modules/chalk": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -1417,8 +852,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1430,15 +863,11 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/commander": { "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", "license": "MIT", "engines": { "node": ">=18" @@ -1446,22 +875,16 @@ }, "node_modules/concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -1475,8 +898,6 @@ }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1493,15 +914,11 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/detect-libc": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1510,15 +927,11 @@ }, "node_modules/es-module-lexer": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", "dev": true, "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -1530,8 +943,6 @@ }, "node_modules/eslint": { "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1590,8 +1001,6 @@ }, "node_modules/eslint-scope": { "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -1607,8 +1016,6 @@ }, "node_modules/eslint-visitor-keys": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1620,8 +1027,6 @@ }, "node_modules/espree": { "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -1638,8 +1043,6 @@ }, "node_modules/esquery": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1651,8 +1054,6 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -1664,8 +1065,6 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -1674,8 +1073,6 @@ }, "node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -1684,8 +1081,6 @@ }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -1694,8 +1089,6 @@ }, "node_modules/expect-type": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1704,29 +1097,21 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -1743,8 +1128,6 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1756,8 +1139,6 @@ }, "node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -1773,8 +1154,6 @@ }, "node_modules/flat-cache": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -1787,17 +1166,12 @@ }, "node_modules/flatted": { "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -1809,8 +1183,6 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -1822,8 +1194,6 @@ }, "node_modules/globals": { "version": "17.6.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", - "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -1835,8 +1205,6 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { @@ -1845,15 +1213,11 @@ }, "node_modules/html-escaper": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true, "license": "MIT" }, "node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -1862,8 +1226,6 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1879,8 +1241,6 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -1889,8 +1249,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -1899,8 +1257,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -1912,15 +1268,11 @@ }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1929,8 +1281,6 @@ }, "node_modules/istanbul-lib-report": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1944,8 +1294,6 @@ }, "node_modules/istanbul-reports": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1958,15 +1306,11 @@ }, "node_modules/js-tokens": { "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, "funding": [ { @@ -1988,29 +1332,21 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -2019,8 +1355,6 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2033,8 +1367,6 @@ }, "node_modules/lightningcss": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -2061,31 +1393,8 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lightningcss-darwin-arm64": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], @@ -2103,199 +1412,8 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -2310,15 +1428,11 @@ }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2327,8 +1441,6 @@ }, "node_modules/magicast": { "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { @@ -2339,8 +1451,6 @@ }, "node_modules/make-dir": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, "license": "MIT", "dependencies": { @@ -2355,8 +1465,6 @@ }, "node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2368,15 +1476,11 @@ }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.14", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", - "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", "dev": true, "funding": [ { @@ -2394,15 +1498,11 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/obug": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", - "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2415,8 +1515,6 @@ }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -2433,8 +1531,6 @@ }, "node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2449,8 +1545,6 @@ }, "node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -2465,8 +1559,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -2478,8 +1570,6 @@ }, "node_modules/path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { @@ -2488,8 +1578,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { @@ -2498,22 +1586,16 @@ }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -2525,8 +1607,6 @@ }, "node_modules/postcss": { "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -2554,8 +1634,6 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -2564,8 +1642,6 @@ }, "node_modules/prettier": { "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -2580,8 +1656,6 @@ }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -2590,8 +1664,6 @@ }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -2600,8 +1672,6 @@ }, "node_modules/rolldown": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { @@ -2634,8 +1704,6 @@ }, "node_modules/semver": { "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -2647,8 +1715,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -2660,8 +1726,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -2670,15 +1734,11 @@ }, "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2687,22 +1747,16 @@ }, "node_modules/stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -2714,8 +1768,6 @@ }, "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -2727,15 +1779,11 @@ }, "node_modules/tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -2744,8 +1792,6 @@ }, "node_modules/tinyglobby": { "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -2761,8 +1807,6 @@ }, "node_modules/tinyrainbow": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -2771,8 +1815,6 @@ }, "node_modules/ts-api-utils": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -2782,18 +1824,8 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -2805,8 +1837,6 @@ }, "node_modules/typescript": { "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2819,8 +1849,6 @@ }, "node_modules/typescript-eslint": { "version": "8.61.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", - "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "dev": true, "license": "MIT", "dependencies": { @@ -2843,15 +1871,11 @@ }, "node_modules/undici-types": { "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2860,8 +1884,6 @@ }, "node_modules/vite": { "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { @@ -2938,8 +1960,6 @@ }, "node_modules/vitest": { "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3028,8 +2048,6 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -3044,8 +2062,6 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -3061,8 +2077,6 @@ }, "node_modules/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -3071,8 +2085,6 @@ }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { diff --git a/clients/web/src/test/core/auth/oauth-persist.test.ts b/clients/web/src/test/core/auth/oauth-persist.test.ts index ccb7b4d1..b8c59624 100644 --- a/clients/web/src/test/core/auth/oauth-persist.test.ts +++ b/clients/web/src/test/core/auth/oauth-persist.test.ts @@ -21,6 +21,16 @@ describe("parseOAuthPersistBlob", () => { expect(parseOAuthPersistBlob(JSON.stringify(snapshot))).toEqual(snapshot); }); + it("accepts an already-parsed object without re-serializing", () => { + const snapshot = { + servers: { + "http://example.com": { codeVerifier: "v1" }, + }, + idpSessions: {}, + }; + expect(parseOAuthPersistBlob(snapshot)).toEqual(snapshot); + }); + it("promotes legacy persist envelope state to the top level", () => { const legacy = { state: { diff --git a/core/auth/oauth-persist.ts b/core/auth/oauth-persist.ts index e6af75c6..ed635ba2 100644 --- a/core/auth/oauth-persist.ts +++ b/core/auth/oauth-persist.ts @@ -36,14 +36,22 @@ function snapshotFromPayload(payload: Partial): OAuthPersi /** * Parse OAuth store JSON from disk, remote API, or sessionStorage. * Accepts plain `{ servers, idpSessions }` or legacy `{ state, version }`. + * `raw` may be a JSON string or an already-parsed object (e.g. from `res.json()`). */ export function parseOAuthPersistBlob( - raw: string | null, + raw: string | null | unknown, ): OAuthPersistSnapshot | null { - if (!raw) { + if (raw === null || raw === undefined) { return null; } - const parsed = parseStore(raw); + + const parsed = + typeof raw === "string" + ? raw + ? parseStore(raw) + : null + : raw; + if (!isRecord(parsed)) { return null; } @@ -131,7 +139,7 @@ export function createRemoteOAuthPersistBackend( if (Object.keys(store as object).length === 0) { return null; } - return parseOAuthPersistBlob(JSON.stringify(store)); + return parseOAuthPersistBlob(store); }, async write(snapshot) { const headers: Record = { diff --git a/core/auth/oauth-storage.ts b/core/auth/oauth-storage.ts index 3b9fe1de..fe5a7c2e 100644 --- a/core/auth/oauth-storage.ts +++ b/core/auth/oauth-storage.ts @@ -24,6 +24,8 @@ import type { export class OAuthStorageBase implements OAuthStorage { private loaded = false; private loadPromise: Promise | undefined; + /** Serializes persist writes so concurrent mutators cannot reorder POSTs. */ + private persistQueue: Promise = Promise.resolve(); private readonly memory: OAuthMemoryStore; private readonly backend: OAuthPersistBackend; @@ -55,7 +57,13 @@ export class OAuthStorageBase implements OAuthStorage { } private async persist(): Promise { - await this.backend.write(this.memory.snapshot()); + const snapshot = this.memory.snapshot(); + const prior = this.persistQueue; + const tracked = prior + .catch(() => {}) + .then(() => this.backend.write(snapshot)); + this.persistQueue = tracked; + await tracked; } async getClientInformation( From ba48e06ffd9779e74b9125bbfe02ae1fc1ac5ccd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 10:59:40 -0400 Subject: [PATCH 10/26] fix(auth): guard OAuth-storage async paths against unhandled rejections Review follow-ups on the RemoteOAuthStorage web migration: - useEmaIdpLoginState.logout(): add a .catch to the now-async clearEmaIdpSession call so a storage failure no longer surfaces as an unhandled rejection; leave loginState unchanged on failure (the session was not actually cleared). Add a test asserting no unhandled rejection. - createOAuthStore: attach a no-op catch to the eager persistLoadPromise so a hydration read that fails before any load() awaits it does not become an unhandled rejection; real awaiters still observe the rejection via the shared reference. - storage-remote.test: replace `as unknown as typeof fetch` double-casts with the typed vi.fn() pattern the same PR established. - remoteOAuthStorage: correct the doc comment to describe the cache-of-one accurately instead of implying a keyed map. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/lib/remoteOAuthStorage.ts | 8 ++- .../src/test/core/auth/storage-remote.test.ts | 8 +-- .../core/react/useEmaIdpLoginState.test.tsx | 52 +++++++++++++++++++ core/auth/store.ts | 7 +++ core/react/useEmaIdpLoginState.ts | 14 +++-- 5 files changed, 80 insertions(+), 9 deletions(-) diff --git a/clients/web/src/lib/remoteOAuthStorage.ts b/clients/web/src/lib/remoteOAuthStorage.ts index 0d95806b..12b13e94 100644 --- a/clients/web/src/lib/remoteOAuthStorage.ts +++ b/clients/web/src/lib/remoteOAuthStorage.ts @@ -17,8 +17,12 @@ const defaultFetch: typeof fetch = (...args) => globalThis.fetch(...args); * Shared web OAuth store: `RemoteOAuthStorage` → `GET/POST /api/storage/oauth` * → `~/.mcp-inspector/storage/oauth.json` (same file as CLI/TUI). * - * Memoized by `{ baseUrl, authToken }` so connect, EMA IdP session, and - * per-server clear all mutate the same in-memory view. + * Caches a single instance keyed by `{ baseUrl, authToken }`: repeat calls with + * the same key return that instance, so connect, EMA IdP session, and per-server + * clear all mutate the same in-memory view. A call with a different key replaces + * the cached instance (cache of one). That is sufficient because the web app + * uses a stable origin + page-lifetime API token, so the key never changes + * within a session. */ export function getRemoteOAuthStorage( options: WebRemoteOAuthStorageOptions, diff --git a/clients/web/src/test/core/auth/storage-remote.test.ts b/clients/web/src/test/core/auth/storage-remote.test.ts index be8536a5..fa1b02fe 100644 --- a/clients/web/src/test/core/auth/storage-remote.test.ts +++ b/clients/web/src/test/core/auth/storage-remote.test.ts @@ -142,12 +142,12 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { version: 0, }; let resolveFetch!: (value: Response) => void; - const delayedFetch = vi.fn( + const delayedFetch = vi.fn( () => new Promise((resolve) => { resolveFetch = resolve; }), - ) as unknown as typeof fetch; + ); const delayedStorage = new RemoteOAuthStorage({ baseUrl: "http://remote.example", @@ -167,9 +167,9 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { }); it("load() rejects when remote GET fails instead of hanging", async () => { - const failingFetch = vi.fn( + const failingFetch = vi.fn( async () => new Response("server error", { status: 500 }), - ) as unknown as typeof fetch; + ); const failingStorage = new RemoteOAuthStorage({ baseUrl: "http://remote.example", diff --git a/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx b/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx index bc7f1d6b..0b141571 100644 --- a/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx +++ b/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx @@ -67,6 +67,58 @@ describe("useEmaIdpLoginState", () => { }); }); + it("logout swallows a clear failure without an unhandled rejection and keeps state", async () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const payload = btoa(JSON.stringify({ exp })) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + vi.mocked(storage.getIdpSession).mockResolvedValue({ + idToken: `h.${payload}.s`, + }); + vi.mocked(storage.clearIdpSession).mockRejectedValue( + new Error("storage backend unreachable"), + ); + const unhandled = vi.fn(); + const onUnhandled = (event: PromiseRejectionEvent) => { + event.preventDefault(); + unhandled(); + }; + window.addEventListener("unhandledrejection", onUnhandled); + + try { + const { result } = renderHook(() => + useEmaIdpLoginState(storage, "https://idp.test", true), + ); + + await waitFor(() => { + expect(result.current.loginState).toBe("logged_in"); + }); + + act(() => { + result.current.logout(); + }); + + await waitFor(() => { + expect(storage.clearIdpSession).toHaveBeenCalledWith( + "https://idp.test", + ); + }); + // Give the rejected promise a turn to settle so any unhandled rejection + // would have fired. + await act(async () => { + await Promise.resolve(); + }); + + // Clear failed, so the session is still present: state stays "logged_in" + // and no unhandled rejection escaped. + expect(result.current.loginState).toBe("logged_in"); + expect(unhandled).not.toHaveBeenCalled(); + } finally { + window.removeEventListener("unhandledrejection", onUnhandled); + } + }); + it("reports 'expired' for an expired token with no refresh token", async () => { const exp = Math.floor(Date.now() / 1000) - 3600; const payload = btoa(JSON.stringify({ exp })) diff --git a/core/auth/store.ts b/core/auth/store.ts index 52d966c3..1be06be6 100644 --- a/core/auth/store.ts +++ b/core/auth/store.ts @@ -78,6 +78,13 @@ export function createOAuthStore( resolvePersistLoad = resolve; rejectPersistLoad = reject; }); + // Zustand kicks off async hydration as soon as the store is created, before + // any consumer calls `OAuthStorageBase.load()`. If that initial read fails + // and no one is awaiting yet, `persistLoadPromise` would reject with no + // handler and surface as an unhandled rejection. Attach a no-op handler so + // the promise is always "handled"; real awaiters still observe the rejection + // through the reference returned by `waitForOAuthStorePersistLoad`. + void persistLoadPromise.catch(() => {}); const settlePersistLoad = (error?: unknown) => { if (persistLoadSettled) { diff --git a/core/react/useEmaIdpLoginState.ts b/core/react/useEmaIdpLoginState.ts index 19f69827..85932561 100644 --- a/core/react/useEmaIdpLoginState.ts +++ b/core/react/useEmaIdpLoginState.ts @@ -40,9 +40,17 @@ export function useEmaIdpLoginState( const logout = useCallback(() => { if (!normalizedIssuer) return; - void clearEmaIdpSession(storage, normalizedIssuer).then(() => { - setLoginState("none"); - }); + void clearEmaIdpSession(storage, normalizedIssuer) + .then(() => { + setLoginState("none"); + }) + .catch(() => { + // Clearing the persisted IdP session failed (e.g. the storage backend + // is unreachable). Swallow the rejection so it does not surface as an + // unhandled promise, and leave loginState unchanged so the UI keeps + // reflecting the still-present session rather than falsely showing + // signed-out. + }); }, [storage, normalizedIssuer]); return { loginState, refresh, logout }; From 54d5b18b1e0dc70e057eff7f7fbf1e0c9436b3ad Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 13:54:14 -0400 Subject: [PATCH 11/26] fix(auth): drop redundant empty-object guard in remote OAuth read() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseOAuthPersistBlob already returns null for {} (the server's missing-file response) and for a literal null body, so the Object.keys(store).length === 0 guard was redundant — and would throw a TypeError on a null body before parseOAuthPersistBlob could handle it. Pass the parsed res.json() straight through. Addresses Claude review item #1 on #1612. Co-Authored-By: Claude Opus 4.8 (1M context) --- core/auth/oauth-persist.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/core/auth/oauth-persist.ts b/core/auth/oauth-persist.ts index ed635ba2..66d36c7d 100644 --- a/core/auth/oauth-persist.ts +++ b/core/auth/oauth-persist.ts @@ -26,7 +26,9 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function snapshotFromPayload(payload: Partial): OAuthPersistSnapshot { +function snapshotFromPayload( + payload: Partial, +): OAuthPersistSnapshot { return { servers: payload.servers ?? {}, idpSessions: payload.idpSessions ?? {}, @@ -45,12 +47,7 @@ export function parseOAuthPersistBlob( return null; } - const parsed = - typeof raw === "string" - ? raw - ? parseStore(raw) - : null - : raw; + const parsed = typeof raw === "string" ? (raw ? parseStore(raw) : null) : raw; if (!isRecord(parsed)) { return null; @@ -135,10 +132,10 @@ export function createRemoteOAuthPersistBackend( throw new Error(`Failed to read store: ${res.status}`); } + // parseOAuthPersistBlob already returns null for {} (the server's + // missing-file response, server.ts) and for a literal null body, so no + // empty-object guard is needed — and Object.keys(null) would throw. const store = await res.json(); - if (Object.keys(store as object).length === 0) { - return null; - } return parseOAuthPersistBlob(store); }, async write(snapshot) { From cdeb2316d05119ccbc185a0cde87c104be101f74 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 13:57:16 -0400 Subject: [PATCH 12/26] test(auth): assert parseOAuthPersistBlob({}) returns null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the contract the remote read() path leans on — the server's missing-file response is `c.json({}, 200)`, and the backend now passes that empty object straight to parseOAuthPersistBlob. Addresses the optional note on Claude review of #1612. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/test/core/auth/oauth-persist.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/clients/web/src/test/core/auth/oauth-persist.test.ts b/clients/web/src/test/core/auth/oauth-persist.test.ts index b8c59624..62f8eadd 100644 --- a/clients/web/src/test/core/auth/oauth-persist.test.ts +++ b/clients/web/src/test/core/auth/oauth-persist.test.ts @@ -9,6 +9,13 @@ describe("parseOAuthPersistBlob", () => { expect(parseOAuthPersistBlob(null)).toBeNull(); }); + it("returns null for an empty object (server missing-file response)", () => { + // The remote read() path relies on this: the server answers a missing + // store with `c.json({}, 200)`, and the backend passes that straight + // through instead of special-casing an empty object. + expect(parseOAuthPersistBlob({})).toBeNull(); + }); + it("reads plain JSON with servers and idpSessions", () => { const snapshot = { servers: { From 1e6a2825d46bc59d259f41cd8b5c0f481b780dac Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 16:20:31 -0400 Subject: [PATCH 13/26] fix(auth): keep oauth-persist browser-safe (move Node file backend out) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refactor routed the browser's RemoteOAuthStorage through `oauth-persist.ts`, which value-imported the Node-only `store-io.js` (`atomically` -> `stubborn-fs` -> `node:process.getuid`, plus `node:fs/promises`). That dragged Node built-ins into the browser bundle and the web app crashed to a blank page at runtime — a regression from v2/main, where browser OAuth storage used a Zustand JSON adapter with no Node imports. Unit/integration tests run in node/happy-dom and `smoke:web` only checks the served HTML, so none of them execute the app in a real browser and the break slipped through. Fix, following the existing `store-id.ts` precedent: - Split the pure JSON (de)serializers into Node-free `core/storage/store-serialize.ts`; `store-io.ts` re-exports them so existing importers are unaffected. - Move the Node-only `createFileOAuthPersistBackend` into `core/auth/node/oauth-persist-file.ts`; update `storage-node.ts` and the storage adapters test to import from there. - `oauth-persist.ts` now imports only the Node-free serializers, so the browser bundle no longer pulls in `node:fs`/`atomically`. Tests: - oauth-persist-browser-safe.test.ts: source-level guard that `oauth-persist.ts` never imports store-io/node:/atomically. - Direct unit tests for the isomorphic remote + session backends (restoring oauth-persist.ts coverage after the file backend moved out). - store.test.ts: OAuthMemoryStore unit coverage incl. replace() fallbacks. - storage.test.ts: clearAllOAuthClientState null-snapshot (no file) path. Verified end-to-end in a real browser (Playwright): DCR OAuth flow completes, tokens persist via the remote store, and after a full page reload the server reconnects with no re-authentication. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../auth/oauth-persist-browser-safe.test.ts | 47 ++++++ .../src/test/core/auth/oauth-persist.test.ts | 153 +++++++++++++++++- clients/web/src/test/core/auth/store.test.ts | 84 ++++++++++ .../integration/auth/node/storage.test.ts | 12 ++ .../test/integration/storage/adapters.test.ts | 2 +- core/auth/node/oauth-persist-file.ts | 42 +++++ core/auth/node/storage-node.ts | 2 +- core/auth/oauth-persist.ts | 44 ++--- core/storage/store-io.ts | 18 +-- core/storage/store-serialize.ts | 16 ++ 10 files changed, 368 insertions(+), 52 deletions(-) create mode 100644 clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts create mode 100644 clients/web/src/test/core/auth/store.test.ts create mode 100644 core/auth/node/oauth-persist-file.ts create mode 100644 core/storage/store-serialize.ts diff --git a/clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts b/clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts new file mode 100644 index 00000000..b689adaa --- /dev/null +++ b/clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +/** + * Regression guard for the browser bundle. `core/auth/oauth-persist.ts` is + * imported by browser code (App.tsx -> RemoteOAuthStorage -> oauth-persist), + * so it must stay Node-free. It previously imported `store-io.js`, which pulls + * `node:fs` + `atomically` (-> `stubborn-fs` -> `node:process.getuid`) into the + * browser bundle and blanked the app at runtime. The Node-only file backend + * now lives in `core/auth/node/oauth-persist-file.ts`; the isomorphic module + * must only import the Node-free `store-serialize` helpers. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const coreAuth = resolve(here, "../../../../../../core/auth"); + +function importSpecifiers(source: string): string[] { + const specs: string[] = []; + const re = /(?:import|export)[^;]*?from\s*["']([^"']+)["']/g; + let m: RegExpExecArray | null; + while ((m = re.exec(source)) !== null) { + specs.push(m[1]); + } + return specs; +} + +describe("oauth-persist.ts stays browser-safe", () => { + const source = readFileSync(resolve(coreAuth, "oauth-persist.ts"), "utf-8"); + const specs = importSpecifiers(source); + + it("does not import the Node-only store-io module", () => { + expect(specs).not.toContain("../storage/store-io.js"); + }); + + it("does not import node: builtins or atomically", () => { + const forbidden = specs.filter( + (s) => s.startsWith("node:") || s === "atomically", + ); + expect(forbidden).toEqual([]); + }); + + it("gets its (de)serializers from the Node-free store-serialize module", () => { + expect(specs).toContain("../storage/store-serialize.js"); + }); +}); diff --git a/clients/web/src/test/core/auth/oauth-persist.test.ts b/clients/web/src/test/core/auth/oauth-persist.test.ts index 62f8eadd..ac0c42ab 100644 --- a/clients/web/src/test/core/auth/oauth-persist.test.ts +++ b/clients/web/src/test/core/auth/oauth-persist.test.ts @@ -1,8 +1,21 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { parseOAuthPersistBlob, serializeOAuthPersistBlob, + createRemoteOAuthPersistBackend, + createSessionOAuthPersistBackend, + OAUTH_PERSIST_STORAGE_KEY, } from "@inspector/core/auth/oauth-persist.js"; +import type { OAuthPersistSnapshot } from "@inspector/core/auth/oauth-persist.js"; + +const SNAPSHOT: OAuthPersistSnapshot = { + servers: { "http://s": { scope: "read" } }, + idpSessions: {}, +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status }); +} describe("parseOAuthPersistBlob", () => { it("returns null for empty input", () => { @@ -69,3 +82,141 @@ describe("serializeOAuthPersistBlob", () => { expect(raw).not.toMatch(/"state"\s*:/); }); }); + +describe("createRemoteOAuthPersistBackend", () => { + const baseUrl = "http://remote.example/"; + const storeId = "oauth"; + const url = "http://remote.example/api/storage/oauth"; + + it("read() returns the parsed snapshot and sends the auth header", async () => { + const fetchFn = vi.fn(async () => jsonResponse(SNAPSHOT)); + const backend = createRemoteOAuthPersistBackend({ + baseUrl, + storeId, + authToken: "tok", + fetchFn: fetchFn as unknown as typeof fetch, + }); + expect(await backend.read()).toEqual(SNAPSHOT); + expect(fetchFn).toHaveBeenCalledWith(url, { + method: "GET", + headers: { "x-mcp-remote-auth": "Bearer tok" }, + }); + }); + + it("read() returns null for the empty-object missing-file response", async () => { + const backend = createRemoteOAuthPersistBackend({ + baseUrl, + storeId, + fetchFn: (async () => jsonResponse({})) as unknown as typeof fetch, + }); + expect(await backend.read()).toBeNull(); + }); + + it("read() returns null on 404 and throws on other errors", async () => { + const notFound = createRemoteOAuthPersistBackend({ + baseUrl, + storeId, + fetchFn: (async () => + new Response("", { status: 404 })) as unknown as typeof fetch, + }); + expect(await notFound.read()).toBeNull(); + + const failing = createRemoteOAuthPersistBackend({ + baseUrl, + storeId, + fetchFn: (async () => + new Response("", { status: 500 })) as unknown as typeof fetch, + }); + await expect(failing.read()).rejects.toThrow(/Failed to read store: 500/); + }); + + it("write() POSTs the serialized snapshot and throws on failure", async () => { + const ok = vi.fn(async () => new Response("", { status: 200 })); + const backend = createRemoteOAuthPersistBackend({ + baseUrl, + storeId, + fetchFn: ok as unknown as typeof fetch, + }); + await backend.write(SNAPSHOT); + expect(ok).toHaveBeenCalledWith( + url, + expect.objectContaining({ method: "POST" }), + ); + const body = ok.mock.calls[0][1].body; + expect(JSON.parse(body)).toEqual(SNAPSHOT); + + const failing = createRemoteOAuthPersistBackend({ + baseUrl, + storeId, + fetchFn: (async () => + new Response("", { status: 500 })) as unknown as typeof fetch, + }); + await expect(failing.write(SNAPSHOT)).rejects.toThrow( + /Failed to write store: 500/, + ); + }); + + it("remove() DELETEs, tolerates 404, and throws on other errors", async () => { + const ok = createRemoteOAuthPersistBackend({ + baseUrl, + storeId, + authToken: "tok", + fetchFn: (async () => + new Response("", { status: 200 })) as unknown as typeof fetch, + }); + await expect(ok.remove!()).resolves.toBeUndefined(); + + const gone = createRemoteOAuthPersistBackend({ + baseUrl, + storeId, + fetchFn: (async () => + new Response("", { status: 404 })) as unknown as typeof fetch, + }); + await expect(gone.remove!()).resolves.toBeUndefined(); + + const failing = createRemoteOAuthPersistBackend({ + baseUrl, + storeId, + fetchFn: (async () => + new Response("", { status: 500 })) as unknown as typeof fetch, + }); + await expect(failing.remove!()).rejects.toThrow( + /Failed to delete store: 500/, + ); + }); +}); + +describe("createSessionOAuthPersistBackend", () => { + function fakeStorage() { + const map = new Map(); + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + map, + } as unknown as Storage & { map: Map }; + } + + it("round-trips a snapshot through the default storage key", async () => { + const storage = fakeStorage(); + const backend = createSessionOAuthPersistBackend({ + getStorage: () => storage, + }); + expect(await backend.read()).toBeNull(); + await backend.write(SNAPSHOT); + expect(storage.map.has(OAUTH_PERSIST_STORAGE_KEY)).toBe(true); + expect(await backend.read()).toEqual(SNAPSHOT); + await backend.remove!(); + expect(await backend.read()).toBeNull(); + }); + + it("honors a custom storage key", async () => { + const storage = fakeStorage(); + const backend = createSessionOAuthPersistBackend({ + storageKey: "custom-key", + getStorage: () => storage, + }); + await backend.write(SNAPSHOT); + expect(storage.map.has("custom-key")).toBe(true); + }); +}); diff --git a/clients/web/src/test/core/auth/store.test.ts b/clients/web/src/test/core/auth/store.test.ts new file mode 100644 index 00000000..131a5ab8 --- /dev/null +++ b/clients/web/src/test/core/auth/store.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { OAuthMemoryStore } from "@inspector/core/auth/store.js"; + +describe("OAuthMemoryStore", () => { + it("returns empty defaults for unknown server / issuer", () => { + const store = new OAuthMemoryStore(); + expect(store.getState().getServerState("http://x")).toEqual({}); + expect(store.getState().getIdpSession("http://idp")).toEqual({}); + }); + + it("merges partial updates immutably via setServerState / setIdpSession", () => { + const store = new OAuthMemoryStore(); + const s = store.getState(); + s.setServerState("http://x", { scope: "read" }); + s.setServerState("http://x", { codeVerifier: "v" }); + expect(store.getState().getServerState("http://x")).toEqual({ + scope: "read", + codeVerifier: "v", + }); + s.setIdpSession("http://idp", { idToken: "t" }); + expect(store.getState().getIdpSession("http://idp")).toEqual({ + idToken: "t", + }); + }); + + it("clears a single server and a single idp session", () => { + const store = new OAuthMemoryStore({ + servers: { "http://x": { scope: "read" } }, + idpSessions: { "http://idp": { idToken: "t" } }, + }); + store.getState().clearServerState("http://x"); + store.getState().clearIdpSession("http://idp"); + expect(store.getState().getServerState("http://x")).toEqual({}); + expect(store.getState().getIdpSession("http://idp")).toEqual({}); + }); + + it("clears only enterprise-managed servers", () => { + const store = new OAuthMemoryStore({ + servers: { + "http://managed": { enterpriseManaged: true }, + "http://plain": { scope: "read" }, + }, + idpSessions: {}, + }); + store.getState().clearEnterpriseManagedResourceServers(); + expect(store.getState().getServerState("http://managed")).toEqual({}); + expect(store.getState().getServerState("http://plain")).toEqual({ + scope: "read", + }); + }); + + it("snapshot() returns a shallow copy of current state", () => { + const store = new OAuthMemoryStore({ + servers: { "http://x": { scope: "read" } }, + idpSessions: {}, + }); + const snap = store.snapshot(); + expect(snap).toEqual({ + servers: { "http://x": { scope: "read" } }, + idpSessions: {}, + }); + // Mutating the store afterward must not change the earlier snapshot. + store.getState().setServerState("http://y", { scope: "write" }); + expect(snap.servers["http://y"]).toBeUndefined(); + }); + + it("replace() tolerates a snapshot missing servers or idpSessions", () => { + // Exercises both `?? {}` fallbacks in replace(). + const noServers = new OAuthMemoryStore({ + idpSessions: { "http://idp": { idToken: "t" } }, + } as never); + expect(noServers.snapshot()).toEqual({ + servers: {}, + idpSessions: { "http://idp": { idToken: "t" } }, + }); + const noSessions = new OAuthMemoryStore({ + servers: { "http://x": { scope: "read" } }, + } as never); + expect(noSessions.snapshot()).toEqual({ + servers: { "http://x": { scope: "read" } }, + idpSessions: {}, + }); + }); +}); diff --git a/clients/web/src/test/integration/auth/node/storage.test.ts b/clients/web/src/test/integration/auth/node/storage.test.ts index 9eea463d..0bb328ad 100644 --- a/clients/web/src/test/integration/auth/node/storage.test.ts +++ b/clients/web/src/test/integration/auth/node/storage.test.ts @@ -579,6 +579,18 @@ describe("NodeOAuthStorage with custom storagePath", () => { resetNodeOAuthStorageCache(); }); + it("clearAllOAuthClientState is a no-op when no store file exists", async () => { + // Exercises the null-snapshot branch: read() returns null, so + // `snapshot?.servers ?? {}` yields no urls and nothing is cleared. + const filePath = getStateFilePath(); + resetNodeOAuthStorageCache(); + await fs.unlink(filePath).catch(() => {}); + + await expect(clearAllOAuthClientState()).resolves.toBeUndefined(); + + resetNodeOAuthStorageCache(); + }); + it("should isolate state from default store", async () => { const customPath = path.join( os.tmpdir(), diff --git a/clients/web/src/test/integration/storage/adapters.test.ts b/clients/web/src/test/integration/storage/adapters.test.ts index a48663a9..998e60e7 100644 --- a/clients/web/src/test/integration/storage/adapters.test.ts +++ b/clients/web/src/test/integration/storage/adapters.test.ts @@ -12,7 +12,7 @@ import type { ServerType } from "@hono/node-server"; import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js"; import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js"; import { OAuthMemoryStore } from "@inspector/core/auth/store.js"; -import { createFileOAuthPersistBackend } from "@inspector/core/auth/oauth-persist.js"; +import { createFileOAuthPersistBackend } from "@inspector/core/auth/node/oauth-persist-file.js"; import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; import { writeStoreFile, diff --git a/core/auth/node/oauth-persist-file.ts b/core/auth/node/oauth-persist-file.ts new file mode 100644 index 00000000..dc0714fb --- /dev/null +++ b/core/auth/node/oauth-persist-file.ts @@ -0,0 +1,42 @@ +/** + * Node-only file backend for OAuth persistence. Kept out of the isomorphic + * `core/auth/oauth-persist.ts` because it imports `store-io.js` (which pulls + * `node:fs`/`atomically`); the browser must never load that. Node consumers + * (e.g. `NodeOAuthStorage`) import the file backend from here, while the + * shared blob (de)serialization and browser/remote backends stay isomorphic. + */ + +import { + readStoreFile, + writeStoreFile, + deleteStoreFile, +} from "../../storage/store-io.js"; +import { + parseOAuthPersistBlob, + serializeOAuthPersistBlob, + type OAuthPersistBackend, +} from "../oauth-persist.js"; + +export interface FileOAuthPersistBackendOptions { + filePath: string; +} + +export function createFileOAuthPersistBackend( + options: FileOAuthPersistBackendOptions, +): OAuthPersistBackend { + return { + async read() { + const raw = await readStoreFile(options.filePath); + return parseOAuthPersistBlob(raw); + }, + async write(snapshot) { + await writeStoreFile( + options.filePath, + serializeOAuthPersistBlob(snapshot), + ); + }, + async remove() { + await deleteStoreFile(options.filePath); + }, + }; +} diff --git a/core/auth/node/storage-node.ts b/core/auth/node/storage-node.ts index 49bb1f34..e46e20a2 100644 --- a/core/auth/node/storage-node.ts +++ b/core/auth/node/storage-node.ts @@ -1,6 +1,6 @@ import { OAuthStorageBase } from "../oauth-storage.js"; import { OAuthMemoryStore } from "../store.js"; -import { createFileOAuthPersistBackend } from "../oauth-persist.js"; +import { createFileOAuthPersistBackend } from "./oauth-persist-file.js"; import { getDefaultStorageDir, getStoreFilePath, diff --git a/core/auth/oauth-persist.ts b/core/auth/oauth-persist.ts index 66d36c7d..133dc419 100644 --- a/core/auth/oauth-persist.ts +++ b/core/auth/oauth-persist.ts @@ -1,17 +1,15 @@ /** - * OAuth persistence format and backends (file, remote HTTP, sessionStorage). - * Writes plain JSON `{ servers, idpSessions }`. On read, accepts legacy - * persist envelopes `{ state: { servers, idpSessions }, version }` and - * promotes the inner payload. + * OAuth persistence format and isomorphic backends (remote HTTP, + * sessionStorage). Writes plain JSON `{ servers, idpSessions }`. On read, + * accepts legacy persist envelopes `{ state: { servers, idpSessions }, + * version }` and promotes the inner payload. + * + * This module must stay browser-safe: it imports only the Node-free + * `store-serialize` helpers, never `store-io` (which pulls `node:fs`). The + * Node-only file backend lives in `./node/oauth-persist-file.ts`. */ -import { - readStoreFile, - writeStoreFile, - deleteStoreFile, - serializeStore, - parseStore, -} from "../storage/store-io.js"; +import { serializeStore, parseStore } from "../storage/store-serialize.js"; import type { IdpSessionState } from "./storage.js"; import type { ServerOAuthState } from "./store.js"; @@ -76,30 +74,6 @@ export interface OAuthPersistBackend { remove?(): Promise; } -export interface FileOAuthPersistBackendOptions { - filePath: string; -} - -export function createFileOAuthPersistBackend( - options: FileOAuthPersistBackendOptions, -): OAuthPersistBackend { - return { - async read() { - const raw = await readStoreFile(options.filePath); - return parseOAuthPersistBlob(raw); - }, - async write(snapshot) { - await writeStoreFile( - options.filePath, - serializeOAuthPersistBlob(snapshot), - ); - }, - async remove() { - await deleteStoreFile(options.filePath); - }, - }; -} - export interface RemoteOAuthPersistBackendOptions { baseUrl: string; storeId: string; diff --git a/core/storage/store-io.ts b/core/storage/store-io.ts index 5e0273b4..db04fe2e 100644 --- a/core/storage/store-io.ts +++ b/core/storage/store-io.ts @@ -12,6 +12,10 @@ import { readFile, writeFile } from "atomically"; // isomorphic code can reuse it without pulling Node deps into the browser. export { validateStoreId } from "./store-id.js"; +// Likewise re-export the pure JSON (de)serializers from the Node-free +// `store-serialize` module so existing `store-io` importers are unaffected. +export { serializeStore, parseStore } from "./store-serialize.js"; + /** * Default storage directory (~/.mcp-inspector/storage or %USERPROFILE%\.mcp-inspector\storage on Windows). */ @@ -120,17 +124,3 @@ export async function deleteStoreFile(filePath: string): Promise { } } } - -/** - * Serialize store data to JSON string (consistent format for server writes). - */ -export function serializeStore(data: unknown): string { - return JSON.stringify(data, null, 2); -} - -/** - * Parse store JSON string. Use after readStoreFile when returning parsed object. - */ -export function parseStore(raw: string): unknown { - return JSON.parse(raw); -} diff --git a/core/storage/store-serialize.ts b/core/storage/store-serialize.ts new file mode 100644 index 00000000..83c435be --- /dev/null +++ b/core/storage/store-serialize.ts @@ -0,0 +1,16 @@ +/** + * Pure, Node-free JSON (de)serialization for store files. Split from + * `store-io.ts` (which imports `node:fs`/`node:path`/`atomically`) so + * isomorphic code — e.g. the browser OAuth persistence flow in + * `core/auth/oauth-persist.ts` — can (de)serialize store blobs without + * pulling Node deps into the browser bundle. `store-io.ts` re-exports these + * so existing importers are unaffected. + */ + +export function serializeStore(data: unknown): string { + return JSON.stringify(data, null, 2); +} + +export function parseStore(raw: string): unknown { + return JSON.parse(raw); +} From 4c30dc6e2ea5db5379ad81e02fdf882b317bf5d0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 16:25:14 -0400 Subject: [PATCH 14/26] test(auth): make the browser-safe guard transitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the Node-free guard beyond oauth-persist.ts to every module reachable from the browser's RemoteOAuthStorage (storage-remote, oauth-storage, store, oauth-persist, store-serialize), so a forbidden store-io/node:/atomically import added one hop away is caught too — not just a direct import in oauth-persist.ts. Addresses the non-blocking hardening note on Claude review of #1612. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../auth/oauth-persist-browser-safe.test.ts | 65 ++++++++++++------- 1 file changed, 42 insertions(+), 23 deletions(-) diff --git a/clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts b/clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts index b689adaa..3b3dc6cf 100644 --- a/clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts +++ b/clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts @@ -4,17 +4,35 @@ import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; /** - * Regression guard for the browser bundle. `core/auth/oauth-persist.ts` is - * imported by browser code (App.tsx -> RemoteOAuthStorage -> oauth-persist), - * so it must stay Node-free. It previously imported `store-io.js`, which pulls - * `node:fs` + `atomically` (-> `stubborn-fs` -> `node:process.getuid`) into the - * browser bundle and blanked the app at runtime. The Node-only file backend - * now lives in `core/auth/node/oauth-persist-file.ts`; the isomorphic module - * must only import the Node-free `store-serialize` helpers. + * Regression guard for the browser bundle. The browser's `RemoteOAuthStorage` + * (App.tsx -> lib/remoteOAuthStorage.ts -> remote/storage-remote.ts) chains + * through a handful of `core/auth` + `core/storage` modules, so ALL of them + * must stay Node-free. `oauth-persist.ts` previously value-imported + * `store-io.js`, which pulls `node:fs` + `atomically` (-> `stubborn-fs` -> + * `node:process.getuid`) into the browser bundle and blanked the app at + * runtime. The Node-only file backend now lives in + * `core/auth/node/oauth-persist-file.ts`. + * + * This guard is transitive: it scans every module reachable from + * `storage-remote.ts` (short of the `node/` boundary), so a forbidden import + * added one hop away — e.g. through `store-serialize.ts` or `store.ts` — is + * caught too, not just a direct import in `oauth-persist.ts`. */ const here = dirname(fileURLToPath(import.meta.url)); -const coreAuth = resolve(here, "../../../../../../core/auth"); +const coreDir = resolve(here, "../../../../../../core"); + +// Browser-reachable modules (value-import graph from storage-remote.ts, +// excluding the intentional `node/` backend which is never bundled). +const BROWSER_REACHABLE = [ + "auth/remote/storage-remote.ts", + "auth/oauth-storage.ts", + "auth/store.ts", + "auth/oauth-persist.ts", + "storage/store-serialize.ts", +]; + +const FORBIDDEN = [/(^|\/)store-io(\.js)?$/, /^node:/, /^atomically$/]; function importSpecifiers(source: string): string[] { const specs: string[] = []; @@ -26,22 +44,23 @@ function importSpecifiers(source: string): string[] { return specs; } -describe("oauth-persist.ts stays browser-safe", () => { - const source = readFileSync(resolve(coreAuth, "oauth-persist.ts"), "utf-8"); - const specs = importSpecifiers(source); +describe("browser-reachable OAuth storage modules stay Node-free", () => { + it.each(BROWSER_REACHABLE)( + "%s imports no Node built-ins, atomically, or store-io", + (relPath) => { + const source = readFileSync(resolve(coreDir, relPath), "utf-8"); + const offenders = importSpecifiers(source).filter((spec) => + FORBIDDEN.some((re) => re.test(spec)), + ); + expect(offenders).toEqual([]); + }, + ); - it("does not import the Node-only store-io module", () => { - expect(specs).not.toContain("../storage/store-io.js"); - }); - - it("does not import node: builtins or atomically", () => { - const forbidden = specs.filter( - (s) => s.startsWith("node:") || s === "atomically", + it("oauth-persist.ts sources its (de)serializers from store-serialize", () => { + const source = readFileSync( + resolve(coreDir, "auth/oauth-persist.ts"), + "utf-8", ); - expect(forbidden).toEqual([]); - }); - - it("gets its (de)serializers from the Node-free store-serialize module", () => { - expect(specs).toContain("../storage/store-serialize.js"); + expect(importSpecifiers(source)).toContain("../storage/store-serialize.js"); }); }); From 98517c27580e7a2426a2c11c7aaf364db65e2942 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 16:43:41 -0400 Subject: [PATCH 15/26] test(auth): fix type error in remote backend write() test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tsc -b` (run in CI's validate step, but not by vitest locally) rejected `ok.mock.calls[0][1].body` — under the project's strict indexing the untyped mock's call tuple is `[]`, so `[0]` is possibly-undefined and `[1]` is out of range. Capture the request body inside a `vi.fn` implementation instead of indexing into `mock.calls`. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/test/core/auth/oauth-persist.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/clients/web/src/test/core/auth/oauth-persist.test.ts b/clients/web/src/test/core/auth/oauth-persist.test.ts index ac0c42ab..f55b37b0 100644 --- a/clients/web/src/test/core/auth/oauth-persist.test.ts +++ b/clients/web/src/test/core/auth/oauth-persist.test.ts @@ -131,19 +131,22 @@ describe("createRemoteOAuthPersistBackend", () => { }); it("write() POSTs the serialized snapshot and throws on failure", async () => { - const ok = vi.fn(async () => new Response("", { status: 200 })); + let capturedBody: string | undefined; + const ok = vi.fn(async (_input, init) => { + capturedBody = init?.body as string | undefined; + return new Response("", { status: 200 }); + }); const backend = createRemoteOAuthPersistBackend({ baseUrl, storeId, - fetchFn: ok as unknown as typeof fetch, + fetchFn: ok, }); await backend.write(SNAPSHOT); expect(ok).toHaveBeenCalledWith( url, expect.objectContaining({ method: "POST" }), ); - const body = ok.mock.calls[0][1].body; - expect(JSON.parse(body)).toEqual(SNAPSHOT); + expect(JSON.parse(capturedBody ?? "")).toEqual(SNAPSHOT); const failing = createRemoteOAuthPersistBackend({ baseUrl, From 081fd789f1e2717b52eb6c7c5997145df8d4b75c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 18:52:41 -0400 Subject: [PATCH 16/26] feat(web): pin Logging/History/Network as a resizable column (#1616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the ability to "pin" the Logging, History, and Network screens into a second column on the right of the InspectorView, so a primary screen and a monitor screen are visible at once (e.g. watch Logs while calling a Tool). - A "pin as column" button on the Logs/History/Network panel toolbars opens the column (shown only when wide + connected). - The column hosts a MonitoringControls tab row (available monitor tabs + close) over the selected embedded screen. Pinning removes the monitor group from the header tab bar; closing (x) returns it. - The divider between the primary area and the column is draggable (pointer + keyboard); width is clamped and persisted. - The split collapses to one column below ~1040px (the ServerListScreen single-card breakpoint) and while disconnected, without discarding the pin preference — it reopens when wide + connected again. State (pinned/tab/width) is view-local and persisted via the existing useLocalStorage pattern, so no new props thread through App and ViewHeader is unchanged (it just receives a filtered tab list). Screens/panels gain an `embedded` mode (fills the parent height, drops the 340px filter sidebar) via a shared EmbeddableScrollArea element. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/App.css | 27 + .../EmbeddableScrollArea.stories.tsx | 43 ++ .../EmbeddableScrollArea.test.tsx | 34 ++ .../EmbeddableScrollArea.tsx | 56 ++ .../PinColumnButton.stories.tsx | 21 + .../PinColumnButton/PinColumnButton.test.tsx | 21 + .../PinColumnButton/PinColumnButton.tsx | 26 + .../ResizeHandle/ResizeHandle.stories.tsx | 51 ++ .../ResizeHandle/ResizeHandle.test.tsx | 144 +++++ .../elements/ResizeHandle/ResizeHandle.tsx | 107 ++++ .../HistoryListPanel/HistoryListPanel.tsx | 19 +- .../groups/LogStreamPanel/LogStreamPanel.tsx | 36 +- .../MonitoringControls.stories.tsx | 35 ++ .../MonitoringControls.test.tsx | 79 +++ .../MonitoringControls/MonitoringControls.tsx | 46 ++ .../MonitoringScreen.stories.tsx | 59 ++ .../MonitoringScreen.test.tsx | 81 +++ .../MonitoringScreen/MonitoringScreen.tsx | 56 ++ .../NetworkStreamPanel/NetworkStreamPanel.tsx | 28 +- .../HistoryScreen/HistoryScreen.test.tsx | 15 + .../screens/HistoryScreen/HistoryScreen.tsx | 44 +- .../LoggingScreen/LoggingScreen.test.tsx | 15 + .../screens/LoggingScreen/LoggingScreen.tsx | 47 +- .../NetworkScreen/NetworkScreen.test.tsx | 16 + .../screens/NetworkScreen/NetworkScreen.tsx | 36 +- .../InspectorView/InspectorView.test.tsx | 284 ++++++++- .../views/InspectorView/InspectorView.tsx | 543 +++++++++++++----- 27 files changed, 1728 insertions(+), 241 deletions(-) create mode 100644 clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx create mode 100644 clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx create mode 100644 clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx create mode 100644 clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx create mode 100644 clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx create mode 100644 clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx create mode 100644 clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx create mode 100644 clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx create mode 100644 clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx create mode 100644 clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx create mode 100644 clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx create mode 100644 clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx create mode 100644 clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx create mode 100644 clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx create mode 100644 clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx diff --git a/clients/web/src/App.css b/clients/web/src/App.css index fc431065..2d35f4b6 100644 --- a/clients/web/src/App.css +++ b/clients/web/src/App.css @@ -422,3 +422,30 @@ z-index: 200; box-shadow: var(--mantine-shadow-sm); } + +/* + * Draggable vertical divider between the primary screen area and the pinned + * monitoring column (#1616). The col-resize cursor, the hover/active accent, + * and the drag-time user-select suppression are all pseudo-selector / cursor / + * cross-element concerns that can't be expressed as Mantine props, so the whole + * unit lives here rather than split across the theme. + */ +.resize-handle { + flex: 0 0 auto; + width: 6px; + cursor: col-resize; + background-color: var(--inspector-border-default); + transition: background-color 120ms ease; + touch-action: none; +} + +.resize-handle:hover, +.resize-handle:focus-visible { + background-color: var(--mantine-primary-color-filled); +} + +/* Suppress text selection anywhere on the page while a drag is in progress. */ +body.resizing-col { + user-select: none; + cursor: col-resize; +} diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx new file mode 100644 index 00000000..e85f9b65 --- /dev/null +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.stories.tsx @@ -0,0 +1,43 @@ +import { createRef } from "react"; +import { Stack, Text } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { EmbeddableScrollArea } from "./EmbeddableScrollArea"; + +const meta: Meta = { + title: "Elements/EmbeddableScrollArea", + component: EmbeddableScrollArea, +}; + +export default meta; +type Story = StoryObj; + +const rows = Array.from({ length: 40 }, (_, i) => ( + Row {i + 1} +)); + +export const FullSize: Story = { + render: () => ( + + {rows} + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Row 1")).toBeInTheDocument(); + }, +}; + +export const Embedded: Story = { + render: () => ( + + + {rows} + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Row 1")).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx new file mode 100644 index 00000000..55c78e53 --- /dev/null +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.test.tsx @@ -0,0 +1,34 @@ +import { createRef } from "react"; +import { describe, it, expect } from "vitest"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { EmbeddableScrollArea } from "./EmbeddableScrollArea"; + +describe("EmbeddableScrollArea", () => { + it("renders its children full-size", () => { + renderWithMantine( + +
full-size content
+
, + ); + expect(screen.getByText("full-size content")).toBeInTheDocument(); + }); + + it("renders its children when embedded", () => { + renderWithMantine( + +
embedded content
+
, + ); + expect(screen.getByText("embedded content")).toBeInTheDocument(); + }); + + it("attaches the viewport ref to the scroll viewport", () => { + const ref = createRef(); + renderWithMantine( + +
content
+
, + ); + expect(ref.current).toBeInstanceOf(HTMLElement); + }); +}); diff --git a/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx new file mode 100644 index 00000000..34dac26b --- /dev/null +++ b/clients/web/src/components/elements/EmbeddableScrollArea/EmbeddableScrollArea.tsx @@ -0,0 +1,56 @@ +import type { ReactNode, Ref } from "react"; +import { ScrollArea, Stack } from "@mantine/core"; + +// Full-size, the monitor stream panels bound their scroll to the viewport +// minus the header and the panel's own chrome. +const FULLSIZE_MAH = + "calc(100vh - var(--app-shell-header-height, 0px) - 150px)"; + +export interface EmbeddableScrollAreaProps { + /** + * True when rendered inside the pinned monitoring column (#1616): the scroll + * region fills its flex parent instead of using the viewport calc. A + * `flex:1 / mih:0` wrapper caps the inner `ScrollArea` at the space remaining + * below the column's controls (via `mah:100%`), so no viewport math is needed + * and the final rows never clip. + */ + embedded: boolean; + viewportRef: Ref; + children: ReactNode; +} + +/** + * The scroll region shared by the Logs / History / Network stream panels, which + * render both full-size (their own tab) and embedded (the monitoring column). + * Centralizes the one layout difference between those two hosts. + */ +export function EmbeddableScrollArea({ + embedded, + viewportRef, + children, +}: EmbeddableScrollAreaProps) { + if (embedded) { + return ( + + + {children} + + + ); + } + return ( + + {children} + + ); +} diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx new file mode 100644 index 00000000..8d6b7fc3 --- /dev/null +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.stories.tsx @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { PinColumnButton } from "./PinColumnButton"; + +const meta: Meta = { + title: "Elements/PinColumnButton", + component: PinColumnButton, + args: { onPin: fn() }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("button", { name: "Pin as column" }), + ).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx new file mode 100644 index 00000000..50ce1b62 --- /dev/null +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { PinColumnButton } from "./PinColumnButton"; + +describe("PinColumnButton", () => { + it("renders a labelled pin-as-column button", () => { + renderWithMantine(); + expect( + screen.getByRole("button", { name: "Pin as column" }), + ).toBeInTheDocument(); + }); + + it("invokes onPin when clicked", async () => { + const user = userEvent.setup(); + const onPin = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Pin as column" })); + expect(onPin).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx new file mode 100644 index 00000000..86467db9 --- /dev/null +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx @@ -0,0 +1,26 @@ +import { ActionIcon } from "@mantine/core"; +import { TbLayoutSidebarRightExpand } from "react-icons/tb"; + +export interface PinColumnButtonProps { + /** Pin this screen into the monitoring column to the right. */ + onPin: () => void; +} + +/** + * Toolbar icon that pins the owning monitor screen (Logs / History / Network) + * into the resizable column on the right of the InspectorView (#1616). Distinct + * from `PinToggle` (which pins individual history entries) — this one opens a + * side column, so it uses a right-sidebar glyph and an "as column" label. + */ +export function PinColumnButton({ onPin }: PinColumnButtonProps) { + return ( + + + + ); +} diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx new file mode 100644 index 00000000..4fb1c122 --- /dev/null +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.stories.tsx @@ -0,0 +1,51 @@ +import { useState } from "react"; +import { Box, Group, Text } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, within } from "storybook/test"; +import { ResizeHandle } from "./ResizeHandle"; + +const meta: Meta = { + title: "Elements/ResizeHandle", + component: ResizeHandle, +}; + +export default meta; +type Story = StoryObj; + +// A minimal split so the handle has a panel to resize in the docs view. The +// panel sits to the right; dragging the handle left widens it. +function ResizeDemo() { + const [width, setWidth] = useState(320); + const [dragWidth, setDragWidth] = useState(null); + return ( + + + Primary + + { + setWidth(next); + setDragWidth(null); + }} + aria-label="Resize demo panel" + /> + + Column ({dragWidth ?? width}px) + + + ); +} + +export const Default: Story = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect( + canvas.getByRole("separator", { name: "Resize demo panel" }), + ).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx new file mode 100644 index 00000000..0cac37d2 --- /dev/null +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + renderWithMantine, + screen, + fireEvent, +} from "../../../test/renderWithMantine"; +import { ResizeHandle } from "./ResizeHandle"; + +// The panel sits to the RIGHT of the handle, so moving the pointer LEFT +// (decreasing clientX) widens it: next = startWidth + (startX - clientX). +function setup(overrides: Partial[0]> = {}) { + const onChange = vi.fn(); + const onCommit = vi.fn(); + renderWithMantine( + , + ); + return { handle: screen.getByRole("separator"), onChange, onCommit }; +} + +describe("ResizeHandle", () => { + afterEach(() => { + document.body.classList.remove("resizing-col"); + }); + + it("exposes the separator role with live aria values", () => { + const { handle } = setup(); + expect(handle).toHaveAttribute("aria-orientation", "vertical"); + expect(handle).toHaveAttribute("aria-valuenow", "400"); + expect(handle).toHaveAttribute("aria-valuemin", "320"); + expect(handle).toHaveAttribute("aria-valuemax", "720"); + expect(handle).toHaveAccessibleName("Resize panel"); + }); + + it("accepts a custom aria-label", () => { + setup({ "aria-label": "Resize monitoring column" }); + expect( + screen.getByRole("separator", { name: "Resize monitoring column" }), + ).toBeInTheDocument(); + }); + + it("reports the widened value while dragging left and commits on release", () => { + const { handle, onChange, onCommit } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientX: 460, pointerId: 1 }); + // startWidth 400 + (500 - 460) = 440 + expect(onChange).toHaveBeenLastCalledWith(440); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.pointerUp(handle, { clientX: 460, pointerId: 1 }); + expect(onCommit).toHaveBeenCalledWith(440); + }); + + it("clamps to max when dragged past the upper bound", () => { + const { handle, onChange } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientX: 0, pointerId: 1 }); + expect(onChange).toHaveBeenLastCalledWith(720); + }); + + it("clamps to min when dragged past the lower bound", () => { + const { handle, onChange } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 }); + fireEvent.pointerMove(handle, { clientX: 900, pointerId: 1 }); + expect(onChange).toHaveBeenLastCalledWith(320); + }); + + it("toggles the drag body class across the gesture", () => { + const { handle } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 }); + expect(document.body.classList.contains("resizing-col")).toBe(true); + fireEvent.pointerUp(handle, { clientX: 500, pointerId: 1 }); + expect(document.body.classList.contains("resizing-col")).toBe(false); + }); + + it("ends the drag on pointer cancel", () => { + const { handle, onCommit } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1 }); + fireEvent.pointerCancel(handle, { clientX: 480, pointerId: 1 }); + expect(onCommit).toHaveBeenCalledWith(420); + expect(document.body.classList.contains("resizing-col")).toBe(false); + }); + + it("ignores pointer moves when no drag is active", () => { + const { handle, onChange, onCommit } = setup(); + fireEvent.pointerMove(handle, { clientX: 460, pointerId: 1 }); + fireEvent.pointerUp(handle, { clientX: 460, pointerId: 1 }); + expect(onChange).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("uses pointer capture when the platform supports it", () => { + const setCapture = vi.fn(); + const releaseCapture = vi.fn(); + const proto = HTMLElement.prototype as unknown as { + setPointerCapture?: (id: number) => void; + releasePointerCapture?: (id: number) => void; + }; + proto.setPointerCapture = setCapture; + proto.releasePointerCapture = releaseCapture; + try { + const { handle } = setup(); + fireEvent.pointerDown(handle, { clientX: 500, pointerId: 7 }); + fireEvent.pointerUp(handle, { clientX: 500, pointerId: 7 }); + expect(setCapture).toHaveBeenCalledWith(7); + expect(releaseCapture).toHaveBeenCalledWith(7); + } finally { + delete proto.setPointerCapture; + delete proto.releasePointerCapture; + } + }); + + it("widens by one step on ArrowLeft and commits", () => { + const { handle, onChange, onCommit } = setup({ step: 20 }); + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(onChange).toHaveBeenCalledWith(420); + expect(onCommit).toHaveBeenCalledWith(420); + }); + + it("narrows by one step on ArrowRight", () => { + const { handle, onChange } = setup(); + fireEvent.keyDown(handle, { key: "ArrowRight" }); + // default step 16: 400 - 16 = 384 + expect(onChange).toHaveBeenCalledWith(384); + }); + + it("clamps keyboard steps to the bounds", () => { + const { handle, onChange } = setup({ value: 712 }); + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(onChange).toHaveBeenCalledWith(720); + }); + + it("ignores non-arrow keys", () => { + const { handle, onChange, onCommit } = setup(); + fireEvent.keyDown(handle, { key: "Enter" }); + expect(onChange).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx new file mode 100644 index 00000000..a181e367 --- /dev/null +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx @@ -0,0 +1,107 @@ +import { useRef, type KeyboardEvent, type PointerEvent } from "react"; +import { Box } from "@mantine/core"; + +export interface ResizeHandleProps { + /** Current width (px) of the panel this handle resizes. */ + value: number; + /** Lower / upper clamp bounds (px). */ + min: number; + max: number; + /** Keyboard arrow step in px (default 16). */ + step?: number; + /** + * Fired continuously while dragging and on each keyboard step, with the + * clamped next width. The parent renders from this for live feedback. + */ + onChange: (next: number) => void; + /** + * Fired once when the gesture ends (pointer up / key up) with the final + * clamped width, so the parent can persist it in a single write. + */ + onCommit: (next: number) => void; + "aria-label"?: string; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** + * A thin draggable vertical divider that resizes a panel sitting to its RIGHT + * (so dragging left widens the panel). Pointer-capture keeps the gesture alive + * even when the pointer crosses an iframe (the Apps screen renders one), which + * would otherwise swallow the move stream and make the drag stick. Exposes the + * ARIA `separator` role with live value so it's operable by keyboard too. + */ +export function ResizeHandle({ + value, + min, + max, + step = 16, + onChange, + onCommit, + "aria-label": ariaLabel = "Resize panel", +}: ResizeHandleProps) { + // Gesture origin, captured on pointer down. `null` while idle. + const drag = useRef<{ startX: number; startWidth: number } | null>(null); + + function handlePointerDown(e: PointerEvent) { + drag.current = { startX: e.clientX, startWidth: value }; + // Optional chaining: happy-dom (unit tests) doesn't implement pointer + // capture, and it's a progressive enhancement, not required for correctness. + e.currentTarget.setPointerCapture?.(e.pointerId); + document.body.classList.add("resizing-col"); + } + + function handlePointerMove(e: PointerEvent) { + if (!drag.current) return; + const next = clamp( + drag.current.startWidth + (drag.current.startX - e.clientX), + min, + max, + ); + onChange(next); + } + + function endDrag(e: PointerEvent) { + if (!drag.current) return; + const next = clamp( + drag.current.startWidth + (drag.current.startX - e.clientX), + min, + max, + ); + drag.current = null; + e.currentTarget.releasePointerCapture?.(e.pointerId); + document.body.classList.remove("resizing-col"); + onCommit(next); + } + + function handleKeyDown(e: KeyboardEvent) { + // Left widens (panel is on the right), Right narrows — mirrors the drag. + const delta = + e.key === "ArrowLeft" ? step : e.key === "ArrowRight" ? -step : 0; + if (delta === 0) return; + e.preventDefault(); + const next = clamp(value + delta, min, max); + onChange(next); + onCommit(next); + } + + return ( + + ); +} diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index cd9125fe..af777001 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -4,7 +4,6 @@ import { Collapse, Group, Paper, - ScrollArea, Stack, Text, Title, @@ -21,6 +20,8 @@ import { SortToggle, type SortDirection, } from "../../elements/SortToggle/SortToggle"; +import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton"; +import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; import { extractMethod } from "../historyUtils.js"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; @@ -43,6 +44,10 @@ export interface HistoryListPanelProps { onSortChange: (next: SortDirection) => void; compact: boolean; onToggleCompact: () => void; + /** See LogStreamPanel: shows a "pin as column" button when set (#1616). */ + onPin?: () => void; + /** See LogStreamPanel: fills the flex parent instead of the viewport calc. */ + embedded?: boolean; } const PanelContainer = Paper.withProps({ @@ -201,6 +206,8 @@ export function HistoryListPanel({ onSortChange, compact, onToggleCompact, + onPin, + embedded = false, }: HistoryListPanelProps) { const viewportRef = useScrollMemory("history-list"); // Per-section expand/collapse, like the LogControls level toggles. Both start @@ -238,6 +245,7 @@ export function HistoryListPanel({ Requests + {onPin ? : null} {hasResults && ( )} @@ -262,12 +270,7 @@ export function HistoryListPanel({ {!hasResults ? ( No request history ) : ( - + {pinnedEntries.length > 0 && ( )} - + )} ); diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index b164f649..bc21b721 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx @@ -1,13 +1,5 @@ import { useMemo } from "react"; -import { - Button, - Group, - Paper, - ScrollArea, - Stack, - Text, - Title, -} from "@mantine/core"; +import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; import { LogEntry } from "../../elements/LogEntry/LogEntry"; import type { LogEntryData } from "../../elements/LogEntry/LogEntry"; @@ -15,6 +7,8 @@ import { SortToggle, type SortDirection, } from "../../elements/SortToggle/SortToggle"; +import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton"; +import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; export interface LogStreamPanelProps { @@ -25,6 +19,18 @@ export interface LogStreamPanelProps { onExport: () => void; sortDirection: SortDirection; onSortChange: (next: SortDirection) => void; + /** + * When set, renders a "pin as column" button in the toolbar that opens this + * screen in the monitoring column (#1616). Omitted when the panel is already + * embedded in that column (or when pinning isn't available). + */ + onPin?: () => void; + /** + * True when this panel is rendered inside the monitoring column. Switches the + * scroll region from the viewport-height calc to filling its flex parent, so + * it fits below the column's controls row without viewport math. + */ + embedded?: boolean; } const PanelContainer = Paper.withProps({ @@ -69,6 +75,8 @@ export function LogStreamPanel({ onExport, sortDirection, onSortChange, + onPin, + embedded = false, }: LogStreamPanelProps) { const viewportRef = useScrollMemory("logs-stream"); const filteredEntries = useMemo(() => { @@ -85,6 +93,7 @@ export function LogStreamPanel({ Log Stream + {onPin ? : null} {filteredEntries.length > 0 ? ( - + {filteredEntries.map((entry, index) => ( ))} - + ) : ( No log entries diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx new file mode 100644 index 00000000..3815d80a --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { MonitoringControls } from "./MonitoringControls"; + +const meta: Meta = { + title: "Groups/MonitoringControls", + component: MonitoringControls, + args: { + tabs: ["Logs", "History", "Network"], + value: "Logs", + onChange: fn(), + onClose: fn(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const AllTabs: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked(); + expect( + canvas.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + }, +}; + +export const StdioServer: Story = { + args: { tabs: ["Logs", "History"], value: "History" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull(); + }, +}; diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx new file mode 100644 index 00000000..458d1cf4 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { MonitoringControls } from "./MonitoringControls"; + +const TABS = ["Logs", "History", "Network"]; + +describe("MonitoringControls", () => { + it("renders a radio option for each available tab", () => { + renderWithMantine( + , + ); + for (const tab of TABS) { + expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument(); + } + }); + + it("renders only the tabs it is given", () => { + renderWithMantine( + , + ); + expect(screen.getAllByRole("radio")).toHaveLength(2); + expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); + }); + + it("marks the active tab as selected", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("radio", { name: "History" })).toBeChecked(); + }); + + it("calls onChange when a different tab is chosen", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("radio", { name: "Network" })); + expect(onChange).toHaveBeenCalledWith("Network"); + }); + + it("calls onClose when the close button is clicked", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + renderWithMantine( + , + ); + await user.click( + screen.getByRole("button", { name: "Close monitoring column" }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx new file mode 100644 index 00000000..028801d2 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -0,0 +1,46 @@ +import { CloseButton, Group, SegmentedControl } from "@mantine/core"; + +export interface MonitoringControlsProps { + /** The monitor screens currently available to pin (e.g. Logs/History/Network). */ + tabs: string[]; + /** The active monitor tab shown in the column below. */ + value: string; + onChange: (tab: string) => void; + /** Close the monitoring column, returning its screens to the header menu. */ + onClose: () => void; +} + +// The controls row that sits at the top of the pinned monitoring column: a +// segmented tab switcher on the left, a close button on the right that unpins +// the whole column. +const ControlsBar = Group.withProps({ + justify: "space-between", + wrap: "nowrap", + gap: "sm", + p: "sm", +}); + +/** + * Tab row + close control for the pinned monitoring column (#1616). Renders only + * the currently-available monitor tabs (the caller filters by capability), so it + * never shows an empty switcher. Selecting a tab swaps the screen below; the + * close button unpins the column so its screens rejoin the header tab menu. + */ +export function MonitoringControls({ + tabs, + value, + onChange, + onClose, +}: MonitoringControlsProps) { + return ( + + + + + ); +} diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx new file mode 100644 index 00000000..6c23380a --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx @@ -0,0 +1,59 @@ +import { Box, Text } from "@mantine/core"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { MonitoringScreen } from "./MonitoringScreen"; + +const demoScreens = { + Logs: ( + + Log stream + + ), + History: ( + + Request history + + ), + Network: ( + + Network requests + + ), +}; + +const meta: Meta = { + title: "Groups/MonitoringScreen", + component: MonitoringScreen, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + tabs: ["Logs", "History", "Network"], + value: "Logs", + onChange: fn(), + onClose: fn(), + screens: demoScreens, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Logs: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Log stream")).toBeInTheDocument(); + }, +}; + +export const History: Story = { + args: { value: "History" }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByText("Request history")).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx new file mode 100644 index 00000000..a0c8cbc2 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { MonitoringScreen } from "./MonitoringScreen"; + +const TABS = ["Logs", "History", "Network"]; + +function screens() { + return { + Logs:
logs-body
, + History:
history-body
, + Network:
network-body
, + }; +} + +describe("MonitoringScreen", () => { + it("renders the screen for the active tab", () => { + renderWithMantine( + , + ); + expect(screen.getByText("history-body")).toBeInTheDocument(); + expect(screen.queryByText("logs-body")).toBeNull(); + expect(screen.queryByText("network-body")).toBeNull(); + }); + + it("renders the controls tab row", () => { + renderWithMantine( + , + ); + expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + }); + + it("forwards tab changes and close from the controls", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onClose = vi.fn(); + renderWithMantine( + , + ); + await user.click(screen.getByRole("radio", { name: "Network" })); + expect(onChange).toHaveBeenCalledWith("Network"); + await user.click( + screen.getByRole("button", { name: "Close monitoring column" }), + ); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("renders nothing in the slot when the active tab has no screen", () => { + renderWithMantine( + logs-body }} + />, + ); + expect(screen.queryByText("logs-body")).toBeNull(); + }); +}); diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx new file mode 100644 index 00000000..d6dde2fd --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import { Divider, Stack } from "@mantine/core"; +import { MonitoringControls } from "../MonitoringControls/MonitoringControls"; + +export interface MonitoringScreenProps { + /** Available monitor tabs (Logs/History/Network, filtered by capability). */ + tabs: string[]; + /** Active monitor tab; keys into `screens` to pick what renders below. */ + value: string; + onChange: (tab: string) => void; + onClose: () => void; + /** + * The embedded screen node for each tab, keyed by tab label. The caller builds + * these (with `embedded` set) so this component stays a pure layout shell. + */ + screens: Record; +} + +// Fills the pinned column: a fixed controls row on top and the selected screen +// filling the remaining height below (mih:0 lets the inner ScrollArea bound). +const ColumnLayout = Stack.withProps({ + h: "100%", + gap: 0, +}); + +const ScreenSlot = Stack.withProps({ + flex: 1, + mih: 0, + gap: 0, +}); + +/** + * The pinned monitoring column's content (#1616): a `MonitoringControls` tab row + * over the currently-selected monitor screen. Layout-only — it renders whichever + * embedded screen node the caller supplies for the active tab. + */ +export function MonitoringScreen({ + tabs, + value, + onChange, + onClose, + screens, +}: MonitoringScreenProps) { + return ( + + + + {screens[value]} + + ); +} diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index 7f8b24d8..727a34be 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -1,13 +1,5 @@ import { useMemo } from "react"; -import { - Button, - Group, - Paper, - ScrollArea, - Stack, - Text, - Title, -} from "@mantine/core"; +import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core"; import type { FetchRequestCategory, FetchRequestEntry, @@ -18,6 +10,8 @@ import { SortToggle, type SortDirection, } from "../../elements/SortToggle/SortToggle"; +import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton"; +import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea"; import { useScrollMemory } from "../../../hooks/useScrollMemory"; export interface NetworkStreamPanelProps { @@ -30,6 +24,10 @@ export interface NetworkStreamPanelProps { onSortChange: (next: SortDirection) => void; compact: boolean; onToggleCompact: () => void; + /** See LogStreamPanel: shows a "pin as column" button when set (#1616). */ + onPin?: () => void; + /** See LogStreamPanel: fills the flex parent instead of the viewport calc. */ + embedded?: boolean; } const PanelContainer = Paper.withProps({ @@ -95,6 +93,8 @@ export function NetworkStreamPanel({ onSortChange, compact, onToggleCompact, + onPin, + embedded = false, }: NetworkStreamPanelProps) { const viewportRef = useScrollMemory("network-stream"); const filteredEntries = useMemo(() => { @@ -114,6 +114,7 @@ export function NetworkStreamPanel({ {formatTitle(filteredEntries.length)} + {onPin ? : null} {hasResults && ( )} @@ -134,12 +135,7 @@ export function NetworkStreamPanel({ {!hasResults ? ( No network requests ) : ( - + {filteredEntries.map((entry) => ( ))} - + )} ); diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx index 943c9cca..aad3f5da 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx @@ -111,4 +111,19 @@ describe("HistoryScreen", () => { expect.objectContaining({ methodFilter: undefined }), ); }); + + it("renders a pin-as-column button when onPin is provided and invokes it", async () => { + const user = userEvent.setup(); + const onPin = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Pin as column" })); + expect(onPin).toHaveBeenCalledTimes(1); + }); + + it("drops the filter sidebar when embedded, keeping the request list", () => { + renderWithMantine(); + expect(screen.getByText("Requests")).toBeInTheDocument(); + // The sidebar (HistoryControls, with its Search box) is not rendered. + expect(screen.queryByPlaceholderText("Search...")).toBeNull(); + }); }); diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx index 65fd4407..5f269b5b 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.tsx @@ -25,6 +25,10 @@ export interface HistoryScreenProps { onSortChange: (next: SortDirection) => void; compact: boolean; onToggleCompact: () => void; + /** See LoggingScreen: shows a "pin as column" button when set (#1616). */ + onPin?: () => void; + /** See LoggingScreen: fills the parent height and drops the filter sidebar. */ + embedded?: boolean; } // Search text, method filter, and per-direction visibility — controlled by the @@ -69,6 +73,8 @@ export function HistoryScreen({ onSortChange, compact, onToggleCompact, + onPin, + embedded = false, }: HistoryScreenProps) { const { search, methodFilter, visibleDirections } = ui; @@ -101,23 +107,25 @@ export function HistoryScreen({ }, [ui, visibleDirections, onUiChange]); return ( - - - - onUiChange({ ...ui, search: value })} - onMethodFilterChange={(value) => - onUiChange({ ...ui, methodFilter: value }) - } - onToggleDirection={handleToggleDirection} - onToggleAllDirections={handleToggleAllDirections} - /> - - + + {embedded ? null : ( + + + onUiChange({ ...ui, search: value })} + onMethodFilterChange={(value) => + onUiChange({ ...ui, methodFilter: value }) + } + onToggleDirection={handleToggleDirection} + onToggleAllDirections={handleToggleAllDirections} + /> + + + )} ); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx index 19f7c50b..11e1e84a 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -131,4 +131,19 @@ describe("LoggingScreen", () => { await user.click(screen.getByRole("button", { name: "Set" })); expect(onSetLevel).toHaveBeenCalledWith("info"); }); + + it("renders a pin-as-column button when onPin is provided and invokes it", async () => { + const user = userEvent.setup(); + const onPin = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Pin as column" })); + expect(onPin).toHaveBeenCalledTimes(1); + }); + + it("drops the filter sidebar when embedded, keeping the stream", () => { + renderWithMantine(); + expect(screen.getByText("Log Stream")).toBeInTheDocument(); + // The sidebar (LogControls, with its Set button) is not rendered. + expect(screen.queryByRole("button", { name: "Set" })).toBeNull(); + }); }); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx index 60754e3f..924f9b11 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.tsx @@ -16,6 +16,17 @@ export interface LoggingScreenProps { onExport: () => void; sortDirection: SortDirection; onSortChange: (next: SortDirection) => void; + /** + * When set, the stream panel shows a "pin as column" button (#1616). Omitted + * when already embedded, or when pinning isn't currently available. + */ + onPin?: () => void; + /** + * True when rendered inside the monitoring column: the screen fills its + * parent's height (instead of the viewport calc) and drops the filter + * sidebar so the narrow column is stream-only. + */ + embedded?: boolean; } // Filter text + visible-level set — controlled by the parent (App) as one @@ -52,6 +63,8 @@ export function LoggingScreen({ onExport, sortDirection, onSortChange, + onPin, + embedded = false, }: LoggingScreenProps) { const { filterText, visibleLevels } = ui; @@ -71,20 +84,24 @@ export function LoggingScreen({ } return ( - - - - onUiChange({ ...ui, filterText: value })} - onToggleLevel={handleToggleLevel} - onToggleAllLevels={handleToggleAllLevels} - /> - - + + {embedded ? null : ( + + + + onUiChange({ ...ui, filterText: value }) + } + onToggleLevel={handleToggleLevel} + onToggleAllLevels={handleToggleAllLevels} + /> + + + )} ); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx index 9d182290..a093d39b 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx @@ -163,4 +163,20 @@ describe("NetworkScreen", () => { expect(screen.getByRole("button", { name: "Clear" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Export" })).toBeDisabled(); }); + + it("renders a pin-as-column button when onPin is provided and invokes it", async () => { + const user = userEvent.setup(); + const onPin = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Pin as column" })); + expect(onPin).toHaveBeenCalledTimes(1); + }); + + it("drops the filter sidebar when embedded, keeping the request list", () => { + renderWithMantine(); + // The sidebar (NetworkControls, with its Search box) is not rendered. + expect(screen.queryByPlaceholderText("Search...")).toBeNull(); + // The stream toolbar (Export) is still present. + expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx index 65b88df2..4e66700f 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.tsx @@ -21,6 +21,10 @@ export interface NetworkScreenProps { onSortChange: (next: SortDirection) => void; compact: boolean; onToggleCompact: () => void; + /** See LoggingScreen: shows a "pin as column" button when set (#1616). */ + onPin?: () => void; + /** See LoggingScreen: fills the parent height and drops the filter sidebar. */ + embedded?: boolean; } // Filter text + visible-category set — controlled by the parent (App) as one @@ -57,6 +61,8 @@ export function NetworkScreen({ onSortChange, compact, onToggleCompact, + onPin, + embedded = false, }: NetworkScreenProps) { const { filterText, visibleCategories } = ui; @@ -81,18 +87,22 @@ export function NetworkScreen({ } return ( - - - - onUiChange({ ...ui, filterText: value })} - onToggleCategory={handleToggleCategory} - onToggleAllCategories={handleToggleAllCategories} - /> - - + + {embedded ? null : ( + + + + onUiChange({ ...ui, filterText: value }) + } + onToggleCategory={handleToggleCategory} + onToggleAllCategories={handleToggleAllCategories} + /> + + + )} ); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 72882b0d..907e8c54 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; import type { InitializeResult, @@ -15,8 +15,25 @@ import { renderWithMantine, screen, waitFor, + within, + fireEvent, } from "../../../test/renderWithMantine"; import { InspectorView, type InspectorViewProps } from "./InspectorView"; + +// The monitoring column (#1616) is gated on a 1040px viewport media query. +// happy-dom's viewport is 1024px, so that query is really false; make just that +// gate controllable per test. ViewHeader's own 992/768 queries stay "wide" (as +// they are in the real 1024px happy-dom viewport), so header rendering — and +// every existing test below — is unaffected. +const monitorWide = vi.hoisted(() => ({ value: false })); +vi.mock("@mantine/hooks", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useMediaQuery: (query: string): boolean => + query === "(min-width: 1040px)" ? monitorWide.value : true, + }; +}); import type { BridgeFactory } from "../../elements/AppRenderer/AppRenderer"; import { EMPTY_TOOLS_UI, @@ -1274,4 +1291,269 @@ describe("InspectorView", () => { expect(screen.queryByText("List updated")).not.toBeInTheDocument(); }); }); + + describe("pinned monitoring column (#1616)", () => { + const httpServer: ServerEntry = { + id: "beta", + name: "Beta", + config: { type: "streamable-http", url: "http://localhost:3000/mcp" }, + connection: { status: "connected" }, + }; + const httpInit = initWithCapabilities(allCapabilities); + + beforeEach(() => { + // Default narrow so a test only pins when it opts in. + monitorWide.value = false; + }); + + function connectedHttp(overrides: Partial = {}) { + return makeProps({ + servers: [httpServer], + activeServer: "beta", + connectionStatus: "connected", + initializeResult: httpInit, + ...overrides, + }); + } + + async function gotoTab(tab: string) { + const user = userEvent.setup(); + const tabSelect = await screen.findByDisplayValue("Servers"); + await user.click(tabSelect); + await user.click(await screen.findByText(tab)); + return user; + } + + it("shows the Pin as column button on a monitor screen only when wide", async () => { + monitorWide.value = false; + const { unmount } = renderWithMantine( + , + ); + await gotoTab("Logs"); + expect( + screen.queryByRole("button", { name: "Pin as column" }), + ).not.toBeInTheDocument(); + unmount(); + + monitorWide.value = true; + renderWithMantine(); + await gotoTab("Logs"); + expect( + await screen.findByRole("button", { name: "Pin as column" }), + ).toBeInTheDocument(); + }); + + it("pins the monitor group into the column and removes it from the header", async () => { + monitorWide.value = true; + renderWithMantine(); + const user = await gotoTab("Logs"); + await user.click( + await screen.findByRole("button", { name: "Pin as column" }), + ); + + // Column is open (its close control is present). + expect( + await screen.findByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + + // The monitor group is gone from the header tab bar... + const header = screen.getByRole("banner"); + expect(within(header).queryByRole("radio", { name: "Logs" })).toBeNull(); + expect( + within(header).queryByRole("radio", { name: "History" }), + ).toBeNull(); + expect( + within(header).queryByRole("radio", { name: "Network" }), + ).toBeNull(); + // ...and a non-monitor tab still sits in the header. + expect( + within(header).getByRole("radio", { name: "Tools" }), + ).toBeInTheDocument(); + + // The column hosts the monitor tabs, defaulting to the pinned one. + expect(screen.getByRole("radio", { name: "Logs" })).toBeChecked(); + expect( + screen.getByRole("radio", { name: "Network" }), + ).toBeInTheDocument(); + }); + + it("returns the monitor group to the header when the column is closed", async () => { + monitorWide.value = true; + renderWithMantine(); + const user = await gotoTab("Logs"); + await user.click( + await screen.findByRole("button", { name: "Pin as column" }), + ); + await user.click( + await screen.findByRole("button", { name: "Close monitoring column" }), + ); + + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + const header = screen.getByRole("banner"); + expect( + within(header).getByRole("radio", { name: "Logs" }), + ).toBeInTheDocument(); + }); + + it("persists the pin preference and reopens the column when wide", () => { + // Stored preference from a prior wide session. + window.localStorage.setItem("inspector.monitor.pinned", "true"); + + // Narrow: the column stays closed and the group is in the header. + monitorWide.value = false; + const { unmount } = renderWithMantine( + , + ); + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + expect( + within(screen.getByRole("banner")).getByRole("radio", { name: "Logs" }), + ).toBeInTheDocument(); + unmount(); + + // Wide: the preserved preference reopens the column without re-pinning. + monitorWide.value = true; + renderWithMantine(); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + }); + + it("keeps the column closed when the stored preference is explicitly false", () => { + window.localStorage.setItem("inspector.monitor.pinned", "false"); + monitorWide.value = true; + renderWithMantine(); + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + }); + + it("hides the column on disconnect but keeps the pin preference", () => { + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + const { rerender } = renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + + rerender( + , + ); + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + // Preference is untouched — only the column's close button clears it. + expect(window.localStorage.getItem("inspector.monitor.pinned")).toBe( + "true", + ); + }); + + it("drops Network from the column tabs for a stdio server", () => { + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + renderWithMantine( + , + ); + // Column open, but Network is unavailable over stdio. + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument(); + expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); + }); + + it("falls back to an available tab when the stored monitor tab is unavailable", () => { + // Stored tab is Network, but a stdio + logging-only server can't offer it. + window.localStorage.setItem("inspector.monitor.pinned", "true"); + window.localStorage.setItem("inspector.monitor.tab", "Network"); + monitorWide.value = true; + renderWithMantine( + , + ); + // Column active tab clamps to the first available monitor tab (Logs). + expect(screen.getByRole("radio", { name: "Logs" })).toBeChecked(); + expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); + }); + + it("clamps the primary tab to Servers when the header has no other tab", () => { + // stdio + logging only ⇒ availableTabs = [Servers, Logs, History]; pinning + // moves both monitor tabs out, leaving [Servers] as the only header tab. + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + renderWithMantine( + , + ); + const header = screen.getByRole("banner"); + expect( + within(header) + .getAllByRole("radio") + .map((r) => r.getAttribute("value")), + ).toEqual(["Servers"]); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + }); + + it("persists the selected column tab", async () => { + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + const user = userEvent.setup(); + renderWithMantine(); + await user.click(await screen.findByRole("radio", { name: "History" })); + await waitFor(() => + expect(window.localStorage.getItem("inspector.monitor.tab")).toBe( + "History", + ), + ); + }); + + it("resizes and persists the column width from the keyboard", async () => { + window.localStorage.setItem("inspector.monitor.pinned", "true"); + window.localStorage.setItem("inspector.monitor.width", "420"); + monitorWide.value = true; + renderWithMantine(); + const handle = await screen.findByRole("separator", { + name: "Resize monitoring column", + }); + // ArrowLeft widens by the 16px step (panel is on the right). + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + await waitFor(() => + expect(window.localStorage.getItem("inspector.monitor.width")).toBe( + "436", + ), + ); + }); + }); }); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 0e7a1eb5..104dd9d0 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -1,6 +1,6 @@ -import { useMemo, type ReactNode, type Ref } from "react"; -import { AppShell, Box, Stack, Transition } from "@mantine/core"; -import { useLocalStorage } from "@mantine/hooks"; +import { useMemo, useState, type ReactNode, type Ref } from "react"; +import { AppShell, Box, Flex, Stack, Transition } from "@mantine/core"; +import { useLocalStorage, useMediaQuery } from "@mantine/hooks"; import type { InitializeResult, LoggingLevel, @@ -64,6 +64,8 @@ import { type NetworkUiState, } from "../../screens/NetworkScreen/NetworkScreen"; import type { SortDirection } from "../../elements/SortToggle/SortToggle"; +import { MonitoringScreen } from "../../groups/MonitoringScreen/MonitoringScreen"; +import { ResizeHandle } from "../../elements/ResizeHandle/ResizeHandle"; import { getServerType } from "@inspector/core/mcp/config.js"; import { INSPECTOR_SERVERS_TAB } from "../../../utils/inspectorTabs"; @@ -127,6 +129,8 @@ function useListCompact( } const SERVERS_TAB = INSPECTOR_SERVERS_TAB; +const LOGS_TAB = "Logs"; +const HISTORY_TAB = "History"; const NETWORK_TAB = "Network"; const ALL_TABS: string[] = [ @@ -136,11 +140,72 @@ const ALL_TABS: string[] = [ "Prompts", "Resources", "Tasks", - "Logs", - "History", + LOGS_TAB, + HISTORY_TAB, NETWORK_TAB, ]; +// The screens that can be pinned into the monitoring column (#1616). Pinning is +// a group action: opening the column removes all *available* monitor tabs from +// the header and hosts them in the column instead. +type MonitorTab = "Logs" | "History" | "Network"; +const MONITOR_TABS: string[] = [LOGS_TAB, HISTORY_TAB, NETWORK_TAB]; + +function isMonitorTab(tab: string): tab is MonitorTab { + return tab === LOGS_TAB || tab === HISTORY_TAB || tab === NETWORK_TAB; +} + +// The viewport width below which the split collapses to a single column: matches +// the point where ServerListScreen drops to one card, so the primary area always +// has room for at least one full-width card beside the column. +const MONITOR_WIDE_QUERY = "(min-width: 1040px)"; + +// Monitoring column width bounds (px). MIN keeps the stream readable; MAX stops +// the column from crowding out the primary area. +const MONITOR_WIDTH_MIN = 320; +const MONITOR_WIDTH_MAX = 720; +const MONITOR_WIDTH_DEFAULT = 420; +const MONITOR_WIDTH_STEP = 16; + +function clampMonitorWidth(value: number): number { + return Math.min(MONITOR_WIDTH_MAX, Math.max(MONITOR_WIDTH_MIN, value)); +} + +// localStorage adapters for the monitoring-column preferences, matching the +// human-readable / clamp-on-read shape of the sort + compact adapters above. +const MONITOR_PINNED_DEFAULT = false; + +function deserializeMonitorPinned(raw: string | undefined): boolean { + if (raw === "true") return true; + if (raw === "false") return false; + return MONITOR_PINNED_DEFAULT; +} + +function serializeMonitorPinned(value: boolean): string { + return value ? "true" : "false"; +} + +const MONITOR_TAB_DEFAULT: MonitorTab = LOGS_TAB; + +function deserializeMonitorTab(raw: string | undefined): MonitorTab { + return raw !== undefined && isMonitorTab(raw) ? raw : MONITOR_TAB_DEFAULT; +} + +function serializeMonitorTab(value: MonitorTab): string { + return value; +} + +function deserializeMonitorWidth(raw: string | undefined): number { + const parsed = raw !== undefined ? Number.parseInt(raw, 10) : Number.NaN; + return Number.isFinite(parsed) + ? clampMonitorWidth(parsed) + : MONITOR_WIDTH_DEFAULT; +} + +function serializeMonitorWidth(value: number): string { + return String(value); +} + const SCREEN_ENTER_MS = 350; const SCREEN_EXIT_MS = 250; @@ -154,6 +219,29 @@ const ScreenStageContainer = Stack.withProps({ gap: 0, flex: 1, mih: "100%", + // Let the pane shrink below its content's intrinsic width when the monitoring + // column opens — without this the Servers grid / SegmentedControl refuse to + // shrink and squeeze the column (or force a page scrollbar). (#1616) + miw: 0, +}); + +// Row wrapper turning AppShell.Main into a [primary | handle | column] split. +// `h/w: 100%` inherit the header-offset height AppShell.Main provides, matching +// the screens' own `calc(100dvh - header)`. (#1616) +const SplitRow = Flex.withProps({ + direction: "row", + gap: 0, + h: "100%", + w: "100%", +}); + +// The pinned monitoring column. Fixed-basis (its width is driven live via the +// `w` style prop at the call site); `miw: 0` so its inner ScrollArea can bound. +const MonitoringColumn = Stack.withProps({ + flex: "0 0 auto", + h: "100%", + gap: 0, + miw: 0, }); // Wraps each screen in a Mantine Transition. With Transition's default @@ -489,6 +577,42 @@ export function InspectorView({ false, ); + // Monitoring-column state (#1616): whether Logs/History/Network are pinned to + // the right column, which one is active there, and the column width. All + // persisted (view-layout preferences), same shape as the sort/compact hooks. + const [monitorPinned, setMonitorPinned] = useLocalStorage({ + key: "inspector.monitor.pinned", + defaultValue: MONITOR_PINNED_DEFAULT, + deserialize: deserializeMonitorPinned, + serialize: serializeMonitorPinned, + getInitialValueInEffect: false, + }); + const [monitorTab, setMonitorTab] = useLocalStorage({ + key: "inspector.monitor.tab", + defaultValue: MONITOR_TAB_DEFAULT, + deserialize: deserializeMonitorTab, + serialize: serializeMonitorTab, + getInitialValueInEffect: false, + }); + const [monitorWidth, setMonitorWidth] = useLocalStorage({ + key: "inspector.monitor.width", + defaultValue: MONITOR_WIDTH_DEFAULT, + deserialize: deserializeMonitorWidth, + serialize: serializeMonitorWidth, + getInitialValueInEffect: false, + }); + // Transient width during a drag; committed to `monitorWidth` on release so + // localStorage takes a single write per drag rather than one per pointer move. + const [dragWidth, setDragWidth] = useState(null); + const columnWidth = dragWidth ?? monitorWidth; + + // The split only exists with enough horizontal room. `true` initial value so + // the first synchronous paint assumes wide (the common desktop case) rather + // than flashing the collapsed layout. + const isWide = useMediaQuery(MONITOR_WIDE_QUERY, true, { + getInitialValueInEffect: false, + }); + const appTools = useMemo(() => { return tools.filter((tool) => { try { @@ -558,15 +682,72 @@ export function InspectorView({ appTools, ]); - // Clamp the rendered tab to whatever's currently available. If the user - // had "Tools" selected and the connection drops, `availableTabs` becomes + // Monitoring column, derived (#1616). The monitor group is pinned into the + // right column only when: the user asked for it, the viewport is wide enough, + // a server is connected, and at least one monitor tab is actually available + // (capability/stdio aware). Narrowing, disconnecting, or losing the last + // monitor capability flips `effectivePinned` false and closes the column — + // WITHOUT clearing `monitorPinned`, so it re-opens when the condition returns. + // Only the column's close button writes `monitorPinned = false`. + const connected = connectionStatus === "connected"; + const monitorAvailable = useMemo( + () => availableTabs.filter((t) => MONITOR_TABS.includes(t)), + [availableTabs], + ); + const effectivePinned = + monitorPinned && !!isWide && connected && monitorAvailable.length > 0; + + // The header loses the monitor group while the column is open (its screens + // live in the column instead); otherwise it shows every available tab. + const headerTabs = useMemo( + () => + effectivePinned + ? availableTabs.filter((t) => !MONITOR_TABS.includes(t)) + : availableTabs, + [effectivePinned, availableTabs], + ); + + // Clamp the rendered primary tab to whatever the header currently shows. If + // the user had "Tools" selected and the connection drops, `headerTabs` becomes // `[Servers]` and the view renders Servers without us having to imperatively - // reset the state (and trip the `set-state-in-effect` lint). When the - // connection comes back, the previous selection pops in again because - // the parent's `activeTab` is preserved. - const activeTab = availableTabs.includes(activeTabProp) + // reset the state (and trip the `set-state-in-effect` lint). When pinning + // moves a monitor tab out of the header, the primary falls back to the first + // non-Servers tab; the lifted `activeTabProp` is left intact so closing the + // column restores the user's prior selection. + const firstNonServersTab = + headerTabs.find((t) => t !== SERVERS_TAB) ?? SERVERS_TAB; + const activeTab = headerTabs.includes(activeTabProp) ? activeTabProp - : SERVERS_TAB; + : effectivePinned + ? firstNonServersTab + : SERVERS_TAB; + + // The monitor tab shown in the column, clamped to what's available (e.g. a + // switch to a stdio server drops Network). `?? LOGS_TAB` is a types-only + // fallback: the column only renders while `monitorAvailable.length > 0`. + const effectiveMonitorTab: MonitorTab = + monitorAvailable.includes(monitorTab) && isMonitorTab(monitorTab) + ? monitorTab + : (monitorAvailable.find(isMonitorTab) ?? LOGS_TAB); + + // Pinning is a group action: remember which tab was clicked and open the + // column. Gated by `canPin` so the pin affordance only appears when a click + // would actually take effect (wide + connected). + const canPin = !!isWide && connected; + function pinMonitor(tab: MonitorTab) { + setMonitorTab(tab); + setMonitorPinned(true); + } + function handleMonitorTabChange(tab: string) { + // The SegmentedControl's data only holds valid monitor tabs, so the guard's + // false arm is unreachable through the UI. + /* v8 ignore next */ + if (isMonitorTab(tab)) setMonitorTab(tab); + } + function commitMonitorWidth(next: number) { + setMonitorWidth(next); + setDragWidth(null); + } // Merge the parent's `serversInput` (static config) with the runtime // connection state owned by the parent — only the active server reflects @@ -616,6 +797,56 @@ export function InspectorView({ ? undefined : activeServer; + // Shared props for each monitor screen, spread into both its primary + // (header-tab) instance and its embedded (column) instance so the two can't + // drift. The primary adds `onPin`; the embedded adds `embedded`. (#1616) + const loggingScreenProps = { + entries: logs, + currentLevel: currentLogLevel, + ui: logsUi, + onUiChange: onLogsUiChange, + onSetLevel: onSetLogLevel, + onClear: onClearLogs, + onExport: onExportLogs, + sortDirection: logsSort, + onSortChange: setLogsSort, + }; + const historyScreenProps = { + entries: history, + pinnedIds: pinnedHistoryIds ?? new Set(), + ui: historyUi, + onUiChange: onHistoryUiChange, + onClearAll: onClearHistory, + onExport: onExportHistory, + onClearSection: onClearHistorySection, + onExportSection: onExportHistorySection, + onReplay: onReplayHistory, + onTogglePin: onTogglePinHistory, + sortDirection: historySort, + onSortChange: setHistorySort, + compact: historyCompact, + onToggleCompact: () => setHistoryCompact((c) => !c), + }; + const networkScreenProps = { + entries: network, + ui: networkUi, + onUiChange: onNetworkUiChange, + onClear: onClearNetwork, + onExport: onExportNetwork, + sortDirection: networkSort, + onSortChange: setNetworkSort, + compact: networkCompact, + onToggleCompact: () => setNetworkCompact((c) => !c), + }; + + // Embedded instances for the pinned column, keyed by tab. MonitoringScreen + // renders only the active one; the rest are unmounted element values. + const monitorScreens: Record = { + [LOGS_TAB]: , + [HISTORY_TAB]: , + [NETWORK_TAB]: , + }; + return ( // padding={0}: each screen fills `calc(100dvh - header)` and supplies its // own `xl` padding, so Main must contribute only the fixed-header offset. @@ -632,7 +863,7 @@ export function InspectorView({ status={connectionStatus} latencyMs={latencyMs} activeTab={activeTab} - availableTabs={availableTabs} + availableTabs={headerTabs} onTabChange={onActiveTabChange} onDisconnect={onDisconnect} onToggleTheme={onToggleTheme} @@ -647,150 +878,148 @@ export function InspectorView({ )} - - - setServersCompact((c) => !c)} - /> - - - - - - - - - - - - - - - - - - - - - setHistoryCompact((c) => !c)} - /> - - - setNetworkCompact((c) => !c)} - /> - - + + + + setServersCompact((c) => !c)} + /> + + + + + + + + + + + + + + + + + + pinMonitor(LOGS_TAB) : undefined} + /> + + + pinMonitor(HISTORY_TAB) : undefined} + /> + + + pinMonitor(NETWORK_TAB) : undefined} + /> + + + {effectivePinned ? ( + <> + + + setMonitorPinned(false)} + screens={monitorScreens} + /> + + + ) : null} + ); From 3713e71b18a97f8944b52bef32057c551c50fbf1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 19:20:24 -0400 Subject: [PATCH 17/26] refactor(web): move the pin-as-column button to the far right of each toolbar Place the "pin as column" icon after Clear/Export (rightmost) on the Logs, History, and Network panel toolbars instead of at the front. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/groups/HistoryListPanel/HistoryListPanel.tsx | 2 +- .../web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx | 2 +- .../components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index af777001..f2bf2f15 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -245,7 +245,6 @@ export function HistoryListPanel({ Requests - {onPin ? : null} {hasResults && ( )} @@ -264,6 +263,7 @@ export function HistoryListPanel({ + {onPin ? : null} diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index bc21b721..784faafe 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx @@ -93,7 +93,6 @@ export function LogStreamPanel({ Log Stream - {onPin ? : null} Export + {onPin ? : null} {filteredEntries.length > 0 ? ( diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index 727a34be..cb644f57 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -114,7 +114,6 @@ export function NetworkStreamPanel({ {formatTitle(filteredEntries.length)} - {onPin ? : null} {hasResults && ( )} @@ -129,6 +128,7 @@ export function NetworkStreamPanel({ + {onPin ? : null} From 591d6b97365b7976130c1b73325ff50d64dfc117 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 19:24:25 -0400 Subject: [PATCH 18/26] refactor(web): move expand/collapse toggle between Export and the pin button On the History and Network panels, place the ListToggle (expand/collapse) after Export and before the pin-as-column button. (Logs has no such toggle.) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/groups/HistoryListPanel/HistoryListPanel.tsx | 6 +++--- .../groups/NetworkStreamPanel/NetworkStreamPanel.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index f2bf2f15..be7840c3 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -245,9 +245,6 @@ export function HistoryListPanel({ Requests - {hasResults && ( - - )} Export + {hasResults && ( + + )} {onPin ? : null} diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index cb644f57..9d66367d 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -114,9 +114,6 @@ export function NetworkStreamPanel({ {formatTitle(filteredEntries.length)} - {hasResults && ( - - )} Export + {hasResults && ( + + )} {onPin ? : null} From daf736c6e7ee062b681c4f4a6175830c6224a5dd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 19:26:35 -0400 Subject: [PATCH 19/26] style(web): match the pin-as-column button to the expand/collapse toggle Render PinColumnButton as a subtle size-sm Button with a size-20 icon, the same style as the panel's ListToggle, instead of a default ActionIcon. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PinColumnButton/PinColumnButton.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx index 86467db9..cd252845 100644 --- a/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx @@ -1,4 +1,4 @@ -import { ActionIcon } from "@mantine/core"; +import { Button } from "@mantine/core"; import { TbLayoutSidebarRightExpand } from "react-icons/tb"; export interface PinColumnButtonProps { @@ -7,20 +7,21 @@ export interface PinColumnButtonProps { } /** - * Toolbar icon that pins the owning monitor screen (Logs / History / Network) + * Toolbar button that pins the owning monitor screen (Logs / History / Network) * into the resizable column on the right of the InspectorView (#1616). Distinct * from `PinToggle` (which pins individual history entries) — this one opens a - * side column, so it uses a right-sidebar glyph and an "as column" label. + * side column, so it uses a right-sidebar glyph and an "as column" label. Styled + * to match the panel's expand/collapse `ListToggle` (subtle icon button). */ export function PinColumnButton({ onPin }: PinColumnButtonProps) { return ( - - - + + ); } From 9b76272ad1fa871e280fc867ded88e6bf948cd95 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 19:41:00 -0400 Subject: [PATCH 20/26] fix(web): address review feedback on the monitoring column (#1616) - ResizeHandle: clear the `resizing-col` body class on unmount so a drag interrupted by the handle unmounting (narrow past the breakpoint / disconnect) can't leave page-wide user-select:none / col-resize applied. - Embedded monitor column now shows the full unfiltered stream instead of silently mirroring the full-size screen's live filter (the column has no filter control to explain an empty/partial list). - MonitoringControls: give the SegmentedControl an aria-label ("Monitoring screen") so the tab group has an accessible name. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ResizeHandle/ResizeHandle.test.tsx | 22 +++++++++++++++++++ .../elements/ResizeHandle/ResizeHandle.tsx | 14 +++++++++++- .../HistoryListPanel/HistoryListPanel.tsx | 19 ++++++++++++---- .../groups/LogStreamPanel/LogStreamPanel.tsx | 9 +++++--- .../MonitoringControls.test.tsx | 14 ++++++++++++ .../MonitoringControls/MonitoringControls.tsx | 1 + .../NetworkStreamPanel/NetworkStreamPanel.tsx | 10 ++++++--- .../HistoryScreen/HistoryScreen.test.tsx | 13 +++++++++++ .../LoggingScreen/LoggingScreen.test.tsx | 20 +++++++++++++++++ .../NetworkScreen/NetworkScreen.test.tsx | 13 +++++++++++ 10 files changed, 124 insertions(+), 11 deletions(-) diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx index 0cac37d2..5ba77b0b 100644 --- a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx @@ -86,6 +86,28 @@ describe("ResizeHandle", () => { expect(document.body.classList.contains("resizing-col")).toBe(false); }); + it("clears the drag body class if it unmounts mid-drag", () => { + const onChange = vi.fn(); + const onCommit = vi.fn(); + const { unmount } = renderWithMantine( + , + ); + fireEvent.pointerDown(screen.getByRole("separator"), { + clientX: 500, + pointerId: 1, + }); + expect(document.body.classList.contains("resizing-col")).toBe(true); + // No pointerup/cancel — the handle is removed from the tree first. + unmount(); + expect(document.body.classList.contains("resizing-col")).toBe(false); + }); + it("ignores pointer moves when no drag is active", () => { const { handle, onChange, onCommit } = setup(); fireEvent.pointerMove(handle, { clientX: 460, pointerId: 1 }); diff --git a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx index a181e367..77545a5e 100644 --- a/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx @@ -1,4 +1,9 @@ -import { useRef, type KeyboardEvent, type PointerEvent } from "react"; +import { + useEffect, + useRef, + type KeyboardEvent, + type PointerEvent, +} from "react"; import { Box } from "@mantine/core"; export interface ResizeHandleProps { @@ -45,6 +50,13 @@ export function ResizeHandle({ // Gesture origin, captured on pointer down. `null` while idle. const drag = useRef<{ startX: number; startWidth: number } | null>(null); + // If the handle unmounts mid-drag — the window narrows past the split + // breakpoint or the server disconnects, either of which removes the handle + // from the tree before a pointerup/cancel fires — the drag body class would + // otherwise linger and leave `user-select: none` + `col-resize` applied + // page-wide. Clear it on unmount so the class is self-healing. + useEffect(() => () => document.body.classList.remove("resizing-col"), []); + function handlePointerDown(e: PointerEvent) { drag.current = { startX: e.clientX, startWidth: value }; // Optional chaining: happy-dom (unit tests) doesn't implement pointer diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index be7840c3..9aaa8187 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -215,15 +215,26 @@ export function HistoryListPanel({ const [pinnedOpen, setPinnedOpen] = useState(true); const [historyOpen, setHistoryOpen] = useState(true); const filteredEntries = useMemo(() => { - // `.filter()` returns a fresh array, so sorting in-place is safe. + // Embedded column: show the full stream (no filter sidebar to explain a + // mirrored filter). See LogStreamPanel (#1616). `.filter()` returns a fresh + // array, so sorting in-place is safe. const sorted = entries - .filter((e) => - matchesFilters(e, searchText, visibleDirections, methodFilter), + .filter( + (e) => + embedded || + matchesFilters(e, searchText, visibleDirections, methodFilter), ) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); if (sortDirection === "newest-first") sorted.reverse(); return sorted; - }, [entries, searchText, visibleDirections, methodFilter, sortDirection]); + }, [ + entries, + searchText, + visibleDirections, + methodFilter, + sortDirection, + embedded, + ]); const pinnedEntries = useMemo( () => filteredEntries.filter((e) => pinnedIds.has(e.id)), diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index 784faafe..a2ddd421 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx @@ -80,13 +80,16 @@ export function LogStreamPanel({ }: LogStreamPanelProps) { const viewportRef = useScrollMemory("logs-stream"); const filteredEntries = useMemo(() => { - // `.filter()` returns a fresh array, so sorting in-place is safe. + // The embedded column has no filter sidebar, so it shows the full stream + // rather than mirroring the full-size screen's live filter with no visible + // control to explain it (#1616). `.filter()` returns a fresh array, so + // sorting in-place is safe. const sorted = entries - .filter((e) => matchesFilters(e, filterText, visibleLevels)) + .filter((e) => embedded || matchesFilters(e, filterText, visibleLevels)) .sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime()); if (sortDirection === "newest-first") sorted.reverse(); return sorted; - }, [entries, filterText, visibleLevels, sortDirection]); + }, [entries, filterText, visibleLevels, sortDirection, embedded]); return ( diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx index 458d1cf4..8d6a0bca 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx @@ -33,6 +33,20 @@ describe("MonitoringControls", () => { expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); }); + it("gives the tab switcher an accessible name", () => { + const { container } = renderWithMantine( + , + ); + expect( + container.querySelector('[aria-label="Monitoring screen"]'), + ).not.toBeNull(); + }); + it("marks the active tab as selected", () => { renderWithMantine( diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index 9d66367d..b1f58bba 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -98,13 +98,17 @@ export function NetworkStreamPanel({ }: NetworkStreamPanelProps) { const viewportRef = useScrollMemory("network-stream"); const filteredEntries = useMemo(() => { - // `.filter()` returns a fresh array, so sorting in-place is safe. + // Embedded column: show the full stream (no filter sidebar to explain a + // mirrored filter). See LogStreamPanel (#1616). `.filter()` returns a fresh + // array, so sorting in-place is safe. const sorted = entries - .filter((e) => matchesFilters(e, filterText, visibleCategories)) + .filter( + (e) => embedded || matchesFilters(e, filterText, visibleCategories), + ) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); if (sortDirection === "newest-first") sorted.reverse(); return sorted; - }, [entries, filterText, visibleCategories, sortDirection]); + }, [entries, filterText, visibleCategories, sortDirection, embedded]); const hasEntries = entries.length > 0; const hasResults = filteredEntries.length > 0; diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx index aad3f5da..c4e23a02 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx @@ -126,4 +126,17 @@ describe("HistoryScreen", () => { // The sidebar (HistoryControls, with its Search box) is not rendered. expect(screen.queryByPlaceholderText("Search...")).toBeNull(); }); + + it("shows the full stream when embedded, ignoring the live filter", () => { + // A search that matches nothing would empty the full-size screen... + renderWithMantine( + , + ); + // ...but the embedded column shows the full stream (not the empty state). + expect(screen.queryByText("No request history")).toBeNull(); + }); }); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx index 11e1e84a..b5df10ab 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -146,4 +146,24 @@ describe("LoggingScreen", () => { // The sidebar (LogControls, with its Set button) is not rendered. expect(screen.queryByRole("button", { name: "Set" })).toBeNull(); }); + + it("shows the full stream when embedded, ignoring the live filter", () => { + const entries = [ + { + receivedAt: new Date(), + params: { level: "info" as const, data: "hello" }, + }, + ]; + // A filter that would hide the entry on the full-size screen... + renderWithMantine( + , + ); + // ...is ignored in the embedded, sidebar-less column. + expect(screen.getByText("hello")).toBeInTheDocument(); + }); }); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx index a093d39b..0059a794 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx @@ -179,4 +179,17 @@ describe("NetworkScreen", () => { // The stream toolbar (Export) is still present. expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument(); }); + + it("shows the full stream when embedded, ignoring the live filter", () => { + // A filter that matches nothing would empty the full-size screen... + renderWithMantine( + , + ); + // ...but the embedded column shows the full stream (not the empty state). + expect(screen.queryByText("No network requests")).toBeNull(); + }); }); From c5e064ff71e84902a81da8ccb0b02b69b949454e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 20:16:15 -0400 Subject: [PATCH 21/26] feat(web): monitoring column search, close-to-fullscreen, and open/close animation (#1616) - Add a search box to MonitoringControls (right of the tabs). It's a single shared search kept across column tab switches, filtering whichever screen is shown. Embedded panels apply this text but still ignore the sidebar-only filters (levels/categories/directions/method), which have no control there. - Close button now uses the "collapse sidebar" icon (TbLayoutSidebarRightCollapse), the mirror of the pin button's expand icon. - Closing the column carries its selection to the primary view: the screen the user was watching becomes the active header tab (rather than snapping back to the pre-pin selection). - Animate the column open/close: the handle + column slide in from the right and fade (reversing on close) via a Mantine Transition. - HistoryListPanel: drop the redundant "History (N)" header when there are no pinned requests (lone unpinned section renders headerless). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../HistoryListPanel.test.tsx | 28 ++-- .../HistoryListPanel/HistoryListPanel.tsx | 45 +++++-- .../groups/LogStreamPanel/LogStreamPanel.tsx | 14 +- .../MonitoringControls.stories.tsx | 2 + .../MonitoringControls.test.tsx | 92 +++++++------ .../MonitoringControls/MonitoringControls.tsx | 56 ++++++-- .../MonitoringScreen.stories.tsx | 2 + .../MonitoringScreen.test.tsx | 70 +++++----- .../MonitoringScreen/MonitoringScreen.tsx | 7 + .../NetworkStreamPanel/NetworkStreamPanel.tsx | 15 ++- .../HistoryScreen/HistoryScreen.test.tsx | 24 +++- .../LoggingScreen/LoggingScreen.test.tsx | 31 ++++- .../NetworkScreen/NetworkScreen.test.tsx | 24 +++- .../InspectorView/InspectorView.test.tsx | 80 ++++++++++- .../views/InspectorView/InspectorView.tsx | 127 ++++++++++++++---- 15 files changed, 448 insertions(+), 169 deletions(-) diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx index 05684e9f..4469d6e1 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.test.tsx @@ -98,11 +98,14 @@ describe("HistoryListPanel", () => { expect(screen.getByText("No request history")).toBeInTheDocument(); }); - it("renders the History title with count for unpinned entries", () => { + it("hides the History header when there are no pinned entries", () => { renderWithMantine( , ); - expect(screen.getByText("History (3)")).toBeInTheDocument(); + // With no pinned section to distinguish it from, the header is dropped... + expect(screen.queryByText("History (3)")).toBeNull(); + // ...but the entries themselves are still shown. + expect(screen.getByText("resources/read")).toBeInTheDocument(); }); it("renders the Pinned title with count when entries are pinned", () => { @@ -135,12 +138,13 @@ describe("HistoryListPanel", () => { expect(header).toHaveAttribute("aria-expanded", "true"); }); - it("renders a lone section as a plain (non-collapsible) header with its entries shown", () => { - // Only the unpinned section → no accordion toggle, entries always visible. + it("renders a lone unpinned section headerless, with its entries shown", () => { + // Only the unpinned section → no header at all, no accordion toggle, + // entries always visible. renderWithMantine( , ); - expect(screen.getByText("History (3)")).toBeInTheDocument(); + expect(screen.queryByText("History (3)")).toBeNull(); expect( screen.queryByRole("button", { name: "History (3)" }), ).not.toBeInTheDocument(); @@ -246,8 +250,9 @@ describe("HistoryListPanel", () => { visibleDirections={{ client: true, server: false }} />, ); - // The server-origin entry is filtered out, leaving one. - expect(screen.getByText("History (1)")).toBeInTheDocument(); + // The server-origin entry is filtered out, leaving the client one. + expect(screen.getByText("tools/call")).toBeInTheDocument(); + expect(screen.queryByText("resources/read")).toBeNull(); }); it("filters entries by searchText (case-insensitive)", () => { @@ -258,7 +263,10 @@ describe("HistoryListPanel", () => { searchText="config.json" />, ); - expect(screen.getByText("History (1)")).toBeInTheDocument(); + // Only the resources/read entry references config.json. + expect(screen.getByText("resources/read")).toBeInTheDocument(); + expect(screen.queryByText("tools/call")).toBeNull(); + expect(screen.queryByText("tools/list")).toBeNull(); }); it("filters entries by methodFilter", () => { @@ -269,7 +277,9 @@ describe("HistoryListPanel", () => { methodFilter="tools/list" />, ); - expect(screen.getByText("History (1)")).toBeInTheDocument(); + expect(screen.getByText("tools/list")).toBeInTheDocument(); + expect(screen.queryByText("tools/call")).toBeNull(); + expect(screen.queryByText("resources/read")).toBeNull(); }); it("invokes onExport when Export is clicked", async () => { diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index 9aaa8187..a6a7514b 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -128,10 +128,14 @@ function SectionActions({ // is a `listItem` toggle — with an optional actions slot on the right — over a // `Collapse` of the entries. When it's the only section, the accordion makes no // sense: the header is a plain title and the entries always show (so a stale -// collapsed state from when both sections were present can't hide them). +// collapsed state from when both sections were present can't hide them) — +// unless `hideHeaderWhenAlone`, in which case the lone section drops its title +// entirely (the "History" label is redundant when there's nothing to +// distinguish it from). function CollapsibleSection({ title, collapsible, + hideHeaderWhenAlone = false, open, onToggle, actions, @@ -139,6 +143,7 @@ function CollapsibleSection({ }: { title: string; collapsible: boolean; + hideHeaderWhenAlone?: boolean; open: boolean; onToggle: () => void; actions?: ReactNode; @@ -147,7 +152,7 @@ function CollapsibleSection({ if (!collapsible) { return ( - {title} + {hideHeaderWhenAlone ? null : {title}} {children} ); @@ -173,13 +178,18 @@ function matchesFilters( entry: MessageEntry, searchText: string, visibleDirections: Record, - methodFilter?: MessageMethod, + methodFilter: MessageMethod | undefined, + // The embedded column exposes only the search box (no direction/method + // controls), so it applies the text filter but skips those (#1616). + ignoreDirectionAndMethod: boolean, ): boolean { - // Hide a direction when its toggle is off. Entries with no recorded origin - // (legacy / pre-origin logs) are never filtered out by direction. - if (entry.origin && !visibleDirections[entry.origin]) return false; const method = extractMethod(entry); - if (methodFilter && method !== methodFilter) return false; + if (!ignoreDirectionAndMethod) { + // Hide a direction when its toggle is off. Entries with no recorded origin + // (legacy / pre-origin logs) are never filtered out by direction. + if (entry.origin && !visibleDirections[entry.origin]) return false; + if (methodFilter && method !== methodFilter) return false; + } if (searchText) { const term = searchText.toLowerCase(); const responseText = entry.response ? JSON.stringify(entry.response) : ""; @@ -215,14 +225,18 @@ export function HistoryListPanel({ const [pinnedOpen, setPinnedOpen] = useState(true); const [historyOpen, setHistoryOpen] = useState(true); const filteredEntries = useMemo(() => { - // Embedded column: show the full stream (no filter sidebar to explain a - // mirrored filter). See LogStreamPanel (#1616). `.filter()` returns a fresh - // array, so sorting in-place is safe. + // Embedded column filters by text only (its direction/method controls live + // in the full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns + // a fresh array, so sorting in-place is safe. const sorted = entries - .filter( - (e) => - embedded || - matchesFilters(e, searchText, visibleDirections, methodFilter), + .filter((e) => + matchesFilters( + e, + searchText, + visibleDirections, + methodFilter, + embedded, + ), ) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); if (sortDirection === "newest-first") sorted.reverse(); @@ -315,6 +329,9 @@ export function HistoryListPanel({ setHistoryOpen((v) => !v)} actions={ diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index a2ddd421..24fda38c 100644 --- a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx +++ b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx @@ -56,8 +56,11 @@ function matchesFilters( entry: LogEntryData, filterText: string, visibleLevels: Record, + // The embedded column exposes only the search box (no level toggles), so it + // applies the text filter but skips the level filter (#1616). + ignoreLevels: boolean, ): boolean { - if (!visibleLevels[entry.params.level]) return false; + if (!ignoreLevels && !visibleLevels[entry.params.level]) return false; if (filterText) { const term = filterText.toLowerCase(); const searchable = @@ -80,12 +83,11 @@ export function LogStreamPanel({ }: LogStreamPanelProps) { const viewportRef = useScrollMemory("logs-stream"); const filteredEntries = useMemo(() => { - // The embedded column has no filter sidebar, so it shows the full stream - // rather than mirroring the full-size screen's live filter with no visible - // control to explain it (#1616). `.filter()` returns a fresh array, so - // sorting in-place is safe. + // The embedded column has only the search box (its level toggles live in the + // full-size sidebar), so it filters by text but ignores the level filter + // (#1616). `.filter()` returns a fresh array, so sorting in-place is safe. const sorted = entries - .filter((e) => embedded || matchesFilters(e, filterText, visibleLevels)) + .filter((e) => matchesFilters(e, filterText, visibleLevels, embedded)) .sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime()); if (sortDirection === "newest-first") sorted.reverse(); return sorted; diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx index 3815d80a..a176508e 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx @@ -9,6 +9,8 @@ const meta: Meta = { tabs: ["Logs", "History", "Network"], value: "Logs", onChange: fn(), + searchValue: "", + onSearchChange: fn(), onClose: fn(), }, }; diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx index 8d6a0bca..8cf86503 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx @@ -1,34 +1,37 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; -import { MonitoringControls } from "./MonitoringControls"; +import { + MonitoringControls, + type MonitoringControlsProps, +} from "./MonitoringControls"; const TABS = ["Logs", "History", "Network"]; +function renderControls(overrides: Partial = {}) { + const props: MonitoringControlsProps = { + tabs: TABS, + value: "Logs", + onChange: vi.fn(), + searchValue: "", + onSearchChange: vi.fn(), + onClose: vi.fn(), + ...overrides, + }; + renderWithMantine(); + return props; +} + describe("MonitoringControls", () => { it("renders a radio option for each available tab", () => { - renderWithMantine( - , - ); + renderControls(); for (const tab of TABS) { expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument(); } }); it("renders only the tabs it is given", () => { - renderWithMantine( - , - ); + renderControls({ tabs: ["Logs", "History"] }); expect(screen.getAllByRole("radio")).toHaveLength(2); expect(screen.queryByRole("radio", { name: "Network" })).toBeNull(); }); @@ -39,6 +42,8 @@ describe("MonitoringControls", () => { tabs={TABS} value="Logs" onChange={vi.fn()} + searchValue="" + onSearchChange={vi.fn()} onClose={vi.fn()} />, ); @@ -48,43 +53,48 @@ describe("MonitoringControls", () => { }); it("marks the active tab as selected", () => { - renderWithMantine( - , - ); + renderControls({ value: "History" }); expect(screen.getByRole("radio", { name: "History" })).toBeChecked(); }); it("calls onChange when a different tab is chosen", async () => { const user = userEvent.setup(); const onChange = vi.fn(); - renderWithMantine( - , - ); + renderControls({ onChange }); await user.click(screen.getByRole("radio", { name: "Network" })); expect(onChange).toHaveBeenCalledWith("Network"); }); + it("renders the search box with the current value", () => { + renderControls({ searchValue: "auth" }); + expect(screen.getByRole("textbox", { name: "Search" })).toHaveValue("auth"); + }); + + it("calls onSearchChange as the user types", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + renderControls({ onSearchChange }); + await user.type(screen.getByRole("textbox", { name: "Search" }), "x"); + expect(onSearchChange).toHaveBeenCalledWith("x"); + }); + + it("clears the search via the clear button", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + renderControls({ searchValue: "term", onSearchChange }); + await user.click(screen.getByRole("button", { name: "Clear" })); + expect(onSearchChange).toHaveBeenCalledWith(""); + }); + + it("shows no clear button when the search is empty", () => { + renderControls({ searchValue: "" }); + expect(screen.queryByRole("button", { name: "Clear" })).toBeNull(); + }); + it("calls onClose when the close button is clicked", async () => { const user = userEvent.setup(); const onClose = vi.fn(); - renderWithMantine( - , - ); + renderControls({ onClose }); await user.click( screen.getByRole("button", { name: "Close monitoring column" }), ); diff --git a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx index bc5cb356..58c79cc1 100644 --- a/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -1,4 +1,6 @@ -import { CloseButton, Group, SegmentedControl } from "@mantine/core"; +import { ActionIcon, Group, SegmentedControl, TextInput } from "@mantine/core"; +import { TbLayoutSidebarRightCollapse } from "react-icons/tb"; +import { ClearButton } from "../../elements/ClearButton/ClearButton"; export interface MonitoringControlsProps { /** The monitor screens currently available to pin (e.g. Logs/History/Network). */ @@ -6,30 +8,46 @@ export interface MonitoringControlsProps { /** The active monitor tab shown in the column below. */ value: string; onChange: (tab: string) => void; + /** Search text for the active screen (shares the screen's own filter state). */ + searchValue: string; + onSearchChange: (next: string) => void; /** Close the monitoring column, returning its screens to the header menu. */ onClose: () => void; } -// The controls row that sits at the top of the pinned monitoring column: a -// segmented tab switcher on the left, a close button on the right that unpins -// the whole column. +// The controls row at the top of the pinned monitoring column: a segmented tab +// switcher on the left, a search box filling the middle, and a close button on +// the right that unpins the whole column. const ControlsBar = Group.withProps({ - justify: "space-between", wrap: "nowrap", gap: "sm", p: "sm", }); +// Search box, styled to match the `*Controls` search fields; `flex:1`/`miw:0` +// so it fills the space between the tabs and the close button. +const SearchInput = TextInput.withProps({ + placeholder: "Search...", + size: "sm", + flex: 1, + miw: 0, + "aria-label": "Search", +}); + /** - * Tab row + close control for the pinned monitoring column (#1616). Renders only - * the currently-available monitor tabs (the caller filters by capability), so it - * never shows an empty switcher. Selecting a tab swaps the screen below; the - * close button unpins the column so its screens rejoin the header tab menu. + * Tab row + search + close control for the pinned monitoring column (#1616). + * Renders only the currently-available monitor tabs (the caller filters by + * capability), so it never shows an empty switcher. Selecting a tab swaps the + * screen below; the search box filters the shown screen (wired by the caller to + * that screen's own filter state); the close button unpins the column so its + * screens rejoin the header tab menu. */ export function MonitoringControls({ tabs, value, onChange, + searchValue, + onSearchChange, onClose, }: MonitoringControlsProps) { return ( @@ -41,7 +59,25 @@ export function MonitoringControls({ data={tabs} aria-label="Monitoring screen" /> - + onSearchChange(e.currentTarget.value)} + rightSectionPointerEvents="auto" + rightSection={ + searchValue ? ( + onSearchChange("")} /> + ) : null + } + /> + + + ); } diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx index 6c23380a..8d934c82 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx @@ -35,6 +35,8 @@ const meta: Meta = { tabs: ["Logs", "History", "Network"], value: "Logs", onChange: fn(), + searchValue: "", + onSearchChange: fn(), onClose: fn(), screens: demoScreens, }, diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx index a0c8cbc2..33f89ace 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx @@ -1,7 +1,10 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; -import { MonitoringScreen } from "./MonitoringScreen"; +import { + MonitoringScreen, + type MonitoringScreenProps, +} from "./MonitoringScreen"; const TABS = ["Logs", "History", "Network"]; @@ -13,32 +16,31 @@ function screens() { }; } +function renderScreen(overrides: Partial = {}) { + const props: MonitoringScreenProps = { + tabs: TABS, + value: "Logs", + onChange: vi.fn(), + searchValue: "", + onSearchChange: vi.fn(), + onClose: vi.fn(), + screens: screens(), + ...overrides, + }; + renderWithMantine(); + return props; +} + describe("MonitoringScreen", () => { it("renders the screen for the active tab", () => { - renderWithMantine( - , - ); + renderScreen({ value: "History" }); expect(screen.getByText("history-body")).toBeInTheDocument(); expect(screen.queryByText("logs-body")).toBeNull(); expect(screen.queryByText("network-body")).toBeNull(); }); it("renders the controls tab row", () => { - renderWithMantine( - , - ); + renderScreen(); expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Close monitoring column" }), @@ -49,15 +51,7 @@ describe("MonitoringScreen", () => { const user = userEvent.setup(); const onChange = vi.fn(); const onClose = vi.fn(); - renderWithMantine( - , - ); + renderScreen({ onChange, onClose }); await user.click(screen.getByRole("radio", { name: "Network" })); expect(onChange).toHaveBeenCalledWith("Network"); await user.click( @@ -66,16 +60,18 @@ describe("MonitoringScreen", () => { expect(onClose).toHaveBeenCalledTimes(1); }); + it("forwards the search value and changes from the controls", async () => { + const user = userEvent.setup(); + const onSearchChange = vi.fn(); + renderScreen({ searchValue: "hi", onSearchChange }); + const box = screen.getByRole("textbox", { name: "Search" }); + expect(box).toHaveValue("hi"); + await user.type(box, "!"); + expect(onSearchChange).toHaveBeenCalledWith("hi!"); + }); + it("renders nothing in the slot when the active tab has no screen", () => { - renderWithMantine( - logs-body }} - />, - ); + renderScreen({ value: "Network", screens: { Logs:
logs-body
} }); expect(screen.queryByText("logs-body")).toBeNull(); }); }); diff --git a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx index d6dde2fd..2d9f392d 100644 --- a/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -8,6 +8,9 @@ export interface MonitoringScreenProps { /** Active monitor tab; keys into `screens` to pick what renders below. */ value: string; onChange: (tab: string) => void; + /** Search text for the active screen; wired by the caller to its filter state. */ + searchValue: string; + onSearchChange: (next: string) => void; onClose: () => void; /** * The embedded screen node for each tab, keyed by tab label. The caller builds @@ -38,6 +41,8 @@ export function MonitoringScreen({ tabs, value, onChange, + searchValue, + onSearchChange, onClose, screens, }: MonitoringScreenProps) { @@ -47,6 +52,8 @@ export function MonitoringScreen({ tabs={tabs} value={value} onChange={onChange} + searchValue={searchValue} + onSearchChange={onSearchChange} onClose={onClose} /> diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index b1f58bba..168c2aa0 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -58,8 +58,11 @@ function matchesFilters( entry: FetchRequestEntry, filterText: string, visibleCategories: Record, + // The embedded column exposes only the search box (no category toggles), so it + // applies the text filter but skips the category filter (#1616). + ignoreCategories: boolean, ): boolean { - if (!visibleCategories[entry.category]) return false; + if (!ignoreCategories && !visibleCategories[entry.category]) return false; if (filterText) { const term = filterText.toLowerCase(); const status = @@ -98,13 +101,11 @@ export function NetworkStreamPanel({ }: NetworkStreamPanelProps) { const viewportRef = useScrollMemory("network-stream"); const filteredEntries = useMemo(() => { - // Embedded column: show the full stream (no filter sidebar to explain a - // mirrored filter). See LogStreamPanel (#1616). `.filter()` returns a fresh - // array, so sorting in-place is safe. + // Embedded column filters by text only (its category toggles live in the + // full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns a + // fresh array, so sorting in-place is safe. const sorted = entries - .filter( - (e) => embedded || matchesFilters(e, filterText, visibleCategories), - ) + .filter((e) => matchesFilters(e, filterText, visibleCategories, embedded)) .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); if (sortDirection === "newest-first") sorted.reverse(); return sorted; diff --git a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx index c4e23a02..f5ae4f10 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx @@ -127,8 +127,25 @@ describe("HistoryScreen", () => { expect(screen.queryByPlaceholderText("Search...")).toBeNull(); }); - it("shows the full stream when embedded, ignoring the live filter", () => { - // A search that matches nothing would empty the full-size screen... + it("applies the search text but ignores the method filter when embedded", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("No request history")).toBeNull(); + }); + + it("hides entries not matching the column search when embedded", () => { renderWithMantine( { embedded />, ); - // ...but the embedded column shows the full stream (not the empty state). - expect(screen.queryByText("No request history")).toBeNull(); + expect(screen.getByText("No request history")).toBeInTheDocument(); }); }); diff --git a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx index b5df10ab..675e028b 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -147,14 +147,38 @@ describe("LoggingScreen", () => { expect(screen.queryByRole("button", { name: "Set" })).toBeNull(); }); - it("shows the full stream when embedded, ignoring the live filter", () => { + it("applies the search text but ignores the level filter when embedded", () => { + const entries = [ + { + receivedAt: new Date(), + params: { level: "info" as const, data: "hello" }, + }, + ]; + renderWithMantine( + , + ); + // Search matches + level filter is ignored → the entry shows. + expect(screen.getByText("hello")).toBeInTheDocument(); + }); + + it("hides entries not matching the column search when embedded", () => { const entries = [ { receivedAt: new Date(), params: { level: "info" as const, data: "hello" }, }, ]; - // A filter that would hide the entry on the full-size screen... renderWithMantine( { embedded />, ); - // ...is ignored in the embedded, sidebar-less column. - expect(screen.getByText("hello")).toBeInTheDocument(); + expect(screen.queryByText("hello")).toBeNull(); }); }); diff --git a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx index 0059a794..3f67adc7 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx @@ -180,8 +180,25 @@ describe("NetworkScreen", () => { expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument(); }); - it("shows the full stream when embedded, ignoring the live filter", () => { - // A filter that matches nothing would empty the full-size screen... + it("applies the search text but ignores the category filter when embedded", () => { + renderWithMantine( + , + ); + expect(screen.queryByText("No network requests")).toBeNull(); + }); + + it("hides entries not matching the column search when embedded", () => { renderWithMantine( { embedded />, ); - // ...but the embedded column shows the full stream (not the empty state). - expect(screen.queryByText("No network requests")).toBeNull(); + expect(screen.getByText("No network requests")).toBeInTheDocument(); }); }); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 907e8c54..1c93bbd6 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -1388,15 +1388,38 @@ describe("InspectorView", () => { await screen.findByRole("button", { name: "Close monitoring column" }), ); - expect( - screen.queryByRole("button", { name: "Close monitoring column" }), - ).toBeNull(); + // The column plays its slide-out animation before unmounting. + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(), + ); const header = screen.getByRole("banner"); expect( within(header).getByRole("radio", { name: "Logs" }), ).toBeInTheDocument(); }); + it("carries the column's selection to the primary view on close", async () => { + monitorWide.value = true; + renderWithMantine(); + const user = await gotoTab("Logs"); + await user.click( + await screen.findByRole("button", { name: "Pin as column" }), + ); + // Switch the column to History, then close it. + await user.click(await screen.findByRole("radio", { name: "History" })); + await user.click( + await screen.findByRole("button", { name: "Close monitoring column" }), + ); + + // The primary header now has History selected (not the pre-pin Logs). + const header = screen.getByRole("banner"); + expect( + within(header).getByRole("radio", { name: "History" }), + ).toBeChecked(); + }); + it("persists the pin preference and reopens the column when wide", () => { // Stored preference from a prior wide session. window.localStorage.setItem("inspector.monitor.pinned", "true"); @@ -1431,7 +1454,7 @@ describe("InspectorView", () => { ).toBeNull(); }); - it("hides the column on disconnect but keeps the pin preference", () => { + it("hides the column on disconnect but keeps the pin preference", async () => { window.localStorage.setItem("inspector.monitor.pinned", "true"); monitorWide.value = true; const { rerender } = renderWithMantine( @@ -1450,9 +1473,12 @@ describe("InspectorView", () => { })} />, ); - expect( - screen.queryByRole("button", { name: "Close monitoring column" }), - ).toBeNull(); + // The column plays its slide-out animation before unmounting. + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(), + ); // Preference is untouched — only the column's close button clears it. expect(window.localStorage.getItem("inspector.monitor.pinned")).toBe( "true", @@ -1539,6 +1565,46 @@ describe("InspectorView", () => { ); }); + it("keeps the column search across tabs and filters each screen", async () => { + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + const user = userEvent.setup(); + renderWithMantine( + , + ); + // Column defaults to Logs; the log entry shows. + expect(await screen.findByText("loghello")).toBeInTheDocument(); + + // A search matching nothing filters the Logs stream... + const searchBox = screen.getByRole("textbox", { name: "Search" }); + await user.type(searchBox, "zzz"); + expect(screen.queryByText("loghello")).toBeNull(); + + // ...and the same term carries over to History (still filtered → empty). + await user.click(screen.getByRole("radio", { name: "History" })); + expect(screen.getByText("No request history")).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Search" })).toHaveValue( + "zzz", + ); + }); + it("resizes and persists the column width from the keyboard", async () => { window.localStorage.setItem("inspector.monitor.pinned", "true"); window.localStorage.setItem("inspector.monitor.width", "420"); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index 104dd9d0..afd36d3a 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -1,5 +1,13 @@ import { useMemo, useState, type ReactNode, type Ref } from "react"; -import { AppShell, Box, Flex, Stack, Transition } from "@mantine/core"; +import { + AppShell, + Box, + Flex, + Group, + Stack, + Transition, + type MantineTransition, +} from "@mantine/core"; import { useLocalStorage, useMediaQuery } from "@mantine/hooks"; import type { InitializeResult, @@ -244,6 +252,27 @@ const MonitoringColumn = Stack.withProps({ miw: 0, }); +// Column open/close animation (#1616): the handle + column slide in from the +// right edge and fade as they mount, reversing on close, so pinning reads as the +// screen moving into a side column rather than snapping in. `AppShell.Main`'s +// `overflow: hidden` clips the off-screen portion during the slide. +const COLUMN_ANIM_MS = 300; +const slideInFromRight: MantineTransition = { + in: { opacity: 1, transform: "translateX(0)" }, + out: { opacity: 0, transform: "translateX(100%)" }, + common: { transformOrigin: "right center" }, + transitionProperty: "transform, opacity", +}; + +// Flex-row wrapper holding the resize handle + column so the whole unit animates +// as one (the Transition interpolation is applied to it via `style`). +const MonitorColumnGroup = Group.withProps({ + wrap: "nowrap", + gap: 0, + h: "100%", + flex: "0 0 auto", +}); + // Wraps each screen in a Mantine Transition. With Transition's default // (`keepMounted={false}`), the outgoing screen unmounts after its exit // animation — any local screen state (search filters, scroll position, @@ -744,11 +773,25 @@ export function InspectorView({ /* v8 ignore next */ if (isMonitorTab(tab)) setMonitorTab(tab); } + // Closing the column carries its selection back to the primary area: the + // screen the user was watching in the column becomes the active header tab, + // rather than snapping back to whatever was selected before they pinned. + function closeMonitorColumn() { + onActiveTabChange(effectiveMonitorTab); + setMonitorPinned(false); + } function commitMonitorWidth(next: number) { setMonitorWidth(next); setDragWidth(null); } + // A single search shared across the column's tabs (kept as you move between + // Logs/History/Network so it filters each one), distinct from the full-size + // screens' own searches. The embedded panels apply only this text; their other + // filters (levels / categories / directions / method) have no control in the + // column and are bypassed there. + const [monitorSearch, setMonitorSearch] = useState(""); + // Merge the parent's `serversInput` (static config) with the runtime // connection state owned by the parent — only the active server reflects // the live status; the rest render as `disconnected`. Handshake errors @@ -840,11 +883,31 @@ export function InspectorView({ }; // Embedded instances for the pinned column, keyed by tab. MonitoringScreen - // renders only the active one; the rest are unmounted element values. + // renders only the active one; the rest are unmounted element values. Each + // screen's search field is overridden with the shared column search so it + // filters whichever tab is showing, and carries over as tabs change. const monitorScreens: Record = { - [LOGS_TAB]: , - [HISTORY_TAB]: , - [NETWORK_TAB]: , + [LOGS_TAB]: ( + + ), + [HISTORY_TAB]: ( + + ), + [NETWORK_TAB]: ( + + ), }; return ( @@ -997,28 +1060,40 @@ export function InspectorView({ /> - {effectivePinned ? ( - <> - - - setMonitorPinned(false)} - screens={monitorScreens} + + {(styles) => ( + // `style={styles}` is Mantine's runtime Transition interpolation, + // not static styling — same pattern as ScreenStage above. + + - - - ) : null} + + + + + )} + From aba77d47f5f9f370a9080e772f1594a3904a1351 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 20:25:36 -0400 Subject: [PATCH 22/26] fix(web): restore resize handle + refine column close/animation behavior (#1616) - Fix the resize handle disappearing: the animation wrapper is a Mantine Group (default align: center), and the full-height handle has no intrinsic height, so it collapsed to 0. Set align: stretch on the wrapper. - Closing the column now leaves the primary on the screen currently selected in the header (commits the active tab first), instead of swapping in the column's screen. - Keep the primary screen's standard ScreenStage transition; only the column (handle + column) slides open/closed from the right with a fade. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InspectorView/InspectorView.test.tsx | 22 +++++++++++++--- .../views/InspectorView/InspectorView.tsx | 26 ++++++++++++------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index 1c93bbd6..ec979431 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -1400,24 +1400,38 @@ describe("InspectorView", () => { ).toBeInTheDocument(); }); - it("carries the column's selection to the primary view on close", async () => { + it("keeps the current header screen (not the column's) when closed", async () => { monitorWide.value = true; renderWithMantine(); const user = await gotoTab("Logs"); await user.click( await screen.findByRole("button", { name: "Pin as column" }), ); + // Pinning moved the primary to the first non-Servers header tab (Tools). + const header = screen.getByRole("banner"); + expect( + within(header).getByRole("radio", { name: "Tools" }), + ).toBeChecked(); + // Switch the column to History, then close it. await user.click(await screen.findByRole("radio", { name: "History" })); await user.click( await screen.findByRole("button", { name: "Close monitoring column" }), ); - // The primary header now has History selected (not the pre-pin Logs). - const header = screen.getByRole("banner"); + // The primary stays on the header's current screen (Tools), not the + // column's History, and the monitor group returns to the header. + await waitFor(() => + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(), + ); + expect( + within(header).getByRole("radio", { name: "Tools" }), + ).toBeChecked(); expect( within(header).getByRole("radio", { name: "History" }), - ).toBeChecked(); + ).toBeInTheDocument(); }); it("persists the pin preference and reopens the column when wide", () => { diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index afd36d3a..bd520298 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -253,11 +253,13 @@ const MonitoringColumn = Stack.withProps({ }); // Column open/close animation (#1616): the handle + column slide in from the -// right edge and fade as they mount, reversing on close, so pinning reads as the -// screen moving into a side column rather than snapping in. `AppShell.Main`'s -// `overflow: hidden` clips the off-screen portion during the slide. +// right edge and fade as they mount, reversing on close, so pinning reads as a +// side column sliding open rather than snapping in. The primary screen keeps its +// standard `ScreenStage` transition. Mantine plays `out → in` on enter and +// `in → out` on exit. `AppShell.Main`'s `overflow: hidden` clips the off-screen +// portion during the slide. const COLUMN_ANIM_MS = 300; -const slideInFromRight: MantineTransition = { +const columnSlide: MantineTransition = { in: { opacity: 1, transform: "translateX(0)" }, out: { opacity: 0, transform: "translateX(100%)" }, common: { transformOrigin: "right center" }, @@ -265,9 +267,12 @@ const slideInFromRight: MantineTransition = { }; // Flex-row wrapper holding the resize handle + column so the whole unit animates -// as one (the Transition interpolation is applied to it via `style`). +// as one (the Transition interpolation is applied to it via `style`). `align: +// stretch` overrides Group's default `center` so the full-height resize handle +// (which has no intrinsic height) fills the column instead of collapsing. const MonitorColumnGroup = Group.withProps({ wrap: "nowrap", + align: "stretch", gap: 0, h: "100%", flex: "0 0 auto", @@ -773,11 +778,12 @@ export function InspectorView({ /* v8 ignore next */ if (isMonitorTab(tab)) setMonitorTab(tab); } - // Closing the column carries its selection back to the primary area: the - // screen the user was watching in the column becomes the active header tab, - // rather than snapping back to whatever was selected before they pinned. + // Closing the column leaves the primary area on whatever screen is currently + // selected in the header — it does not swap in the column's screen. We commit + // the current `activeTab` to the lifted state first so unpinning can't resurrect + // the (now stale) monitor tab that was selected before pinning. function closeMonitorColumn() { - onActiveTabChange(effectiveMonitorTab); + onActiveTabChange(activeTab); setMonitorPinned(false); } function commitMonitorWidth(next: number) { @@ -1062,7 +1068,7 @@ export function InspectorView({ Date: Wed, 8 Jul 2026 20:28:48 -0400 Subject: [PATCH 23/26] style(web): put the timestamp first in HistoryEntry to match NetworkEntry Move the Client/Server direction badge to the right of the timestamp so the monospace timestamp is leftmost, matching the NetworkEntry header layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx index 3a1599b5..77ac2e73 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx @@ -124,12 +124,12 @@ export function HistoryEntry({ + {formatTimestamp(entry.timestamp)} {entry.origin && ( )} - {formatTimestamp(entry.timestamp)} {method} {target && {target}} From 3550b6403242ab386d768ce4b6b55b178ed4688d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 20:53:23 -0400 Subject: [PATCH 24/26] feat(web): compact two-line HistoryEntry/NetworkEntry for the narrow column (#1616) In the pinned monitoring column (embedded), the single-line entry headers wrap badly. Add compact two-line variants gated on `embedded`: - HistoryEntry: line 1 = time + client/server badge + elapsed + status; line 2 = method (and target, truncated) on the left with the controls on the right, where Replay becomes an icon (new ReplayButton element) next to Pin/Expand. - NetworkEntry: line 1 = time + HTTP method + category + elapsed + status; line 2 = the URL in a horizontal ScrollArea (new Text `nowrap` variant) with the expand toggle on the right. The full-size (non-embedded) layouts are unchanged. `embedded` is threaded from the History/Network panels into the entries. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ReplayButton/ReplayButton.stories.tsx | 19 +++ .../ReplayButton/ReplayButton.test.tsx | 19 +++ .../elements/ReplayButton/ReplayButton.tsx | 26 ++++ .../groups/HistoryEntry/HistoryEntry.test.tsx | 43 ++++++ .../groups/HistoryEntry/HistoryEntry.tsx | 133 +++++++++++++----- .../HistoryListPanel/HistoryListPanel.tsx | 2 + .../groups/NetworkEntry/NetworkEntry.test.tsx | 14 ++ .../groups/NetworkEntry/NetworkEntry.tsx | 116 +++++++++++---- .../NetworkStreamPanel/NetworkStreamPanel.tsx | 1 + clients/web/src/theme/Text.ts | 10 ++ 10 files changed, 325 insertions(+), 58 deletions(-) create mode 100644 clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx create mode 100644 clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx create mode 100644 clients/web/src/components/elements/ReplayButton/ReplayButton.tsx diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx new file mode 100644 index 00000000..6391f2b4 --- /dev/null +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { expect, fn, within } from "storybook/test"; +import { ReplayButton } from "./ReplayButton"; + +const meta: Meta = { + title: "Elements/ReplayButton", + component: ReplayButton, + args: { onReplay: fn() }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + expect(canvas.getByRole("button", { name: "Replay" })).toBeInTheDocument(); + }, +}; diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx new file mode 100644 index 00000000..e992e920 --- /dev/null +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.test.tsx @@ -0,0 +1,19 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { ReplayButton } from "./ReplayButton"; + +describe("ReplayButton", () => { + it("renders a labelled replay button", () => { + renderWithMantine(); + expect(screen.getByRole("button", { name: "Replay" })).toBeInTheDocument(); + }); + + it("invokes onReplay when clicked", async () => { + const user = userEvent.setup(); + const onReplay = vi.fn(); + renderWithMantine(); + await user.click(screen.getByRole("button", { name: "Replay" })); + expect(onReplay).toHaveBeenCalledTimes(1); + }); +}); diff --git a/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx new file mode 100644 index 00000000..6cac503e --- /dev/null +++ b/clients/web/src/components/elements/ReplayButton/ReplayButton.tsx @@ -0,0 +1,26 @@ +import { ActionIcon } from "@mantine/core"; +import { MdReplay } from "react-icons/md"; + +export interface ReplayButtonProps { + /** Re-send the owning history request. */ + onReplay: () => void; +} + +/** + * Icon form of the "Replay" action, used in the compact (column) HistoryEntry + * layout where the text button is replaced by a replay icon sitting next to the + * pin toggle (#1616). Matches PinToggle's subtle gray icon-button styling. + */ +export function ReplayButton({ onReplay }: ReplayButtonProps) { + return ( + + + + ); +} diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx index 6ed5e769..8ebce289 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx @@ -208,6 +208,49 @@ describe("HistoryEntry", () => { expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand")); }); + it("renders the compact two-line layout with Replay as an icon when embedded", () => { + renderWithMantine( + , + ); + // Line 1 essentials plus the method are still shown. + expect(screen.getByText("client → server")).toBeInTheDocument(); + expect(screen.getByText("142ms")).toBeInTheDocument(); + expect(screen.getByText("OK")).toBeInTheDocument(); + expect(screen.getByText("tools/call")).toBeInTheDocument(); + // Replay is an icon button (aria-label), not the text button. + expect(screen.getByRole("button", { name: "Replay" })).toBeInTheDocument(); + expect(screen.queryByText("Replay")).toBeNull(); + }); + + it("keeps action order Replay, Pin, Expand in the compact layout", () => { + renderWithMantine( + , + ); + const names = screen + .getAllByRole("button") + .map((b) => b.getAttribute("aria-label") ?? b.textContent); + expect(names.indexOf("Replay")).toBeLessThan(names.indexOf("Pin")); + expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand")); + }); + + it("does not render a Replay icon for a non-replayable method when embedded", () => { + // A server→client response isn't replayable. + const responseEntry: MessageEntry = { + id: "resp-1", + timestamp: new Date("2026-03-17T10:30:00Z"), + direction: "response", + origin: "server", + message: { jsonrpc: "2.0", id: 1, result: {} }, + }; + renderWithMantine( + , + ); + expect( + screen.queryByRole("button", { name: "Replay" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Pin" })).toBeInTheDocument(); + }); + it("invokes onTogglePin when Pin button is clicked", async () => { const user = userEvent.setup(); const onTogglePin = vi.fn(); diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx index 77ac2e73..9546ee3b 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx @@ -14,6 +14,7 @@ import { ContentViewer } from "../../elements/ContentViewer/ContentViewer"; import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge"; import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle"; import { PinToggle } from "../../elements/PinToggle/PinToggle"; +import { ReplayButton } from "../../elements/ReplayButton/ReplayButton"; import { extractMethod, isReplayableHistoryMethod } from "../historyUtils.js"; export interface HistoryEntryProps { @@ -22,6 +23,12 @@ export interface HistoryEntryProps { isListExpanded: boolean; onReplay: () => void; onTogglePin: () => void; + /** + * Compact two-line header for the narrow monitoring column (#1616): line 1 is + * time + direction + duration + status; line 2 is the method (and target) with + * the controls — Replay as an icon — on the right. + */ + embedded?: boolean; } const EntryContainer = Card.withProps({ @@ -34,6 +41,19 @@ const HeaderRow = Group.withProps({ wrap: "nowrap", }); +// Left / right clusters within a compact header line. The left cluster shrinks +// (`miw: 0`) so a long target can truncate rather than push the row wider. +const HeaderCluster = Group.withProps({ + gap: "sm", + wrap: "nowrap", + miw: 0, +}); + +const ControlsCluster = Group.withProps({ + gap: "xs", + wrap: "nowrap", +}); + const TimestampText = Text.withProps({ size: "sm", c: "dimmed", @@ -108,6 +128,7 @@ export function HistoryEntry({ isListExpanded, onReplay, onTogglePin, + embedded = false, }: HistoryEntryProps) { const [isExpanded, setIsExpanded] = useState(isListExpanded); const method = extractMethod(entry); @@ -119,44 +140,88 @@ export function HistoryEntry({ setIsExpanded(isListExpanded); }, [isListExpanded]); + const directionBadge = entry.origin && ( + + ); + const statusBadge = status !== "none" && ( + {statusLabel(status)} + ); + const durationText = entry.duration != null && ( + {formatDuration(entry.duration)} + ); + return ( - - - {formatTimestamp(entry.timestamp)} - {entry.origin && ( - - )} - {method} - {target && {target}} - - - {entry.duration != null && ( - {formatDuration(entry.duration)} - )} - {status !== "none" && ( - {statusLabel(status)} - )} - - + {embedded ? ( + // Compact two-line header for the narrow column. + + + + + {formatTimestamp(entry.timestamp)} + + {directionBadge} + + + {durationText} + {statusBadge} + + + + + {method} + {target && ( + + {target} + + )} + + + {canReplay && } + + setIsExpanded((v) => !v)} + /> + + + + ) : ( + <> + + + + {formatTimestamp(entry.timestamp)} + + {directionBadge} + {method} + {target && {target}} + + + {durationText} + {statusBadge} + + - - - {canReplay && ( - Replay - )} - - - - setIsExpanded((v) => !v)} - /> - - + + + {canReplay && ( + Replay + )} + + + + setIsExpanded((v) => !v)} + /> + + + + )} diff --git a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx index a6a7514b..05133ba0 100644 --- a/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx +++ b/clients/web/src/components/groups/HistoryListPanel/HistoryListPanel.tsx @@ -318,6 +318,7 @@ export function HistoryListPanel({ entry={entry} isPinned={true} isListExpanded={!compact} + embedded={embedded} onReplay={() => onReplay(entry.id)} onTogglePin={() => onTogglePin(entry.id)} /> @@ -349,6 +350,7 @@ export function HistoryListPanel({ entry={entry} isPinned={false} isListExpanded={!compact} + embedded={embedded} onReplay={() => onReplay(entry.id)} onTogglePin={() => onTogglePin(entry.id)} /> diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index 514e78e8..5f37ad51 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -31,6 +31,20 @@ describe("NetworkEntry", () => { expect(screen.getByText("transport")).toBeInTheDocument(); }); + it("renders the compact two-line layout (line 1 meta, line 2 URL) when embedded", () => { + renderWithMantine( + , + ); + // Line 1: method, category, duration, status. + expect(screen.getByText("POST")).toBeInTheDocument(); + expect(screen.getByText("transport")).toBeInTheDocument(); + expect(screen.getByText("45ms")).toBeInTheDocument(); + expect(screen.getByText("200 OK")).toBeInTheDocument(); + // Line 2: the URL (in its scroll area) and the expand toggle. + expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument(); + }); + it("shows body / header detail when expanded", async () => { const user = userEvent.setup(); renderWithMantine( diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index a0a58cfb..c80baee5 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -6,6 +6,7 @@ import { Collapse, Divider, Group, + ScrollArea, Stack, Table, Text, @@ -19,6 +20,12 @@ import { maskSecretsInBody } from "../../../utils/maskSecrets"; export interface NetworkEntryProps { entry: FetchRequestEntry; isListExpanded: boolean; + /** + * Compact two-line header for the narrow monitoring column (#1616): line 1 is + * time + method + category + duration + status; line 2 is the URL in a + * horizontal scroll area with the expand toggle on the right. + */ + embedded?: boolean; } const EntryContainer = Card.withProps({ @@ -43,6 +50,26 @@ const UrlText = Text.withProps({ truncate: "end", }); +// Compact-header URL: never wraps, so a long URL scrolls horizontally inside its +// ScrollArea instead of wrapping to many lines. +const UrlScroll = Text.withProps({ + size: "sm", + fw: 500, + variant: "nowrap", +}); + +// Left / right clusters for a compact header line (mirrors HistoryEntry). +const HeaderCluster = Group.withProps({ + gap: "sm", + wrap: "nowrap", + miw: 0, +}); + +const ControlsCluster = Group.withProps({ + gap: "sm", + wrap: "nowrap", +}); + const DurationText = Text.withProps({ size: "sm", c: "dimmed", @@ -197,7 +224,11 @@ function BodyPreview({ ); } -export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { +export function NetworkEntry({ + entry, + isListExpanded, + embedded = false, +}: NetworkEntryProps) { const [isExpanded, setIsExpanded] = useState(isListExpanded); // The list-level Expand/Collapse toggle is authoritative: each time the @@ -210,33 +241,70 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) { setIsExpanded(isListExpanded); }, [isListExpanded]); + const metaBadges = ( + <> + {entry.duration != null && ( + {formatDuration(entry.duration)} + )} + {isLongLivedStream(entry) && SSE} + {statusLabel(entry)} + + ); + const expandToggle = ( + setIsExpanded((v) => !v)} + /> + ); + return ( - - - {formatTimestamp(entry.timestamp)} - {entry.method} - - {entry.category} - - {entry.url} - - - {entry.duration != null && ( - {formatDuration(entry.duration)} - )} - {isLongLivedStream(entry) && SSE} - {statusLabel(entry)} - - + {embedded ? ( + // Compact two-line header for the narrow column. + + + + + {formatTimestamp(entry.timestamp)} + + {entry.method} + + {entry.category} + + + {metaBadges} + + + + {entry.url} + + {expandToggle} + + + ) : ( + <> + + + + {formatTimestamp(entry.timestamp)} + + {entry.method} + + {entry.category} + + {entry.url} + + + {metaBadges} + + - - setIsExpanded((v) => !v)} - /> - + + {expandToggle} + + + )} diff --git a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx index 168c2aa0..509f5a98 100644 --- a/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx +++ b/clients/web/src/components/groups/NetworkStreamPanel/NetworkStreamPanel.tsx @@ -147,6 +147,7 @@ export function NetworkStreamPanel({ key={entry.id} entry={entry} isListExpanded={!compact} + embedded={embedded} /> ))} diff --git a/clients/web/src/theme/Text.ts b/clients/web/src/theme/Text.ts index fa5cbde5..818ceb00 100644 --- a/clients/web/src/theme/Text.ts +++ b/clients/web/src/theme/Text.ts @@ -16,6 +16,16 @@ export const ThemeText = Text.extend({ }, }; } + // Single-line text that never wraps — used inside a horizontal ScrollArea so + // a long value (e.g. a network URL in the compact column) scrolls instead of + // wrapping to many lines (#1616). + if (props.variant === "nowrap") { + return { + root: { + whiteSpace: "nowrap", + }, + }; + } return { root: {} }; }, }); From efddd94bea5c69e681acdef4b2e83b46ad4524ee Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 20:59:23 -0400 Subject: [PATCH 25/26] fix(web): use a time-only timestamp in the compact column entries (#1616) At the 320px column minimum the full ISO timestamp ate most of line 1. Use an HH:MM:SS (UTC) form in the embedded HistoryEntry/NetworkEntry headers only; the full-size layouts keep the full ISO string. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/groups/HistoryEntry/HistoryEntry.test.tsx | 7 ++++++- .../src/components/groups/HistoryEntry/HistoryEntry.tsx | 8 +++++++- .../components/groups/NetworkEntry/NetworkEntry.test.tsx | 6 +++++- .../src/components/groups/NetworkEntry/NetworkEntry.tsx | 8 +++++++- 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx index 8ebce289..e144e65d 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx @@ -212,7 +212,12 @@ describe("HistoryEntry", () => { renderWithMantine( , ); - // Line 1 essentials plus the method are still shown. + // Line 1 essentials plus the method are still shown. The timestamp is the + // compact time-only form (not the full ISO) to fit the narrow line. + expect(screen.getByText("10:30:00")).toBeInTheDocument(); + expect( + screen.queryByText("2026-03-17T10:30:00.000Z"), + ).not.toBeInTheDocument(); expect(screen.getByText("client → server")).toBeInTheDocument(); expect(screen.getByText("142ms")).toBeInTheDocument(); expect(screen.getByText("OK")).toBeInTheDocument(); diff --git a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx index 9546ee3b..a76ad4f9 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.tsx @@ -83,6 +83,12 @@ function formatTimestamp(date: Date): string { return date.toISOString(); } +// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO +// string would eat most of the narrow line-1 width (#1616). +function formatTimestampCompact(date: Date): string { + return date.toISOString().slice(11, 19); +} + function extractTarget(entry: MessageEntry): string | undefined { const msg = entry.message; if (!("params" in msg) || !msg.params) return undefined; @@ -161,7 +167,7 @@ export function HistoryEntry({ - {formatTimestamp(entry.timestamp)} + {formatTimestampCompact(entry.timestamp)} {directionBadge} diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index 5f37ad51..e0c09b5a 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -35,7 +35,11 @@ describe("NetworkEntry", () => { renderWithMantine( , ); - // Line 1: method, category, duration, status. + // Line 1: compact time-only timestamp, method, category, duration, status. + expect(screen.getByText("10:00:00")).toBeInTheDocument(); + expect( + screen.queryByText("2026-03-17T10:00:00.000Z"), + ).not.toBeInTheDocument(); expect(screen.getByText("POST")).toBeInTheDocument(); expect(screen.getByText("transport")).toBeInTheDocument(); expect(screen.getByText("45ms")).toBeInTheDocument(); diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index c80baee5..08bb937b 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -89,6 +89,12 @@ function formatTimestamp(date: Date): string { return date.toISOString(); } +// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO +// string would eat most of the narrow line-1 width (#1616). +function formatTimestampCompact(date: Date): string { + return date.toISOString().slice(11, 19); +} + function statusColor(entry: FetchRequestEntry): string { if (entry.error) return "red"; const status = entry.responseStatus; @@ -266,7 +272,7 @@ export function NetworkEntry({ - {formatTimestamp(entry.timestamp)} + {formatTimestampCompact(entry.timestamp)} {entry.method} From 20e08e2b1d38d17d65a3a5a4167dfafcfb9296b9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 8 Jul 2026 22:26:06 -0400 Subject: [PATCH 26/26] feat(web): open the monitoring column on connect (#1616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-open the monitoring column when a connection is established. Gated on the disconnected → connected transition (a ref-tracked edge), so it fires on an actual connect rather than on every render while connected or on a mount that starts already-connected (which would fight a user who closed it). The column still only appears when wide and a monitor tab is available. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../InspectorView/InspectorView.test.tsx | 33 +++++++++++++++++++ .../views/InspectorView/InspectorView.tsx | 26 ++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx index ec979431..99197eaf 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.test.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.test.tsx @@ -1343,6 +1343,39 @@ describe("InspectorView", () => { ).toBeInTheDocument(); }); + it("opens the monitoring column when a connection is established", async () => { + monitorWide.value = true; + const { rerender } = renderWithMantine( + , + ); + // Disconnected: the column is closed. + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + + // Connecting opens it (the disconnected → connected transition). + rerender(); + expect( + await screen.findByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + }); + + it("does not auto-open on a mount that starts already connected", () => { + // No disconnected → connected transition, and no stored preference, so the + // column stays closed (a user who closed it isn't fought on remount). + monitorWide.value = true; + renderWithMantine(); + expect( + screen.queryByRole("button", { name: "Close monitoring column" }), + ).toBeNull(); + }); + it("pins the monitor group into the column and removes it from the header", async () => { monitorWide.value = true; renderWithMantine(); diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx index bd520298..b61f1ed3 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -1,4 +1,11 @@ -import { useMemo, useState, type ReactNode, type Ref } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type ReactNode, + type Ref, +} from "react"; import { AppShell, Box, @@ -647,6 +654,23 @@ export function InspectorView({ getInitialValueInEffect: false, }); + // Open the monitoring column when a connection is established (#1616). Gated on + // the disconnected → connected *transition* (via the ref) rather than the + // "connected" state itself, so it fires on an actual connect — not on every + // render while connected, and not on a mount that starts already-connected + // (which would fight a user who closed it). The column still only *appears* + // when wide + a monitor tab is available (`effectivePinned`); this just sets + // the preference. Closing it stays closed until the next connect, since this + // effect only re-runs when `connectionStatus` changes. + const wasConnectedRef = useRef(connectionStatus === "connected"); + useEffect(() => { + const isConnected = connectionStatus === "connected"; + if (isConnected && !wasConnectedRef.current) { + setMonitorPinned(true); + } + wasConnectedRef.current = isConnected; + }, [connectionStatus, setMonitorPinned]); + const appTools = useMemo(() => { return tools.filter((tool) => { try {