diff --git a/AGENTS.md b/AGENTS.md index 12500c434..3e9aea493 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ inspector/ │ ├── tui/ # TUI client (Ink + React, tsup bundle) │ ├── launcher/ # Shared launcher (relative imports into sibling build/ outputs) ├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias) -│ ├── auth/ # OAuth: state machine, providers, discovery, storage; +│ ├── auth/ # OAuth: providers, discovery, OAuthStorage + persist backends; │ │ # mid-session recovery (challenge.ts WWW-Authenticate │ │ # parsing, scopes.ts SEP-2350 scope union, oauthUx.ts │ │ # shared copy, mcpAuth.ts force-reauthorization) @@ -41,9 +41,9 @@ inspector/ │ │ ├── node/ # Node stdio transport factory │ │ ├── remote/ # Browser HTTP/SSE transport + remote logger/fetch │ │ │ └── node/ # Hono-based remote server backend (used by remote/ above) -│ │ └── state/ # Zustand-style state stores consumed by core/react/ +│ │ └── state/ # InspectorClient state stores consumed by core/react/ │ ├── react/ # React hooks over the state stores -│ └── storage/ # File and remote storage adapters (Zustand middleware) +│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends ├── test-servers/ # Composable MCP test servers + fixtures used by integration tests. │ ├── src/ # TypeScript sources. │ ├── build/ # Built JS (gitignored). Produced by `npm run test-servers:build` diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json index 940cfb559..7be38fe9a 100644 --- a/clients/cli/package-lock.json +++ b/clients/cli/package-lock.json @@ -15,8 +15,7 @@ "atomically": "^2.1.1", "commander": "^13.1.0", "pino": "^9.14.0", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "bin": { "mcp-inspector-cli": "build/index.js" @@ -5675,35 +5674,6 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } - }, - "node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } } } } diff --git a/clients/cli/package.json b/clients/cli/package.json index d76b3b455..b025ab8bb 100644 --- a/clients/cli/package.json +++ b/clients/cli/package.json @@ -38,8 +38,7 @@ "atomically": "^2.1.1", "commander": "^13.1.0", "pino": "^9.14.0", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts index 0091499c5..63278f078 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 ece3eed9f..d23208e5b 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/package-lock.json b/clients/tui/package-lock.json index 0eb6b0a4a..91c3e9a14 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 cfd4ea3e7..73070e2ff 100644 --- a/clients/tui/package.json +++ b/clients/tui/package.json @@ -38,8 +38,7 @@ "open": "^10.2.0", "pino": "^9.14.0", "react": "^19.2.4", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index 039a245f9..568d97d79 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 8553cda84..c45069043 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 b51cb69bd..3c666856d 100644 --- a/clients/web/package.json +++ b/clients/web/package.json @@ -59,8 +59,7 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", "remark-gfm": "^4.0.1", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "devDependencies": { "@chromatic-com/storybook": "^5.0.1", diff --git a/clients/web/src/test/core/auth/cimd.test.ts b/clients/web/src/test/core/auth/cimd.test.ts index 7f3bc38bf..afb7ac3a6 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 d060439b5..0e70ef077 100644 --- a/clients/web/src/test/core/auth/connection-state.test.ts +++ b/clients/web/src/test/core/auth/connection-state.test.ts @@ -14,11 +14,13 @@ function createStorage( tokens: Awaited>; preregistered: Awaited>; dynamic: Awaited>; - registrationKind: ReturnType; + registrationKind: Awaited< + ReturnType + >; scope: string | undefined; - serverMetadata: ReturnType; + serverMetadata: Awaited>; idpSession: Awaited>; - idpMetadata: ReturnType; + idpMetadata: Awaited>; }> = {}, ): OAuthStorage { return { @@ -27,15 +29,17 @@ function createStorage( getClientInformation: vi.fn(async (_url, isPreregistered) => isPreregistered ? overrides.preregistered : overrides.dynamic, ), - getScope: vi.fn().mockReturnValue(overrides.scope), - getServerMetadata: vi.fn((url: string) => { + getScope: vi.fn().mockResolvedValue(overrides.scope), + getServerMetadata: vi.fn(async (url: string) => { if (url.startsWith("ema-idp:")) { return overrides.idpMetadata ?? null; } return overrides.serverMetadata ?? null; }), getIdpSession: vi.fn().mockResolvedValue(overrides.idpSession), - getClientRegistrationKind: vi.fn(() => overrides.registrationKind), + getClientRegistrationKind: vi + .fn() + .mockResolvedValue(overrides.registrationKind), saveClientInformation: vi.fn(), savePreregisteredClientInformation: vi.fn(), saveTokens: vi.fn(), diff --git a/clients/web/src/test/core/auth/ema/emaFlow.test.ts b/clients/web/src/test/core/auth/ema/emaFlow.test.ts index d88109cb8..25ff3b78b 100644 --- a/clients/web/src/test/core/auth/ema/emaFlow.test.ts +++ b/clients/web/src/test/core/auth/ema/emaFlow.test.ts @@ -49,19 +49,21 @@ function createMemoryStorage( idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; }), clearIdpSession: vi.fn(), - getServerMetadata: vi.fn((key: string) => metadataByKey[key]), - saveServerMetadata: vi.fn((key: string, metadata: unknown) => { + getServerMetadata: vi.fn(async (key: string) => metadataByKey[key] ?? null), + saveServerMetadata: vi.fn(async (key: string, metadata: unknown) => { metadataByKey[key] = metadata; }), - getClientInformation: vi.fn((key: string) => clientInfoByKey[key]), - savePreregisteredClientInformation: vi.fn((key: string, info: unknown) => { - clientInfoByKey[key] = info; - }), - getCodeVerifier: vi.fn((key: string) => codeVerifierByKey[key]), - saveCodeVerifier: vi.fn((key: string, verifier: string) => { + getClientInformation: vi.fn(async (key: string) => clientInfoByKey[key]), + savePreregisteredClientInformation: vi.fn( + async (key: string, info: unknown) => { + clientInfoByKey[key] = info; + }, + ), + getCodeVerifier: vi.fn(async (key: string) => codeVerifierByKey[key]), + saveCodeVerifier: vi.fn(async (key: string, verifier: string) => { codeVerifierByKey[key] = verifier; }), - clearCodeVerifier: vi.fn((key: string) => { + clearCodeVerifier: vi.fn(async (key: string) => { delete codeVerifierByKey[key]; }), getTokens: vi.fn(async (url: string) => savedTokens[url] as never), diff --git a/clients/web/src/test/core/auth/ema/idpOidc.test.ts b/clients/web/src/test/core/auth/ema/idpOidc.test.ts index baebb8453..1c6d73f30 100644 --- a/clients/web/src/test/core/auth/ema/idpOidc.test.ts +++ b/clients/web/src/test/core/auth/ema/idpOidc.test.ts @@ -34,7 +34,7 @@ describe("idpOidc refresh", () => { saveIdpSession: vi.fn(async (issuer: string, updates) => { idpSessions[issuer] = { ...idpSessions[issuer], ...updates }; }), - getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)), + getServerMetadata: vi.fn(async () => minimalOAuthAsMetadata(IDP_ISSUER)), clearIdpSession: vi.fn(), } as unknown as OAuthStorage; }); @@ -266,7 +266,7 @@ describe("startIdpOidcAuthorization", () => { Object.keys(saved).forEach((k) => delete saved[k]); storage = { load: vi.fn().mockResolvedValue(undefined), - getServerMetadata: vi.fn(() => null), + getServerMetadata: vi.fn(async () => null), saveCodeVerifier: vi.fn(async (key: string, value: string) => { saved[`cv:${key}`] = value; }), @@ -335,13 +335,13 @@ describe("completeIdpOidcAuthorization", () => { function buildStorage(overrides: Partial = {}): OAuthStorage { return { load: vi.fn().mockResolvedValue(undefined), - getServerMetadata: vi.fn(() => minimalOAuthAsMetadata(IDP_ISSUER)), + getServerMetadata: vi.fn(async () => minimalOAuthAsMetadata(IDP_ISSUER)), getClientInformation: vi.fn(async () => ({ client_id: "idp-client", client_secret: "idp-secret", token_endpoint_auth_method: "client_secret_post", })), - getCodeVerifier: vi.fn(() => "verifier-123"), + getCodeVerifier: vi.fn(async () => "verifier-123"), clearCodeVerifier: vi.fn((key: string) => { clearedVerifiers.push(key); }), @@ -474,7 +474,7 @@ describe("completeIdpOidcAuthorization", () => { }); it("throws when the PKCE verifier is missing", async () => { - storage = buildStorage({ getCodeVerifier: vi.fn(() => undefined) }); + storage = buildStorage({ getCodeVerifier: vi.fn(async () => undefined) }); await expect( completeIdpOidcAuthorization({ idp, diff --git a/clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts b/clients/web/src/test/core/auth/oauth-persist-browser-safe.test.ts new file mode 100644 index 000000000..3b3dc6cf2 --- /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 000000000..f55b37b09 --- /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/providers.test.ts b/clients/web/src/test/core/auth/providers.test.ts index d7270584c..fc0665394 100644 --- a/clients/web/src/test/core/auth/providers.test.ts +++ b/clients/web/src/test/core/auth/providers.test.ts @@ -214,7 +214,7 @@ describe("OAuthNavigation", () => { function makeStorage(): OAuthStorage { return { load: vi.fn().mockResolvedValue(undefined), - getScope: vi.fn(() => undefined), + getScope: vi.fn().mockResolvedValue(undefined), getClientInformation: vi.fn(async () => undefined), saveClientInformation: vi.fn(async () => undefined), savePreregisteredClientInformation: vi.fn(async () => undefined), @@ -222,9 +222,9 @@ describe("OAuthNavigation", () => { getTokens: vi.fn(async () => undefined), saveTokens: vi.fn(async () => undefined), saveCodeVerifier: vi.fn(async () => undefined), - getCodeVerifier: vi.fn(() => undefined), + getCodeVerifier: vi.fn().mockResolvedValue(undefined), clear: vi.fn(), - getServerMetadata: vi.fn(() => null), + getServerMetadata: vi.fn().mockResolvedValue(null), saveServerMetadata: vi.fn(async () => undefined), } as unknown as OAuthStorage; } @@ -272,27 +272,28 @@ describe("OAuthNavigation", () => { expect(await provider.clientInformation()).toEqual({ client_id: "dyn" }); }); - it("codeVerifier() throws when none is saved", () => { + it("codeVerifier() throws when none is saved", async () => { const storage = makeStorage(); const provider = makeProvider(storage); - expect(() => provider.codeVerifier()).toThrow( + await expect(provider.codeVerifier()).rejects.toThrow( /No code verifier saved for session/, ); }); - it("codeVerifier() returns the saved verifier", () => { + it("codeVerifier() returns the saved verifier", async () => { const storage = makeStorage(); - vi.mocked(storage.getCodeVerifier).mockReturnValue("cv-1"); + vi.mocked(storage.getCodeVerifier).mockResolvedValue("cv-1"); const provider = makeProvider(storage); - expect(provider.codeVerifier()).toBe("cv-1"); + expect(await provider.codeVerifier()).toBe("cv-1"); }); - it("clientMetadata reflects the stored scope when present", () => { + it("clientMetadata reflects the stored scope when present", async () => { const storage = makeStorage(); - vi.mocked(storage.getScope).mockReturnValue("read write"); + vi.mocked(storage.getScope).mockResolvedValue("read write"); const provider = makeProvider(storage); + await provider.prepareForAuth(); expect(provider.clientMetadata.scope).toBe("read write"); }); @@ -380,7 +381,7 @@ describe("OAuthNavigation", () => { expect(storage.saveCodeVerifier).toHaveBeenCalledWith(SERVER, "cv"); expect(storage.saveServerMetadata).toHaveBeenCalled(); expect(await provider.tokens()).toBeUndefined(); - expect(provider.getServerMetadata()).toBeNull(); + expect(await provider.getServerMetadata()).toBeNull(); const state = await provider.state(); expect(typeof state).toBe("string"); expect(state.length).toBeGreaterThan(0); diff --git a/clients/web/src/test/core/auth/storage-browser.test.ts b/clients/web/src/test/core/auth/storage-browser.test.ts index 52afc0348..bb855584e 100644 --- a/clients/web/src/test/core/auth/storage-browser.test.ts +++ b/clients/web/src/test/core/auth/storage-browser.test.ts @@ -91,16 +91,11 @@ describe("BrowserOAuthStorage", () => { client_secret: "preregistered-secret", }; - // Use the storage API instead of manually setting sessionStorage - // since BrowserOAuthStorage now uses Zustand with a different storage format await storage.savePreregisteredClientInformation( testServerUrl, preregisteredInfo, ); - // Wait for Zustand to persist - await new Promise((resolve) => setTimeout(resolve, 100)); - const result = await storage.getClientInformation(testServerUrl, true); expect(result).toEqual(preregisteredInfo); @@ -139,14 +134,18 @@ describe("BrowserOAuthStorage", () => { const result = await storage.getClientInformation(testServerUrl); expect(result).toEqual(secondInfo); - expect(storage.getClientRegistrationKind(testServerUrl)).toBe("cimd"); + expect(await storage.getClientRegistrationKind(testServerUrl)).toBe( + "cimd", + ); }); it("savePreregisteredClientInformation sets static registration kind", async () => { await storage.savePreregisteredClientInformation(testServerUrl, { client_id: "static-id", }); - expect(storage.getClientRegistrationKind(testServerUrl)).toBe("static"); + expect(await storage.getClientRegistrationKind(testServerUrl)).toBe( + "static", + ); }); }); @@ -333,16 +332,16 @@ describe("BrowserOAuthStorage", () => { it("clearCodeVerifier removes only the PKCE verifier", async () => { await storage.saveCodeVerifier(testServerUrl, "verifier"); - expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier"); + expect(await storage.getCodeVerifier(testServerUrl)).toBe("verifier"); await storage.clearCodeVerifier(testServerUrl); - expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined(); + expect(await storage.getCodeVerifier(testServerUrl)).toBeUndefined(); }); it("clearScope removes only the scope", async () => { await storage.saveScope(testServerUrl, "read"); - expect(storage.getScope(testServerUrl)).toBe("read"); + expect(await storage.getScope(testServerUrl)).toBe("read"); await storage.clearScope(testServerUrl); - expect(storage.getScope(testServerUrl)).toBeUndefined(); + expect(await storage.getScope(testServerUrl)).toBeUndefined(); }); it("clearServerMetadata removes only the cached metadata", async () => { @@ -353,9 +352,9 @@ describe("BrowserOAuthStorage", () => { response_types_supported: ["code"], }; await storage.saveServerMetadata(testServerUrl, metadata); - expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata); + expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata); await storage.clearServerMetadata(testServerUrl); - expect(storage.getServerMetadata(testServerUrl)).toBeNull(); + expect(await storage.getServerMetadata(testServerUrl)).toBeNull(); }); }); diff --git a/clients/web/src/test/core/auth/storage-remote.test.ts b/clients/web/src/test/core/auth/storage-remote.test.ts index fa1b02fec..7650925f4 100644 --- a/clients/web/src/test/core/auth/storage-remote.test.ts +++ b/clients/web/src/test/core/auth/storage-remote.test.ts @@ -1,9 +1,5 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js"; -import { - type OAuthStore, - waitForOAuthStorePersistLoad, -} from "@inspector/core/auth/store.js"; const NOOP_FETCH = vi.fn( async () => @@ -76,16 +72,16 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { it("codeVerifier round-trip and clearCodeVerifier", async () => { await storage.saveCodeVerifier(serverUrl, "verifier"); - expect(storage.getCodeVerifier(serverUrl)).toBe("verifier"); + expect(await storage.getCodeVerifier(serverUrl)).toBe("verifier"); await storage.clearCodeVerifier(serverUrl); - expect(storage.getCodeVerifier(serverUrl)).toBeUndefined(); + expect(await storage.getCodeVerifier(serverUrl)).toBeUndefined(); }); it("scope round-trip and clearScope", async () => { await storage.saveScope(serverUrl, "read write"); - expect(storage.getScope(serverUrl)).toBe("read write"); + expect(await storage.getScope(serverUrl)).toBe("read write"); await storage.clearScope(serverUrl); - expect(storage.getScope(serverUrl)).toBeUndefined(); + expect(await storage.getScope(serverUrl)).toBeUndefined(); }); it("serverMetadata round-trip and clearServerMetadata", async () => { @@ -96,9 +92,9 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { response_types_supported: ["code"], }; await storage.saveServerMetadata(serverUrl, md); - expect(storage.getServerMetadata(serverUrl)).toEqual(md); + expect(await storage.getServerMetadata(serverUrl)).toEqual(md); await storage.clearServerMetadata(serverUrl); - expect(storage.getServerMetadata(serverUrl)).toBeNull(); + expect(await storage.getServerMetadata(serverUrl)).toBeNull(); }); it("clear() wipes all state for a server", async () => { @@ -125,13 +121,7 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { expect(s).toBeInstanceOf(RemoteOAuthStorage); }); - it("waitForOAuthStorePersistLoad resolves for an unregistered store handle", async () => { - await expect( - waitForOAuthStorePersistLoad({} as OAuthStore), - ).resolves.toBeUndefined(); - }); - - it("load() waits for remote GET before sync reads see persisted state", async () => { + it("getCodeVerifier loads remote state automatically when not preloaded", async () => { const persisted = { state: { servers: { @@ -155,15 +145,10 @@ describe("RemoteOAuthStorage (unit, mocked fetch)", () => { fetchFn: delayedFetch, }); - const loadPromise = delayedStorage.load(); - expect(delayedStorage.getCodeVerifier(serverUrl)).toBeUndefined(); - + const getPromise = delayedStorage.getCodeVerifier(serverUrl); resolveFetch(new Response(JSON.stringify(persisted), { status: 200 })); - await loadPromise; - expect(delayedStorage.getCodeVerifier(serverUrl)).toBe( - "persisted-verifier", - ); + expect(await getPromise).toBe("persisted-verifier"); }); it("load() rejects when remote GET fails instead of hanging", async () => { diff --git a/clients/web/src/test/core/auth/store.test.ts b/clients/web/src/test/core/auth/store.test.ts new file mode 100644 index 000000000..131a5ab8b --- /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 b4bfa7bb8..064c93017 100644 --- a/clients/web/src/test/core/mcp/oauthManager.test.ts +++ b/clients/web/src/test/core/mcp/oauthManager.test.ts @@ -36,15 +36,15 @@ function createMockParams( const storage = { load: vi.fn().mockResolvedValue(undefined), - getScope: vi.fn().mockReturnValue(undefined), + getScope: vi.fn().mockResolvedValue(undefined), getClientInformation: vi.fn().mockResolvedValue(undefined), - getClientRegistrationKind: vi.fn().mockReturnValue(undefined), + getClientRegistrationKind: vi.fn().mockResolvedValue(undefined), saveClientInformation: vi.fn().mockResolvedValue(undefined), savePreregisteredClientInformation: vi.fn().mockResolvedValue(undefined), saveScope: vi.fn().mockResolvedValue(undefined), getTokens: vi.fn().mockResolvedValue(undefined), saveTokens: vi.fn().mockResolvedValue(undefined), - getCodeVerifier: vi.fn().mockReturnValue("verifier"), + getCodeVerifier: vi.fn().mockResolvedValue("verifier"), saveCodeVerifier: vi.fn().mockResolvedValue(undefined), clear: vi.fn().mockResolvedValue(undefined), clearClientInformation: vi.fn(), @@ -52,7 +52,7 @@ function createMockParams( clearCodeVerifier: vi.fn(), clearScope: vi.fn(), clearServerMetadata: vi.fn(), - getServerMetadata: vi.fn().mockReturnValue(null), + getServerMetadata: vi.fn().mockResolvedValue(null), saveServerMetadata: vi.fn().mockResolvedValue(undefined), getIdpSession: vi.fn().mockResolvedValue(undefined), saveIdpSession: vi.fn().mockResolvedValue(undefined), @@ -428,7 +428,7 @@ describe("OAuthManager", () => { const params = createMockParams({ onBeforeOAuthRedirect }); // A configured scope exercises the saveScope branch in createOAuthProvider. params.initialConfig.scope = "read write"; - storageOf(params).getScope.mockReturnValue(undefined); + storageOf(params).getScope.mockResolvedValue(undefined); storageOf(params).getClientInformation.mockResolvedValue({ client_id: "cid", }); @@ -466,7 +466,9 @@ describe("OAuthManager", () => { mockedMcpAuth.mockResolvedValue("REDIRECT"); const params = createMockParams(); params.initialConfig.scope = "mcp tools:read"; - storageOf(params).getScope.mockReturnValue("mcp tools:read weather:read"); + storageOf(params).getScope.mockResolvedValue( + "mcp tools:read weather:read", + ); storageOf(params).getClientInformation.mockResolvedValue({ client_id: "cid", }); @@ -586,7 +588,7 @@ describe("OAuthManager", () => { .mockResolvedValueOnce("REDIRECT") .mockResolvedValueOnce("AUTHORIZED"); const params = createMockParams(); - storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getScope.mockResolvedValue("mcp"); storageOf(params).getTokens.mockResolvedValue({ access_token: "access", refresh_token: "refresh", @@ -629,7 +631,7 @@ describe("OAuthManager", () => { it("persists granted scope when AS down-scopes the token response", async () => { mockedMcpAuth.mockResolvedValue("AUTHORIZED"); const params = createMockParams(); - storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getScope.mockResolvedValue("mcp"); storageOf(params).getTokens.mockResolvedValue({ access_token: "access", token_type: "Bearer", @@ -984,7 +986,9 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read tools:write", }); - storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write"); + storageOf(params).getScope.mockResolvedValue( + "mcp tools:read tools:write", + ); const manager = new OAuthManager(params); expect( @@ -1002,7 +1006,7 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read", }); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); const manager = new OAuthManager(params); expect( @@ -1020,7 +1024,7 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp", }); - storageOf(params).getScope.mockReturnValue("mcp weather:read"); + storageOf(params).getScope.mockResolvedValue("mcp weather:read"); const manager = new OAuthManager(params); expect( @@ -1037,7 +1041,7 @@ describe("OAuthManager", () => { access_token: "tok", token_type: "Bearer", }); - storageOf(params).getScope.mockReturnValue(undefined); + storageOf(params).getScope.mockResolvedValue(undefined); const manager = new OAuthManager(params); expect( @@ -1054,7 +1058,9 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read tools:write", }); - storageOf(params).getScope.mockReturnValue("mcp tools:read tools:write"); + storageOf(params).getScope.mockResolvedValue( + "mcp tools:read tools:write", + ); const manager = new OAuthManager(params); const outcome = await manager.handleAuthChallenge({ @@ -1136,7 +1142,7 @@ describe("OAuthManager", () => { const params = createMockParams(); const manager = new OAuthManager(params); manager.setOAuthConfig({ scope: "catalog:scope" }); - storageOf(params).getScope.mockReturnValue("stored union scope"); + storageOf(params).getScope.mockResolvedValue("stored union scope"); const captureSpy = vi .spyOn( (await import("@inspector/core/auth/providers.js")) @@ -1164,7 +1170,7 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read", }); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); const manager = new OAuthManager(params); const outcome = await manager.handleAuthChallenge({ @@ -1201,7 +1207,7 @@ describe("OAuthManager", () => { ); mockedMcpAuth.mockResolvedValue("REDIRECT"); const params = createMockParams(); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); const manager = new OAuthManager(params); const captureSpy = vi .spyOn( @@ -1234,7 +1240,7 @@ describe("OAuthManager", () => { ); mockedMcpAuth.mockResolvedValue("REDIRECT"); const params = createMockParams(); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params).getTokens.mockResolvedValue({ access_token: "access", refresh_token: "refresh", @@ -1322,7 +1328,7 @@ describe("OAuthManager", () => { }, }, }); - storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getScope.mockResolvedValue("mcp"); storageOf(params).getTokens.mockResolvedValue({ access_token: "tok", token_type: "Bearer", @@ -1360,7 +1366,7 @@ describe("OAuthManager", () => { }, }, }); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params).getTokens.mockResolvedValue({ access_token: "tok", token_type: "Bearer", @@ -1453,7 +1459,7 @@ describe("OAuthManager", () => { }, }, }); - storageOf(params).getScope.mockReturnValue("mcp"); + storageOf(params).getScope.mockResolvedValue("mcp"); storageOf(params).getTokens.mockResolvedValue({ access_token: "tok", token_type: "Bearer", @@ -1501,7 +1507,7 @@ describe("OAuthManager", () => { }, }, }); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params).getTokens.mockResolvedValue({ access_token: "old", token_type: "Bearer", @@ -1635,7 +1641,7 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read weather:read", }; - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params) .getTokens.mockResolvedValueOnce(insufficientTokens) .mockResolvedValue(sufficientTokens); @@ -1665,7 +1671,7 @@ describe("OAuthManager", () => { token_type: "Bearer", scope: "mcp tools:read weather:read", }; - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params) .getTokens.mockResolvedValueOnce(insufficientTokens) .mockResolvedValueOnce(insufficientTokens) @@ -1801,7 +1807,7 @@ describe("OAuthManager", () => { .mockResolvedValueOnce("AUTHORIZED") .mockResolvedValueOnce("REDIRECT"); const params = createMockParams(); - storageOf(params).getScope.mockReturnValue("mcp tools:read"); + storageOf(params).getScope.mockResolvedValue("mcp tools:read"); storageOf(params).getTokens.mockResolvedValue({ access_token: "access", token_type: "Bearer", diff --git a/clients/web/src/test/integration/auth/node/storage.test.ts b/clients/web/src/test/integration/auth/node/storage.test.ts index eb43eab3e..0bb328ad8 100644 --- a/clients/web/src/test/integration/auth/node/storage.test.ts +++ b/clients/web/src/test/integration/auth/node/storage.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { NodeOAuthStorage, - getOAuthStore, + getStateFilePath, + resetNodeOAuthStorageCache, clearAllOAuthClientState, } from "@inspector/core/auth/node/storage-node.js"; import type { @@ -20,44 +21,27 @@ const testStatePath = path.join( `mcp-inspector-oauth-${process.pid}-storage-node.json`, ); +async function resetTestStorage(stateFilePath: string): Promise { + try { + await fs.unlink(stateFilePath); + } catch { + // Ignore if file doesn't exist + } + resetNodeOAuthStorageCache(stateFilePath); +} + describe("NodeOAuthStorage", () => { let storage: NodeOAuthStorage; const testServerUrl = "http://localhost:3000"; const stateFilePath = testStatePath; beforeEach(async () => { - // Clean up any existing state file - try { - await fs.unlink(stateFilePath); - } catch { - // Ignore if file doesn't exist - } - - // Reset store state by clearing all servers - const store = getOAuthStore(testStatePath); - const state = store.getState(); - // Clear all server states - Object.keys(state.servers).forEach((url) => { - state.clearServerState(url); - }); - + await resetTestStorage(stateFilePath); storage = new NodeOAuthStorage(testStatePath); }); afterEach(async () => { - // Clean up state file after each test - try { - await fs.unlink(stateFilePath); - } catch { - // Ignore if file doesn't exist - } - - // Reset store state - const store = getOAuthStore(testStatePath); - const state = store.getState(); - Object.keys(state.servers).forEach((url) => { - state.clearServerState(url); - }); + await resetTestStorage(stateFilePath); }); describe("getClientInformation", () => { @@ -88,11 +72,10 @@ describe("NodeOAuthStorage", () => { client_secret: "preregistered-secret", }; - // Store as preregistered by directly setting it in the store - const store = getOAuthStore(testStatePath); - store.getState().setServerState(testServerUrl, { - preregisteredClientInformation: preregisteredInfo, - }); + await storage.savePreregisteredClientInformation( + testServerUrl, + preregisteredInfo, + ); const result = await storage.getClientInformation(testServerUrl, true); @@ -341,16 +324,16 @@ describe("NodeOAuthStorage", () => { it("clearCodeVerifier removes only the PKCE verifier", async () => { await storage.saveCodeVerifier(testServerUrl, "verifier"); - expect(storage.getCodeVerifier(testServerUrl)).toBe("verifier"); + expect(await storage.getCodeVerifier(testServerUrl)).toBe("verifier"); await storage.clearCodeVerifier(testServerUrl); - expect(storage.getCodeVerifier(testServerUrl)).toBeUndefined(); + expect(await storage.getCodeVerifier(testServerUrl)).toBeUndefined(); }); it("clearScope removes only the scope", async () => { await storage.saveScope(testServerUrl, "read write"); - expect(storage.getScope(testServerUrl)).toBe("read write"); + expect(await storage.getScope(testServerUrl)).toBe("read write"); await storage.clearScope(testServerUrl); - expect(storage.getScope(testServerUrl)).toBeUndefined(); + expect(await storage.getScope(testServerUrl)).toBeUndefined(); }); it("clearServerMetadata removes only the cached metadata", async () => { @@ -361,9 +344,9 @@ describe("NodeOAuthStorage", () => { response_types_supported: ["code"], }; await storage.saveServerMetadata(testServerUrl, metadata); - expect(storage.getServerMetadata(testServerUrl)).toEqual(metadata); + expect(await storage.getServerMetadata(testServerUrl)).toEqual(metadata); await storage.clearServerMetadata(testServerUrl); - expect(storage.getServerMetadata(testServerUrl)).toBeNull(); + expect(await storage.getServerMetadata(testServerUrl)).toBeNull(); }); }); @@ -437,83 +420,52 @@ describe("NodeOAuthStorage", () => { expect(result2).toEqual(clientInfo2); }); }); -}); -describe("OAuth Store (Zustand)", () => { - const stateFilePath = testStatePath; + it("reuses in-memory state for the same file path", async () => { + const serverUrl = "http://localhost:3000"; + const tokens: OAuthTokens = { + access_token: "shared-memory-token", + token_type: "Bearer", + }; - beforeEach(async () => { - try { - await fs.unlink(stateFilePath); - } catch { - // Ignore if file doesn't exist - } - }); - - afterEach(async () => { - try { - await fs.unlink(stateFilePath); - } catch { - // Ignore if file doesn't exist - } - }); + await storage.saveTokens(serverUrl, tokens); - it("should create a new store", () => { - const store = getOAuthStore(testStatePath); - expect(store).toBeDefined(); - expect(store.getState).toBeDefined(); - expect(store.setState).toBeDefined(); + const otherView = new NodeOAuthStorage(testStatePath); + expect(await otherView.getTokens(serverUrl)).toEqual(tokens); }); - it("should return the same store instance via getOAuthStore", () => { - const store1 = getOAuthStore(testStatePath); - const store2 = getOAuthStore(testStatePath); - expect(store1).toBe(store2); - }); - - it("should persist state to file", async () => { - // Use a unique path so no other test (e.g. "should overwrite..." with second-id) can - // write to the same file; Zustand persist is async and can race with shared paths. + it("persists state to file on save", async () => { const persistTestPath = path.join( os.tmpdir(), `mcp-inspector-oauth-persist-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, ); try { - if (process.env.DEBUG_PERSIST_PATH === "1") { - console.error("[storage-node.test] state file path:", persistTestPath); - } - const store = getOAuthStore(persistTestPath); + resetNodeOAuthStorageCache(persistTestPath); + const fileStorage = new NodeOAuthStorage(persistTestPath); const serverUrl = "http://localhost:3000"; const clientInfo: OAuthClientInformation = { client_id: "test-client-id", }; - store.getState().setServerState(serverUrl, { - clientInformation: clientInfo, + await fileStorage.saveClientInformation(serverUrl, clientInfo, { + registrationKind: "dcr", }); type StateShape = { - state: { - servers: Record< - string, - { clientInformation?: OAuthClientInformation } - >; - }; + servers: Record; }; - // Persistence is fire-and-forget; await the write rather than polling. await flushStoreFileWrites(persistTestPath); const parsed = JSON.parse( await fs.readFile(persistTestPath, "utf-8"), ) as StateShape; - expect(parsed.state.servers[serverUrl]?.clientInformation).toEqual( - clientInfo, - ); + expect(parsed.servers[serverUrl]?.clientInformation).toEqual(clientInfo); } finally { try { await fs.unlink(persistTestPath); } catch { /* ignore */ } + resetNodeOAuthStorageCache(persistTestPath); } }); }); @@ -572,9 +524,7 @@ describe("NodeOAuthStorage with custom storagePath", () => { await storage.saveTokens(testServerUrl, tokens); type StateShape = { - state: { - servers: Record; - }; + servers: Record; }; // Persistence is fire-and-forget; await the write rather than polling. await flushStoreFileWrites(customPath); @@ -582,7 +532,7 @@ describe("NodeOAuthStorage with custom storagePath", () => { await fs.readFile(customPath, "utf-8"), ) as StateShape; - expect(parsed.state.servers[testServerUrl]?.tokens?.access_token).toBe( + expect(parsed.servers[testServerUrl]?.tokens?.access_token).toBe( tokens.access_token, ); @@ -599,33 +549,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 persisted state without a servers map", async () => { + const filePath = getStateFilePath(); + resetNodeOAuthStorageCache(); + await fs.writeFile(filePath, JSON.stringify({ idpSessions: {} }), "utf-8"); + + await expect(clearAllOAuthClientState()).resolves.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 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(() => {}); - expect(() => clearAllOAuthClientState()).not.toThrow(); - expect(clearServerState).not.toHaveBeenCalled(); + await expect(clearAllOAuthClientState()).resolves.toBeUndefined(); - vi.mocked(defaultStore.getState).mockRestore(); + resetNodeOAuthStorageCache(); }); it("should isolate state from default store", async () => { @@ -635,12 +598,14 @@ describe("NodeOAuthStorage with custom storagePath", () => { ); try { + resetNodeOAuthStorageCache(); const defaultStorage = new NodeOAuthStorage(); await defaultStorage.saveTokens(testServerUrl, { access_token: "default-token", token_type: "Bearer", }); + resetNodeOAuthStorageCache(customPath); const customStorage = new NodeOAuthStorage(customPath); await customStorage.saveTokens(testServerUrl, { access_token: "custom-token", @@ -653,13 +618,15 @@ describe("NodeOAuthStorage with custom storagePath", () => { const fromDefault = await defaultStorage.getTokens(testServerUrl); expect(fromDefault?.access_token).toBe("default-token"); - getOAuthStore().getState().clearServerState(testServerUrl); + await defaultStorage.clear(testServerUrl); } finally { try { await fs.unlink(customPath); } catch { /* ignore */ } + resetNodeOAuthStorageCache(customPath); + resetNodeOAuthStorageCache(); } }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-ema-e2e.test.ts index 8ce12e79a..adc1f6065 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 f0aa91ce2..65cb7a8a2 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-e2e.test.ts @@ -91,9 +91,9 @@ describe("InspectorClient OAuth E2E", () => { } }); - beforeEach(() => { + beforeEach(async () => { clearOAuthTestData(); - clearAllOAuthClientState(); + await clearAllOAuthClientState(); // Capture console.log output instead of printing to stdout during tests vi.spyOn(console, "log").mockImplementation(() => {}); }); @@ -1072,16 +1072,14 @@ describe("InspectorClient OAuth E2E", () => { expect(client.getStatus()).toBe("connected"); type StateShape = { - state?: { - servers?: Record; - }; + servers?: Record; }; // Persistence is fire-and-forget; await the write rather than polling. await flushStoreFileWrites(customPath); const parsed = JSON.parse( await fs.readFile(customPath, "utf-8"), ) as StateShape; - const servers = parsed.state?.servers ?? {}; + const servers = parsed.servers ?? {}; expect(Object.keys(servers).length).toBeGreaterThan(0); expect( Object.values(servers).some((s) => !!s?.tokens?.access_token), diff --git a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-mid-session-e2e.test.ts index f2e0bb666..a825be954 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 37c2d47fa..10bb82718 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-oauth-remote-storage-e2e.test.ts @@ -352,18 +352,11 @@ describe("InspectorClient OAuth E2E with Remote Storage", () => { remoteAuthToken!, (body) => { const b = body as { - state?: { - servers?: Record< - string, - { tokens?: { access_token?: string } } - >; - }; + servers?: Record; }; return !!( - b?.state?.servers && - Object.values(b.state.servers).some( - (s) => s?.tokens?.access_token, - ) + b?.servers && + Object.values(b.servers).some((s) => s?.tokens?.access_token) ); }, ); diff --git a/clients/web/src/test/integration/storage/adapters.test.ts b/clients/web/src/test/integration/storage/adapters.test.ts index 8c248a3fd..998e60e74 100644 --- a/clients/web/src/test/integration/storage/adapters.test.ts +++ b/clients/web/src/test/integration/storage/adapters.test.ts @@ -1,17 +1,18 @@ /** - * Tests for storage adapters (file, remote). + * Tests for OAuth persistence (file + remote) and store-io flush helpers. */ -import { describe, it, expect, afterEach, vi } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { waitForRemoteStore } from "@modelcontextprotocol/inspector-test-server"; import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { serve } from "@hono/node-server"; import type { ServerType } from "@hono/node-server"; -import { createFileStorageAdapter } from "@inspector/core/storage/adapters/file-storage.js"; -import { createRemoteStorageAdapter } from "@inspector/core/storage/adapters/remote-storage.js"; -import { createOAuthStore } from "@inspector/core/auth/store.js"; +import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js"; +import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js"; +import { OAuthMemoryStore } from "@inspector/core/auth/store.js"; +import { createFileOAuthPersistBackend } from "@inspector/core/auth/node/oauth-persist-file.js"; import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js"; import { writeStoreFile, @@ -53,80 +54,8 @@ async function startRemoteServer( }); } -describe("Storage adapters", () => { - describe("createRemoteStorageAdapter (unit, mocked fetch)", () => { - function makeAdapter(fetchFn: typeof fetch, authToken?: string) { - return createRemoteStorageAdapter({ - baseUrl: "http://remote.example/", - storeId: "test", - fetchFn, - authToken, - })!; - } - - it("getItem returns null on 404 and parses stored blob otherwise", async () => { - const fetchFn = vi - .fn() - .mockResolvedValueOnce(new Response("not found", { status: 404 })) - .mockResolvedValueOnce( - new Response(JSON.stringify({ state: { hi: 1 }, version: 0 })), - ) - .mockResolvedValueOnce(new Response("{}")); - - const adapter = makeAdapter(fetchFn as typeof fetch); - expect(await adapter.getItem("anything")).toBeNull(); - const blob = await adapter.getItem("anything"); - expect(blob).toBeTruthy(); - // Empty object response → treated as "not initialized" → null. - expect(await adapter.getItem("anything")).toBeNull(); - }); - - it("getItem throws on non-404 error responses", async () => { - const fetchFn = vi - .fn() - .mockResolvedValueOnce(new Response("boom", { status: 500 })); - const adapter = makeAdapter(fetchFn as typeof fetch); - await expect(adapter.getItem("x")).rejects.toThrow(/500/); - }); - - it("setItem throws when the POST fails", async () => { - const fetchFn = vi - .fn() - .mockResolvedValueOnce(new Response("nope", { status: 500 })); - const adapter = makeAdapter(fetchFn as typeof fetch); - await expect( - adapter.setItem("name", { state: { a: 1 }, version: 0 }), - ).rejects.toThrow(/500/); - }); - - it("removeItem tolerates 404 but rethrows on other failures", async () => { - const fetchFn = vi - .fn() - .mockResolvedValueOnce(new Response(null, { status: 204 })) - .mockResolvedValueOnce(new Response(null, { status: 404 })) - .mockResolvedValueOnce(new Response("boom", { status: 500 })); - const adapter = makeAdapter(fetchFn as typeof fetch); - await expect(adapter.removeItem("x")).resolves.toBeUndefined(); - await expect(adapter.removeItem("x")).resolves.toBeUndefined(); - await expect(adapter.removeItem("x")).rejects.toThrow(/500/); - }); - - it("sends the x-mcp-remote-auth header on all three verbs when authToken is set", async () => { - const fetchFn = vi - .fn() - .mockResolvedValue(new Response("{}")); - const adapter = makeAdapter(fetchFn as typeof fetch, "secret"); - await adapter.getItem("x"); - await adapter.setItem("x", { state: {}, version: 0 }); - await adapter.removeItem("x"); - for (const call of fetchFn.mock.calls) { - const headers = call[1]?.headers as Record | undefined; - expect(headers?.["x-mcp-remote-auth"]).toBe("Bearer secret"); - } - }); - }); - - describe("FileStorageAdapter", () => { +describe("OAuth persistence", () => { + describe("OAuth file persistence", () => { let tempDir: string | null = null; afterEach(() => { @@ -143,19 +72,17 @@ describe("Storage adapters", () => { it("creates store and persists state", async () => { tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); const filePath = join(tempDir!, "test-store.json"); - const storage = createFileStorageAdapter({ filePath }); - const store = createOAuthStore(storage); + const storage = new NodeOAuthStorage(filePath); - // Set some state - store.getState().setServerState("https://example.com", { - tokens: { access_token: "test-token", token_type: "Bearer" }, + await storage.saveTokens("https://example.com", { + access_token: "test-token", + token_type: "Bearer", }); - // Persistence is fire-and-forget; await the write rather than polling. await flushStoreFileWrites(filePath); const fileContent = readFileSync(filePath, "utf-8"); const parsed = JSON.parse(fileContent); - expect(parsed.state.servers["https://example.com"].tokens).toEqual({ + expect(parsed.servers["https://example.com"].tokens).toEqual({ access_token: "test-token", token_type: "Bearer", }); @@ -165,67 +92,91 @@ describe("Storage adapters", () => { tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); const filePath = join(tempDir!, "test-store.json"); - // Create initial store and persist - const storage1 = createFileStorageAdapter({ filePath }); - const store1 = createOAuthStore(storage1); - store1.getState().setServerState("https://example.com", { - tokens: { access_token: "initial-token", token_type: "Bearer" }, + const storage1 = new NodeOAuthStorage(filePath); + await storage1.saveTokens("https://example.com", { + access_token: "initial-token", + token_type: "Bearer", }); await flushStoreFileWrites(filePath); - // Create new store instance (should load persisted state) - const storage2 = createFileStorageAdapter({ filePath }); - const store2 = createOAuthStore(storage2); - await new Promise((resolve) => setTimeout(resolve, 100)); - - const state = store2.getState().getServerState("https://example.com"); + const backend = createFileOAuthPersistBackend({ filePath }); + const snapshot = await backend.read(); + const freshMemory = new OAuthMemoryStore(snapshot ?? undefined); + const state = freshMemory + .getState() + .getServerState("https://example.com"); expect(state.tokens).toEqual({ access_token: "initial-token", token_type: "Bearer", }); }); + it("reads legacy persist envelope and rewrites as plain JSON on save", async () => { + tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); + const filePath = join(tempDir!, "test-store.json"); + await writeStoreFile( + filePath, + JSON.stringify({ + state: { + servers: { + "https://example.com": { + tokens: { access_token: "legacy", token_type: "Bearer" }, + }, + }, + idpSessions: {}, + }, + version: 0, + }), + ); + + const storage = new NodeOAuthStorage(filePath); + expect(await storage.getTokens("https://example.com")).toEqual({ + access_token: "legacy", + token_type: "Bearer", + }); + + await storage.saveScope("https://example.com", "read"); + await flushStoreFileWrites(filePath); + + const parsed = JSON.parse(readFileSync(filePath, "utf-8")); + expect(parsed.servers["https://example.com"].scope).toBe("read"); + expect(parsed.version).toBeUndefined(); + expect(parsed.state).toBeUndefined(); + }); + it("handles empty state after clear", async () => { tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); const filePath = join(tempDir!, "test-store.json"); - const storage = createFileStorageAdapter({ filePath }); - const store = createOAuthStore(storage); + const storage = new NodeOAuthStorage(filePath); - // Set state and persist - store.getState().setServerState("https://example.com", { - tokens: { access_token: "test-token", token_type: "Bearer" }, + await storage.saveTokens("https://example.com", { + access_token: "test-token", + token_type: "Bearer", }); await flushStoreFileWrites(filePath); expect(existsSync(filePath)).toBe(true); - // Clear all servers (this will persist empty state) - const state = store.getState(); - const urls = Object.keys(state.servers); - for (const url of urls) { - state.clearServerState(url); - } + await storage.clear("https://example.com"); await flushStoreFileWrites(filePath); - // Verify file still exists but with empty servers expect(existsSync(filePath)).toBe(true); const fileContent = readFileSync(filePath, "utf-8"); const parsed = JSON.parse(fileContent); - expect(Object.keys(parsed.state.servers).length).toBe(0); + expect(Object.keys(parsed.servers).length).toBe(0); }); - it("removeItem deletes the underlying file", async () => { + it("remove deletes the underlying file", async () => { tempDir = mkdtempSync(join(tmpdir(), "inspector-storage-test-")); const filePath = join(tempDir!, "test-store.json"); - const storage = createFileStorageAdapter({ filePath }); - // createJSONStorage returns a PersistStorage wrapper that delegates to - // our adapter's removeItem callback (line 25 of file-storage.ts). - const store = createOAuthStore(storage); - store.getState().setServerState("https://example.com", { - tokens: { access_token: "t", token_type: "Bearer" }, + const storage = new NodeOAuthStorage(filePath); + await storage.saveTokens("https://example.com", { + access_token: "t", + token_type: "Bearer", }); await flushStoreFileWrites(filePath); expect(existsSync(filePath)).toBe(true); - await storage!.removeItem("inspector-oauth-store"); + const backend = createFileOAuthPersistBackend({ filePath }); + await backend.remove!(); expect(existsSync(filePath)).toBe(false); }); }); @@ -255,7 +206,6 @@ describe("Storage adapters", () => { tempDir = mkdtempSync(join(tmpdir(), "inspector-flush-test-")); const filePath = join(tempDir, "store.json"); - // Kick off the write without awaiting it, then flush. const write = writeStoreFile(filePath, '{"hello":"world"}'); await flushStoreFileWrites(filePath); expect(existsSync(filePath)).toBe(true); @@ -292,7 +242,7 @@ describe("Storage adapters", () => { }); }); - describe("RemoteStorageAdapter", () => { + describe("Remote OAuth persistence", () => { let remoteServer: ServerType | null = null; let tempDir: string | null = null; @@ -320,31 +270,27 @@ describe("Storage adapters", () => { }); remoteServer = server; - const storage = createRemoteStorageAdapter({ + const storage = new RemoteOAuthStorage({ baseUrl, storeId: "test-store", authToken, }); - const store = createOAuthStore(storage); - // Set some state - store.getState().setServerState("https://example.com", { - tokens: { access_token: "test-token", token_type: "Bearer" }, + await storage.saveTokens("https://example.com", { + access_token: "test-token", + token_type: "Bearer", }); await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { const d = body as { - state?: { - servers?: Record; - }; + servers?: Record; }; return ( - d?.state?.servers?.["https://example.com"]?.tokens?.access_token === + d?.servers?.["https://example.com"]?.tokens?.access_token === "test-token" ); }); - // Verify via API const res = await fetch(`${baseUrl}/api/storage/test-store`, { method: "GET", headers: { @@ -353,7 +299,7 @@ describe("Storage adapters", () => { }); expect(res.status).toBe(200); const storeData = await res.json(); - expect(storeData.state.servers["https://example.com"].tokens).toEqual({ + expect(storeData.servers["https://example.com"].tokens).toEqual({ access_token: "test-token", token_type: "Bearer", }); @@ -366,46 +312,32 @@ describe("Storage adapters", () => { }); remoteServer = server; - // Create initial store and persist - const storage1 = createRemoteStorageAdapter({ + const storage1 = new RemoteOAuthStorage({ baseUrl, storeId: "test-store", authToken, }); - const store1 = createOAuthStore(storage1); - store1.getState().setServerState("https://example.com", { - tokens: { access_token: "initial-token", token_type: "Bearer" }, + await storage1.saveTokens("https://example.com", { + access_token: "initial-token", + token_type: "Bearer", }); await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { const d = body as { - state?: { - servers?: Record; - }; + servers?: Record; }; return ( - d?.state?.servers?.["https://example.com"]?.tokens?.access_token === + d?.servers?.["https://example.com"]?.tokens?.access_token === "initial-token" ); }); - // Create new store instance (should load persisted state) - const storage2 = createRemoteStorageAdapter({ + const storage2 = new RemoteOAuthStorage({ baseUrl, storeId: "test-store", authToken, }); - const store2 = createOAuthStore(storage2); - await vi.waitFor( - () => { - const state = store2.getState().getServerState("https://example.com"); - if (!state.tokens) throw new Error("Store not yet hydrated"); - return state; - }, - { timeout: 2000, interval: 50 }, - ); - const state = store2.getState().getServerState("https://example.com"); - expect(state.tokens).toEqual({ + expect(await storage2.getTokens("https://example.com")).toEqual({ access_token: "initial-token", token_type: "Bearer", }); @@ -418,23 +350,21 @@ describe("Storage adapters", () => { }); remoteServer = server; - const storage = createRemoteStorageAdapter({ + const storage = new RemoteOAuthStorage({ baseUrl, storeId: "test-store", authToken, }); - const store = createOAuthStore(storage); - // Set state and persist - store.getState().setServerState("https://example.com", { - tokens: { access_token: "test-token", token_type: "Bearer" }, + await storage.saveTokens("https://example.com", { + access_token: "test-token", + token_type: "Bearer", }); await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { - const d = body as { state?: { servers?: Record } }; - return !!d?.state?.servers && Object.keys(d.state.servers).length > 0; + const d = body as { servers?: Record }; + return !!d?.servers && Object.keys(d.servers).length > 0; }); - // Verify it exists let res = await fetch(`${baseUrl}/api/storage/test-store`, { method: "GET", headers: { @@ -443,20 +373,14 @@ describe("Storage adapters", () => { }); expect(res.status).toBe(200); const storeData = await res.json(); - expect(Object.keys(storeData.state.servers).length).toBeGreaterThan(0); + expect(Object.keys(storeData.servers).length).toBeGreaterThan(0); - // Clear all servers (this will persist empty state) - const state = store.getState(); - const urls = Object.keys(state.servers); - for (const url of urls) { - state.clearServerState(url); - } + await storage.clear("https://example.com"); await waitForRemoteStore(baseUrl, "test-store", authToken, (body) => { - const d = body as { state?: { servers?: Record } }; - return !d?.state?.servers || Object.keys(d.state.servers).length === 0; + const d = body as { servers?: Record }; + return !d?.servers || Object.keys(d.servers).length === 0; }); - // Verify it's empty res = await fetch(`${baseUrl}/api/storage/test-store`, { method: "GET", headers: { @@ -465,7 +389,7 @@ describe("Storage adapters", () => { }); expect(res.status).toBe(200); const emptyStore = await res.json(); - expect(Object.keys(emptyStore.state.servers).length).toBe(0); + expect(Object.keys(emptyStore.servers).length).toBe(0); }); }); }); diff --git a/clients/web/tsconfig.app.json b/clients/web/tsconfig.app.json index 167e4ba81..62da4bd08 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 d9757fd29..23dd96254 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 afbc9f917..6841de442 100644 --- a/clients/web/tsup.runner.config.ts +++ b/clients/web/tsup.runner.config.ts @@ -22,7 +22,6 @@ export default defineConfig({ '@hono/node-server', 'vite', '@vitejs/plugin-react', - 'zustand', 'atomically', 'chokidar', '@napi-rs/keyring', diff --git a/core/auth/browser/storage.ts b/core/auth/browser/storage.ts index 09ccee926..217e0b5dc 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 cf5176224..f0642019a 100644 --- a/core/auth/connection-state.ts +++ b/core/auth/connection-state.ts @@ -88,13 +88,13 @@ export async function buildOAuthConnectionState( const preregistered = await storage.getClientInformation(serverUrl, true); const dynamic = await storage.getClientInformation(serverUrl); - const storageScope = storage.getScope(serverUrl); - const serverMetadata = storage.getServerMetadata(serverUrl); + const storageScope = await storage.getScope(serverUrl); + const serverMetadata = await storage.getServerMetadata(serverUrl); const authorized = isAccessTokenUsable(tokens); const grantedScope = resolveGrantedScope(tokens, storageScope); - const registrationKind = storage.getClientRegistrationKind(serverUrl); + const registrationKind = await storage.getClientRegistrationKind(serverUrl); const dynamicRegistrationKind = registrationKind === "dcr" || registrationKind === "cimd" ? registrationKind @@ -123,7 +123,7 @@ export async function buildOAuthConnectionState( const idp = params.enterpriseManagedAuth.idp; const issuer = normalizeIdpIssuer(idp.issuer); const idpSession = await getEmaIdpLoginState(storage, issuer); - const idpMetadata = storage.getServerMetadata(idpOAuthStorageKey(issuer)); + const idpMetadata = await storage.getServerMetadata(idpOAuthStorageKey(issuer)); state.ema = { idpIssuer: issuer, idpClientId: idp.clientId, diff --git a/core/auth/ema/idpOidc.ts b/core/auth/ema/idpOidc.ts index e860d7e0f..0f0237c89 100644 --- a/core/auth/ema/idpOidc.ts +++ b/core/auth/ema/idpOidc.ts @@ -34,9 +34,8 @@ async function resolveIdpMetadata( storage: OAuthStorage, fetchFn?: typeof fetch, ): Promise { - await storage.load(); const storageKey = idpOAuthStorageKey(issuer); - const cached = storage.getServerMetadata(storageKey); + const cached = await storage.getServerMetadata(storageKey); if (cached?.token_endpoint) { return cached; } @@ -102,8 +101,7 @@ export async function completeIdpOidcAuthorization(params: { }> { const issuer = normalizeIdpIssuer(params.idp.issuer); const storageKey = idpOAuthStorageKey(issuer); - await params.storage.load(); - const metadata = params.storage.getServerMetadata(storageKey); + const metadata = await params.storage.getServerMetadata(storageKey); if (!metadata) { throw new Error("IdP OAuth metadata not found — restart EMA IdP login"); } @@ -113,7 +111,7 @@ export async function completeIdpOidcAuthorization(params: { if (!clientInformation) { throw new Error("IdP client information not found — restart EMA IdP login"); } - const codeVerifier = params.storage.getCodeVerifier(storageKey); + const codeVerifier = await params.storage.getCodeVerifier(storageKey); if (!codeVerifier) { throw new Error("IdP PKCE verifier not found — restart EMA IdP login"); } diff --git a/core/auth/node/index.ts b/core/auth/node/index.ts index b4fdab894..b7526f206 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 000000000..dc0714fb0 --- /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 952cb13b4..e46e20a2a 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, @@ -17,22 +17,27 @@ export function getStateFilePath(customPath?: string): string { return customPath ?? DEFAULT_STATE_PATH; } -const storeCache = new Map>(); +const memoryCache = new Map(); +const storageCache = new Map(); + +function getSharedMemory(stateFilePath?: string): OAuthMemoryStore { + const key = getStateFilePath(stateFilePath); + let memory = memoryCache.get(key); + if (!memory) { + memory = new OAuthMemoryStore(); + memoryCache.set(key, memory); + } + return memory; +} /** - * Get or create the OAuth store instance for the given path. - * @param stateFilePath - Optional custom path to state file. Default: ~/.mcp-inspector/storage/oauth.json + * Drop cached in-memory and {@link NodeOAuthStorage} instances for a path. + * @internal Test isolation only. */ -export function getOAuthStore(stateFilePath?: string) { +export function resetNodeOAuthStorageCache(stateFilePath?: string): void { const key = getStateFilePath(stateFilePath); - let store = storeCache.get(key); - if (!store) { - const filePath = getStateFilePath(stateFilePath); - const storage = createFileStorageAdapter({ filePath }); - store = createOAuthStore(storage); - storeCache.set(key, store); - } - return store; + memoryCache.delete(key); + storageCache.delete(key); } /** @@ -40,24 +45,40 @@ export function getOAuthStore(stateFilePath?: string) { * Useful for test isolation in E2E OAuth tests. * Use a custom-path store and clear per serverUrl if you need to clear non-default storage. */ -export function clearAllOAuthClientState(): void { - const store = getOAuthStore(); - const state = store.getState(); - const urls = Object.keys(state.servers ?? {}); +export async function clearAllOAuthClientState(): Promise { + const storage = getNodeOAuthStorage(); + const filePath = getStateFilePath(); + const snapshot = await createFileOAuthPersistBackend({ filePath }).read(); + const urls = Object.keys(snapshot?.servers ?? {}); for (const url of urls) { - state.clearServerState(url); + await storage.clear(url); } } /** - * Node.js storage implementation using Zustand with file-based persistence. + * Node.js storage implementation with file-based persistence. * For InspectorClient, CLI, and TUI. */ export class NodeOAuthStorage extends OAuthStorageBase { /** - * @param storagePath - Optional path to state file. Default: ~/.mcp-inspector/oauth/state.json + * @param storagePath - Optional path to state file. Default: ~/.mcp-inspector/storage/oauth.json */ constructor(storagePath?: string) { - super(getOAuthStore(storagePath)); + const filePath = getStateFilePath(storagePath); + super( + getSharedMemory(storagePath), + createFileOAuthPersistBackend({ filePath }), + ); + } +} + +/** Cached NodeOAuthStorage instances keyed by resolved file path. */ +function getNodeOAuthStorage(storagePath?: string): NodeOAuthStorage { + const key = getStateFilePath(storagePath); + let storage = storageCache.get(key); + if (!storage) { + storage = new NodeOAuthStorage(storagePath); + storageCache.set(key, storage); } + return storage; } diff --git a/core/auth/oauth-persist.ts b/core/auth/oauth-persist.ts new file mode 100644 index 000000000..133dc4198 --- /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 64c295345..fe5a7c2ed 100644 --- a/core/auth/oauth-storage.ts +++ b/core/auth/oauth-storage.ts @@ -8,11 +8,8 @@ import { OAuthTokensSchema, } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { OAuthStorage } from "./storage.js"; -import { - type OAuthStore, - type ServerOAuthState, - waitForOAuthStorePersistLoad, -} from "./store.js"; +import { type OAuthMemoryStore, type ServerOAuthState } from "./store.js"; +import type { OAuthPersistBackend } from "./oauth-persist.js"; import type { IdpSessionState, OAuthClientRegistrationKind, @@ -20,47 +17,61 @@ import type { SaveTokensOptions, } from "./storage.js"; -type OAuthStoreWithPersist = OAuthStore & { - persist: { - hasHydrated: () => boolean; - }; -}; - /** - * Concrete OAuthStorage implementation parameterized on a Zustand store. - * The store carries the storage adapter (sessionStorage, file, remote HTTP, …), - * so the same body works for browser, Node, and remote environments. + * Concrete OAuthStorage implementation backed by in-memory state and an explicit + * persist backend (file, remote HTTP, sessionStorage, …). */ export class OAuthStorageBase implements OAuthStorage { - private readonly store: OAuthStoreWithPersist; - private loadedPromise: Promise | undefined; - - constructor(store: OAuthStore) { - this.store = store as OAuthStoreWithPersist; + 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(memory: OAuthMemoryStore, backend: OAuthPersistBackend) { + this.memory = memory; + this.backend = backend; } load(): Promise { - if (!this.loadedPromise) { - this.loadedPromise = (async () => { - if (this.store.persist.hasHydrated()) { - return; - } - await waitForOAuthStorePersistLoad(this.store); - })(); + if (!this.loadPromise) { + this.loadPromise = this.doLoad(); + } + return this.loadPromise; + } + + private async doLoad(): Promise { + if (this.loaded) { + return; } - return this.loadedPromise; + 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.ensureLoaded(); - const state = this.store.getState().getServerState(serverUrl); + const state = this.memory.getState().getServerState(serverUrl); const clientInfo = isPreregistered ? state.preregisteredClientInformation : state.clientInformation; @@ -72,10 +83,11 @@ export class OAuthStorageBase implements OAuthStorage { return await OAuthClientInformationSchema.parseAsync(clientInfo); } - getClientRegistrationKind( + async getClientRegistrationKind( serverUrl: string, - ): OAuthClientRegistrationKind | undefined { - return this.store.getState().getServerState(serverUrl) + ): Promise { + await this.ensureLoaded(); + return this.memory.getState().getServerState(serverUrl) .clientRegistrationKind; } @@ -85,10 +97,11 @@ export class OAuthStorageBase implements OAuthStorage { options: SaveClientInformationOptions, ): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { + this.memory.getState().setServerState(serverUrl, { clientInformation, clientRegistrationKind: options.registrationKind, }); + await this.persist(); } async savePreregisteredClientInformation( @@ -96,10 +109,11 @@ export class OAuthStorageBase implements OAuthStorage { clientInformation: OAuthClientInformation, ): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { + this.memory.getState().setServerState(serverUrl, { preregisteredClientInformation: clientInformation, clientRegistrationKind: "static", }); + await this.persist(); } async clearClientInformation( @@ -116,12 +130,13 @@ export class OAuthStorageBase implements OAuthStorage { updates.clientRegistrationKind = undefined; } - this.store.getState().setServerState(serverUrl, updates); + this.memory.getState().setServerState(serverUrl, updates); + await this.persist(); } async getTokens(serverUrl: string): Promise { await this.ensureLoaded(); - const state = this.store.getState().getServerState(serverUrl); + const state = this.memory.getState().getServerState(serverUrl); if (!state.tokens) { return undefined; } @@ -135,19 +150,22 @@ export class OAuthStorageBase implements OAuthStorage { options?: SaveTokensOptions, ): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { + this.memory.getState().setServerState(serverUrl, { tokens, ...(options?.enterpriseManaged === true && { enterpriseManaged: true }), }); + await this.persist(); } async clearTokens(serverUrl: string): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { tokens: undefined }); + this.memory.getState().setServerState(serverUrl, { tokens: undefined }); + await this.persist(); } - getCodeVerifier(serverUrl: string): string | undefined { - const state = this.store.getState().getServerState(serverUrl); + async getCodeVerifier(serverUrl: string): Promise { + await this.ensureLoaded(); + const state = this.memory.getState().getServerState(serverUrl); return state.codeVerifier; } @@ -156,33 +174,37 @@ export class OAuthStorageBase implements OAuthStorage { codeVerifier: string, ): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { codeVerifier }); + this.memory.getState().setServerState(serverUrl, { codeVerifier }); + await this.persist(); } async clearCodeVerifier(serverUrl: string): Promise { await this.ensureLoaded(); - this.store - .getState() - .setServerState(serverUrl, { codeVerifier: undefined }); + this.memory.getState().setServerState(serverUrl, { codeVerifier: undefined }); + await this.persist(); } - getScope(serverUrl: string): string | undefined { - const state = this.store.getState().getServerState(serverUrl); + async getScope(serverUrl: string): Promise { + await this.ensureLoaded(); + const state = this.memory.getState().getServerState(serverUrl); return state.scope; } async saveScope(serverUrl: string, scope: string | undefined): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { scope }); + this.memory.getState().setServerState(serverUrl, { scope }); + await this.persist(); } async clearScope(serverUrl: string): Promise { await this.ensureLoaded(); - this.store.getState().setServerState(serverUrl, { scope: undefined }); + this.memory.getState().setServerState(serverUrl, { scope: undefined }); + await this.persist(); } - getServerMetadata(serverUrl: string): OAuthMetadata | null { - const state = this.store.getState().getServerState(serverUrl); + async getServerMetadata(serverUrl: string): Promise { + await this.ensureLoaded(); + const state = this.memory.getState().getServerState(serverUrl); return state.serverMetadata || null; } @@ -191,26 +213,27 @@ export class OAuthStorageBase implements OAuthStorage { metadata: OAuthMetadata, ): Promise { await this.ensureLoaded(); - this.store - .getState() - .setServerState(serverUrl, { serverMetadata: metadata }); + this.memory.getState().setServerState(serverUrl, { serverMetadata: metadata }); + await this.persist(); } async clearServerMetadata(serverUrl: string): Promise { await this.ensureLoaded(); - this.store + this.memory .getState() .setServerState(serverUrl, { serverMetadata: undefined }); + await this.persist(); } async clear(serverUrl: string): Promise { await this.ensureLoaded(); - this.store.getState().clearServerState(serverUrl); + this.memory.getState().clearServerState(serverUrl); + await this.persist(); } async getIdpSession(issuer: string): Promise { await this.ensureLoaded(); - const session = this.store.getState().getIdpSession(issuer); + const session = this.memory.getState().getIdpSession(issuer); if ( !session.idToken && !session.refreshToken && @@ -226,16 +249,19 @@ export class OAuthStorageBase implements OAuthStorage { session: Partial, ): Promise { await this.ensureLoaded(); - this.store.getState().setIdpSession(issuer, session); + this.memory.getState().setIdpSession(issuer, session); + await this.persist(); } async clearIdpSession(issuer: string): Promise { await this.ensureLoaded(); - this.store.getState().clearIdpSession(issuer); + this.memory.getState().clearIdpSession(issuer); + await this.persist(); } async clearEnterpriseManagedResourceServers(): Promise { await this.ensureLoaded(); - this.store.getState().clearEnterpriseManagedResourceServers(); + this.memory.getState().clearEnterpriseManagedResourceServers(); + await this.persist(); } } diff --git a/core/auth/providers.ts b/core/auth/providers.ts index 6e4c46ad3..b9cbdf301 100644 --- a/core/auth/providers.ts +++ b/core/auth/providers.ts @@ -103,6 +103,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { private capturedAuthUrl: URL | null = null; private eventTarget: EventTarget | null = null; private suppressAuthorizationNavigation = false; + /** Cached after {@link prepareForAuth} for sync SDK `clientMetadata.scope`. */ + private cachedScope: string | undefined; protected serverUrl: string; protected storage: OAuthStorage; @@ -118,6 +120,14 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { this.clientMetadataUrl = oauthConfig.clientMetadataUrl; } + /** + * Load persisted scope into {@link cachedScope} before SDK `auth()` (which + * reads {@link clientMetadata.scope} synchronously). + */ + async prepareForAuth(): Promise { + this.cachedScope = await this.storage.getScope(this.serverUrl); + } + /** * Set the event target for dispatching oauthAuthorizationRequired events */ @@ -145,7 +155,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { } get scope(): string | undefined { - return this.storage.getScope(this.serverUrl); + return this.cachedScope; } get redirectUrl(): string { @@ -200,6 +210,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { async saveScope(scope: string | undefined): Promise { await this.storage.saveScope(this.serverUrl, scope); + this.cachedScope = scope; } async savePreregisteredClientInformation( @@ -239,8 +250,8 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { await this.storage.saveCodeVerifier(this.serverUrl, codeVerifier); } - codeVerifier(): string { - const verifier = this.storage.getCodeVerifier(this.serverUrl); + async codeVerifier(): Promise { + const verifier = await this.storage.getCodeVerifier(this.serverUrl); if (!verifier) { throw new Error("No code verifier saved for session"); } @@ -251,7 +262,7 @@ export class BaseOAuthClientProvider implements OAuthClientProvider { await this.storage.clear(this.serverUrl); } - getServerMetadata(): OAuthMetadata | null { + async getServerMetadata(): Promise { return this.storage.getServerMetadata(this.serverUrl); } diff --git a/core/auth/remote/storage-remote.ts b/core/auth/remote/storage-remote.ts index 12fc78cfa..c2e23df37 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 441821e8a..7ac9bf1ee 100644 --- a/core/auth/storage.ts +++ b/core/auth/storage.ts @@ -7,7 +7,7 @@ import type { OAuthClientRegistrationKind } from "./types.js"; /** * Abstract storage interface for OAuth state - * Supports both browser (sessionStorage) and Node.js (Zustand) environments + * Supports browser (sessionStorage), Node.js (file), and remote HTTP backends. */ export interface SaveTokensOptions { /** Marks resource tokens minted via EMA (legs 2–3) for sign-out cleanup. */ @@ -22,9 +22,9 @@ export interface SaveClientInformationOptions { export interface OAuthStorage { /** - * Load persisted OAuth state into memory. Resolves when the backing store - * (file, remote API, sessionStorage, …) has been read. Call before relying on - * sync reads, or await mutating methods which call this internally. + * Optional preload of persisted state into memory. Getters and setters load + * automatically when needed; use this only for fail-fast at known boundaries + * (e.g. OAuth callback resume after a full-page navigation). */ load(): Promise; @@ -41,7 +41,7 @@ export interface OAuthStorage { */ getClientRegistrationKind( serverUrl: string, - ): OAuthClientRegistrationKind | undefined; + ): Promise; /** * Save client information (dynamically registered) @@ -90,7 +90,7 @@ export interface OAuthStorage { /** * Get code verifier (for PKCE) */ - getCodeVerifier(serverUrl: string): string | undefined; + getCodeVerifier(serverUrl: string): Promise; /** * Save code verifier (for PKCE) @@ -105,7 +105,7 @@ export interface OAuthStorage { /** * Get scope */ - getScope(serverUrl: string): string | undefined; + getScope(serverUrl: string): Promise; /** * Save scope @@ -120,7 +120,7 @@ export interface OAuthStorage { /** * Get server metadata discovered during OAuth */ - getServerMetadata(serverUrl: string): OAuthMetadata | null; + getServerMetadata(serverUrl: string): Promise; /** * Save server metadata discovered during OAuth diff --git a/core/auth/store.ts b/core/auth/store.ts index 1be06be60..08e2084b0 100644 --- a/core/auth/store.ts +++ b/core/auth/store.ts @@ -1,16 +1,18 @@ /** - * OAuth store factory using Zustand. - * Creates a store with any storage adapter (file, remote, sessionStorage). + * In-memory OAuth state store (servers + IdP sessions). + * Persisted via `OAuthStorageBase` + `OAuthPersistBackend` backends. */ -import { createStore } from "zustand/vanilla"; -import { persist, createJSONStorage } from "zustand/middleware"; import type { OAuthClientInformation, OAuthTokens, OAuthMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; -import type { IdpSessionState, OAuthClientRegistrationKind } from "./storage.js"; +import type { + IdpSessionState, + OAuthClientRegistrationKind, +} from "./storage.js"; +import type { OAuthPersistSnapshot } from "./oauth-persist.js"; /** * OAuth state for a single server @@ -29,7 +31,7 @@ export interface ServerOAuthState { } /** - * Zustand store state (all servers) + * OAuth store state (all servers) plus mutation helpers. */ export interface OAuthStoreState { servers: Record; @@ -43,134 +45,81 @@ export interface OAuthStoreState { clearEnterpriseManagedResourceServers: () => void; } -export type OAuthStore = ReturnType; - -const persistLoadByStore = new WeakMap>(); - /** - * Wait until the store's initial persist read has finished (success or failure). - * Used by {@link OAuthStorageBase.load}; resolves on success, rejects when the - * storage adapter's getItem fails (Zustand does not fire onFinishHydration on error). + * Mutable in-memory OAuth state keyed by server URL and IdP issuer. */ -export function waitForOAuthStorePersistLoad(store: OAuthStore): Promise { - const promise = persistLoadByStore.get(store); - if (!promise) { - return Promise.resolve(); - } - return promise; -} +export class OAuthMemoryStore { + private servers: Record = {}; + private idpSessions: Record = {}; -/** - * Creates a Zustand store for OAuth state with the given storage adapter. - * The storage adapter handles persistence (file, remote HTTP, sessionStorage, etc.). - * - * @param storage - Zustand storage adapter (from createJSONStorage) - * @returns Zustand store instance - */ -export function createOAuthStore( - storage: ReturnType, -) { - let resolvePersistLoad!: () => void; - let rejectPersistLoad!: (error: unknown) => void; - let persistLoadSettled = false; - - const persistLoadPromise = new Promise((resolve, reject) => { - resolvePersistLoad = resolve; - rejectPersistLoad = reject; - }); - // Zustand kicks off async hydration as soon as the store is created, before - // any consumer calls `OAuthStorageBase.load()`. If that initial read fails - // and no one is awaiting yet, `persistLoadPromise` would reject with no - // handler and surface as an unhandled rejection. Attach a no-op handler so - // the promise is always "handled"; real awaiters still observe the rejection - // through the reference returned by `waitForOAuthStorePersistLoad`. - void persistLoadPromise.catch(() => {}); - - const settlePersistLoad = (error?: unknown) => { - if (persistLoadSettled) { - /* v8 ignore next -- idempotent if persist signals completion more than once */ - return; + constructor(initial?: OAuthPersistSnapshot) { + if (initial) { + this.replace(initial); } - persistLoadSettled = true; - if (error) { - rejectPersistLoad(error); - return; - } - resolvePersistLoad(); - }; + } - const store = createStore()( - persist( - (set, get) => ({ - servers: {}, - idpSessions: {}, - getServerState: (serverUrl: string) => { - return get().servers[serverUrl] || {}; - }, - setServerState: ( - serverUrl: string, - updates: Partial, - ) => { - set((state) => ({ - servers: { - ...state.servers, - [serverUrl]: { - ...state.servers[serverUrl], - ...updates, - }, - }, - })); - }, - clearServerState: (serverUrl: string) => { - set((state) => { - const rest = { ...state.servers }; - delete rest[serverUrl]; - return { servers: rest }; - }); - }, - getIdpSession: (issuer: string) => { - return get().idpSessions[issuer] || {}; - }, - setIdpSession: (issuer: string, updates: Partial) => { - set((state) => ({ - idpSessions: { - ...state.idpSessions, - [issuer]: { - ...state.idpSessions[issuer], - ...updates, - }, - }, - })); - }, - clearIdpSession: (issuer: string) => { - set((state) => { - const rest = { ...state.idpSessions }; - delete rest[issuer]; - return { idpSessions: rest }; - }); - }, - clearEnterpriseManagedResourceServers: () => { - set((state) => { - const rest = { ...state.servers }; - for (const [url, serverState] of Object.entries(state.servers)) { - if (serverState.enterpriseManaged === true) { - delete rest[url]; - } - } - return { servers: rest }; - }); - }, - }), - { - name: "mcp-inspector-oauth", - storage, - onRehydrateStorage: () => (_state, error) => { - settlePersistLoad(error); - }, + getState(): OAuthStoreState { + return { + servers: this.servers, + idpSessions: this.idpSessions, + getServerState: (serverUrl: string) => { + return this.servers[serverUrl] || {}; }, - ), - ); + setServerState: ( + serverUrl: string, + updates: Partial, + ) => { + this.servers = { + ...this.servers, + [serverUrl]: { + ...this.servers[serverUrl], + ...updates, + }, + }; + }, + clearServerState: (serverUrl: string) => { + const rest = { ...this.servers }; + delete rest[serverUrl]; + this.servers = rest; + }, + getIdpSession: (issuer: string) => { + return this.idpSessions[issuer] || {}; + }, + setIdpSession: (issuer: string, updates: Partial) => { + this.idpSessions = { + ...this.idpSessions, + [issuer]: { + ...this.idpSessions[issuer], + ...updates, + }, + }; + }, + clearIdpSession: (issuer: string) => { + const rest = { ...this.idpSessions }; + delete rest[issuer]; + this.idpSessions = rest; + }, + clearEnterpriseManagedResourceServers: () => { + const rest = { ...this.servers }; + for (const [url, serverState] of Object.entries(this.servers)) { + if (serverState.enterpriseManaged === true) { + delete rest[url]; + } + } + this.servers = rest; + }, + }; + } - persistLoadByStore.set(store, persistLoadPromise); - return store; + snapshot(): OAuthPersistSnapshot { + return { + servers: { ...this.servers }, + idpSessions: { ...this.idpSessions }, + }; + } + + replace(snapshot: OAuthPersistSnapshot): void { + this.servers = { ...(snapshot.servers ?? {}) }; + this.idpSessions = { ...(snapshot.idpSessions ?? {}) }; + } } diff --git a/core/mcp/oauthManager.ts b/core/mcp/oauthManager.ts index ef8da8bb3..f413e5a4b 100644 --- a/core/mcp/oauthManager.ts +++ b/core/mcp/oauthManager.ts @@ -139,7 +139,8 @@ export class OAuthManager { provider.setEventTarget(this.params.getEventTarget()); - const storedScope = this.oauthConfig.storage.getScope(serverUrl); + await provider.prepareForAuth(); + const storedScope = provider.scope; if (storedScope === undefined && this.oauthConfig.scope) { await provider.saveScope(this.oauthConfig.scope); } @@ -162,7 +163,7 @@ export class OAuthManager { return this.oauthConfig.enterpriseManaged === true; } - private getEmaFlowConfig(): EmaFlowConfig { + private async getEmaFlowConfig(): Promise { if (!this.oauthConfig.storage || !this.oauthConfig.redirectUrlProvider) { throw new Error( "OAuth environment components (storage, redirectUrlProvider) are required.", @@ -177,16 +178,17 @@ export class OAuthManager { : "not_configured", ); } + const storedScope = await this.oauthConfig.storage.getScope( + this.getServerUrl(), + ); return { serverUrl: this.getServerUrl(), idp, resourceClientId: this.oauthConfig.clientId, resourceClientSecret: this.oauthConfig.clientSecret, scope: - computeScopeUnion( - this.oauthConfig.scope, - this.oauthConfig.storage.getScope(this.getServerUrl()), - ) || this.oauthConfig.scope, + computeScopeUnion(this.oauthConfig.scope, storedScope) || + this.oauthConfig.scope, redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl(), storage: this.oauthConfig.storage, fetchFn: this.params.effectiveAuthFetch, @@ -196,14 +198,14 @@ export class OAuthManager { /** Attempt silent EMA (cached IdP session + legs 2–3). */ async trySilentEnterpriseManagedAuth(): Promise { if (!this.isEnterpriseManaged()) return false; - const result = await trySilentEmaAuth(this.getEmaFlowConfig()); + const result = await trySilentEmaAuth(await this.getEmaFlowConfig()); if (result.status === "success") return true; if (result.status === "mint_failed") throw result.error; return false; } private async authenticateEnterpriseManaged(): Promise { - const config = this.getEmaFlowConfig(); + const config = await this.getEmaFlowConfig(); const silent = await trySilentEmaAuth(config); if (silent.status === "success") { return undefined; @@ -282,14 +284,13 @@ export class OAuthManager { iss?: string, ): Promise { try { - await this.requireStorage().load(); - if (this.isEnterpriseManaged()) { + const emaConfig = await this.getEmaFlowConfig(); const scopeForMint = - this.pendingAuthorizationScope ?? this.getEmaFlowConfig().scope; + this.pendingAuthorizationScope ?? emaConfig.scope; const config = scopeForMint - ? { ...this.getEmaFlowConfig(), scope: scopeForMint } - : this.getEmaFlowConfig(); + ? { ...emaConfig, scope: scopeForMint } + : emaConfig; const tokens = await completeEmaIdpAuthorizationAndMint( config, authorizationCode, @@ -448,7 +449,7 @@ export class OAuthManager { */ async refreshEnterpriseManagedTokens(): Promise { if (!this.isEnterpriseManaged()) return false; - const tokens = await refreshEmaResourceTokens(this.getEmaFlowConfig()); + const tokens = await refreshEmaResourceTokens(await this.getEmaFlowConfig()); return tokens !== undefined; } @@ -494,7 +495,7 @@ export class OAuthManager { } const effectiveScope = resolveEffectiveGrantedScope( - storage.getScope(serverUrl), + await storage.getScope(serverUrl), tokens.scope, ); return !isStrictScopeSuperset(scopeForAuth, effectiveScope); @@ -570,7 +571,7 @@ export class OAuthManager { } const previousScope = computeScopeUnion( - storage?.getScope(serverUrl), + storage ? await storage.getScope(serverUrl) : undefined, grantedTokenScope, ); const requiredFromChallenge = @@ -607,8 +608,10 @@ export class OAuthManager { return this.oauthConfig.scope?.trim() || undefined; } - private emaFlowConfigForChallenge(challenge: AuthChallenge): EmaFlowConfig { - const base = this.getEmaFlowConfig(); + private async emaFlowConfigForChallenge( + challenge: AuthChallenge, + ): Promise { + const base = await this.getEmaFlowConfig(); const enriched = challenge.authorizationScopes; if (enriched && enriched.length > 0) { return { ...base, scope: enriched.join(" ") }; @@ -628,7 +631,7 @@ export class OAuthManager { return { kind: "step_up_confirm", challenge: enriched }; } - const config = this.emaFlowConfigForChallenge(enriched); + const config = await this.emaFlowConfigForChallenge(enriched); if (enriched.reason === "insufficient_scope") { const silent = await trySilentEmaAuth(config); @@ -863,7 +866,10 @@ export class OAuthManager { > { const provider = await this.createOAuthProvider(); if (this.isEnterpriseManaged()) { - return new EmaTransportOAuthProvider(provider, this.getEmaFlowConfig()); + return new EmaTransportOAuthProvider( + provider, + await this.getEmaFlowConfig(), + ); } return provider; } diff --git a/core/storage/adapters/file-storage.ts b/core/storage/adapters/file-storage.ts deleted file mode 100644 index 079b25485..000000000 --- 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 214ba563d..000000000 --- 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 d143584a5..000000000 --- a/core/storage/adapters/remote-storage.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Remote HTTP storage adapter for Zustand persist middleware. - * Stores entire store state via HTTP API (GET/POST/DELETE /api/storage/:storeId). - */ - -import { createJSONStorage } from "zustand/middleware"; - -export interface RemoteStorageAdapterOptions { - /** Base URL of the remote server (e.g. http://localhost:3000) */ - baseUrl: string; - /** Store ID (e.g. "oauth", "inspector-settings") */ - storeId: string; - /** Optional auth token for x-mcp-remote-auth header */ - authToken?: string; - /** Fetch function to use (default: globalThis.fetch) */ - fetchFn?: typeof fetch; -} - -/** - * Creates a Zustand storage adapter that reads/writes via HTTP API. - * Conforms to Zustand's StateStorage interface. - */ -export function createRemoteStorageAdapter( - options: RemoteStorageAdapterOptions, -): ReturnType { - const baseUrl = options.baseUrl.replace(/\/$/, ""); - const fetchFn = options.fetchFn ?? globalThis.fetch; - - return createJSONStorage(() => ({ - getItem: async (_name: string) => { - const headers: Record = {}; - if (options.authToken) { - headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; - } - - const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { - method: "GET", - headers, - }); - - if (!res.ok) { - if (res.status === 404) { - return null; - } - throw new Error(`Failed to read store: ${res.status}`); - } - - const store = await res.json(); - // Zustand stores: { state: {...}, version: number } - // API returns the stored blob. If empty, Zustand hasn't initialized yet. - if (Object.keys(store).length === 0) { - return null; // Empty store means not initialized yet - } - // Return the stored Zustand format as string - return JSON.stringify(store); - }, - setItem: async (_name: string, value: string) => { - const headers: Record = { - "Content-Type": "application/json", - }; - if (options.authToken) { - headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; - } - - // Zustand gives us the full persisted format as a string - // Store it as-is (the API treats it as an opaque blob) - const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { - method: "POST", - headers, - body: value, // Already a JSON string from Zustand - }); - - if (!res.ok) { - throw new Error(`Failed to write store: ${res.status}`); - } - }, - removeItem: async (_name: string) => { - const headers: Record = {}; - if (options.authToken) { - headers["x-mcp-remote-auth"] = `Bearer ${options.authToken}`; - } - - const res = await fetchFn(`${baseUrl}/api/storage/${options.storeId}`, { - method: "DELETE", - headers, - }); - - // 404 is fine (already deleted), but other errors should propagate - if (!res.ok && res.status !== 404) { - throw new Error(`Failed to delete store: ${res.status}`); - } - }, - })); -} diff --git a/core/storage/store-io.ts b/core/storage/store-io.ts index 8b8fa3d9a..db04fe2ee 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 000000000..83c435be4 --- /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 fc02e30d0..e43c923bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,7 @@ "react": "^19.2.4", "vite": "^8.0.0", "yaml": "^2.9.0", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "bin": { "mcp-inspector": "clients/launcher/build/index.js" @@ -3257,35 +3256,6 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } - }, - "node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } } } } diff --git a/package.json b/package.json index dd56fe208..3adbbc62f 100644 --- a/package.json +++ b/package.json @@ -77,8 +77,7 @@ "react": "^19.2.4", "vite": "^8.0.0", "yaml": "^2.9.0", - "zod": "^4.3.6", - "zustand": "^5.0.13" + "zod": "^4.3.6" }, "overrides": { "ink-select-input": "^6.2.0" diff --git a/specification/v2_auth_ema.md b/specification/v2_auth_ema.md index da4d8f021..8e46d57d4 100644 --- a/specification/v2_auth_ema.md +++ b/specification/v2_auth_ema.md @@ -109,7 +109,7 @@ Until **client profiles** (see below) land, install-level client config — star **Do not** put IdP credentials in the OAuth store or per-server `mcp.json`. **Do not** use environment variables for IdP config — all clients read/write the same `client.json` file. -**Runtime auth state** (standard OAuth tokens, PKCE, **and** EMA runtime state: cached IdP ID Token / refresh token, leg-1 in-flight PKCE under `ema-idp:{issuer}`, and per-server resource tokens tagged `enterpriseManaged: true` when minted via EMA legs 2–3) uses the existing **`OAuthStorage`** interface (`core/auth/storage.ts`) — whatever adapter each client already passes to `InspectorClient`. ID-JAG is **not** cached — legs 2–3 re-mint on each connect or 401 refresh. EMA does **not** mandate a specific backing store; it extends the serialized auth state that adapter already persists. +**Runtime auth state** (standard OAuth tokens, PKCE, **and** EMA runtime state: cached IdP ID Token / refresh token, leg-1 in-flight PKCE under `ema-idp:{issuer}`, and per-server resource tokens tagged `enterpriseManaged: true` when minted via EMA legs 2–3) uses the **`OAuthStorage`** interface (`core/auth/storage.ts`) — whichever implementation each client passes to `InspectorClient` (`NodeOAuthStorage`, `RemoteOAuthStorage`, etc.). ID-JAG is **not** cached — legs 2–3 re-mint on each connect or 401 refresh. Web uses shared file-backed OAuth via `RemoteOAuthStorage` → `/api/storage/oauth` (same `oauth.json` as CLI/TUI). Existing **sessionStorage** OAuth blobs from before that wiring are not migrated automatically — users start fresh or re-authorize once. @@ -215,7 +215,7 @@ interface OAuthStoreState { } ``` -Extend `OAuthStorage` / store methods accordingly. IdP **credentials** stay in `client.json`; only runtime tokens belong in `idpSessions`. Per-server `ServerOAuthState` may include `enterpriseManaged: true` when EMA legs 2–3 persist resource tokens (`saveTokens(..., { enterpriseManaged: true })`). **Sign out** clears `idpSessions` for the configured issuer and removes tagged EMA resource entries from the shared OAuth blob — standard OAuth server entries are not cleared. All adapters (`BrowserOAuthStorage`, `NodeOAuthStorage`, `RemoteOAuthStorage`) serialize the same extended shape through their existing backends. +Extend `OAuthStorage` / store methods accordingly. IdP **credentials** stay in `client.json`; only runtime tokens belong in `idpSessions`. Per-server `ServerOAuthState` may include `enterpriseManaged: true` when EMA legs 2–3 persist resource tokens (`saveTokens(..., { enterpriseManaged: true })`). **Sign out** clears `idpSessions` for the configured issuer and removes tagged EMA resource entries from the shared OAuth blob — standard OAuth server entries are not cleared. All implementations (`BrowserOAuthStorage`, `NodeOAuthStorage`, `RemoteOAuthStorage`) extend `OAuthStorageBase` and serialize the same shape through their persist backends (`core/auth/oauth-persist.ts`). #### Later: client profiles @@ -298,7 +298,7 @@ Install-level IdP credentials are edited in **Client Settings**, separate from p 2. Clears leg-1 in-flight state at `servers["ema-idp:{issuer}"]` 3. Calls `OAuthStorage.clearEnterpriseManagedResourceServers()` — scans the shared OAuth blob and removes `servers[url]` entries where `enterpriseManaged === true` (set when EMA legs 2–3 saved tokens via `saveTokens(url, tokens, { enterpriseManaged: true })` in `emaFlow.ts` / `EmaTransportOAuthProvider`). Standard OAuth entries in the same blob are **not** cleared. Untagged legacy EMA tokens (saved before tagging landed) are not removed until the next EMA connect re-saves with the tag. The next connect to a cleared EMA server has no cached access token, so a **401** triggers leg 1 (IdP login) via `authenticate()`. -- **Web OAuth store singleton:** web uses `getWebRemoteOAuthStorage()` (`clients/web/src/lib/remoteOAuthStorage.ts`) — memoized `RemoteOAuthStorage` backed by `/api/storage/oauth` — so Client Settings sign-out, EMA IdP session, connect, and per-server clear all mutate the same in-memory Zustand view as the active `InspectorClient`. +- **Web OAuth store singleton:** web uses `getWebRemoteOAuthStorage()` (`clients/web/src/lib/remoteOAuthStorage.ts`) — memoized `RemoteOAuthStorage` backed by `/api/storage/oauth` — so Client Settings sign-out, EMA IdP session, connect, and per-server clear all mutate the same in-memory `OAuthStorageBase` view as the active `InspectorClient`. **Future (not implemented):** explicit **Sign out** may additionally invoke the IdP's OIDC **end-session** / logout endpoint (RP-initiated logout) so the IdP SSO cookie is cleared — not just inspector-local IdP/resource token state. Today sign-out is **local-only** (clear `idpSessions`, tagged EMA resource entries, and leg-1 PKCE); the IdP may still treat the browser as signed in and skip the login prompt on the next authorize redirect until the IdP session expires or the user signs out at the IdP. @@ -416,7 +416,7 @@ Inspector touchpoints to extend: - `core/client/` (or `core/storage/`) — `loadClientConfig` / `saveClientConfig`; `NodeClientStorage`; remote adapter for `/api/storage/client` - `core/auth/utils.ts` — `parseHttpUrl` (issuer and EMA URL validation) - `core/auth/connection-state.ts` — `buildOAuthConnectionState`, `OAuthConnectionState` -- `core/auth/storage.ts` + `core/auth/store.ts` — `OAuthStorage` / `OAuthStoreState`: store-root `idpSessions`; `ServerOAuthState.enterpriseManaged` tag; `SaveTokensOptions`; `clearEnterpriseManagedResourceServers()`; all adapters pick up the extended shape automatically +- `core/auth/storage.ts` + `core/auth/store.ts` + `core/auth/oauth-storage.ts` + `core/auth/oauth-persist.ts` — `OAuthStorage` / `OAuthMemoryStore`: store-root `idpSessions`; `ServerOAuthState.enterpriseManaged` tag; async getters with auto-load; setters auto-persist; legacy `{ state, version }` envelope promoted on read, plain JSON on write - `core/auth/ema/` — `idpOidc.ts` (leg 1), `wire.ts` (legs 2–3), `emaFlow.ts` (orchestration + tagged `saveTokens`), `transportProvider.ts` (401 re-auth + tagged `saveTokens`), `resourceContext.ts`, `idpSession.ts` (`getEmaIdpLoginState`, `clearEmaIdpSession`), `jwt.ts`, `storage.ts`, `constants.ts`, `clientConfigError.ts` (`EmaClientNotConfiguredError`) - `core/mcp/oauthManager.ts` — branch on `enterpriseManaged`; EMA connect via `emaFlow`; `getOAuthState()`; `createOAuthProvider()` returns `EmaTransportOAuthProvider` for EMA servers (401 re-auth); standard OAuth unchanged - `core/mcp/inspectorClient.ts` — declare EMA extension in `initialize` capabilities when `enterpriseManaged`; `getOAuthState()` @@ -503,7 +503,7 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che - [x] **Manual staging validation** — full EMA connect against live xaa.dev (see §Staging validation). Confirms legs 1–3 and web UX outside CI. - [x] **Automated integration tests** — mock IdP + mock resource AS + composable protected-resource server (see §Phase 3b test plan). Live xaa.dev is **not** required for CI. -- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (shared-storage follow-up). +- [ ] *(optional)* `RemoteOAuthStorage` EMA E2E variant — same happy path via `/api/storage/oauth` (standard OAuth remote E2E exists; EMA-specific variant optional). - [ ] *(optional)* 401 re-auth stretch — invalidate resource token, assert legs 2–3 re-run. ### Phase 4 — Other clients @@ -514,8 +514,8 @@ Design decisions for EMA are complete. Remaining work is the phased plan and che ### Later -- [ ] **Remove Zustand from OAuth persistence** — direct read/write of OAuth blob; see §Follow-up work -- [x] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity (#1548); see §Follow-up work +- [x] **Remove Zustand from OAuth persistence** — `OAuthStorageBase` + persist backends (#1549); see §OAuth persistence +- [x] **Web shared OAuth store** — `RemoteOAuthStorage` / `oauth.json` for web + CLI + TUI parity (#1548); see §Shared file-backed OAuth state - [ ] Client profile persistence (migrate from `client.json`; may extend or replace web Client Settings) - [ ] Optional: adopt `@modelcontextprotocol/client` v2 Layer-2 helpers for legs 2–3 (replace `wire.ts`) or full v2 transport for EMA - [ ] Optional: IdP **end-session** / RP-initiated logout on explicit **Sign out** (today sign-out clears inspector-local IdP session and tagged EMA resource tokens only; IdP browser SSO may remain active) @@ -553,14 +553,21 @@ Registration on xaa.dev: composable test server registered as a **resource serve These items came out of EMA staging and apply beyond EMA. They are **not** required to ship EMA on web but should be tracked. -### Remove Zustand from OAuth persistence +### OAuth persistence (#1549 — done) -Today `OAuthStorage` is backed by a **Zustand** `persist` store (`core/auth/store.ts`) with adapters for sessionStorage (`BrowserOAuthStorage`), file (`NodeOAuthStorage` via `file-storage.ts`), and HTTP (`RemoteOAuthStorage` via `remote-storage.ts`). The Zustand layer wraps a simple `{ servers, idpSessions }` blob and adds: +`OAuthStorage` is implemented by **`OAuthStorageBase`** (`core/auth/oauth-storage.ts`) backed by **`OAuthMemoryStore`** (`core/auth/store.ts`) and an explicit **`OAuthPersistBackend`** (`core/auth/oauth-persist.ts`): -- `{ state, version }` envelope on file/remote adapters (see [Servers file](v2_servers_file.md) — intentionally avoided for `mcp.json`) -- A second in-memory copy and async persist semantics that complicate sign-out, callback rebuild, and multi-consumer sharing (web uses `getWebRemoteOAuthStorage()`; legacy `getBrowserOAuthStorage()` existed partly to paper over this for sessionStorage) +| Backend | Class | Where | +| ------- | ----- | ----- | +| File | `NodeOAuthStorage` | CLI, TUI, default `~/.mcp-inspector/storage/oauth.json` | +| Remote HTTP | `RemoteOAuthStorage` | Web → `GET/POST/DELETE /api/storage/oauth` on the Hono backend | +| Session | `BrowserOAuthStorage` | Reference/tests only; v2 web uses shared file-backed remote store | -**Direction:** refactor `OAuthStorage` implementations to read/write the serialized shape **directly** (file I/O, `/api/storage/oauth`, or in-memory for tests) without Zustand. Keep the `OAuthStorage` interface stable for `InspectorClient` / `OAuthManager`. +**On disk / wire:** writes plain JSON `{ servers, idpSessions }`. **Reads** still accept legacy blobs wrapped as `{ state: { servers, idpSessions }, version }` (produced by the old Zustand `persist` middleware) and promote the inner payload — migrate-on-write on the next save. + +**API shape:** all getters are async (`ensureLoaded()` internally); setters `await` persist. `load()` is optional preload (OAuth callback fail-fast on web), not required before reads. `BaseOAuthClientProvider.prepareForAuth()` caches scope for the SDK’s sync `clientMetadata.scope`. + +**Removed:** Zustand dependency, `core/storage/adapters/*`, public `getOAuthStore()`. ### Shared file-backed OAuth state (web + CLI + TUI) @@ -639,7 +646,7 @@ Shared helper `minimalOAuthAsMetadata()` satisfies SDK OAuth metadata schema. `c Runs under `integration` project; `test-servers:build` prerequisite (existing). Transport: `streamable-http` only (EMA is HTTP-only). -**Storage variant (optional — not implemented):** same flow with `RemoteOAuthStorage` + `createRemoteApp` tmp dir — proves EMA tokens persist via `/api/storage/oauth` (feeds shared-storage follow-up). +**Storage variant (optional — not implemented):** same flow with `RemoteOAuthStorage` + `createRemoteApp` tmp dir — proves EMA tokens persist via `/api/storage/oauth` (standard remote OAuth E2E already covers the storage API). #### Layer 4 — Protected-resource discovery — **partial** @@ -658,7 +665,7 @@ Existing coverage: `xaa-ema-http.json` config load, `ExternalAccessTokenValidato 1. ~~Layer 1 (`wire` / `emaFlow` unit tests)~~ — **done** 2. ~~Layer 2 mock servers helper~~ — **done** 3. ~~Layer 3 single happy-path E2E (`streamable-http`)~~ — **done** -4. Layer 3 storage variant with `RemoteOAuthStorage` — optional, aligns with shared-storage refactor. +4. Layer 3 storage variant with `RemoteOAuthStorage` — optional EMA-specific EMA E2E (shared storage already covered by standard remote OAuth tests). 5. 401 re-auth case — connect, invalidate resource token, assert legs 2–3 re-run (stretch). ### Done criteria for Phase 3b checklist diff --git a/specification/v2_auth_hardening.md b/specification/v2_auth_hardening.md index 95e02793f..1affdfc4e 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 0fdc5b56f..601eac0d8 100644 --- a/specification/v2_auth_mid_session.md +++ b/specification/v2_auth_mid_session.md @@ -536,13 +536,13 @@ On each MCP request, after bearer validation: | [v2_auth_hardening.md](v2_auth_hardening.md) | Connect-time SEPs; v2 SDK upgrade; direct transport silent retry delegation | | [v2_auth_ema.md](v2_auth_ema.md) | EMA legs 2–3 re-mint; scope resolution; no resource-OAuth fallback | | [v2_auth_smoke_testing.md](v2_auth_smoke_testing.md) | Manual OAuth and mid-session validation procedures | -| [v2_storage.md](v2_storage.md) | Target: shared `oauth.json` via `RemoteOAuthStorage` on web | +| [v2_storage.md](v2_storage.md) | Shared `oauth.json` via `RemoteOAuthStorage` on web (implemented) | --- ## Future work -- **Web default `RemoteOAuthStorage`** — shared `oauth.json` with TUI/CLI; optional `navigator.locks` for silent refresh single-flight across browser tabs. +- **Optional `navigator.locks`** on shared `RemoteOAuthStorage` — single-flight silent refresh across browser tabs (see [EMA §Shared storage](v2_auth_ema.md)). - **v2 SDK transport upgrade** — delegate direct streamable HTTP silent 401/403 + SEP-2350 union to SDK; remove `mcpAuth` / client intercept shims where redundant. - **Server-side token refresh** on the node using `RemoteAuthState.oauthClient` + `refresh_token` (browser owns refresh today). - **Connection Info** — display effective vs pending scopes. diff --git a/specification/v2_auth_smoke_testing.md b/specification/v2_auth_smoke_testing.md index d9fbe4747..b37c76922 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 47c9dbcf4..93836f680 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 26b2f4c49..5910255e4 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 3d012d722..9358d3e48 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 2a895502e..6e019c8f2 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 12da6073d..33e8687af 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 bc58aa592..0d32d264e 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 244d4fe2f..da73f6f28 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 1c7e6cd2d..0326e3c27 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'),