diff --git a/AGENTS.md b/AGENTS.md index a69546a9..092233e5 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/__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)"); + }); + }); }); diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 6dc3d8b4..eedd0680 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -16,8 +16,7 @@ "commander": "^13.1.0", "pino": "^9.14.0", "undici": "^8.5.0", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "bin": { "mcp-inspector-cli": "build/index.js" @@ -5685,35 +5684,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 5f185550..0a1232d9 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -39,8 +39,7 @@ "commander": "^13.1.0", "pino": "^9.14.0", "undici": "^8.5.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/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/tui/__tests__/App.test.tsx b/clients/tui/__tests__/App.test.tsx index 7ea98657..b8efdb7c 100644 --- a/clients/tui/__tests__/App.test.tsx +++ b/clients/tui/__tests__/App.test.tsx @@ -75,13 +75,44 @@ 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. + // 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((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 + // 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(); } 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 @@ -106,8 +137,22 @@ 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({ client: this, fn }); + }); + removeEventListener = vi.fn( + (event: string, fn: (event: unknown) => void) => { + 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. disconnect = vi.fn().mockRejectedValue(new Error("cleanup disconnect")); } @@ -121,6 +166,11 @@ const h = vi.hoisted(() => { createOAuthCallbackServer, callbackStart, callbackStop, + clientEvents, + clientInstances, + fireClientEvent, + fireClientEventFor, + runner, FakeManager, FakeClient, useInspectorClient: vi.fn(() => ({ @@ -200,6 +250,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 +268,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 +329,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 +564,9 @@ beforeEach(() => { h.cb.opts = null; 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"); h.clientSpies.clearOAuthTokens.mockReset(); @@ -1041,3 +1111,350 @@ 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"]); + 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 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"); + }); +}); 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); 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/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/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/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/App.tsx b/clients/web/src/App.tsx index c197c8ac..feb17352 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -86,10 +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 { - getRemoteOAuthStorage, - getWebOAuthBaseUrl, -} from "./lib/remoteOAuthStorage"; +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"; @@ -1431,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 @@ -2101,10 +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 the - // backend-backed `RemoteOAuthStorage` (`~/.mcp-inspector/storage/oauth.json`) - // 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; @@ -2212,6 +2216,15 @@ function App() { } void (async () => { + 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 { @@ -2267,7 +2280,7 @@ function App() { }); } })(); - }, [servers, setupClientForServer, showReAuthBanner]); + }, [servers, setupClientForServer, showReAuthBanner, webOAuthStorage]); const onToggleConnection = useCallback( async (id: string) => { @@ -3268,13 +3281,9 @@ function App() { const clientSettingsModalValue = clientSettingsDraft ?? EMPTY_CLIENT_SETTINGS; - const emaOAuthStorage = useMemo( - () => getRemoteOAuthStorage(getWebOAuthBaseUrl(), getAuthToken()), - [], - ); const { loginState: emaIdpLoginState, logout: logoutEmaIdp } = useEmaIdpLoginState( - emaOAuthStorage, + webOAuthStorage, clientSettingsModalValue.emaEnabled ? clientSettingsModalValue.issuer : undefined, @@ -3298,14 +3307,11 @@ 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, - oauthStorage: getRemoteOAuthStorage( - getWebOAuthBaseUrl(), - getAuthToken(), - ), + oauthStorage: webOAuthStorage, }); if (!cleared) return; @@ -3331,6 +3337,7 @@ function App() { [ activeServerId, inspectorClient, + webOAuthStorage, finalizeExplicitDisconnect, clearOAuthResumeOnExplicitDisconnect, ], 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..cd252845 --- /dev/null +++ b/clients/web/src/components/elements/PinColumnButton/PinColumnButton.tsx @@ -0,0 +1,27 @@ +import { Button } 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 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. Styled + * to match the panel's expand/collapse `ListToggle` (subtle icon button). + */ +export function PinColumnButton({ onPin }: PinColumnButtonProps) { + return ( + + ); +} 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/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..5ba77b0b --- /dev/null +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.test.tsx @@ -0,0 +1,166 @@ +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("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 }); + 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..77545a5e --- /dev/null +++ b/clients/web/src/components/elements/ResizeHandle/ResizeHandle.tsx @@ -0,0 +1,119 @@ +import { + useEffect, + 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); + + // 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 + // 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/HistoryEntry/HistoryEntry.test.tsx b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx index 6ed5e769..e144e65d 100644 --- a/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx +++ b/clients/web/src/components/groups/HistoryEntry/HistoryEntry.test.tsx @@ -208,6 +208,54 @@ 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. 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(); + 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 3a1599b5..a76ad4f9 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", @@ -63,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; @@ -108,6 +134,7 @@ export function HistoryEntry({ isListExpanded, onReplay, onTogglePin, + embedded = false, }: HistoryEntryProps) { const [isExpanded, setIsExpanded] = useState(isListExpanded); const method = extractMethod(entry); @@ -119,44 +146,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 ( - - - {entry.origin && ( - - )} - {formatTimestamp(entry.timestamp)} - {method} - {target && {target}} - - - {entry.duration != null && ( - {formatDuration(entry.duration)} - )} - {status !== "none" && ( - {statusLabel(status)} - )} - - + {embedded ? ( + // Compact two-line header for the narrow column. + + + + + {formatTimestampCompact(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.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 cd9125fe..05133ba0 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({ @@ -123,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, @@ -134,6 +143,7 @@ function CollapsibleSection({ }: { title: string; collapsible: boolean; + hideHeaderWhenAlone?: boolean; open: boolean; onToggle: () => void; actions?: ReactNode; @@ -142,7 +152,7 @@ function CollapsibleSection({ if (!collapsible) { return ( - {title} + {hideHeaderWhenAlone ? null : {title}} {children} ); @@ -168,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) : ""; @@ -201,6 +216,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 @@ -208,15 +225,30 @@ 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 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) => - matchesFilters(e, searchText, visibleDirections, methodFilter), + matchesFilters( + e, + searchText, + visibleDirections, + methodFilter, + embedded, + ), ) .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)), @@ -238,9 +270,6 @@ export function HistoryListPanel({ Requests - {hasResults && ( - - )} Export + {hasResults && ( + + )} + {onPin ? : null} {!hasResults ? ( No request history ) : ( - + {pinnedEntries.length > 0 && ( onReplay(entry.id)} onTogglePin={() => onTogglePin(entry.id)} /> @@ -301,6 +330,9 @@ export function HistoryListPanel({ setHistoryOpen((v) => !v)} actions={ @@ -318,6 +350,7 @@ export function HistoryListPanel({ entry={entry} isPinned={false} isListExpanded={!compact} + embedded={embedded} onReplay={() => onReplay(entry.id)} onTogglePin={() => onTogglePin(entry.id)} /> @@ -325,7 +358,7 @@ export function HistoryListPanel({ )} - + )} ); diff --git a/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx b/clients/web/src/components/groups/LogStreamPanel/LogStreamPanel.tsx index b164f649..24fda38c 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({ @@ -50,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 = @@ -69,16 +78,20 @@ export function LogStreamPanel({ onExport, sortDirection, onSortChange, + onPin, + embedded = false, }: LogStreamPanelProps) { const viewportRef = useScrollMemory("logs-stream"); const filteredEntries = useMemo(() => { - // `.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) => 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; - }, [entries, filterText, visibleLevels, sortDirection]); + }, [entries, filterText, visibleLevels, sortDirection, embedded]); return ( @@ -104,21 +117,17 @@ export function LogStreamPanel({ > Export + {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..a176508e --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.stories.tsx @@ -0,0 +1,37 @@ +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(), + searchValue: "", + onSearchChange: 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..8cf86503 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.test.tsx @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +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", () => { + renderControls(); + for (const tab of TABS) { + expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument(); + } + }); + + it("renders only the tabs it is given", () => { + renderControls({ tabs: ["Logs", "History"] }); + expect(screen.getAllByRole("radio")).toHaveLength(2); + 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", () => { + 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(); + 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(); + renderControls({ onClose }); + 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..58c79cc1 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringControls/MonitoringControls.tsx @@ -0,0 +1,83 @@ +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). */ + tabs: string[]; + /** 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 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({ + 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 + 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 ( + + + 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 new file mode 100644 index 00000000..8d934c82 --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.stories.tsx @@ -0,0 +1,61 @@ +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(), + searchValue: "", + onSearchChange: 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..33f89ace --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.test.tsx @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { + MonitoringScreen, + type MonitoringScreenProps, +} from "./MonitoringScreen"; + +const TABS = ["Logs", "History", "Network"]; + +function screens() { + return { + Logs:
logs-body
, + History:
history-body
, + Network:
network-body
, + }; +} + +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", () => { + 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", () => { + renderScreen(); + 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(); + renderScreen({ onChange, onClose }); + 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("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", () => { + 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 new file mode 100644 index 00000000..2d9f392d --- /dev/null +++ b/clients/web/src/components/groups/MonitoringScreen/MonitoringScreen.tsx @@ -0,0 +1,63 @@ +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; + /** 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 + * 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, + searchValue, + onSearchChange, + onClose, + screens, +}: MonitoringScreenProps) { + return ( + + + + {screens[value]} + + ); +} diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx index 514e78e8..e0c09b5a 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.test.tsx @@ -31,6 +31,24 @@ 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: 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(); + 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..08bb937b 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", @@ -62,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; @@ -197,7 +230,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 +247,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. + + + + + {formatTimestampCompact(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 7f8b24d8..509f5a98 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({ @@ -60,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 = @@ -95,16 +96,20 @@ export function NetworkStreamPanel({ onSortChange, compact, onToggleCompact, + onPin, + embedded = false, }: NetworkStreamPanelProps) { const viewportRef = useScrollMemory("network-stream"); const filteredEntries = useMemo(() => { - // `.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) => 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; - }, [entries, filterText, visibleCategories, sortDirection]); + }, [entries, filterText, visibleCategories, sortDirection, embedded]); const hasEntries = entries.length > 0; const hasResults = filteredEntries.length > 0; @@ -114,9 +119,6 @@ export function NetworkStreamPanel({ {formatTitle(filteredEntries.length)} - {hasResults && ( - - )} Export + {hasResults && ( + + )} + {onPin ? : null} {!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..f5ae4f10 100644 --- a/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx +++ b/clients/web/src/components/screens/HistoryScreen/HistoryScreen.test.tsx @@ -111,4 +111,48 @@ 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(); + }); + + 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( + , + ); + expect(screen.getByText("No request history")).toBeInTheDocument(); + }); }); 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..675e028b 100644 --- a/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx +++ b/clients/web/src/components/screens/LoggingScreen/LoggingScreen.test.tsx @@ -131,4 +131,62 @@ 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(); + }); + + 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" }, + }, + ]; + renderWithMantine( + , + ); + expect(screen.queryByText("hello")).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..3f67adc7 100644 --- a/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx +++ b/clients/web/src/components/screens/NetworkScreen/NetworkScreen.test.tsx @@ -163,4 +163,49 @@ 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(); + }); + + 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( + , + ); + expect(screen.getByText("No network requests")).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 08d8b26f..75e5ba7c 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,382 @@ 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("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(); + 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" }), + ); + + // 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("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 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" }), + ).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", async () => { + window.localStorage.setItem("inspector.monitor.pinned", "true"); + monitorWide.value = true; + const { rerender } = renderWithMantine( + , + ); + expect( + screen.getByRole("button", { name: "Close monitoring column" }), + ).toBeInTheDocument(); + + rerender( + , + ); + // 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", + ); + }); + + 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("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"); + 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..b61f1ed3 100644 --- a/clients/web/src/components/views/InspectorView/InspectorView.tsx +++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx @@ -1,6 +1,21 @@ -import { useMemo, type ReactNode, type Ref } from "react"; -import { AppShell, Box, Stack, Transition } from "@mantine/core"; -import { useLocalStorage } from "@mantine/hooks"; +import { + useEffect, + useMemo, + useRef, + useState, + type ReactNode, + type Ref, +} from "react"; +import { + AppShell, + Box, + Flex, + Group, + Stack, + Transition, + type MantineTransition, +} from "@mantine/core"; +import { useLocalStorage, useMediaQuery } from "@mantine/hooks"; import type { InitializeResult, LoggingLevel, @@ -64,6 +79,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 +144,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 +155,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 +234,55 @@ 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, +}); + +// 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 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 columnSlide: 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`). `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", }); // Wraps each screen in a Mantine Transition. With Transition's default @@ -489,6 +618,59 @@ 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, + }); + + // 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 { @@ -558,15 +740,87 @@ 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); + } + // 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(activeTab); + 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 @@ -616,6 +870,76 @@ 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. 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]: ( + + ), + }; + 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 +956,7 @@ export function InspectorView({ status={connectionStatus} latencyMs={latencyMs} activeTab={activeTab} - availableTabs={availableTabs} + availableTabs={headerTabs} onTabChange={onActiveTabChange} onDisconnect={onDisconnect} onToggleTheme={onToggleTheme} @@ -647,150 +971,160 @@ 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} + /> + + + + {(styles) => ( + // `style={styles}` is Mantine's runtime Transition interpolation, + // not static styling — same pattern as ScreenStage above. + + + + + + + )} + + ); diff --git a/clients/web/src/lib/environmentFactory.test.ts b/clients/web/src/lib/environmentFactory.test.ts index 742b78d4..c3c4e70e 100644 --- a/clients/web/src/lib/environmentFactory.test.ts +++ b/clients/web/src/lib/environmentFactory.test.ts @@ -1,111 +1,124 @@ -/** - * Tests for the browser `InspectorClientEnvironment` assembly. - * - * The three remote factories are mocked so we can (a) assert the `baseUrl` / - * `authToken` derived from `window.location` are threaded into each, and - * (b) capture the internal `fetchFn` wrapper and invoke it to prove it - * delegates to `globalThis.fetch` (exercising the arrow body that exists to - * preserve the global receiver). `BrowserNavigation` and the OAuth storage - * accessor are the real implementations. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BrowserNavigation } from "@inspector/core/auth/browser/index.js"; -import type { RedirectUrlProvider } from "@inspector/core/auth/index.js"; - -interface CapturedOptions { - baseUrl: string; - authToken: string | undefined; - fetchFn: typeof fetch; -} - -const captured: { - transport?: CapturedOptions; - fetch?: CapturedOptions; - logger?: CapturedOptions; -} = {}; - -vi.mock("@inspector/core/mcp/remote/index.js", () => ({ - createRemoteTransport: (opts: CapturedOptions) => { - captured.transport = opts; - return { transport: true }; - }, - createRemoteFetch: (opts: CapturedOptions) => { - captured.fetch = opts; - return (async () => new Response("ok")) as unknown as typeof fetch; - }, - createRemoteLogger: (opts: CapturedOptions) => { - captured.logger = opts; - return { info: vi.fn() }; - }, -})); - +import { MutableRedirectUrlProvider } from "@inspector/core/auth/providers.js"; import { createWebEnvironment } from "./environmentFactory"; - -const REDIRECT: RedirectUrlProvider = { - getRedirectUrl: () => "http://localhost/callback", -}; +import { + getWebRemoteOAuthStorage, + resetWebRemoteOAuthStorageCacheForTests, +} from "./remoteOAuthStorage"; describe("createWebEnvironment", () => { beforeEach(() => { - captured.transport = undefined; - captured.fetch = undefined; - captured.logger = undefined; + vi.stubGlobal("window", { + location: { + protocol: "http:", + host: "127.0.0.1:6299", + }, + }); }); afterEach(() => { - vi.restoreAllMocks(); + resetWebRemoteOAuthStorageCacheForTests(); + vi.unstubAllGlobals(); }); - it("threads the window.location origin and auth token into every remote factory", () => { - const expectedBaseUrl = `${window.location.protocol}//${window.location.host}`; - const { environment, logger } = createWebEnvironment("tok-123", REDIRECT); + it("wires RemoteOAuthStorage shared with getWebRemoteOAuthStorage", () => { + const redirectUrlProvider = new MutableRedirectUrlProvider(); + const first = createWebEnvironment("unit-test-token", redirectUrlProvider); + const second = createWebEnvironment("unit-test-token", redirectUrlProvider); - for (const opts of [captured.transport, captured.fetch, captured.logger]) { - expect(opts?.baseUrl).toBe(expectedBaseUrl); - expect(opts?.authToken).toBe("tok-123"); - } + 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"), + ); + }); - // The returned logger is the same instance the factory produced. + 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); - expect(environment.transport).toEqual({ transport: true }); - expect(typeof environment.fetch).toBe("function"); }); - it("passes an undefined auth token straight through", () => { - createWebEnvironment(undefined, REDIRECT); - expect(captured.logger?.authToken).toBeUndefined(); + 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("wraps fetch so the call preserves the global receiver", async () => { - const spy = vi - .spyOn(globalThis, "fetch") - .mockResolvedValue(new Response("delegated", { status: 200 })); - createWebEnvironment("tok", REDIRECT); - - // Every factory receives the identical wrapper; invoking it must delegate. - const wrapper = captured.transport!.fetchFn; - const res = await wrapper("http://example.test/x"); - expect(spy).toHaveBeenCalledWith("http://example.test/x"); - expect(res.status).toBe(200); - }); - - it("builds a BrowserNavigation and wires the OAuth storage + redirect provider", () => { - const onBeforeRedirect = vi.fn(); + it("forwards onBeforeOAuthRedirect to BrowserNavigation", () => { + const onBeforeOAuthRedirect = vi.fn<(authorizationUrl: URL) => void>(); const { environment } = createWebEnvironment( "tok", - REDIRECT, - onBeforeRedirect, + new MutableRedirectUrlProvider(), + onBeforeOAuthRedirect, ); - - const oauth = environment.oauth; - expect(oauth?.navigation).toBeInstanceOf(BrowserNavigation); - expect(oauth?.redirectUrlProvider).toBe(REDIRECT); - expect(oauth?.storage).toBeDefined(); + 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("works without an onBeforeOAuthRedirect callback", () => { - const { environment } = createWebEnvironment(undefined, REDIRECT); - expect(environment.oauth?.navigation).toBeInstanceOf(BrowserNavigation); + 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 7c9497a0..3b08460b 100644 --- a/clients/web/src/lib/environmentFactory.ts +++ b/clients/web/src/lib/environmentFactory.ts @@ -6,7 +6,7 @@ import { } from "@inspector/core/mcp/remote/index.js"; import { BrowserNavigation } from "@inspector/core/auth/browser/index.js"; import type { RedirectUrlProvider } from "@inspector/core/auth/index.js"; -import { getRemoteOAuthStorage } from "./remoteOAuthStorage"; +import { getWebRemoteOAuthStorage } from "./remoteOAuthStorage.js"; export interface WebEnvironmentResult { environment: InspectorClientEnvironment; @@ -18,19 +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 uses `RemoteOAuthStorage` (POSTs to the same backend's - * `/api/storage/oauth`, which writes `~/.mcp-inspector/storage/oauth.json` - * mode 0600), so a token obtained in this browser session is also - * available to the CLI/TUI on the same host. Navigation still uses - * `BrowserNavigation` (full-page redirect). + * - 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 @@ -68,7 +66,7 @@ export function createWebEnvironment( }), logger, oauth: { - storage: getRemoteOAuthStorage(baseUrl, authToken, fetchFn), + 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 index c1d57dbe..0077480c 100644 --- a/clients/web/src/lib/remoteOAuthStorage.test.ts +++ b/clients/web/src/lib/remoteOAuthStorage.test.ts @@ -1,64 +1,66 @@ -import { describe, it, expect, vi } from "vitest"; -import { RemoteOAuthStorage } from "@inspector/core/auth/remote/index.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { getRemoteOAuthStorage, - getWebOAuthBaseUrl, - webOAuthFetch, + getWebRemoteOAuthStorage, + resetWebRemoteOAuthStorageCacheForTests, } from "./remoteOAuthStorage"; -const NOOP_FETCH = vi.fn( - async () => - new Response(JSON.stringify({ state: { servers: {} }, version: 0 }), { - status: 200, - }), -) as unknown as typeof fetch; - describe("remoteOAuthStorage", () => { - it("getWebOAuthBaseUrl derives the backend origin from window.location", () => { - expect(getWebOAuthBaseUrl()).toBe( - `${window.location.protocol}//${window.location.host}`, + 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("returns a RemoteOAuthStorage instance", () => { - const storage = getRemoteOAuthStorage( - "http://a.example", - undefined, - NOOP_FETCH, - ); - expect(storage).toBeInstanceOf(RemoteOAuthStorage); - }); - - it("memoizes one instance per {baseUrl, authToken}", () => { - const a1 = getRemoteOAuthStorage("http://memo.example", "tok", NOOP_FETCH); - const a2 = getRemoteOAuthStorage("http://memo.example", "tok", NOOP_FETCH); - expect(a1).toBe(a2); - - // A different auth token is a distinct key → distinct instance. - const b = getRemoteOAuthStorage("http://memo.example", "other", NOOP_FETCH); - expect(b).not.toBe(a1); - - // A different base URL is a distinct key → distinct instance. - const c = getRemoteOAuthStorage("http://memo2.example", "tok", NOOP_FETCH); - expect(c).not.toBe(a1); - }); - - it("treats an undefined authToken as its own stable key", () => { - const a = getRemoteOAuthStorage("http://undef.example", undefined); - const b = getRemoteOAuthStorage("http://undef.example", undefined); - expect(a).toBe(b); - }); - - it("webOAuthFetch delegates to globalThis.fetch preserving the receiver", async () => { - const spy = vi - .spyOn(globalThis, "fetch") - .mockResolvedValue(new Response("ok", { status: 200 })); - try { - const res = await webOAuthFetch("http://x.example"); - expect(spy).toHaveBeenCalledWith("http://x.example"); - expect(res.status).toBe(200); - } finally { - spy.mockRestore(); - } + 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 index d0497c37..12b13e94 100644 --- a/clients/web/src/lib/remoteOAuthStorage.ts +++ b/clients/web/src/lib/remoteOAuthStorage.ts @@ -1,56 +1,59 @@ -import { RemoteOAuthStorage } from "@inspector/core/auth/remote/index.js"; +import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js"; -/** - * Shared `RemoteOAuthStorage` accessor for the web client. - * - * The browser's OAuth state is persisted through the in-process Hono backend - * (`POST /api/storage/oauth` → `~/.mcp-inspector/storage/oauth.json`, mode - * 0600) rather than `sessionStorage`, so a credential obtained in the browser - * is the same blob the TUI/CLI read on the same host (web ⇄ TUI ⇄ CLI parity). - * - * One instance per `{baseUrl, authToken}`. The environment factory is called - * per-connect (a fresh `InspectorClient` is built for each server switch), but - * the OAuth blob on disk is a single shared resource: a stale instance's - * whole-blob POST could clobber a write made by a newer one. Memoizing also - * avoids re-hydrating the same file on every connect, and lets the connection - * path, the EMA IdP hook, and the per-server "clear OAuth" action all share - * one in-memory view of the store. - */ -const oauthStorageCache = new Map(); - -/** Backend origin the web client talks to (same origin that serves the SPA). */ -export function getWebOAuthBaseUrl(): string { - return `${window.location.protocol}//${window.location.host}`; +export interface WebRemoteOAuthStorageOptions { + baseUrl: string; + authToken?: string; } -/** - * `window.fetch` loses its `this` binding when extracted, raising "Illegal - * invocation"; wrap so the call site preserves the global receiver. - */ -export const webOAuthFetch: typeof fetch = (...args) => - globalThis.fetch(...args); +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); /** - * Memoized `RemoteOAuthStorage` for the given backend + auth token. + * Shared web OAuth store: `RemoteOAuthStorage` → `GET/POST /api/storage/oauth` + * → `~/.mcp-inspector/storage/oauth.json` (same file as CLI/TUI). * - * The cache key is intentionally `{baseUrl, authToken}` only — `fetchFn` is NOT - * part of it. Whichever call constructs an instance first wins, so a later - * caller's `fetchFn` is ignored. This is safe today because every call site - * either omits `fetchFn` (defaulting to {@link webOAuthFetch}) or passes an - * `environmentFactory` wrapper that is functionally identical (`globalThis.fetch`), - * so the shared-instance goal (one whole-blob writer per backend) matters more - * than which wrapper is used. A future caller needing a genuinely different - * transport must key on it (or bypass the cache). + * 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( - baseUrl: string, - authToken: string | undefined, - fetchFn: typeof fetch = webOAuthFetch, + options: WebRemoteOAuthStorageOptions, ): RemoteOAuthStorage { - const key = `${baseUrl} ${authToken ?? ""}`; - const cached = oauthStorageCache.get(key); - if (cached) return cached; - const storage = new RemoteOAuthStorage({ baseUrl, authToken, fetchFn }); - oauthStorageCache.set(key, storage); - return storage; + 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/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 a65c8383..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,7 +14,9 @@ function createStorage( tokens: Awaited>; preregistered: Awaited>; dynamic: Awaited>; - registrationKind: ReturnType; + registrationKind: Awaited< + ReturnType + >; scope: string | undefined; serverMetadata: Awaited>; idpSession: Awaited>; @@ -22,12 +24,12 @@ function createStorage( }> = {}, ): OAuthStorage { return { - ready: vi.fn().mockResolvedValue(undefined), + load: vi.fn().mockResolvedValue(undefined), getTokens: vi.fn().mockResolvedValue(overrides.tokens), getClientInformation: vi.fn(async (_url, isPreregistered) => isPreregistered ? overrides.preregistered : overrides.dynamic, ), - getScope: vi.fn().mockReturnValue(overrides.scope), + getScope: vi.fn().mockResolvedValue(overrides.scope), getServerMetadata: vi.fn(async (url: string) => { if (url.startsWith("ema-idp:")) { return overrides.idpMetadata ?? null; @@ -35,7 +37,9 @@ function createStorage( 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 37ef84a1..25ff3b78 100644 --- a/clients/web/src/test/core/auth/ema/emaFlow.test.ts +++ b/clients/web/src/test/core/auth/ema/emaFlow.test.ts @@ -43,24 +43,27 @@ 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 }; }), 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 9966b88b..1c6d73f3 100644 --- a/clients/web/src/test/core/auth/ema/idpOidc.test.ts +++ b/clients/web/src/test/core/auth/ema/idpOidc.test.ts @@ -29,11 +29,12 @@ 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 }; }), - getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)), + getServerMetadata: vi.fn(async () => minimalOAuthAsMetadata(IDP_ISSUER)), clearIdpSession: vi.fn(), } as unknown as OAuthStorage; }); @@ -264,6 +265,8 @@ describe("startIdpOidcAuthorization", () => { beforeEach(() => { Object.keys(saved).forEach((k) => delete saved[k]); storage = { + load: vi.fn().mockResolvedValue(undefined), + getServerMetadata: vi.fn(async () => null), saveCodeVerifier: vi.fn(async (key: string, value: string) => { saved[`cv:${key}`] = value; }), @@ -331,13 +334,14 @@ describe("completeIdpOidcAuthorization", () => { function buildStorage(overrides: Partial = {}): OAuthStorage { return { - getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)), + load: vi.fn().mockResolvedValue(undefined), + 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); }), 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/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..3b3dc6cf --- /dev/null +++ b/clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts @@ -0,0 +1,66 @@ +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. 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 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[] = []; + const re = /(?:import|export)[^;]*?from\s*["']([^"']+)["']/g; + let m: RegExpExecArray | null; + while ((m = re.exec(source)) !== null) { + specs.push(m[1]); + } + return specs; +} + +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("oauth-persist.ts sources its (de)serializers from store-serialize", () => { + const source = readFileSync( + resolve(coreDir, "auth/oauth-persist.ts"), + "utf-8", + ); + expect(importSpecifiers(source)).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 new file mode 100644 index 00000000..f55b37b0 --- /dev/null +++ b/clients/web/src/test/core/auth/oauth-persist.test.ts @@ -0,0 +1,225 @@ +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", () => { + 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: { + "http://example.com": { codeVerifier: "v1" }, + }, + idpSessions: { + "https://idp.example": { idToken: "token" }, + }, + }; + 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: { + 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*:/); + }); +}); + +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 () => { + 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, + }); + await backend.write(SNAPSHOT); + expect(ok).toHaveBeenCalledWith( + url, + expect.objectContaining({ method: "POST" }), + ); + expect(JSON.parse(capturedBody ?? "")).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/oauth-storage.test.ts b/clients/web/src/test/core/auth/oauth-storage.test.ts deleted file mode 100644 index 91821244..00000000 --- a/clients/web/src/test/core/auth/oauth-storage.test.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { createJSONStorage } from "zustand/middleware"; -import { OAuthStorageBase } from "@inspector/core/auth/oauth-storage.js"; -import { - createOAuthStore, - normalizeServerUrl, -} from "@inspector/core/auth/store.js"; - -/** - * Builds an OAuthStorageBase backed by an *async* in-memory storage adapter - * pre-seeded with the given persisted blob. Hydration of the store completes - * only after the returned `release()` is called, so tests can assert that - * getters wait for it. - */ -function makeAsyncBackedStorage( - persistedServers: Record = {}, -) { - let mem: string | null = JSON.stringify({ - state: { servers: persistedServers }, - version: 0, - }); - let releaseGetItem!: () => void; - const gate = new Promise((resolve) => { - releaseGetItem = resolve; - }); - const adapter = createJSONStorage(() => ({ - getItem: async () => { - await gate; - return mem; - }, - setItem: async (name: string, value: string) => { - void name; - mem = value; - }, - removeItem: async () => { - mem = null; - }, - })); - const store = createOAuthStore(adapter); - return { storage: new OAuthStorageBase(store), release: releaseGetItem }; -} - -const SERVER = "https://example.com/mcp"; -const METADATA = { - issuer: SERVER, - authorization_endpoint: `${SERVER}/authorize`, - token_endpoint: `${SERVER}/token`, - response_types_supported: ["code"], -}; - -describe("OAuthStorageBase — async hydration", () => { - it("getCodeVerifier awaits hydration before reading state", async () => { - const { storage, release } = makeAsyncBackedStorage({ - [SERVER]: { codeVerifier: "persisted-verifier" }, - }); - // The store is empty until release() lets the async getItem resolve. - let resolvedWith: string | undefined | "pending" = "pending"; - const p = storage.getCodeVerifier(SERVER).then((v) => { - resolvedWith = v; - }); - // Give the microtask queue a chance — the getter should NOT have resolved - // because hydration is still gated. - await Promise.resolve(); - expect(resolvedWith).toBe("pending"); - release(); - await p; - expect(resolvedWith).toBe("persisted-verifier"); - }); - - it("getServerMetadata awaits hydration before reading state", async () => { - const { storage, release } = makeAsyncBackedStorage({ - [SERVER]: { serverMetadata: METADATA }, - }); - const p = storage.getServerMetadata(SERVER); - release(); - expect(await p).toEqual(METADATA); - }); - - it("getTokens and getClientInformation await hydration", async () => { - const { storage, release } = makeAsyncBackedStorage({ - [SERVER]: { - tokens: { access_token: "t", token_type: "Bearer" }, - clientInformation: { client_id: "abc" }, - }, - }); - const tokens = storage.getTokens(SERVER); - const client = storage.getClientInformation(SERVER); - release(); - expect(await tokens).toEqual({ access_token: "t", token_type: "Bearer" }); - expect(await client).toEqual({ client_id: "abc" }); - }); - - it("ready() resolves once hydration completes", async () => { - const { storage, release } = makeAsyncBackedStorage(); - let ready = false; - void storage.ready().then(() => { - ready = true; - }); - await Promise.resolve(); - expect(ready).toBe(false); - release(); - await storage.ready(); - expect(ready).toBe(true); - }); - - it("ready() resolves promptly when the adapter is synchronous", async () => { - let mem: string | null = null; - const syncAdapter = createJSONStorage(() => ({ - getItem: () => mem, - setItem: (name: string, value: string) => { - void name; - mem = value; - }, - removeItem: () => { - mem = null; - }, - })); - const storage = new OAuthStorageBase(createOAuthStore(syncAdapter)); - // With a sync adapter the constructor's rehydrate() resolves on the next - // microtask, so ready() and the getters settle without any release step. - await storage.ready(); - expect(await storage.getCodeVerifier(SERVER)).toBeUndefined(); - }); - - it("save* awaits hydration so a late merge cannot clobber the write", async () => { - const { storage, release } = makeAsyncBackedStorage({ - [SERVER]: { codeVerifier: "persisted-old" }, - }); - // Kick off a save while hydration is still gated. It must not write until - // hydration completes, otherwise the persist merge would overwrite "new" - // with "persisted-old". - const save = storage.saveCodeVerifier(SERVER, "new"); - let saved = false; - void save.then(() => { - saved = true; - }); - await Promise.resolve(); - expect(saved).toBe(false); - release(); - await save; - expect(await storage.getCodeVerifier(SERVER)).toBe("new"); - }); - - it("clear() before hydration is not resurrected by the late merge", async () => { - // A clear issued while hydration is still gated must survive the pending - // rehydrate() that would otherwise merge the persisted (un-cleared) blob - // back on top and silently restore the credential. - const { storage, release } = makeAsyncBackedStorage({ - [SERVER]: { tokens: { access_token: "t", token_type: "Bearer" } }, - }); - storage.clear(SERVER); - release(); - await storage.ready(); - // Give the deferred re-apply (scheduled off `hydrated`) a microtask to run. - await Promise.resolve(); - expect(await storage.getTokens(SERVER)).toBeUndefined(); - }); - - it("clearTokens before hydration clears only tokens and survives the merge", async () => { - const { storage, release } = makeAsyncBackedStorage({ - [SERVER]: { - tokens: { access_token: "t", token_type: "Bearer" }, - codeVerifier: "keep-me", - }, - }); - storage.clearTokens(SERVER); - release(); - await storage.ready(); - await Promise.resolve(); - // Tokens cleared, but the sibling field from the persisted blob remains. - expect(await storage.getTokens(SERVER)).toBeUndefined(); - expect(await storage.getCodeVerifier(SERVER)).toBe("keep-me"); - }); - - it("clearClientInformation before hydration is deferred and clears only client info", async () => { - const { storage, release } = makeAsyncBackedStorage({ - [SERVER]: { - clientInformation: { client_id: "abc" }, - clientRegistrationKind: "dynamic", - tokens: { access_token: "t", token_type: "Bearer" }, - }, - }); - storage.clearClientInformation(SERVER); - release(); - await storage.ready(); - await Promise.resolve(); - // Client info cleared, but the sibling token from the persisted blob (and - // every other server's blob, in the whole-blob adapter) survives — proving - // the pre-hydration clear did not clobber on-disk state. - expect(await storage.getClientInformation(SERVER)).toBeUndefined(); - expect(await storage.getTokens(SERVER)).toEqual({ - access_token: "t", - token_type: "Bearer", - }); - }); - - it("clear() after hydration does not schedule a deferred re-apply", async () => { - const { storage, release } = makeAsyncBackedStorage({ - [SERVER]: { tokens: { access_token: "t", token_type: "Bearer" } }, - }); - release(); - await storage.ready(); - // Hydration already settled → synchronous clear, immediately observable. - storage.clear(SERVER); - expect(await storage.getTokens(SERVER)).toBeUndefined(); - }); - - it("normalizeServerUrl canonicalizes host case, bare-origin trailing slash, default port, and whitespace", () => { - expect(normalizeServerUrl("https://Example.COM/mcp")).toBe( - "https://example.com/mcp", - ); - expect(normalizeServerUrl("https://example.com")).toBe( - "https://example.com/", - ); - expect(normalizeServerUrl("https://example.com:443/mcp")).toBe( - "https://example.com/mcp", - ); - expect(normalizeServerUrl(" https://example.com/mcp ")).toBe( - "https://example.com/mcp", - ); - // Non-URL strings (e.g. a stdio server name) are passed through trimmed. - expect(normalizeServerUrl(" my-stdio-server ")).toBe("my-stdio-server"); - }); - - it("store keys are normalized so save and get tolerate cosmetic URL differences", async () => { - const { storage, release } = makeAsyncBackedStorage(); - release(); - await storage.saveCodeVerifier("https://Example.COM/mcp", "v"); - expect(await storage.getCodeVerifier("https://example.com/mcp")).toBe("v"); - // Bare-origin: the web inspector stores `new URL().href` (trailing slash); - // a CLI caller passing the slash-less form must still hit it. - await storage.saveTokens("https://api.partner.dev/", { - access_token: "t", - token_type: "Bearer", - }); - expect(await storage.getTokens("https://api.partner.dev")).toEqual({ - access_token: "t", - token_type: "Bearer", - }); - }); - - it("a partial write migrates a pre-normalization raw-key blob onto the canonical key (no orphaned fields)", async () => { - // Persisted under the bare-origin raw key (no trailing slash), the form a - // pre-normalization writer used; canonical form is `https://api.partner.dev/`. - // The caller consistently addresses the endpoint with that same raw string - // (matching getServerState's raw-key fallback). - const RAW = "https://api.partner.dev"; - const { storage, release } = makeAsyncBackedStorage({ - [RAW]: { tokens: { access_token: "existing", token_type: "Bearer" } }, - }); - release(); - await storage.ready(); - // A partial write must MERGE onto the existing blob, not shadow it with a - // fresh canonical entry — the pre-existing token must remain reachable. - await storage.saveCodeVerifier(RAW, "v"); - expect(await storage.getCodeVerifier(RAW)).toBe("v"); - expect(await storage.getTokens(RAW)).toEqual({ - access_token: "existing", - token_type: "Bearer", - }); - // The blob now lives under the canonical key, so the canonical form finds it - // too and the raw-key orphan no longer shadows anything. - expect(await storage.getTokens("https://api.partner.dev/")).toEqual({ - access_token: "existing", - token_type: "Bearer", - }); - }); - - it("clear() removes a pre-normalization raw-key blob, not just the canonical key", async () => { - const RAW = "https://api.partner.dev"; - const { storage, release } = makeAsyncBackedStorage({ - [RAW]: { tokens: { access_token: "existing", token_type: "Bearer" } }, - }); - release(); - await storage.ready(); - storage.clear(RAW); - // Both the canonical and raw keys must be gone — no orphan resurfacing via - // getServerState's raw-key fallback. - expect(await storage.getTokens(RAW)).toBeUndefined(); - expect(await storage.getTokens("https://api.partner.dev/")).toBeUndefined(); - }); - - it("getServerState falls back to a pre-normalization persisted key", async () => { - // A blob persisted before normalization existed may carry a raw key that - // the canonical form differs from (here: bare-origin without slash). - const { storage, release } = makeAsyncBackedStorage({ - "https://legacy.example": { codeVerifier: "legacy" }, - }); - release(); - expect(await storage.getCodeVerifier("https://legacy.example")).toBe( - "legacy", - ); - }); - - it("ready()/getters resolve (empty) when the adapter throws instead of hanging", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - try { - const throwingAdapter = createJSONStorage(() => ({ - getItem: async () => { - throw new Error("Failed to read store: 500"); - }, - setItem: async () => {}, - removeItem: async () => {}, - })); - const storage = new OAuthStorageBase(createOAuthStore(throwingAdapter)); - // Race ready() against a short timeout — if the catch path doesn't - // resolve `hydrated`, this would hit "TIMEOUT" instead of "ok". - const result = await Promise.race([ - storage.ready().then(() => "ok"), - new Promise((r) => setTimeout(() => r("TIMEOUT"), 200)), - ]); - expect(result).toBe("ok"); - expect(await storage.getTokens(SERVER)).toBeUndefined(); - expect(warn).toHaveBeenCalled(); - // The failure is recorded so callers can distinguish "no token" from - // "store unreadable". - expect(storage.getHydrationError()).toBeInstanceOf(Error); - } finally { - warn.mockRestore(); - } - }); - - it("getHydrationError() is undefined after a successful hydration", async () => { - const { storage, release } = makeAsyncBackedStorage(); - release(); - await storage.ready(); - expect(storage.getHydrationError()).toBeUndefined(); - }); -}); diff --git a/clients/web/src/test/core/auth/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index 0bf8b849..fc066539 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -213,7 +213,8 @@ describe("OAuthNavigation", () => { function makeStorage(): OAuthStorage { return { - getScope: vi.fn(() => undefined), + load: vi.fn().mockResolvedValue(undefined), + getScope: vi.fn().mockResolvedValue(undefined), getClientInformation: vi.fn(async () => undefined), saveClientInformation: vi.fn(async () => undefined), savePreregisteredClientInformation: vi.fn(async () => undefined), @@ -221,9 +222,9 @@ describe("OAuthNavigation", () => { getTokens: vi.fn(async () => undefined), saveTokens: vi.fn(async () => undefined), saveCodeVerifier: vi.fn(async () => undefined), - getCodeVerifier: vi.fn(async () => undefined), + getCodeVerifier: vi.fn().mockResolvedValue(undefined), clear: vi.fn(), - getServerMetadata: vi.fn(async () => null), + getServerMetadata: vi.fn().mockResolvedValue(null), saveServerMetadata: vi.fn(async () => undefined), } as unknown as OAuthStorage; } @@ -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); }); @@ -271,7 +272,7 @@ describe("OAuthNavigation", () => { expect(await provider.clientInformation()).toEqual({ client_id: "dyn" }); }); - it("codeVerifier() rejects when none is saved", async () => { + it("codeVerifier() throws when none is saved", async () => { const storage = makeStorage(); const provider = makeProvider(storage); @@ -288,10 +289,11 @@ describe("OAuthNavigation", () => { 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"); }); 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 cb97b460..bb855584 100644 --- a/clients/web/src/test/core/auth/storage-browser.test.ts +++ b/clients/web/src/test/core/auth/storage-browser.test.ts @@ -45,11 +45,9 @@ const mockSessionStorage = new MockSessionStorage(); mockSessionStorage; describe("getBrowserOAuthStorage", () => { - it("returns a memoized singleton across calls", () => { + it("returns the same singleton on repeated calls", () => { const first = getBrowserOAuthStorage(); const second = getBrowserOAuthStorage(); - expect(first).toBeInstanceOf(BrowserOAuthStorage); - // Second call hits the cached-instance branch rather than constructing anew. expect(second).toBe(first); }); }); @@ -79,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,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 - storage.savePreregisteredClientInformation( + 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); @@ -115,7 +108,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); @@ -132,23 +125,27 @@ 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); 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", + ); }); }); @@ -165,7 +162,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); @@ -179,7 +176,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); @@ -196,7 +193,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); @@ -212,7 +209,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); @@ -223,7 +220,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); @@ -239,10 +236,8 @@ describe("BrowserOAuthStorage", () => { it("should return stored scope", async () => { const scope = "read write"; - // getScope is intentionally synchronous; await the save (which waits for - // hydration) so the value has landed before the sync read. await storage.saveScope(testServerUrl, scope); - const result = storage.getScope(testServerUrl); + const result = await storage.getScope(testServerUrl); expect(result).toBe(scope); }); @@ -253,7 +248,7 @@ describe("BrowserOAuthStorage", () => { const scope = "read write"; await storage.saveScope(testServerUrl, scope); - const result = storage.getScope(testServerUrl); + const result = await storage.getScope(testServerUrl); expect(result).toBe(scope); }); @@ -273,7 +268,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); @@ -289,7 +284,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); @@ -298,7 +293,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" }, @@ -306,18 +301,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(); @@ -326,27 +321,27 @@ 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(await storage.getCodeVerifier(testServerUrl)).toBe("verifier"); - storage.clearCodeVerifier(testServerUrl); + await storage.clearCodeVerifier(testServerUrl); expect(await storage.getCodeVerifier(testServerUrl)).toBeUndefined(); }); it("clearScope removes only the scope", async () => { await storage.saveScope(testServerUrl, "read"); - expect(storage.getScope(testServerUrl)).toBe("read"); - storage.clearScope(testServerUrl); - expect(storage.getScope(testServerUrl)).toBeUndefined(); + expect(await storage.getScope(testServerUrl)).toBe("read"); + await storage.clearScope(testServerUrl); + expect(await storage.getScope(testServerUrl)).toBeUndefined(); }); it("clearServerMetadata removes only the cached metadata", async () => { @@ -356,9 +351,9 @@ describe("BrowserOAuthStorage", () => { token_endpoint: "http://localhost:3000/token", response_types_supported: ["code"], }; - storage.saveServerMetadata(testServerUrl, metadata); + await storage.saveServerMetadata(testServerUrl, metadata); expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata); - storage.clearServerMetadata(testServerUrl); + await storage.clearServerMetadata(testServerUrl); expect(await storage.getServerMetadata(testServerUrl)).toBeNull(); }); }); @@ -378,7 +373,7 @@ describe("BrowserOAuthStorage", () => { }); await storage.saveTokens(testServerUrl, tokens); - storage.clear(testServerUrl); + await storage.clear(testServerUrl); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getTokens(testServerUrl)).toBeUndefined(); @@ -397,7 +392,7 @@ describe("BrowserOAuthStorage", () => { registrationKind: "dcr", }); - storage.clear(testServerUrl); + await storage.clear(testServerUrl); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); expect(await storage.getClientInformation(otherServerUrl)).toEqual( @@ -419,10 +414,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 8cd787d4..7650925f 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,22 +66,22 @@ 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(await storage.getCodeVerifier(serverUrl)).toBe("verifier"); - storage.clearCodeVerifier(serverUrl); + await storage.clearCodeVerifier(serverUrl); 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"); - storage.clearScope(serverUrl); - expect(storage.getScope(serverUrl)).toBeUndefined(); + expect(await storage.getScope(serverUrl)).toBe("read write"); + await storage.clearScope(serverUrl); + expect(await storage.getScope(serverUrl)).toBeUndefined(); }); it("serverMetadata round-trip and clearServerMetadata", async () => { @@ -93,7 +93,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { }; await storage.saveServerMetadata(serverUrl, md); expect(await storage.getServerMetadata(serverUrl)).toEqual(md); - storage.clearServerMetadata(serverUrl); + await storage.clearServerMetadata(serverUrl); expect(await 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(); }); @@ -121,64 +121,49 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { expect(s).toBeInstanceOf(RemoteOAuthStorage); }); - it("getCodeVerifier waits for the async hydration GET before reading", async () => { - // Unlike the other tests in this file (which write-then-read in the same - // session), this asserts that a value PERSISTED on the backend before the - // store was constructed is still returned — i.e. the getter awaits the - // remote storage adapter's hydration GET. - let releaseGet!: () => void; - const getGate = new Promise((resolve) => { - releaseGet = resolve; - }); - const fetchFn = vi.fn( - async (url: RequestInfo | URL, init?: RequestInit) => { - if ((init?.method ?? "GET") === "GET") { - await getGate; - return new Response( - JSON.stringify({ - state: { - servers: { [serverUrl]: { codeVerifier: "from-disk" } }, - }, - version: 0, - }), - { status: 200 }, - ); - } - void url; - return new Response("{}", { status: 200 }); + it("getCodeVerifier loads remote state automatically when not preloaded", 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; + }), ); - const s = new RemoteOAuthStorage({ + + const delayedStorage = new RemoteOAuthStorage({ baseUrl: "http://remote.example", - storeId: "hydration-test", - fetchFn: fetchFn as unknown as typeof fetch, + storeId: `delayed-${Math.random().toString(36).slice(2)}`, + fetchFn: delayedFetch, }); - const p = s.getCodeVerifier(serverUrl); - releaseGet(); - expect(await p).toBe("from-disk"); + + const getPromise = delayedStorage.getCodeVerifier(serverUrl); + resolveFetch(new Response(JSON.stringify(persisted), { status: 200 })); + + expect(await getPromise).toBe("persisted-verifier"); }); - it("persist POST is sent with keepalive so it survives an immediate redirect", async () => { - const fetchFn = vi.fn( - async (url: RequestInfo | URL, init?: RequestInit) => { - void url; - void init; - return new Response("{}", { status: 200 }); - }, + it("load() rejects when remote GET fails instead of hanging", async () => { + const failingFetch = vi.fn( + async () => new Response("server error", { status: 500 }), ); - const s = new RemoteOAuthStorage({ + + const failingStorage = new RemoteOAuthStorage({ baseUrl: "http://remote.example", - storeId: "keepalive-test", - fetchFn: fetchFn as unknown as typeof fetch, + storeId: `fail-${Math.random().toString(36).slice(2)}`, + fetchFn: failingFetch, }); - await s.ready(); - await s.saveCodeVerifier(serverUrl, "v"); - // Zustand persist fires setItem on the next microtask after set(); flush - // a couple of ticks so the POST has been issued. - await Promise.resolve(); - await Promise.resolve(); - const post = fetchFn.mock.calls.find((c) => c[1]?.method === "POST"); - expect(post).toBeDefined(); - expect(post?.[1]?.keepalive).toBe(true); + + await expect(failingStorage.load()).rejects.toThrow( + "Failed to read store: 500", + ); }); }); 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/core/mcp/oauthManager.test.ts b/clients/web/src/test/core/mcp/oauthManager.test.ts index a49cb400..064c9301 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -35,24 +35,24 @@ function createMockParams( const dispatchOAuthError = vi.fn(); const storage = { - ready: vi.fn().mockResolvedValue(undefined), - getScope: vi.fn().mockReturnValue(undefined), + load: vi.fn().mockResolvedValue(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(), + clear: vi.fn().mockResolvedValue(undefined), clearClientInformation: vi.fn(), clearTokens: vi.fn(), 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), @@ -127,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, ); @@ -138,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: { @@ -148,7 +148,7 @@ describe("OAuthManager", () => { } as OAuthManagerConfig, }); const manager = new OAuthManager(params); - manager.clearOAuthTokens(); + await manager.clearOAuthTokens(); expect(params.getServerUrl).not.toHaveBeenCalled(); }); }); @@ -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/core/react/useEmaIdpLoginState.test.tsx b/clients/web/src/test/core/react/useEmaIdpLoginState.test.tsx index f8b9c0e6..0b141571 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,64 @@ 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("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 () => { 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 3956a53a..0bb328ad 100644 --- a/clients/web/src/test/integration/auth/node/storage.test.ts +++ b/clients/web/src/test/integration/auth/node/storage.test.ts @@ -1,10 +1,10 @@ -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 { normalizeServerUrl } from "@inspector/core/auth/store.js"; import type { OAuthClientInformation, OAuthTokens, @@ -21,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", () => { @@ -89,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); @@ -127,10 +109,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); @@ -311,7 +293,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(); }); @@ -322,7 +304,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(); @@ -336,22 +318,22 @@ 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(await storage.getCodeVerifier(testServerUrl)).toBe("verifier"); - storage.clearCodeVerifier(testServerUrl); + await storage.clearCodeVerifier(testServerUrl); 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"); - storage.clearScope(testServerUrl); - expect(storage.getScope(testServerUrl)).toBeUndefined(); + expect(await storage.getScope(testServerUrl)).toBe("read write"); + await storage.clearScope(testServerUrl); + expect(await storage.getScope(testServerUrl)).toBeUndefined(); }); it("clearServerMetadata removes only the cached metadata", async () => { @@ -363,7 +345,7 @@ describe("NodeOAuthStorage", () => { }; await storage.saveServerMetadata(testServerUrl, metadata); expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata); - storage.clearServerMetadata(testServerUrl); + await storage.clearServerMetadata(testServerUrl); expect(await storage.getServerMetadata(testServerUrl)).toBeNull(); }); }); @@ -383,7 +365,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(); @@ -402,7 +384,7 @@ describe("NodeOAuthStorage", () => { registrationKind: "dcr", }); - storage.clear(testServerUrl); + await storage.clear(testServerUrl); expect(await storage.getClientInformation(testServerUrl)).toBeUndefined(); const otherResult = await storage.getClientInformation(otherServerUrl); @@ -425,10 +407,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", }); @@ -438,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[normalizeServerUrl(serverUrl)]?.clientInformation, - ).toEqual(clientInfo); + expect(parsed.servers[serverUrl]?.clientInformation).toEqual(clientInfo); } finally { try { await fs.unlink(persistTestPath); } catch { /* ignore */ } + resetNodeOAuthStorageCache(persistTestPath); } }); }); @@ -548,7 +499,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(); }); @@ -573,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); @@ -583,10 +532,9 @@ describe("NodeOAuthStorage with custom storagePath", () => { await fs.readFile(customPath, "utf-8"), ) as StateShape; - expect( - parsed.state.servers[normalizeServerUrl(testServerUrl)]?.tokens - ?.access_token, - ).toBe(tokens.access_token); + expect(parsed.servers[testServerUrl]?.tokens?.access_token).toBe( + tokens.access_token, + ); const stored = await storage.getTokens(testServerUrl); expect(stored?.access_token).toBe(tokens.access_token); @@ -601,33 +549,46 @@ 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("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 () => { @@ -637,20 +598,14 @@ describe("NodeOAuthStorage with custom storagePath", () => { ); try { - // The store is created with skipHydration: true; OAuthStorageBase - // normally drives rehydrate(), but this test writes via the raw store - // so hydrate it explicitly first (otherwise the later `new - // NodeOAuthStorage()` wrapper would drive the first rehydrate and clobber - // these unpersisted in-memory writes). - const defaultStore = getOAuthStore(); - await defaultStore.persist.rehydrate(); - defaultStore.getState().setServerState(testServerUrl, { - tokens: { - access_token: "default-token", - token_type: "Bearer", - }, + 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", @@ -660,17 +615,18 @@ 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); + 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 b2ed94b0..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(() => {}); }); @@ -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(); }); @@ -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/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 f79ab992..237f374f 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -4959,7 +4959,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/test/integration/storage/adapters.test.ts b/clients/web/src/test/integration/storage/adapters.test.ts index 2c63047d..998e60e7 100644 --- a/clients/web/src/test/integration/storage/adapters.test.ts +++ b/clients/web/src/test/integration/storage/adapters.test.ts @@ -1,20 +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, - normalizeServerUrl, -} 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/node/oauth-persist-file.js"; import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; import { writeStoreFile, @@ -56,113 +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 () => { - // The adapter logs the swallowed-persist failure via console.error before - // rethrowing; suppress the expected noise. - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - try { - 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/); - expect(errorSpy).toHaveBeenCalled(); - } finally { - errorSpy.mockRestore(); - } - }); - - it("setItem sends keepalive so the POST survives an immediate redirect", async () => { - const fetchFn = vi - .fn() - .mockResolvedValueOnce(new Response("{}")); - const adapter = makeAdapter(fetchFn as typeof fetch); - await adapter.setItem("name", { state: { a: 1 }, version: 0 }); - expect(fetchFn.mock.calls[0]?.[1]?.keepalive).toBe(true); - }); - - it("setItem rethrows a network-level fetch rejection after logging it", async () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - try { - const fetchFn = vi - .fn() - .mockRejectedValueOnce(new Error("network down")); - const adapter = makeAdapter(fetchFn as typeof fetch); - await expect( - adapter.setItem("name", { state: { a: 1 }, version: 0 }), - ).rejects.toThrow(/network down/); - expect(errorSpy).toHaveBeenCalled(); - } finally { - errorSpy.mockRestore(); - } - }); - - 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(() => { @@ -179,21 +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[normalizeServerUrl("https://example.com")].tokens, - ).toEqual({ + expect(parsed.servers["https://example.com"].tokens).toEqual({ access_token: "test-token", token_type: "Bearer", }); @@ -203,69 +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 and load persisted state. The store is - // created with skipHydration: true, so drive the single rehydrate - // explicitly (OAuthStorageBase does this in production). - const storage2 = createFileStorageAdapter({ filePath }); - const store2 = createOAuthStore(storage2); - await store2.persist.rehydrate(); - - 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); }); }); @@ -295,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); @@ -332,7 +242,7 @@ describe("Storage adapters", () => { }); }); - describe("RemoteStorageAdapter", () => { + describe("Remote OAuth persistence", () => { let remoteServer: ServerType | null = null; let tempDir: string | null = null; @@ -360,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", }); - const exampleKey = normalizeServerUrl("https://example.com"); await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { const d = body as { - state?: { - servers?: Record; - }; + servers?: Record; }; return ( - d?.state?.servers?.[exampleKey]?.tokens?.access_token === "test-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: { @@ -393,7 +299,7 @@ describe("Storage adapters", () => { }); expect(res.status).toBe(200); const storeData = await res.json(); - expect(storeData.state.servers[exampleKey].tokens).toEqual({ + expect(storeData.servers["https://example.com"].tokens).toEqual({ access_token: "test-token", token_type: "Bearer", }); @@ -406,42 +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", }); - const exampleKey = normalizeServerUrl("https://example.com"); await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { const d = body as { - state?: { - servers?: Record; - }; + servers?: Record; }; return ( - d?.state?.servers?.[exampleKey]?.tokens?.access_token === + d?.servers?.["https://example.com"]?.tokens?.access_token === "initial-token" ); }); - // Create new store instance and load persisted state. The store is - // created with skipHydration: true, so drive the single rehydrate - // explicitly (OAuthStorageBase does this in production). - const storage2 = createRemoteStorageAdapter({ + const storage2 = new RemoteOAuthStorage({ baseUrl, storeId: "test-store", authToken, }); - const store2 = createOAuthStore(storage2); - await store2.persist.rehydrate(); - 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", }); @@ -454,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: { @@ -479,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: { @@ -501,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/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: {} }; }, }); diff --git a/clients/web/src/utils/clearServerOAuthState.test.ts b/clients/web/src/utils/clearServerOAuthState.test.ts index 713f7b97..137bc9c3 100644 --- a/clients/web/src/utils/clearServerOAuthState.test.ts +++ b/clients/web/src/utils/clearServerOAuthState.test.ts @@ -1,50 +1,56 @@ -import { describe, it, expect, vi } from "vitest"; +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", () => { - it("clears storage by server URL when not the active connection", () => { - const oauthStorage = { clear: vi.fn() }; + let storage: BrowserOAuthStorage; - const cleared = clearServerOAuthState({ + beforeEach(async () => { + storage = new BrowserOAuthStorage(); + await storage.clear("https://mcp.example.com/mcp"); + }); + + it("clears storage by server URL when not the active connection", async () => { + await storage.saveTokens("https://mcp.example.com/mcp", { + access_token: "tok", + token_type: "Bearer", + }); + + const cleared = await clearServerOAuthState({ config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, isActiveConnection: false, - oauthStorage, + oauthStorage: storage, }); expect(cleared).toBe(true); - expect(oauthStorage.clear).toHaveBeenCalledWith( - "https://mcp.example.com/mcp", - ); - }); - - it("uses the live client when clearing the active connection", () => { - const inspectorClient = { - clearOAuthTokens: vi.fn(), - }; - const oauthStorage = { clear: vi.fn() }; - - const cleared = clearServerOAuthState({ - config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, - inspectorClient: inspectorClient as never, - isActiveConnection: true, - oauthStorage, - }); - - expect(cleared).toBe(true); - expect(inspectorClient.clearOAuthTokens).toHaveBeenCalledTimes(1); - // The backend-backed store is not touched directly when a live client is - // present — the client owns resetting both persisted and in-memory state. - expect(oauthStorage.clear).not.toHaveBeenCalled(); - }); - - it("returns false for stdio servers", () => { - const oauthStorage = { clear: vi.fn() }; expect( + await storage.getTokens("https://mcp.example.com/mcp"), + ).toBeUndefined(); + }); + + it("uses the live client when clearing the active connection", async () => { + const clearOAuthTokens = vi.fn(); + const inspectorClient = { clearOAuthTokens }; + + const cleared = await clearServerOAuthState({ + config: { type: "streamable-http", url: "https://mcp.example.com/mcp" }, + inspectorClient, + isActiveConnection: true, + oauthStorage: storage, + }); + + expect(cleared).toBe(true); + expect(clearOAuthTokens).toHaveBeenCalledTimes(1); + }); + + it("returns false for stdio servers", async () => { + await expect( clearServerOAuthState({ config: { type: "stdio", command: "node", args: [] }, isActiveConnection: false, - oauthStorage, + oauthStorage: storage, }), - ).toBe(false); + ).resolves.toBe(false); }); }); diff --git a/clients/web/src/utils/clearServerOAuthState.ts b/clients/web/src/utils/clearServerOAuthState.ts index 975308d0..f7eb5233 100644 --- a/clients/web/src/utils/clearServerOAuthState.ts +++ b/clients/web/src/utils/clearServerOAuthState.ts @@ -6,15 +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; - /** - * Backend-backed OAuth storage (`RemoteOAuthStorage`) used to clear a - * non-active server's persisted state. Must be the same shared instance the - * connection path writes through so the clear targets `oauth.json`, not a - * divergent sessionStorage copy. - */ - oauthStorage: Pick; + /** Shared web OAuth store; required so clear hits the same blob as connect. */ + oauthStorage: OAuthStorage; } /** @@ -22,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/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/clients/web/vite.config.ts b/clients/web/vite.config.ts index b69338fe..cc2ed813 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}', 'src/lib/**/*.{ts,tsx}', 'clients/web/server/**/*.{ts,tsx}', 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 b31c3299..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 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,9 +123,7 @@ export async function buildOAuthConnectionState( const idp = params.enterpriseManagedAuth.idp; const issuer = normalizeIdpIssuer(idp.issuer); const idpSession = await getEmaIdpLoginState(storage, issuer); - const idpMetadata = await 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 a5199315..0f0237c8 100644 --- a/core/auth/ema/idpOidc.ts +++ b/core/auth/ema/idpOidc.ts @@ -63,7 +63,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(); @@ -131,7 +131,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/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/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 8e66ffe6..c2039628 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-file.js"; import { getDefaultStorageDir, getStoreFilePath, @@ -23,22 +23,27 @@ export function getStateFilePath(customPath?: string): string { ); } -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); } /** @@ -46,24 +51,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..133dc419 --- /dev/null +++ b/core/auth/oauth-persist.ts @@ -0,0 +1,173 @@ +/** + * 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 { serializeStore, parseStore } from "../storage/store-serialize.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 }`. + * `raw` may be a JSON string or an already-parsed object (e.g. from `res.json()`). + */ +export function parseOAuthPersistBlob( + raw: string | null | unknown, +): OAuthPersistSnapshot | null { + if (raw === null || raw === undefined) { + return null; + } + + const parsed = typeof raw === "string" ? (raw ? parseStore(raw) : null) : 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 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}`); + } + + // 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(); + return parseOAuthPersistBlob(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 b428392e..fe5a7c2e 100644 --- a/core/auth/oauth-storage.ts +++ b/core/auth/oauth-storage.ts @@ -8,7 +8,8 @@ import { OAuthTokensSchema, } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthStorage } from "./storage.js"; -import { type createOAuthStore, type ServerOAuthState } from "./store.js"; +import { type OAuthMemoryStore, type ServerOAuthState } from "./store.js"; +import type { OAuthPersistBackend } from "./oauth-persist.js"; import type { IdpSessionState, OAuthClientRegistrationKind, @@ -17,119 +18,60 @@ import type { } from "./storage.js"; /** - * 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. - * - * With an async storage adapter (file, remote HTTP) the store is empty until - * hydration completes. Every read used on the post-OAuth-redirect callback path - * (`getCodeVerifier`, `getServerMetadata`, `getClientInformation`, `getTokens`) - * — and every save — therefore awaits {@link ready} first; saves wait so a - * late hydration merge cannot clobber a value written before it landed. The - * underlying store is created with `skipHydration: true` - * ({@link createOAuthStore}), so the constructor's `rehydrate()` is the single - * hydration and there is no auto-hydration to race it. With a synchronous - * adapter (sessionStorage) hydration resolves on the next microtask and the - * behaviour is effectively unchanged. + * 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: ReturnType; - private readonly hydrated: Promise; - private hydrationError?: Error; + 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; - constructor(store: ReturnType) { - this.store = store; - // The store is created with `skipHydration: true` so this is the SOLE - // hydration. Driving it explicitly (rather than relying on persist's - // auto-hydration + onFinishHydration) lets us catch a rejecting adapter — - // onFinishHydration only fires on success, so a throwing getItem would - // otherwise leave `hydrated` pending forever and hang every getter. - // A failed hydration resolves to "empty store", which is the correct - // fallback (reads return undefined; writes proceed) — but the failure is - // recorded so callers can distinguish "no token stored" from "could not - // read the store" (e.g. corrupt file, EACCES, backend 500). - const recordFailure = (err: unknown) => { - this.hydrationError = - err instanceof Error - ? err - : new Error( - typeof err === "string" - ? err - : "OAuth storage hydration failed (adapter getItem threw or returned invalid data)", - ); - console.warn( - "[OAuthStorage] hydration failed; continuing with empty state:", - this.hydrationError, - ); - }; - this.hydrated = this.store.persist.hasHydrated() - ? Promise.resolve() - : Promise.resolve(this.store.persist.rehydrate()).then( - () => { - // Zustand swallows getItem errors internally (routing them to - // onRehydrateStorage) and resolves rehydrate() regardless; - // hasHydrated() only flips true on success, so use it to detect a - // silently-failed hydration. The underlying error is lost in this - // path — record a generic one so getHydrationError() is non-null. - if (!this.store.persist.hasHydrated()) recordFailure(undefined); - }, - (err: unknown) => recordFailure(err), - ); + constructor(memory: OAuthMemoryStore, backend: OAuthPersistBackend) { + this.memory = memory; + this.backend = backend; } - /** - * If hydration failed (adapter threw, file unreadable/corrupt, backend - * non-2xx), the captured error. Undefined when hydration succeeded or has - * not yet completed — call after `await ready()`. Lets callers report - * "oauth.json is unreadable" instead of a misleading "no token stored". - */ - getHydrationError(): Error | undefined { - return this.hydrationError; - } - - /** - * Resolves once the underlying persist adapter has hydrated the store. - * Callers that need to read state outside the typed getters can await this - * directly (e.g. before reading the store via `getState()` for diagnostics). - */ - ready(): Promise { - return this.hydrated; - } - - /** - * Run a `clear*` mutation against a hydrated store: synchronously if - * hydration has already landed, otherwise deferred until it does. - * - * `clear*` are declared synchronous in {@link OAuthStorage} (some feed sync - * SDK getters), so unlike the `save*` family they cannot `await this.hydrated` - * up front. But a clear applied to the still-empty pre-hydration store is - * doubly wrong: (a) the pending `rehydrate()` would merge the persisted - * (un-cleared) blob back over it, resurrecting the credential; and worse, - * (b) applying it triggers a persist write of the near-empty store, which for - * a whole-blob adapter (file / remote HTTP) can clobber the on-disk blob — - * every other server's tokens included — before hydration ever reads it. - * Deferring the mutation until after hydration avoids both: it merges onto - * the real persisted state. The common case (hydration already settled) stays - * synchronous and immediately observable. A failed hydration still resolves - * {@link hydrated} (see constructor), so a deferred clear runs harmlessly - * against the empty store. The effect of a pre-hydration clear is therefore - * asynchronous, which is safe: the only callers are fire-and-forget - * invalidations that never synchronously read the value straight back. - */ - private clearAfterHydration(mutate: () => void): void { - if (this.store.persist.hasHydrated()) { - mutate(); - } else { - void this.hydrated.then(mutate); + load(): Promise { + if (!this.loadPromise) { + this.loadPromise = this.doLoad(); } + 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 { + 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( serverUrl: string, isPreregistered?: boolean, ): Promise { - await this.hydrated; - const state = this.store.getState().getServerState(serverUrl); + await this.ensureLoaded(); + const state = this.memory.getState().getServerState(serverUrl); const clientInfo = isPreregistered ? state.preregisteredClientInformation : state.clientInformation; @@ -141,20 +83,11 @@ export class OAuthStorageBase implements OAuthStorage { return await OAuthClientInformationSchema.parseAsync(clientInfo); } - /** - * Intentionally synchronous — same invariant as {@link getScope}: safe - * without awaiting hydration only because a preceding awaited read has - * already flushed it. Its sole caller, `buildOAuthConnectionState` - * (`connection-state.ts`), reads this right after awaiting - * `getClientInformation`/`getServerMetadata`, so on the post-redirect - * callback path (where the in-memory store is reset) hydration has landed - * by the time this runs. A future refactor reading it without a preceding - * awaited storage read would get a pre-hydration `undefined`. - */ - getClientRegistrationKind( + async getClientRegistrationKind( serverUrl: string, - ): OAuthClientRegistrationKind | undefined { - return this.store.getState().getServerState(serverUrl) + ): Promise { + await this.ensureLoaded(); + return this.memory.getState().getServerState(serverUrl) .clientRegistrationKind; } @@ -163,40 +96,47 @@ export class OAuthStorageBase implements OAuthStorage { clientInformation: OAuthClientInformation, options: SaveClientInformationOptions, ): Promise { - await this.hydrated; - this.store.getState().setServerState(serverUrl, { + await this.ensureLoaded(); + this.memory.getState().setServerState(serverUrl, { clientInformation, clientRegistrationKind: options.registrationKind, }); + await this.persist(); } async savePreregisteredClientInformation( serverUrl: string, clientInformation: OAuthClientInformation, ): Promise { - await this.hydrated; - this.store.getState().setServerState(serverUrl, { + await this.ensureLoaded(); + this.memory.getState().setServerState(serverUrl, { preregisteredClientInformation: clientInformation, clientRegistrationKind: "static", }); + await this.persist(); } - clearClientInformation(serverUrl: string, isPreregistered?: boolean): void { - this.clearAfterHydration(() => { - const updates: Partial = {}; - if (isPreregistered) { - updates.preregisteredClientInformation = undefined; - } else { - updates.clientInformation = undefined; - updates.clientRegistrationKind = undefined; - } - this.store.getState().setServerState(serverUrl, updates); - }); + async clearClientInformation( + serverUrl: string, + isPreregistered?: boolean, + ): Promise { + await this.ensureLoaded(); + const updates: Partial = {}; + + if (isPreregistered) { + updates.preregisteredClientInformation = undefined; + } else { + updates.clientInformation = undefined; + updates.clientRegistrationKind = undefined; + } + + this.memory.getState().setServerState(serverUrl, updates); + await this.persist(); } async getTokens(serverUrl: string): Promise { - await this.hydrated; - const state = this.store.getState().getServerState(serverUrl); + await this.ensureLoaded(); + const state = this.memory.getState().getServerState(serverUrl); if (!state.tokens) { return undefined; } @@ -209,22 +149,23 @@ export class OAuthStorageBase implements OAuthStorage { tokens: OAuthTokens, options?: SaveTokensOptions, ): Promise { - await this.hydrated; - this.store.getState().setServerState(serverUrl, { + await this.ensureLoaded(); + this.memory.getState().setServerState(serverUrl, { tokens, ...(options?.enterpriseManaged === true && { enterpriseManaged: true }), }); + await this.persist(); } - clearTokens(serverUrl: string): void { - this.clearAfterHydration(() => - this.store.getState().setServerState(serverUrl, { tokens: undefined }), - ); + async clearTokens(serverUrl: string): Promise { + await this.ensureLoaded(); + this.memory.getState().setServerState(serverUrl, { tokens: undefined }); + await this.persist(); } async getCodeVerifier(serverUrl: string): Promise { - await this.hydrated; - const state = this.store.getState().getServerState(serverUrl); + await this.ensureLoaded(); + const state = this.memory.getState().getServerState(serverUrl); return state.codeVerifier; } @@ -232,53 +173,38 @@ export class OAuthStorageBase implements OAuthStorage { serverUrl: string, codeVerifier: string, ): Promise { - await this.hydrated; - this.store.getState().setServerState(serverUrl, { codeVerifier }); + await this.ensureLoaded(); + this.memory.getState().setServerState(serverUrl, { codeVerifier }); + await this.persist(); } - clearCodeVerifier(serverUrl: string): void { - this.clearAfterHydration(() => - this.store - .getState() - .setServerState(serverUrl, { codeVerifier: undefined }), - ); + async clearCodeVerifier(serverUrl: string): Promise { + await this.ensureLoaded(); + this.memory.getState().setServerState(serverUrl, { codeVerifier: undefined }); + await this.persist(); } - /** - * Intentionally synchronous. The only caller is - * {@link BaseOAuthClientProvider.scope}, a sync getter the SDK requires - * (it feeds the sync `clientMetadata` getter). It is safe without awaiting - * hydration because of an ordering invariant the callers uphold: some - * awaited read always precedes it, flushing hydration first. - * - Pre-redirect half of the flow: `saveScope` (which awaits hydration) - * writes the value in the same session, so the in-memory store has it. - * - Post-redirect callback path: the in-memory store is reset, so the - * value comes from hydration — but `buildOAuthConnectionState` - * (`connection-state.ts`) awaits `getClientInformation`/ - * `getServerMetadata` (both of which await hydration) before it reads - * `getScope`, so hydration has already landed by then. - * A future refactor that reads `getScope` without a preceding awaited - * storage read would break this invariant. - */ - 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.hydrated; - this.store.getState().setServerState(serverUrl, { scope }); + await this.ensureLoaded(); + this.memory.getState().setServerState(serverUrl, { scope }); + await this.persist(); } - clearScope(serverUrl: string): void { - this.clearAfterHydration(() => - this.store.getState().setServerState(serverUrl, { scope: undefined }), - ); + async clearScope(serverUrl: string): Promise { + await this.ensureLoaded(); + this.memory.getState().setServerState(serverUrl, { scope: undefined }); + await this.persist(); } async getServerMetadata(serverUrl: string): Promise { - await this.hydrated; - const state = this.store.getState().getServerState(serverUrl); + await this.ensureLoaded(); + const state = this.memory.getState().getServerState(serverUrl); return state.serverMetadata || null; } @@ -286,29 +212,28 @@ export class OAuthStorageBase implements OAuthStorage { serverUrl: string, metadata: OAuthMetadata, ): Promise { - await this.hydrated; - this.store + await this.ensureLoaded(); + this.memory.getState().setServerState(serverUrl, { serverMetadata: metadata }); + await this.persist(); + } + + async clearServerMetadata(serverUrl: string): Promise { + await this.ensureLoaded(); + this.memory .getState() - .setServerState(serverUrl, { serverMetadata: metadata }); + .setServerState(serverUrl, { serverMetadata: undefined }); + await this.persist(); } - clearServerMetadata(serverUrl: string): void { - this.clearAfterHydration(() => - this.store - .getState() - .setServerState(serverUrl, { serverMetadata: undefined }), - ); - } - - clear(serverUrl: string): void { - this.clearAfterHydration(() => - this.store.getState().clearServerState(serverUrl), - ); + async clear(serverUrl: string): Promise { + await this.ensureLoaded(); + this.memory.getState().clearServerState(serverUrl); + await this.persist(); } async getIdpSession(issuer: string): Promise { - await this.hydrated; - const session = this.store.getState().getIdpSession(issuer); + await this.ensureLoaded(); + const session = this.memory.getState().getIdpSession(issuer); if ( !session.idToken && !session.refreshToken && @@ -323,19 +248,20 @@ export class OAuthStorageBase implements OAuthStorage { issuer: string, session: Partial, ): Promise { - await this.hydrated; - this.store.getState().setIdpSession(issuer, session); + await this.ensureLoaded(); + this.memory.getState().setIdpSession(issuer, session); + await this.persist(); } - clearIdpSession(issuer: string): void { - this.clearAfterHydration(() => - this.store.getState().clearIdpSession(issuer), - ); + async clearIdpSession(issuer: string): Promise { + await this.ensureLoaded(); + this.memory.getState().clearIdpSession(issuer); + await this.persist(); } - clearEnterpriseManagedResourceServers(): void { - this.clearAfterHydration(() => - this.store.getState().clearEnterpriseManagedResourceServers(), - ); + async clearEnterpriseManagedResourceServers(): Promise { + await this.ensureLoaded(); + this.memory.getState().clearEnterpriseManagedResourceServers(); + await this.persist(); } } diff --git a/core/auth/providers.ts b/core/auth/providers.ts index 5e351179..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( @@ -247,12 +258,12 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { return verifier; } - clear(): void { - this.storage.clear(this.serverUrl); + async clear(): Promise { + await this.storage.clear(this.serverUrl); } async getServerMetadata(): Promise { - return await this.storage.getServerMetadata(this.serverUrl); + return this.storage.getServerMetadata(this.serverUrl); } async saveServerMetadata(metadata: OAuthMetadata): Promise { 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 c95ff084..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,10 +22,11 @@ export interface SaveClientInformationOptions { export interface OAuthStorage { /** - * Resolves once any async hydration of the underlying store has completed. - * With a synchronous storage adapter this resolves immediately. + * 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). */ - ready(): Promise; + load(): Promise; /** * Get client information (preregistered or dynamically registered) @@ -40,7 +41,7 @@ export interface OAuthStorage { */ getClientRegistrationKind( serverUrl: string, - ): OAuthClientRegistrationKind | undefined; + ): Promise; /** * Save client information (dynamically registered) @@ -62,7 +63,10 @@ export interface OAuthStorage { /** * Clear client information */ - clearClientInformation(serverUrl: string, isPreregistered?: boolean): void; + clearClientInformation( + serverUrl: string, + isPreregistered?: boolean, + ): Promise; /** * Get OAuth tokens @@ -81,14 +85,10 @@ export interface OAuthStorage { /** * Clear OAuth tokens */ - clearTokens(serverUrl: string): void; + clearTokens(serverUrl: string): Promise; /** - * Get code verifier (for PKCE). - * - * Async because the backend-backed store may still be hydrating when this is - * read on the post-redirect OAuth callback path; the implementation awaits - * hydration before reading. + * Get code verifier (for PKCE) */ getCodeVerifier(serverUrl: string): Promise; @@ -100,12 +100,12 @@ export interface OAuthStorage { /** * Clear code verifier */ - clearCodeVerifier(serverUrl: string): void; + clearCodeVerifier(serverUrl: string): Promise; /** * Get scope */ - getScope(serverUrl: string): string | undefined; + getScope(serverUrl: string): Promise; /** * Save scope @@ -115,14 +115,10 @@ export interface OAuthStorage { /** * Clear scope */ - clearScope(serverUrl: string): void; + clearScope(serverUrl: string): Promise; /** - * Get server metadata discovered during OAuth. - * - * Async because the backend-backed store may still be hydrating when this is - * read on the post-redirect OAuth callback path; the implementation awaits - * hydration before reading. + * Get server metadata discovered during OAuth */ getServerMetadata(serverUrl: string): Promise; @@ -134,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). @@ -157,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/auth/store.ts b/core/auth/store.ts index eed06ed4..08e2084b 100644 --- a/core/auth/store.ts +++ b/core/auth/store.ts @@ -1,10 +1,8 @@ /** - * 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, @@ -14,6 +12,7 @@ import type { IdpSessionState, OAuthClientRegistrationKind, } from "./storage.js"; +import type { OAuthPersistSnapshot } from "./oauth-persist.js"; /** * OAuth state for a single server @@ -32,27 +31,7 @@ export interface ServerOAuthState { } /** - * Normalize a server URL to its canonical form for use as a store key. - * - * Different callers (web deep-link parser, CLI `--server-url`, the SDK's own - * client constructor) can present the same endpoint with cosmetic differences - * — host case, a trailing slash on a bare-origin URL, default-port omission — - * that `new URL().href` collapses. Keying by the canonical form means a token - * the web inspector saved under `https://Example.com/mcp` is found when the - * CLI later asks for `https://example.com/mcp/`. Non-URL strings (e.g. a - * stdio server name) are returned trimmed so the store still works for them. - */ -export function normalizeServerUrl(serverUrl: string): string { - const trimmed = serverUrl.trim(); - try { - return new URL(trimmed).href; - } catch { - return trimmed; - } -} - -/** - * Zustand store state (all servers) + * OAuth store state (all servers) plus mutation helpers. */ export interface OAuthStoreState { servers: Record; @@ -67,109 +46,80 @@ export interface OAuthStoreState { } /** - * 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 + * Mutable in-memory OAuth state keyed by server URL and IdP issuer. */ -export function createOAuthStore( - storage: ReturnType, -) { - return createStore()( - persist( - (set, get) => ({ - servers: {}, - idpSessions: {}, - getServerState: (serverUrl: string) => { - const servers = get().servers; - // Look up by canonical key, falling back to the raw key so an - // already-persisted blob written before normalization existed (or by - // another writer) is still found. - return ( - servers[normalizeServerUrl(serverUrl)] ?? servers[serverUrl] ?? {} - ); - }, - setServerState: ( - serverUrl: string, - updates: Partial, - ) => { - const key = normalizeServerUrl(serverUrl); - set((state) => { - const servers = { ...state.servers }; - // Mirror getServerState's canonical→raw fallback for writes: if no - // canonical entry exists yet but a pre-normalization blob lives - // under the raw key, migrate it onto the canonical key so this - // partial write MERGES onto the existing credential instead of - // shadowing it with a fresh canonical entry (which would orphan the - // raw-key blob's other fields — e.g. a token — behind the new - // canonical one that getServerState now prefers). - let base = servers[key]; - if (base === undefined && servers[serverUrl] !== undefined) { - // Reaching here implies `serverUrl !== key`: if they were equal, - // `servers[serverUrl]` would equal the `servers[key]` we just - // found undefined, so the guard above could not have passed. - base = servers[serverUrl]; - delete servers[serverUrl]; - } - servers[key] = { ...base, ...updates }; - return { servers }; - }); - }, - clearServerState: (serverUrl: string) => { - const key = normalizeServerUrl(serverUrl); - set((state) => { - const rest = { ...state.servers }; - delete rest[key]; - // Also drop a pre-normalization blob under the raw key so a clear - // fully removes the credential rather than leaving an orphan that - // getServerState's raw-key fallback would keep surfacing. - if (serverUrl !== key) 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, - // OAuthStorageBase drives `persist.rehydrate()` itself so it can await - // (and catch errors from) the single hydration. Auto-hydration here - // would race the explicit one and could clobber a save that landed - // between them. - skipHydration: true, +export class OAuthMemoryStore { + private servers: Record = {}; + private idpSessions: Record = {}; + + constructor(initial?: OAuthPersistSnapshot) { + if (initial) { + this.replace(initial); + } + } + + 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; + }, + }; + } + + 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/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..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; @@ -283,11 +285,12 @@ export class OAuthManager { ): Promise { try { 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, @@ -387,13 +390,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; @@ -446,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; } @@ -492,7 +495,7 @@ export class OAuthManager { } const effectiveScope = resolveEffectiveGrantedScope( - storage.getScope(serverUrl), + await storage.getScope(serverUrl), tokens.scope, ); return !isStrictScopeSuperset(scopeForAuth, effectiveScope); @@ -568,7 +571,7 @@ export class OAuthManager { } const previousScope = computeScopeUnion( - storage?.getScope(serverUrl), + storage ? await storage.getScope(serverUrl) : undefined, grantedTokenScope, ); const requiredFromChallenge = @@ -605,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(" ") }; @@ -626,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); @@ -861,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/react/useEmaIdpLoginState.ts b/core/react/useEmaIdpLoginState.ts index a9a65393..85932561 100644 --- a/core/react/useEmaIdpLoginState.ts +++ b/core/react/useEmaIdpLoginState.ts @@ -40,8 +40,17 @@ export function useEmaIdpLoginState( const logout = useCallback(() => { if (!normalizedIssuer) return; - clearEmaIdpSession(storage, normalizedIssuer); - 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 }; 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 c216c98a..00000000 --- a/core/storage/adapters/remote-storage.ts +++ /dev/null @@ -1,121 +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 '${options.storeId}' from ${baseUrl}/api/storage/${options.storeId}: ${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}`; - } - - const url = `${baseUrl}/api/storage/${options.storeId}`; - try { - // Zustand gives us the full persisted format as a string - // Store it as-is (the API treats it as an opaque blob). - // `keepalive` lets this POST outlive an immediately-following - // full-page navigation (the OAuth authorize redirect): without it the - // browser may abort the request mid-flight and the just-saved - // codeVerifier/clientInformation never reaches disk. - // - // Caveat: the fetch spec caps the *combined* body size of all in-flight - // keepalive requests at 64 KB. `value` here is the whole store blob - // (every server's tokens + metadata), so a user with a very large - // number of authorized servers could in theory exceed that and have the - // browser reject this write. That ceiling is well above realistic - // inspector usage; if it ever bites, the fix is per-server stores rather - // than dropping `keepalive` (which would reintroduce the redirect-abort - // bug this guards against). The catch below surfaces such a rejection. - const res = await fetchFn(url, { - method: "POST", - headers, - body: value, // Already a JSON string from Zustand - keepalive: true, - }); - - if (!res.ok) { - throw new Error( - `Failed to write store '${options.storeId}' to ${url}: ${res.status}`, - ); - } - } catch (err) { - // Zustand's persist middleware swallows setItem rejections — without - // this the user sees a green "Connected" while the token never landed - // on disk. Surface the failure so it's at least observable. - console.error("[remote-storage] persist write failed:", err); - throw err; - } - }, - 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..db04fe2e 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"; @@ -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). */ @@ -57,15 +61,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 +100,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) { @@ -129,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); +} diff --git a/package-lock.json b/package-lock.json index a1b26113..ac8a1921 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,8 +29,7 @@ "undici": "^8.5.0", "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" @@ -3267,35 +3266,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 ad759d51..6040a732 100644 --- a/package.json +++ b/package.json @@ -78,8 +78,7 @@ "undici": "^8.5.0", "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 2c38693f..8e46d57d 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -109,14 +109,13 @@ 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. -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): @@ -216,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 @@ -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 `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. @@ -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. @@ -417,12 +416,13 @@ 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()` - `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 @@ -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 -- [ ] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity; 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,29 +553,38 @@ 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 (`getBrowserOAuthStorage()` singleton exists partly to paper over this) +| 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) -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)). --- @@ -637,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** @@ -656,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 7b1cf3a9..601eac0d 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** | @@ -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 10a812ee..fb42b296 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'),