* wip(core): bulk-copy v1.5 InspectorClient runtime files (#1302) Verbatim ports from origin/v1.5/main into v2/main core/: - logging/ (2 files) - storage/ + storage/adapters/ (4 files) - auth/ + auth/browser/ + auth/node/ + auth/remote/ (16 files) - mcp/config.ts + mcp/node/ (4 files, stdio transport) - mcp/remote/ + mcp/remote/node/ (12 files, HTTP/SSE transport) - mcp/inspectorClient.ts (~2169 LOC), messageTrackingTransport, fetchTracking, oauthManager, index, state/index Total: ~7,800 LOC of source files, copied unmodified via `git show origin/v1.5/main:<path> > <dest>`. Also: - Add deps: pino, hono, @hono/node-server, atomically, zustand, @types/node - tsconfig.app.json: extend paths to resolve new node-only deps from core/, add "node" to types, disable erasableSyntaxOnly (v1.5 code uses parameter properties — TODO to re-enable in follow-up) Compiles down to 47 type errors, all in three categories: - 24 TS2305: v2 dropped v1.5 type exports (InspectorClientOptions, CreateTransport, InspectorClientEnvironment, AppRendererClient, SamplingCreateMessage class, ElicitationCreateMessage class, jsonUtils helpers, OAuth events). Per user direction, will re-add these to v2's diverged files (types.ts, samplingCreateMessage.ts, etc.) so the ported code stays verbatim. - 15 TS7006: implicit-any params in v1.5 code (v1.5 tsconfig was looser than v2's noImplicitAny). Need explicit annotations. - 8 other TS errors: ../package.json import, Transport|null narrowing, hono Context generic mismatch, one unused parameter. Tests not yet ported (19 files, ~11,170 LOC). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip(core): integrate ported v1.5 InspectorClient + port tests (#1302) Source integration (typecheck + build now pass): - Re-add v1.5 types/classes dropped during v2 evolution: - types.ts: CreateTransport*, AppRendererClient, InspectorClient{Environment,Options} - samplingCreateMessage.ts / elicitationCreateMessage.ts: classes + keep v2's InspectorPending{Sampling,Elicitation} interface - inspectorClientEventTarget.ts: oauth* event keys - json/jsonUtils.ts: convertParameterValue / convertToolParameters / convertPromptArguments + ParameterSchema - inspectorClient.ts: replace `../package.json` import (v2 has no core/package.json) with a hardcoded identity constant - remote/node/server.ts: cast hono Context for streamSSE (hono's generic typing tightened since v1.5) - oauth-callback-server.ts: drop unused hostname field - tsconfig: enable @types/node, add paths for new bare-module deps (pino, zustand, hono, atomically) in app + test configs - vite.config.ts: mirror those module aliases for the unit project (which runs from repoRoot) Tests ported (19 files, ~11K LOC) under clients/web/src/test/core/: - All core/__tests__/auth/* (7 files) - inspectorClient.test.ts + 4 OAuth variants - mcp/oauthManager.test.ts, jsonUtils.test.ts - transport, remote-transport, remote-server-config, storage-adapters - helpers/oauth-client-fixtures.ts Bulk-rewrote `from "../mcp/…"` → `from "@inspector/core/mcp/…"` etc. Current state: - typecheck: 0 errors - build: passes (vite v8, 788 modules) - unit tests: 1014 / 1034 pass (937 pre-existing + 77 new core tests) - 20 failures in 13 files; failure modes: - 13 file-load failures needing the v1.5 `test-servers/` package (@modelcontextprotocol/inspector-test-server) which isn't in v2 yet or real fs/network not available in happy-dom - 7 assertion failures in discovery.test.ts + state-machine.test.ts (SDK 1.29 vs 1.25 API drift — mocks don't match new shape) Coverage gate not yet checked against new files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip(core): port test-servers, more types, partial test integration (#1302) - Port v1.5 test-servers/ package (12 files, ~5K LOC) under repo-root/test-servers/, wire @modelcontextprotocol/inspector-test-server alias in both vite.config.ts (regex form) and tsconfig.test.json. - Add deps: express ^5, yaml ^2, @types/express ^5. - Re-export InspectorClientOptions/Environment/Transport* from inspectorClient.ts so v1.5 tests that import them from that module path keep resolving. - Switch InspectorClientEventMap to use SamplingCreateMessage / ElicitationCreateMessage classes (matches v1.5 event payload contracts) while keeping the InspectorPending{Sampling,Elicitation} interfaces in the *.ts modules for v2 consumers that still use them. - Fix verbatim-module-syntax issue in test-servers and drop unused stdio config field. - Adjust inspectorClient.test.ts task types to TaskWithOptionalCreatedAt (SDK 1.29 tightened Task shape vs v1.5's 1.25). - Drop spurious maxMessages/maxFetchRequests/maxStderrLogEvents from remote-transport.test.ts (never on InspectorClientOptions). State: - typecheck: 0 errors - build: passes - unit tests: 1099 / 1270 pass - 171 failures in 10 test files, almost all integration tests that need real HTTP/fs/network (happy-dom can't host them). Next: add a node-env vitest project to run these properly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(core): port v1.5 InspectorClient + transports + auth subsystem (#1302) Bulk-port of the v1.5 core runtime (~7,800 LOC source + ~16K LOC tests + ~5K LOC test-servers/) into v2. Files copied verbatim from origin/v1.5/main via `git show >`; only minimal adapters added on the v2 side. Subsystems landed ----------------- - core/logging/, core/storage/, core/auth/ (browser + node + remote) - core/mcp/ runtime: inspectorClient.ts (2169 LOC), oauthManager, messageTrackingTransport, fetchTracking, config, index - core/mcp/node/ (stdio transport) - core/mcp/remote/ + remote/node/ (HTTP/SSE transport + Hono server) - test-servers/ package (composable MCP test servers, fixtures, harness) v2 adapter layer ---------------- - types.ts: append CreateTransport*, AppRendererClient, InspectorClient{Environment,Options} (v2 had pruned these — v1.5 needs them) - samplingCreateMessage / elicitationCreateMessage: keep v1.5 classes AND v2's Inspector Pending* interfaces in the same files - inspectorClientEventTarget: add oauth* event keys; switch pending sampling/elicitation payloads to the classes (matches v1.5 contracts) - json/jsonUtils: append convertParameterValue/convertToolParameters/ convertPromptArguments + ParameterSchema - inspectorClient.ts: re-export Options/Environment/Transport* so tests that import these from `inspectorClient.js` keep resolving; replace `../package.json` import with a hardcoded identity (v2 has no core/package.json) - remote/node/server.ts: cast hono Context for streamSSE (hono generic tightened since v1.5) - oauth-callback-server: drop unused hostname field - tsconfig.app.json / tsconfig.test.json: add @types/node + paths for pino/zustand/hono/@hono/node-server/atomically/express/yaml, disable erasableSyntaxOnly (parameter-property syntax from v1.5) - vite.config.ts: regex-form module aliases (so the package's exports field handles subpaths like `hono/streaming`, `zustand/middleware`), alias for @modelcontextprotocol/inspector-test-server → test-servers/src Tests ----- - 19 v1.5 test files copied under clients/web/src/test/core/, imports rewired to @inspector/core/* and @modelcontextprotocol/inspector-test-server - 1004 unit tests pass; 298 storybook tests pass; coverage gate passes Known follow-ups (out-of-scope here, tracked in TODOs) ------------------------------------------------------ - 13 integration test files are excluded from the unit project (spawn real HTTP/stdio servers; run real OAuth flows; mock SDK auth → happy-dom CORS). Needs a node-env vitest project. Files listed under `test.projects[0].test.exclude`. - Source files exercised only by those integration tests are excluded from the coverage gate pending the node-env project. Listed under `test.coverage.exclude` with comment. - Manual smoke tests against real stdio / HTTP / OAuth server per the AC on #1302 still need verification locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs/fix: address #1306 review (AGENTS tree, #1307 links, @types/node, .substr) - AGENTS.md: expand the project-structure tree to include all v1.5-ported subsystems (core/auth/{browser,node,remote}, core/storage, core/logging, core/mcp/{node,remote/{,node}}, core/mcp/state) plus the new top-level test-servers/ directory (with a note about its @modelcontextprotocol/ inspector-test-server alias). - vite.config.ts: reference #1307 from both the test exclude block and the coverage exclude block so the next person editing either sees the marching order for shrinking the lists. - package.json: restore @types/node to ^24 (an earlier `npm install --save-dev @types/node@^22` accidentally pinned it lower than the repo was using; nothing here required ^22). - core/mcp/fetchTracking.ts: replace deprecated .substr(2, 9) with .slice(2, 11) to match the convention in core/mcp/inspectorClient.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e154378178
commit
0ead1deea6
88 changed files with 24387 additions and 44 deletions
30
AGENTS.md
30
AGENTS.md
|
|
@ -6,13 +6,29 @@ This is an application for inspecting MCP servers. Has three incarnations, Web,
|
|||
|
||||
```
|
||||
inspector/
|
||||
├── clients/
|
||||
│ ├── web/ # Web client code
|
||||
│ ├── cli/ # CLI client code
|
||||
│ ├── tui/ # TUI client code
|
||||
│ ├── launcher/ # Shared launcher
|
||||
├── core/ # Shared core code
|
||||
├── soecification/ # Build specification
|
||||
├── clients/
|
||||
│ ├── web/ # Web client (Vite + React + Mantine)
|
||||
│ ├── cli/ # CLI client
|
||||
│ ├── tui/ # TUI client
|
||||
│ ├── launcher/ # Shared launcher
|
||||
├── core/ # Shared core code (no package.json — consumed via the `@inspector/core` vite alias)
|
||||
│ ├── auth/ # OAuth: state machine, providers, discovery, storage
|
||||
│ │ ├── browser/ # Browser-side OAuth (sessionStorage, BrowserNavigation)
|
||||
│ │ ├── node/ # Node-side OAuth (NodeOAuthStorage, OAuthCallbackServer)
|
||||
│ │ └── remote/ # Remote OAuth storage (delegates to the remote server)
|
||||
│ ├── json/ # JSON utilities and parameter/argument conversion
|
||||
│ ├── logging/ # Silent pino logger singleton
|
||||
│ ├── mcp/ # InspectorClient runtime + state stores
|
||||
│ │ ├── 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/
|
||||
│ ├── react/ # React hooks over the state stores
|
||||
│ └── storage/ # File and remote storage adapters (Zustand middleware)
|
||||
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
|
||||
│ # Aliased as `@modelcontextprotocol/inspector-test-server`
|
||||
│ # in clients/web/vite.config.ts and tsconfig.test.json.
|
||||
├── specification/ # Build specification
|
||||
...
|
||||
```
|
||||
|
||||
|
|
|
|||
320
clients/web/package-lock.json
generated
320
clients/web/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "0.0.0",
|
||||
"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",
|
||||
|
|
@ -16,10 +17,14 @@
|
|||
"@modelcontextprotocol/ext-apps": "^1.7.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
"hono": "^4.12.18",
|
||||
"pino": "^9.14.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
|
|
@ -32,7 +37,8 @@
|
|||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@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",
|
||||
|
|
@ -42,6 +48,7 @@
|
|||
"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",
|
||||
|
|
@ -50,7 +57,8 @@
|
|||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "^4.1.0",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
|
|
@ -1474,6 +1482,12 @@
|
|||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
|
|
@ -2198,6 +2212,17 @@
|
|||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
|
@ -2209,6 +2234,16 @@
|
|||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
|
|
@ -2230,6 +2265,38 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/serve-static": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"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",
|
||||
|
|
@ -2245,9 +2312,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz",
|
||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||
"version": "24.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz",
|
||||
"integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -2260,6 +2327,20 @@
|
|||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
|
|
@ -2287,6 +2368,27 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/whatwg-mimetype": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
|
|
@ -3041,6 +3143,25 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomically": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz",
|
||||
"integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"stubborn-fs": "^2.0.0",
|
||||
"when-exit": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.11.1",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
|
||||
|
|
@ -4543,9 +4664,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.12",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz",
|
||||
"integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==",
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
|
@ -5522,6 +5643,15 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
|
|
@ -5758,6 +5888,43 @@
|
|||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^2.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
|
||||
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
|
|
@ -5917,6 +6084,22 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -5966,6 +6149,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
|
|
@ -6170,6 +6359,15 @@
|
|||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recast": {
|
||||
"version": "0.23.11",
|
||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz",
|
||||
|
|
@ -6332,6 +6530,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
|
|
@ -6520,6 +6727,15 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
|
|
@ -6539,6 +6755,15 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
|
|
@ -6689,6 +6914,21 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stubborn-fs": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz",
|
||||
"integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"stubborn-utils": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/stubborn-utils": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz",
|
||||
"integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stylis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
|
|
@ -6726,6 +6966,15 @@
|
|||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
|
|
@ -7102,7 +7351,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==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
|
@ -7323,6 +7572,12 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/when-exit": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz",
|
||||
"integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
@ -7416,6 +7671,22 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
@ -7459,6 +7730,35 @@
|
|||
"peerDependencies": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
},
|
||||
"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",
|
||||
|
|
@ -28,10 +29,14 @@
|
|||
"@modelcontextprotocol/ext-apps": "^1.7.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
"hono": "^4.12.18",
|
||||
"pino": "^9.14.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"zod": "^4.3.6"
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
|
|
@ -44,7 +49,8 @@
|
|||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.12.0",
|
||||
"@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",
|
||||
|
|
@ -54,6 +60,7 @@
|
|||
"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",
|
||||
|
|
@ -62,6 +69,7 @@
|
|||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "^4.1.0",
|
||||
"yaml": "^2.9.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
317
clients/web/src/test/core/auth/discovery.test.ts
Normal file
317
clients/web/src/test/core/auth/discovery.test.ts
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
discoverScopes,
|
||||
getAuthorizationServerUrl,
|
||||
} from "@inspector/core/auth/discovery.js";
|
||||
import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
// Mock SDK functions
|
||||
vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
discoverAuthorizationServerMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("OAuth Scope Discovery", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return scopes from resource metadata when available", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["read", "write"],
|
||||
});
|
||||
|
||||
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: "http://localhost:3000",
|
||||
authorization_servers: ["http://localhost:3000"],
|
||||
scopes_supported: ["read", "write", "admin"],
|
||||
};
|
||||
|
||||
const scopes = await discoverScopes(
|
||||
"http://localhost:3000",
|
||||
resourceMetadata,
|
||||
);
|
||||
|
||||
expect(scopes).toBe("read write admin");
|
||||
});
|
||||
|
||||
it("should fall back to OAuth metadata scopes when resource metadata has no scopes", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["read", "write"],
|
||||
});
|
||||
|
||||
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: "http://localhost:3000",
|
||||
authorization_servers: ["http://localhost:3000"],
|
||||
scopes_supported: [],
|
||||
};
|
||||
|
||||
const scopes = await discoverScopes(
|
||||
"http://localhost:3000",
|
||||
resourceMetadata,
|
||||
);
|
||||
|
||||
expect(scopes).toBe("read write");
|
||||
});
|
||||
|
||||
it("should fall back to OAuth metadata scopes when resource metadata is not provided", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["read", "write"],
|
||||
});
|
||||
|
||||
const scopes = await discoverScopes("http://localhost:3000");
|
||||
|
||||
expect(scopes).toBe("read write");
|
||||
});
|
||||
|
||||
it("should return undefined when no scopes are available", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: [],
|
||||
});
|
||||
|
||||
const scopes = await discoverScopes("http://localhost:3000");
|
||||
|
||||
expect(scopes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined when discovery fails", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockRejectedValue(
|
||||
new Error("Discovery failed"),
|
||||
);
|
||||
|
||||
const scopes = await discoverScopes("http://localhost:3000");
|
||||
|
||||
expect(scopes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined when metadata is undefined", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue(undefined);
|
||||
|
||||
const scopes = await discoverScopes("http://localhost:3000");
|
||||
|
||||
expect(scopes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use OAuth metadata scopes when resource has scopes_supported undefined", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["read", "write"],
|
||||
});
|
||||
|
||||
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: "http://localhost:3000",
|
||||
authorization_servers: ["http://localhost:3000"],
|
||||
scopes_supported: undefined as unknown as string[],
|
||||
};
|
||||
|
||||
const scopes = await discoverScopes(
|
||||
"http://localhost:3000",
|
||||
resourceMetadata,
|
||||
);
|
||||
|
||||
expect(scopes).toBe("read write");
|
||||
});
|
||||
|
||||
it("should return single scope when only one scope is supported", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["openid"],
|
||||
});
|
||||
|
||||
const scopes = await discoverScopes("http://localhost:3000");
|
||||
|
||||
expect(scopes).toBe("openid");
|
||||
});
|
||||
|
||||
it("should pass fetchFn to discoverAuthorizationServerMetadata when provided", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
const mockFetchFn = vi.fn();
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["read", "write"],
|
||||
});
|
||||
|
||||
await discoverScopes("http://localhost:3000", undefined, mockFetchFn);
|
||||
|
||||
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||
new URL("/", "http://localhost:3000"),
|
||||
{ fetchFn: mockFetchFn },
|
||||
);
|
||||
});
|
||||
|
||||
it("should use authorization_servers URL from resource metadata for discovery (different domain)", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "https://auth-server.com",
|
||||
authorization_endpoint: "https://auth-server.com/authorize",
|
||||
token_endpoint: "https://auth-server.com/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["read", "write"],
|
||||
});
|
||||
|
||||
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: "https://mcp-server.com",
|
||||
authorization_servers: ["https://auth-server.com/"],
|
||||
scopes_supported: ["read", "write"],
|
||||
};
|
||||
|
||||
const scopes = await discoverScopes(
|
||||
"https://mcp-server.com",
|
||||
resourceMetadata,
|
||||
);
|
||||
|
||||
expect(scopes).toBe("read write");
|
||||
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||
new URL("https://auth-server.com/"),
|
||||
{ fetchFn: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve full path in authorization_servers URL", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "https://auth-server.com/realms/my-realm",
|
||||
authorization_endpoint:
|
||||
"https://auth-server.com/realms/my-realm/authorize",
|
||||
token_endpoint: "https://auth-server.com/realms/my-realm/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["read", "write"],
|
||||
});
|
||||
|
||||
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: "https://mcp-server.com",
|
||||
authorization_servers: ["https://auth-server.com/realms/my-realm/"],
|
||||
scopes_supported: ["read", "write"],
|
||||
};
|
||||
|
||||
const scopes = await discoverScopes(
|
||||
"https://mcp-server.com",
|
||||
resourceMetadata,
|
||||
);
|
||||
|
||||
expect(scopes).toBe("read write");
|
||||
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||
new URL("https://auth-server.com/realms/my-realm/"),
|
||||
{ fetchFn: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("should fall back to serverUrl when authorization_servers is empty", async () => {
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "https://mcp-server.com",
|
||||
authorization_endpoint: "https://mcp-server.com/authorize",
|
||||
token_endpoint: "https://mcp-server.com/token",
|
||||
response_types_supported: ["code"],
|
||||
scopes_supported: ["read", "write"],
|
||||
});
|
||||
|
||||
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: "https://mcp-server.com",
|
||||
authorization_servers: [],
|
||||
scopes_supported: ["read", "write"],
|
||||
};
|
||||
|
||||
const scopes = await discoverScopes(
|
||||
"https://mcp-server.com",
|
||||
resourceMetadata,
|
||||
);
|
||||
|
||||
expect(scopes).toBe("read write");
|
||||
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||
new URL("/", "https://mcp-server.com"),
|
||||
{ fetchFn: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthorizationServerUrl", () => {
|
||||
const serverUrl = "https://mcp.example.com";
|
||||
|
||||
it("returns server URL when resourceMetadata is null", () => {
|
||||
expect(getAuthorizationServerUrl(serverUrl, null)).toEqual(
|
||||
new URL("/", serverUrl),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns server URL when resourceMetadata is undefined", () => {
|
||||
expect(getAuthorizationServerUrl(serverUrl)).toEqual(
|
||||
new URL("/", serverUrl),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns server URL when authorization_servers is empty array", () => {
|
||||
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: serverUrl,
|
||||
authorization_servers: [],
|
||||
};
|
||||
expect(getAuthorizationServerUrl(serverUrl, resourceMetadata)).toEqual(
|
||||
new URL("/", serverUrl),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to server URL when authorization_servers[0] is empty string", () => {
|
||||
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: serverUrl,
|
||||
authorization_servers: [""],
|
||||
};
|
||||
expect(getAuthorizationServerUrl(serverUrl, resourceMetadata)).toEqual(
|
||||
new URL("/", serverUrl),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns authorization_servers[0] when present and truthy", () => {
|
||||
const authUrl = "https://auth.example.com/";
|
||||
const resourceMetadata: OAuthProtectedResourceMetadata = {
|
||||
resource: serverUrl,
|
||||
authorization_servers: [authUrl],
|
||||
};
|
||||
expect(getAuthorizationServerUrl(serverUrl, resourceMetadata)).toEqual(
|
||||
new URL(authUrl),
|
||||
);
|
||||
});
|
||||
});
|
||||
196
clients/web/src/test/core/auth/oauth-callback-server.test.ts
Normal file
196
clients/web/src/test/core/auth/oauth-callback-server.test.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
createOAuthCallbackServer,
|
||||
type OAuthCallbackServer,
|
||||
} from "@inspector/core/auth/node/oauth-callback-server.js";
|
||||
|
||||
describe("OAuthCallbackServer", () => {
|
||||
let server: OAuthCallbackServer;
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) await server.stop();
|
||||
});
|
||||
|
||||
it("start() returns port and redirectUrl", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0 });
|
||||
|
||||
expect(result.port).toBeGreaterThan(0);
|
||||
expect(result.redirectUrl).toBe(
|
||||
`http://127.0.0.1:${result.port}/oauth/callback`,
|
||||
);
|
||||
});
|
||||
|
||||
it("start() supports custom host, path, and port", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
path: "/custom/path",
|
||||
});
|
||||
|
||||
expect(result.redirectUrl).toBe(
|
||||
`http://127.0.0.1:${result.port}/custom/path`,
|
||||
);
|
||||
});
|
||||
|
||||
it("GET /oauth/callback?code=abc&state=xyz returns 200 and invokes onCallback", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const received: { code?: string; state?: string } = {};
|
||||
const result = await server.start({
|
||||
port: 0,
|
||||
onCallback: async (p) => {
|
||||
received.code = p.code;
|
||||
received.state = p.state;
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?code=authcode123&state=mystate`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("text/html");
|
||||
const html = await res.text();
|
||||
expect(html).toContain("OAuth complete");
|
||||
expect(html).toContain("close this window");
|
||||
expect(received.code).toBe("authcode123");
|
||||
expect(received.state).toBe("mystate");
|
||||
});
|
||||
|
||||
it("GET /oauth/callback?code=abc returns 200 and invokes onCallback without state", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const received: { code?: string; state?: string } = {};
|
||||
const result = await server.start({
|
||||
port: 0,
|
||||
onCallback: async (p) => {
|
||||
received.code = p.code;
|
||||
received.state = p.state;
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?code=xyz`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(received.code).toBe("xyz");
|
||||
expect(received.state).toBeUndefined();
|
||||
});
|
||||
|
||||
it("GET /oauth/callback/guided returns 404 (single path only)", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0 });
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback/guided?code=guided-code`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /oauth/callback?error=access_denied returns 400 and invokes onError", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const errors: Array<{
|
||||
error: string;
|
||||
error_description?: string | null;
|
||||
}> = [];
|
||||
const result = await server.start({
|
||||
port: 0,
|
||||
onError: (p) => errors.push(p),
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?error=access_denied&error_description=User%20denied`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("OAuth failed");
|
||||
expect(html).toContain("access_denied");
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]!.error).toBe("access_denied");
|
||||
expect(errors[0]!.error_description).toBe("User denied");
|
||||
});
|
||||
|
||||
it("GET /oauth/callback (missing code and error) returns 400", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0 });
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?state=foo`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("OAuth failed");
|
||||
});
|
||||
|
||||
it("GET /other returns 404", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0 });
|
||||
|
||||
const res = await fetch(`http://localhost:${result.port}/other`);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("POST /oauth/callback returns 405", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0 });
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?code=x`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(405);
|
||||
});
|
||||
|
||||
it("stops server after first successful callback so second request fails", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({
|
||||
port: 0,
|
||||
onCallback: async () => {},
|
||||
});
|
||||
|
||||
const first = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?code=first`,
|
||||
);
|
||||
expect(first.status).toBe(200);
|
||||
|
||||
// Server stops after sending 200, so second request gets connection refused
|
||||
await expect(
|
||||
fetch(`http://localhost:${result.port}/oauth/callback?code=second`),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("stop() closes the server", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({ port: 0 });
|
||||
await server.stop();
|
||||
|
||||
await expect(
|
||||
fetch(`http://localhost:${result.port}/oauth/callback?code=x`),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("onCallback rejection returns 500 and error HTML", async () => {
|
||||
server = createOAuthCallbackServer();
|
||||
const result = await server.start({
|
||||
port: 0,
|
||||
onCallback: async () => {
|
||||
throw new Error("exchange failed");
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`http://localhost:${result.port}/oauth/callback?code=abc`,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const html = await res.text();
|
||||
expect(html).toContain("OAuth failed");
|
||||
expect(html).toContain("exchange failed");
|
||||
});
|
||||
});
|
||||
80
clients/web/src/test/core/auth/providers.test.ts
Normal file
80
clients/web/src/test/core/auth/providers.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
ConsoleNavigation,
|
||||
CallbackNavigation,
|
||||
} from "@inspector/core/auth/providers.js";
|
||||
import { BrowserNavigation } from "@inspector/core/auth/browser/providers.js";
|
||||
|
||||
describe("OAuthNavigation", () => {
|
||||
describe("ConsoleNavigation", () => {
|
||||
it("should log authorization URL to console", () => {
|
||||
const navigation = new ConsoleNavigation();
|
||||
const authUrl = new URL("http://example.com/authorize?client_id=123");
|
||||
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
navigation.navigateToAuthorization(authUrl);
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
"Please navigate to: http://example.com/authorize?client_id=123",
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CallbackNavigation", () => {
|
||||
it("should invoke callback and store authorization URL for retrieval", () => {
|
||||
const callback = vi.fn();
|
||||
const navigation = new CallbackNavigation(callback);
|
||||
const authUrl = new URL("http://example.com/authorize?client_id=123");
|
||||
|
||||
expect(navigation.getAuthorizationUrl()).toBeNull();
|
||||
|
||||
navigation.navigateToAuthorization(authUrl);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith(authUrl);
|
||||
expect(navigation.getAuthorizationUrl()).toBe(authUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BrowserNavigation", () => {
|
||||
// Mock window.location for Node.js environment
|
||||
type GlobalWithWindow = typeof globalThis & {
|
||||
window?: { location: { href: string } };
|
||||
};
|
||||
const originalWindow = (global as GlobalWithWindow).window;
|
||||
|
||||
beforeEach(() => {
|
||||
(global as GlobalWithWindow).window = {
|
||||
location: { href: "http://localhost:5173" },
|
||||
} as GlobalWithWindow["window"];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(global as GlobalWithWindow).window = originalWindow;
|
||||
});
|
||||
|
||||
it("should set window.location.href to authorization URL", () => {
|
||||
const navigation = new BrowserNavigation();
|
||||
const authUrl = new URL("http://example.com/authorize?client_id=123");
|
||||
|
||||
navigation.navigateToAuthorization(authUrl);
|
||||
|
||||
expect((global as GlobalWithWindow).window!.location.href).toBe(
|
||||
authUrl.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error in non-browser environment", () => {
|
||||
(global as GlobalWithWindow).window =
|
||||
undefined as unknown as GlobalWithWindow["window"];
|
||||
const navigation = new BrowserNavigation();
|
||||
const authUrl = new URL("http://example.com/authorize");
|
||||
|
||||
expect(() => navigation.navigateToAuthorization(authUrl)).toThrow(
|
||||
"BrowserNavigation requires browser environment",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
374
clients/web/src/test/core/auth/state-machine.test.ts
Normal file
374
clients/web/src/test/core/auth/state-machine.test.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
OAuthStateMachine,
|
||||
oauthTransitions,
|
||||
} from "@inspector/core/auth/state-machine.js";
|
||||
import type { AuthGuidedState, OAuthStep } from "@inspector/core/auth/types.js";
|
||||
import { EMPTY_GUIDED_STATE } from "@inspector/core/auth/types.js";
|
||||
import type { BaseOAuthClientProvider } from "@inspector/core/auth/providers.js";
|
||||
import type {
|
||||
OAuthMetadata,
|
||||
OAuthProtectedResourceMetadata,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
// Mock SDK functions
|
||||
vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
discoverAuthorizationServerMetadata: vi.fn(),
|
||||
discoverOAuthProtectedResourceMetadata: vi.fn(),
|
||||
registerClient: vi.fn(),
|
||||
startAuthorization: vi.fn(),
|
||||
exchangeAuthorization: vi.fn(),
|
||||
selectResourceURL: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("OAuthStateMachine", () => {
|
||||
let mockProvider: BaseOAuthClientProvider;
|
||||
let updateState: (updates: Partial<AuthGuidedState>) => void;
|
||||
let state: AuthGuidedState;
|
||||
|
||||
beforeEach(() => {
|
||||
state = { ...EMPTY_GUIDED_STATE };
|
||||
updateState = vi.fn((updates: Partial<AuthGuidedState>) => {
|
||||
state = { ...state, ...updates };
|
||||
});
|
||||
|
||||
mockProvider = {
|
||||
serverUrl: "http://localhost:3000",
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
scope: "read write",
|
||||
clientMetadata: {
|
||||
redirect_uris: ["http://localhost:3000/callback"],
|
||||
token_endpoint_auth_method: "none",
|
||||
grant_types: ["authorization_code"],
|
||||
response_types: ["code"],
|
||||
client_name: "Test Client",
|
||||
scope: "read write",
|
||||
},
|
||||
clientInformation: vi.fn(),
|
||||
saveClientInformation: vi.fn(),
|
||||
tokens: vi.fn(),
|
||||
saveTokens: vi.fn(),
|
||||
codeVerifier: vi.fn(() => "test-code-verifier"),
|
||||
clear: vi.fn(),
|
||||
state: vi.fn(() => "test-state"),
|
||||
getServerMetadata: vi.fn(() => null),
|
||||
saveServerMetadata: vi.fn(),
|
||||
} as unknown as BaseOAuthClientProvider;
|
||||
});
|
||||
|
||||
describe("oauthTransitions", () => {
|
||||
it("should have transitions for all OAuth steps", () => {
|
||||
const steps: OAuthStep[] = [
|
||||
"metadata_discovery",
|
||||
"client_registration",
|
||||
"authorization_redirect",
|
||||
"authorization_code",
|
||||
"token_request",
|
||||
"complete",
|
||||
];
|
||||
|
||||
steps.forEach((step) => {
|
||||
expect(oauthTransitions[step]).toBeDefined();
|
||||
expect(oauthTransitions[step].canTransition).toBeDefined();
|
||||
expect(oauthTransitions[step].execute).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuthStateMachine", () => {
|
||||
it("should create state machine instance", () => {
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
"http://localhost:3000",
|
||||
mockProvider,
|
||||
updateState,
|
||||
);
|
||||
|
||||
expect(stateMachine).toBeDefined();
|
||||
});
|
||||
|
||||
it("should update state when executeStep is called", async () => {
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
"http://localhost:3000",
|
||||
mockProvider,
|
||||
updateState,
|
||||
);
|
||||
|
||||
const { discoverAuthorizationServerMetadata } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
} as OAuthMetadata);
|
||||
|
||||
await stateMachine.executeStep(state);
|
||||
|
||||
expect(updateState).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Resource metadata discovery and selection", () => {
|
||||
const serverUrl = "http://localhost:3000";
|
||||
const resourceMetadata = {
|
||||
resource: "http://localhost:3000",
|
||||
authorization_servers: ["http://localhost:3000"],
|
||||
scopes_supported: ["read", "write"],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const {
|
||||
discoverAuthorizationServerMetadata,
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
selectResourceURL,
|
||||
} = await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
} as OAuthMetadata);
|
||||
vi.mocked(discoverOAuthProtectedResourceMetadata).mockReset();
|
||||
vi.mocked(selectResourceURL).mockReset();
|
||||
});
|
||||
|
||||
it("should discover resource metadata from well-known and use first authorization server", async () => {
|
||||
const selectedResource = new URL("http://localhost:3000");
|
||||
const { discoverOAuthProtectedResourceMetadata, selectResourceURL } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue(
|
||||
resourceMetadata as OAuthProtectedResourceMetadata,
|
||||
);
|
||||
vi.mocked(selectResourceURL).mockResolvedValue(selectedResource);
|
||||
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
serverUrl,
|
||||
mockProvider,
|
||||
updateState,
|
||||
);
|
||||
await stateMachine.executeStep(state);
|
||||
|
||||
expect(discoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith(
|
||||
serverUrl,
|
||||
);
|
||||
expect(selectResourceURL).toHaveBeenCalledWith(
|
||||
serverUrl,
|
||||
mockProvider,
|
||||
resourceMetadata,
|
||||
);
|
||||
expect(updateState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceMetadata,
|
||||
resource: selectedResource,
|
||||
resourceMetadataError: null,
|
||||
authServerUrl: new URL("http://localhost:3000"),
|
||||
oauthStep: "client_registration",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should use authorization_servers URL from resource metadata for auth server discovery", async () => {
|
||||
const authServerUrl = "https://auth-server.com/";
|
||||
const resourceMetaDifferentAuth: OAuthProtectedResourceMetadata = {
|
||||
resource: serverUrl,
|
||||
authorization_servers: [authServerUrl],
|
||||
scopes_supported: ["read", "write"],
|
||||
};
|
||||
const selectedResource = new URL(serverUrl);
|
||||
const {
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
discoverAuthorizationServerMetadata,
|
||||
selectResourceURL,
|
||||
} = await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue(
|
||||
resourceMetaDifferentAuth,
|
||||
);
|
||||
vi.mocked(selectResourceURL).mockResolvedValue(selectedResource);
|
||||
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
serverUrl,
|
||||
mockProvider,
|
||||
updateState,
|
||||
);
|
||||
await stateMachine.executeStep(state);
|
||||
|
||||
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||
new URL(authServerUrl),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(updateState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceMetadata: resourceMetaDifferentAuth,
|
||||
authServerUrl: new URL(authServerUrl),
|
||||
oauthStep: "client_registration",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call selectResourceURL only when resource metadata is present", async () => {
|
||||
const { discoverOAuthProtectedResourceMetadata, selectResourceURL } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue(
|
||||
new Error(
|
||||
"Resource server does not implement OAuth 2.0 Protected Resource Metadata.",
|
||||
),
|
||||
);
|
||||
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
serverUrl,
|
||||
mockProvider,
|
||||
updateState,
|
||||
);
|
||||
await stateMachine.executeStep(state);
|
||||
|
||||
expect(selectResourceURL).not.toHaveBeenCalled();
|
||||
expect(updateState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceMetadata: null,
|
||||
resourceMetadataError: expect.any(Error),
|
||||
oauthStep: "client_registration",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should use default auth server URL when discovery fails", async () => {
|
||||
const {
|
||||
discoverOAuthProtectedResourceMetadata,
|
||||
discoverAuthorizationServerMetadata,
|
||||
} = await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
vi.mocked(discoverOAuthProtectedResourceMetadata).mockRejectedValue(
|
||||
new Error("Discovery failed"),
|
||||
);
|
||||
vi.mocked(discoverAuthorizationServerMetadata).mockResolvedValue({
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
} as OAuthMetadata);
|
||||
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
serverUrl,
|
||||
mockProvider,
|
||||
updateState,
|
||||
);
|
||||
await stateMachine.executeStep(state);
|
||||
|
||||
expect(discoverAuthorizationServerMetadata).toHaveBeenCalledWith(
|
||||
new URL("/", serverUrl),
|
||||
{}, // No fetchFn when not provided (conditional spread omits it)
|
||||
);
|
||||
expect(updateState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authServerUrl: new URL("/", serverUrl),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should use default auth server when metadata has empty authorization_servers", async () => {
|
||||
const { discoverOAuthProtectedResourceMetadata, selectResourceURL } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
const metaNoServers = {
|
||||
...resourceMetadata,
|
||||
authorization_servers: [] as string[],
|
||||
};
|
||||
vi.mocked(discoverOAuthProtectedResourceMetadata).mockResolvedValue(
|
||||
metaNoServers as OAuthProtectedResourceMetadata,
|
||||
);
|
||||
vi.mocked(selectResourceURL).mockResolvedValue(
|
||||
new URL("http://localhost:3000"),
|
||||
);
|
||||
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
serverUrl,
|
||||
mockProvider,
|
||||
updateState,
|
||||
);
|
||||
await stateMachine.executeStep(state);
|
||||
|
||||
expect(selectResourceURL).toHaveBeenCalledWith(
|
||||
serverUrl,
|
||||
mockProvider,
|
||||
metaNoServers,
|
||||
);
|
||||
expect(updateState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
resourceMetadata: metaNoServers,
|
||||
authServerUrl: new URL("/", serverUrl),
|
||||
oauthStep: "client_registration",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass fetchFn to registerClient when provided", async () => {
|
||||
const { registerClient } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
const mockFetchFn = vi.fn();
|
||||
vi.mocked(registerClient).mockResolvedValue({
|
||||
redirect_uris: ["http://localhost/callback"],
|
||||
client_id: "registered-client-id",
|
||||
});
|
||||
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
serverUrl,
|
||||
mockProvider,
|
||||
updateState,
|
||||
mockFetchFn,
|
||||
);
|
||||
await stateMachine.executeStep(state);
|
||||
expect(state.oauthStep).toBe("client_registration");
|
||||
|
||||
await stateMachine.executeStep(state);
|
||||
|
||||
expect(registerClient).toHaveBeenCalledWith(
|
||||
serverUrl,
|
||||
expect.objectContaining({
|
||||
fetchFn: mockFetchFn,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass fetchFn to exchangeAuthorization when provided", async () => {
|
||||
const { exchangeAuthorization } =
|
||||
await import("@modelcontextprotocol/sdk/client/auth.js");
|
||||
const mockFetchFn = vi.fn();
|
||||
const metadata = {
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
vi.mocked(exchangeAuthorization).mockResolvedValue({
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
const providerWithMetadata = {
|
||||
...mockProvider,
|
||||
getServerMetadata: vi.fn(() => metadata),
|
||||
} as unknown as BaseOAuthClientProvider;
|
||||
|
||||
const tokenRequestState: AuthGuidedState = {
|
||||
...EMPTY_GUIDED_STATE,
|
||||
oauthStep: "token_request",
|
||||
oauthMetadata: metadata as OAuthMetadata,
|
||||
oauthClientInfo: { client_id: "test-client" },
|
||||
authorizationCode: "test-code",
|
||||
};
|
||||
|
||||
const stateMachine = new OAuthStateMachine(
|
||||
serverUrl,
|
||||
providerWithMetadata,
|
||||
updateState,
|
||||
mockFetchFn,
|
||||
);
|
||||
await stateMachine.executeStep(tokenRequestState);
|
||||
|
||||
expect(exchangeAuthorization).toHaveBeenCalledWith(
|
||||
serverUrl,
|
||||
expect.objectContaining({
|
||||
fetchFn: mockFetchFn,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
312
clients/web/src/test/core/auth/storage-browser.test.ts
Normal file
312
clients/web/src/test/core/auth/storage-browser.test.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthMetadata,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
|
||||
// Mock sessionStorage for Node.js environment
|
||||
class MockSessionStorage implements Storage {
|
||||
private storage: Map<string, string> = new Map();
|
||||
|
||||
get length(): number {
|
||||
return this.storage.size;
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
const keys = [...this.storage.keys()];
|
||||
return keys[index] ?? null;
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.storage.get(key) || null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.storage.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.storage.delete(key);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.storage.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Set up global sessionStorage mock
|
||||
const mockSessionStorage = new MockSessionStorage();
|
||||
(global as typeof globalThis & { sessionStorage?: Storage }).sessionStorage =
|
||||
mockSessionStorage;
|
||||
|
||||
describe("BrowserOAuthStorage", () => {
|
||||
let storage: BrowserOAuthStorage;
|
||||
const testServerUrl = "http://localhost:3000";
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new BrowserOAuthStorage();
|
||||
mockSessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockSessionStorage.clear();
|
||||
});
|
||||
|
||||
describe("getClientInformation", () => {
|
||||
it("should return undefined when no client information is stored", async () => {
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return stored client information", async () => {
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
client_id: "test-client-id",
|
||||
client_secret: "test-secret",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
||||
expect(result).toEqual(clientInfo);
|
||||
});
|
||||
|
||||
it("should return preregistered client information when requested", async () => {
|
||||
const preregisteredInfo: OAuthClientInformation = {
|
||||
client_id: "preregistered-id",
|
||||
client_secret: "preregistered-secret",
|
||||
};
|
||||
|
||||
// Use the storage API instead of manually setting sessionStorage
|
||||
// since BrowserOAuthStorage now uses Zustand with a different storage format
|
||||
storage.savePreregisteredClientInformation(
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveClientInformation", () => {
|
||||
it("should save client information", async () => {
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
client_id: "test-client-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
||||
expect(result).toEqual(clientInfo);
|
||||
});
|
||||
|
||||
it("should overwrite existing client information", async () => {
|
||||
const firstInfo: OAuthClientInformation = {
|
||||
client_id: "first-id",
|
||||
};
|
||||
|
||||
const secondInfo: OAuthClientInformation = {
|
||||
client_id: "second-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, firstInfo);
|
||||
storage.saveClientInformation(testServerUrl, secondInfo);
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
||||
expect(result).toEqual(secondInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTokens", () => {
|
||||
it("should return undefined when no tokens are stored", async () => {
|
||||
const result = await storage.getTokens(testServerUrl);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return stored tokens", async () => {
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "test-access-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
storage.saveTokens(testServerUrl, tokens);
|
||||
const result = await storage.getTokens(testServerUrl);
|
||||
|
||||
expect(result).toEqual(tokens);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveTokens", () => {
|
||||
it("should save tokens", async () => {
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "test-access-token",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
|
||||
storage.saveTokens(testServerUrl, tokens);
|
||||
const result = await storage.getTokens(testServerUrl);
|
||||
|
||||
expect(result).toEqual(tokens);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCodeVerifier", () => {
|
||||
it("should return undefined when no code verifier is stored", async () => {
|
||||
const result = await storage.getCodeVerifier(testServerUrl);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return stored code verifier", async () => {
|
||||
const codeVerifier = "test-code-verifier";
|
||||
|
||||
storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
||||
const result = await storage.getCodeVerifier(testServerUrl);
|
||||
|
||||
expect(result).toBe(codeVerifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveCodeVerifier", () => {
|
||||
it("should save code verifier", async () => {
|
||||
const codeVerifier = "test-code-verifier";
|
||||
|
||||
storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
||||
const result = await storage.getCodeVerifier(testServerUrl);
|
||||
|
||||
expect(result).toBe(codeVerifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScope", () => {
|
||||
it("should return undefined when no scope is stored", async () => {
|
||||
const result = await storage.getScope(testServerUrl);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return stored scope", async () => {
|
||||
const scope = "read write";
|
||||
|
||||
storage.saveScope(testServerUrl, scope);
|
||||
const result = await storage.getScope(testServerUrl);
|
||||
|
||||
expect(result).toBe(scope);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveScope", () => {
|
||||
it("should save scope", async () => {
|
||||
const scope = "read write";
|
||||
|
||||
storage.saveScope(testServerUrl, scope);
|
||||
const result = await storage.getScope(testServerUrl);
|
||||
|
||||
expect(result).toBe(scope);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getServerMetadata", () => {
|
||||
it("should return null when no metadata is stored", async () => {
|
||||
const result = await storage.getServerMetadata(testServerUrl);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return stored metadata", async () => {
|
||||
const metadata: OAuthMetadata = {
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
|
||||
storage.saveServerMetadata(testServerUrl, metadata);
|
||||
const result = await storage.getServerMetadata(testServerUrl);
|
||||
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveServerMetadata", () => {
|
||||
it("should save server metadata", async () => {
|
||||
const metadata: OAuthMetadata = {
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
|
||||
storage.saveServerMetadata(testServerUrl, metadata);
|
||||
const result = await storage.getServerMetadata(testServerUrl);
|
||||
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearServerState", () => {
|
||||
it("should clear all state for a server", async () => {
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
client_id: "test-client-id",
|
||||
};
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
storage.saveTokens(testServerUrl, tokens);
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not affect state for other servers", async () => {
|
||||
const otherServerUrl = "http://localhost:4000";
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
client_id: "test-client-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
storage.saveClientInformation(otherServerUrl, clientInfo);
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getClientInformation(otherServerUrl)).toEqual(
|
||||
clientInfo,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple servers", () => {
|
||||
it("should store separate state for different servers", async () => {
|
||||
const server1Url = "http://localhost:3000";
|
||||
const server2Url = "http://localhost:4000";
|
||||
|
||||
const clientInfo1: OAuthClientInformation = {
|
||||
client_id: "client-1",
|
||||
};
|
||||
|
||||
const clientInfo2: OAuthClientInformation = {
|
||||
client_id: "client-2",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(server1Url, clientInfo1);
|
||||
storage.saveClientInformation(server2Url, clientInfo2);
|
||||
|
||||
expect(await storage.getClientInformation(server1Url)).toEqual(
|
||||
clientInfo1,
|
||||
);
|
||||
expect(await storage.getClientInformation(server2Url)).toEqual(
|
||||
clientInfo2,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
521
clients/web/src/test/core/auth/storage-node.test.ts
Normal file
521
clients/web/src/test/core/auth/storage-node.test.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
NodeOAuthStorage,
|
||||
getOAuthStore,
|
||||
} from "@inspector/core/auth/node/storage-node.js";
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
OAuthMetadata,
|
||||
} from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { waitForStateFile } from "@modelcontextprotocol/inspector-test-server";
|
||||
|
||||
// Unique path per process so parallel test files don't share the same state file
|
||||
const testStatePath = path.join(
|
||||
os.tmpdir(),
|
||||
`mcp-inspector-oauth-${process.pid}-storage-node.json`,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClientInformation", () => {
|
||||
it("should return undefined when no client information is stored", async () => {
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return stored client information", async () => {
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
client_id: "test-client-id",
|
||||
client_secret: "test-secret",
|
||||
};
|
||||
|
||||
await storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.client_id).toBe(clientInfo.client_id);
|
||||
expect(result?.client_secret).toBe(clientInfo.client_secret);
|
||||
});
|
||||
|
||||
it("should return preregistered client information when requested", async () => {
|
||||
const preregisteredInfo: OAuthClientInformation = {
|
||||
client_id: "preregistered-id",
|
||||
client_secret: "preregistered-secret",
|
||||
};
|
||||
|
||||
// Store as preregistered by directly setting it in the store
|
||||
const store = getOAuthStore(testStatePath);
|
||||
store.getState().setServerState(testServerUrl, {
|
||||
preregisteredClientInformation: preregisteredInfo,
|
||||
});
|
||||
|
||||
const result = await storage.getClientInformation(testServerUrl, true);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.client_id).toBe(preregisteredInfo.client_id);
|
||||
expect(result?.client_secret).toBe(preregisteredInfo.client_secret);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveClientInformation", () => {
|
||||
it("should save client information", async () => {
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
client_id: "test-client-id",
|
||||
};
|
||||
|
||||
await storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.client_id).toBe(clientInfo.client_id);
|
||||
});
|
||||
|
||||
it("should overwrite existing client information", async () => {
|
||||
const firstInfo: OAuthClientInformation = {
|
||||
client_id: "first-id",
|
||||
};
|
||||
|
||||
const secondInfo: OAuthClientInformation = {
|
||||
client_id: "second-id",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(testServerUrl, firstInfo);
|
||||
storage.saveClientInformation(testServerUrl, secondInfo);
|
||||
const result = await storage.getClientInformation(testServerUrl);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.client_id).toBe(secondInfo.client_id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTokens", () => {
|
||||
it("should return undefined when no tokens are stored", async () => {
|
||||
const result = await storage.getTokens(testServerUrl);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return stored tokens", async () => {
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "test-access-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
await storage.saveTokens(testServerUrl, tokens);
|
||||
const result = await storage.getTokens(testServerUrl);
|
||||
|
||||
expect(result).toEqual(tokens);
|
||||
});
|
||||
|
||||
it("should persist and return refresh_token", async () => {
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "test-access-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
refresh_token: "test-refresh-token",
|
||||
};
|
||||
|
||||
await storage.saveTokens(testServerUrl, tokens);
|
||||
const result = await storage.getTokens(testServerUrl);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.access_token).toBe(tokens.access_token);
|
||||
expect(result?.refresh_token).toBe(tokens.refresh_token);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveTokens", () => {
|
||||
it("should save tokens", async () => {
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "test-access-token",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
|
||||
await storage.saveTokens(testServerUrl, tokens);
|
||||
const result = await storage.getTokens(testServerUrl);
|
||||
|
||||
expect(result).toEqual(tokens);
|
||||
});
|
||||
|
||||
it("should overwrite existing tokens", async () => {
|
||||
const firstTokens: OAuthTokens = {
|
||||
access_token: "first-token",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
|
||||
const secondTokens: OAuthTokens = {
|
||||
access_token: "second-token",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
|
||||
await storage.saveTokens(testServerUrl, firstTokens);
|
||||
await storage.saveTokens(testServerUrl, secondTokens);
|
||||
const result = await storage.getTokens(testServerUrl);
|
||||
|
||||
expect(result).toEqual(secondTokens);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCodeVerifier", () => {
|
||||
it("should return undefined when no code verifier is stored", async () => {
|
||||
const result = await storage.getCodeVerifier(testServerUrl);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return stored code verifier", async () => {
|
||||
const codeVerifier = "test-code-verifier";
|
||||
|
||||
await storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
||||
const result = await storage.getCodeVerifier(testServerUrl);
|
||||
|
||||
expect(result).toBe(codeVerifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveCodeVerifier", () => {
|
||||
it("should save code verifier", async () => {
|
||||
const codeVerifier = "test-code-verifier";
|
||||
|
||||
await storage.saveCodeVerifier(testServerUrl, codeVerifier);
|
||||
const result = await storage.getCodeVerifier(testServerUrl);
|
||||
|
||||
expect(result).toBe(codeVerifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScope", () => {
|
||||
it("should return undefined when no scope is stored", async () => {
|
||||
const result = await storage.getScope(testServerUrl);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return stored scope", async () => {
|
||||
const scope = "read write";
|
||||
|
||||
await storage.saveScope(testServerUrl, scope);
|
||||
const result = await storage.getScope(testServerUrl);
|
||||
|
||||
expect(result).toBe(scope);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveScope", () => {
|
||||
it("should save scope", async () => {
|
||||
const scope = "read write";
|
||||
|
||||
await storage.saveScope(testServerUrl, scope);
|
||||
const result = await storage.getScope(testServerUrl);
|
||||
|
||||
expect(result).toBe(scope);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getServerMetadata", () => {
|
||||
it("should return null when no metadata is stored", async () => {
|
||||
const result = await storage.getServerMetadata(testServerUrl);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return stored metadata", async () => {
|
||||
const metadata: OAuthMetadata = {
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
|
||||
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||
const result = await storage.getServerMetadata(testServerUrl);
|
||||
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveServerMetadata", () => {
|
||||
it("should save server metadata", async () => {
|
||||
const metadata: OAuthMetadata = {
|
||||
issuer: "http://localhost:3000",
|
||||
authorization_endpoint: "http://localhost:3000/authorize",
|
||||
token_endpoint: "http://localhost:3000/token",
|
||||
response_types_supported: ["code"],
|
||||
};
|
||||
|
||||
await storage.saveServerMetadata(testServerUrl, metadata);
|
||||
const result = await storage.getServerMetadata(testServerUrl);
|
||||
|
||||
expect(result).toEqual(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearServerState", () => {
|
||||
it("should clear all state for a server", async () => {
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
client_id: "test-client-id",
|
||||
};
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
|
||||
await storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
await storage.saveTokens(testServerUrl, tokens);
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
expect(await storage.getTokens(testServerUrl)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not affect state for other servers", async () => {
|
||||
const otherServerUrl = "http://localhost:4000";
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
client_id: "test-client-id",
|
||||
};
|
||||
|
||||
await storage.saveClientInformation(testServerUrl, clientInfo);
|
||||
await storage.saveClientInformation(otherServerUrl, clientInfo);
|
||||
|
||||
storage.clear(testServerUrl);
|
||||
|
||||
expect(await storage.getClientInformation(testServerUrl)).toBeUndefined();
|
||||
const otherResult = await storage.getClientInformation(otherServerUrl);
|
||||
expect(otherResult).toBeDefined();
|
||||
expect(otherResult?.client_id).toBe(clientInfo.client_id);
|
||||
expect(otherResult).toEqual(clientInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple servers", () => {
|
||||
it("should store separate state for different servers", async () => {
|
||||
const server1Url = "http://localhost:3000";
|
||||
const server2Url = "http://localhost:4000";
|
||||
|
||||
const clientInfo1: OAuthClientInformation = {
|
||||
client_id: "client-1",
|
||||
};
|
||||
|
||||
const clientInfo2: OAuthClientInformation = {
|
||||
client_id: "client-2",
|
||||
};
|
||||
|
||||
storage.saveClientInformation(server1Url, clientInfo1);
|
||||
storage.saveClientInformation(server2Url, clientInfo2);
|
||||
|
||||
const result1 = await storage.getClientInformation(server1Url);
|
||||
const result2 = await storage.getClientInformation(server2Url);
|
||||
expect(result1).toEqual(clientInfo1);
|
||||
expect(result2).toEqual(clientInfo2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Store (Zustand)", () => {
|
||||
const stateFilePath = testStatePath;
|
||||
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
it("should create a new store", () => {
|
||||
const store = getOAuthStore(testStatePath);
|
||||
expect(store).toBeDefined();
|
||||
expect(store.getState).toBeDefined();
|
||||
expect(store.setState).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return the same store instance via getOAuthStore", () => {
|
||||
const store1 = getOAuthStore(testStatePath);
|
||||
const store2 = getOAuthStore(testStatePath);
|
||||
expect(store1).toBe(store2);
|
||||
});
|
||||
|
||||
it("should persist state to file", async () => {
|
||||
// Use a unique path so no other test (e.g. "should overwrite..." with second-id) can
|
||||
// write to the same file; Zustand persist is async and can race with shared paths.
|
||||
const persistTestPath = path.join(
|
||||
os.tmpdir(),
|
||||
`mcp-inspector-oauth-persist-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
||||
);
|
||||
try {
|
||||
if (process.env.DEBUG_WAIT_FOR_STATE_FILE === "1") {
|
||||
console.error("[storage-node.test] state file path:", persistTestPath);
|
||||
}
|
||||
const store = getOAuthStore(persistTestPath);
|
||||
const serverUrl = "http://localhost:3000";
|
||||
const clientInfo: OAuthClientInformation = {
|
||||
client_id: "test-client-id",
|
||||
};
|
||||
|
||||
store.getState().setServerState(serverUrl, {
|
||||
clientInformation: clientInfo,
|
||||
});
|
||||
|
||||
type StateShape = {
|
||||
state: {
|
||||
servers: Record<
|
||||
string,
|
||||
{ clientInformation?: OAuthClientInformation }
|
||||
>;
|
||||
};
|
||||
};
|
||||
const parsed = await waitForStateFile<StateShape>(
|
||||
persistTestPath,
|
||||
(p) => {
|
||||
const s = (p as StateShape)?.state?.servers?.[serverUrl];
|
||||
return !!s?.clientInformation;
|
||||
},
|
||||
{ timeout: 2000, interval: 50 },
|
||||
);
|
||||
expect(parsed.state.servers[serverUrl]?.clientInformation).toEqual(
|
||||
clientInfo,
|
||||
);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(persistTestPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NodeOAuthStorage with custom storagePath", () => {
|
||||
const testServerUrl = "http://localhost:3999";
|
||||
|
||||
it("should use custom path for state file", async () => {
|
||||
const customPath = path.join(
|
||||
os.tmpdir(),
|
||||
`mcp-inspector-oauth-test-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
||||
);
|
||||
|
||||
try {
|
||||
const storage = new NodeOAuthStorage(customPath);
|
||||
const tokens: OAuthTokens = {
|
||||
access_token: "custom-path-token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "custom-refresh",
|
||||
};
|
||||
await storage.saveTokens(testServerUrl, tokens);
|
||||
|
||||
type StateShape = {
|
||||
state: {
|
||||
servers: Record<string, { tokens?: { access_token?: string } }>;
|
||||
};
|
||||
};
|
||||
const parsed = await waitForStateFile<StateShape>(
|
||||
customPath,
|
||||
(p) => {
|
||||
const t = (p as StateShape)?.state?.servers?.[testServerUrl]?.tokens;
|
||||
return t?.access_token === tokens.access_token;
|
||||
},
|
||||
{ timeout: 2000, interval: 50 },
|
||||
);
|
||||
|
||||
expect(parsed.state.servers[testServerUrl]?.tokens?.access_token).toBe(
|
||||
tokens.access_token,
|
||||
);
|
||||
|
||||
const stored = await storage.getTokens(testServerUrl);
|
||||
expect(stored?.access_token).toBe(tokens.access_token);
|
||||
expect(stored?.refresh_token).toBe(tokens.refresh_token);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(customPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should isolate state from default store", async () => {
|
||||
const customPath = path.join(
|
||||
os.tmpdir(),
|
||||
`mcp-inspector-oauth-isolate-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
|
||||
);
|
||||
|
||||
try {
|
||||
const defaultStore = getOAuthStore();
|
||||
defaultStore.getState().setServerState(testServerUrl, {
|
||||
tokens: {
|
||||
access_token: "default-token",
|
||||
token_type: "Bearer",
|
||||
},
|
||||
});
|
||||
|
||||
const customStorage = new NodeOAuthStorage(customPath);
|
||||
await customStorage.saveTokens(testServerUrl, {
|
||||
access_token: "custom-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
const fromCustom = await customStorage.getTokens(testServerUrl);
|
||||
expect(fromCustom?.access_token).toBe("custom-token");
|
||||
|
||||
const defaultStorage = new NodeOAuthStorage();
|
||||
const fromDefault = await defaultStorage.getTokens(testServerUrl);
|
||||
expect(fromDefault?.access_token).toBe("default-token");
|
||||
|
||||
defaultStore.getState().clearServerState(testServerUrl);
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(customPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
232
clients/web/src/test/core/auth/utils.test.ts
Normal file
232
clients/web/src/test/core/auth/utils.test.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseOAuthCallbackParams,
|
||||
generateOAuthState,
|
||||
generateOAuthStateWithMode,
|
||||
parseOAuthState,
|
||||
generateOAuthErrorDescription,
|
||||
} from "@inspector/core/auth/utils.js";
|
||||
|
||||
describe("OAuth Utils", () => {
|
||||
describe("parseOAuthCallbackParams", () => {
|
||||
it("should parse successful callback with code", () => {
|
||||
const location = "?code=abc123&state=xyz789";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(true);
|
||||
if (result.successful) {
|
||||
expect(result.code).toBe("abc123");
|
||||
}
|
||||
});
|
||||
|
||||
it("should parse error callback", () => {
|
||||
const location =
|
||||
"?error=access_denied&error_description=User%20denied%20access";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
if (!result.successful) {
|
||||
expect(result.error).toBe("access_denied");
|
||||
expect(result.error_description).toBe("User denied access");
|
||||
}
|
||||
});
|
||||
|
||||
it("should parse error callback with error_uri", () => {
|
||||
const location =
|
||||
"?error=invalid_request&error_description=Invalid%20request&error_uri=https://example.com/error";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
if (!result.successful) {
|
||||
expect(result.error).toBe("invalid_request");
|
||||
expect(result.error_description).toBe("Invalid request");
|
||||
expect(result.error_uri).toBe("https://example.com/error");
|
||||
}
|
||||
});
|
||||
|
||||
it("should return invalid_request when neither code nor error is present", () => {
|
||||
const location = "?state=xyz789";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
if (!result.successful) {
|
||||
expect(result.error).toBe("invalid_request");
|
||||
expect(result.error_description).toBe(
|
||||
"Missing code or error in response",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle empty query string", () => {
|
||||
const location = "";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(false);
|
||||
if (!result.successful) {
|
||||
expect(result.error).toBe("invalid_request");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle URL-encoded values", () => {
|
||||
const location = "?code=abc%20123&error_description=Test%20%26%20More";
|
||||
const result = parseOAuthCallbackParams(location);
|
||||
|
||||
expect(result.successful).toBe(true);
|
||||
if (result.successful) {
|
||||
expect(result.code).toBe("abc 123");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthState", () => {
|
||||
it("should generate a random state string", () => {
|
||||
const state1 = generateOAuthState();
|
||||
const state2 = generateOAuthState();
|
||||
|
||||
expect(typeof state1).toBe("string");
|
||||
expect(state1.length).toBeGreaterThan(0);
|
||||
expect(state1).not.toBe(state2); // Should be different each time
|
||||
});
|
||||
|
||||
it("should generate state with consistent length", () => {
|
||||
const states = Array.from({ length: 10 }, () => generateOAuthState());
|
||||
const lengths = states.map((s) => s.length);
|
||||
const uniqueLengths = new Set(lengths);
|
||||
|
||||
// All states should have the same length (64 hex characters for 32 bytes)
|
||||
expect(uniqueLengths.size).toBe(1);
|
||||
expect(lengths[0]).toBe(64);
|
||||
});
|
||||
|
||||
it("should generate valid hex string", () => {
|
||||
const state = generateOAuthState();
|
||||
const hexPattern = /^[0-9a-f]+$/;
|
||||
|
||||
expect(hexPattern.test(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthStateWithMode", () => {
|
||||
it("should generate state with normal prefix", () => {
|
||||
const state = generateOAuthStateWithMode("normal");
|
||||
expect(state.startsWith("normal:")).toBe(true);
|
||||
expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("should generate state with guided prefix", () => {
|
||||
const state = generateOAuthStateWithMode("guided");
|
||||
expect(state.startsWith("guided:")).toBe(true);
|
||||
expect(state.slice(7)).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("should generate unique states", () => {
|
||||
const s1 = generateOAuthStateWithMode("normal");
|
||||
const s2 = generateOAuthStateWithMode("normal");
|
||||
expect(s1).not.toBe(s2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOAuthState", () => {
|
||||
it("should parse normal prefix", () => {
|
||||
const parsed = parseOAuthState("normal:abc123def456");
|
||||
expect(parsed).toEqual({ mode: "normal", authId: "abc123def456" });
|
||||
});
|
||||
|
||||
it("should parse guided prefix", () => {
|
||||
const parsed = parseOAuthState("guided:a1b2c3d4e5f6");
|
||||
expect(parsed).toEqual({ mode: "guided", authId: "a1b2c3d4e5f6" });
|
||||
});
|
||||
|
||||
it("should parse legacy 64-char hex as normal", () => {
|
||||
const hex = "a".repeat(64);
|
||||
const parsed = parseOAuthState(hex);
|
||||
expect(parsed).toEqual({ mode: "normal", authId: hex });
|
||||
});
|
||||
|
||||
it("should return null for invalid state", () => {
|
||||
expect(parseOAuthState("")).toBeNull();
|
||||
expect(parseOAuthState("invalid")).toBeNull();
|
||||
expect(parseOAuthState("other:xyz")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateOAuthErrorDescription", () => {
|
||||
it("should generate error description with error code only", () => {
|
||||
const params = {
|
||||
successful: false as const,
|
||||
error: "access_denied",
|
||||
error_description: null,
|
||||
error_uri: null,
|
||||
};
|
||||
|
||||
const description = generateOAuthErrorDescription(params);
|
||||
|
||||
expect(description).toBe("Error: access_denied.");
|
||||
});
|
||||
|
||||
it("should generate error description with error code and description", () => {
|
||||
const params = {
|
||||
successful: false as const,
|
||||
error: "invalid_request",
|
||||
error_description: "The request is missing a required parameter",
|
||||
error_uri: null,
|
||||
};
|
||||
|
||||
const description = generateOAuthErrorDescription(params);
|
||||
|
||||
expect(description).toContain("Error: invalid_request.");
|
||||
expect(description).toContain(
|
||||
"Details: The request is missing a required parameter.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate error description with all fields", () => {
|
||||
const params = {
|
||||
successful: false as const,
|
||||
error: "server_error",
|
||||
error_description: "An internal server error occurred",
|
||||
error_uri: "https://example.com/errors/server_error",
|
||||
};
|
||||
|
||||
const description = generateOAuthErrorDescription(params);
|
||||
|
||||
expect(description).toContain("Error: server_error.");
|
||||
expect(description).toContain(
|
||||
"Details: An internal server error occurred.",
|
||||
);
|
||||
expect(description).toContain(
|
||||
"More info: https://example.com/errors/server_error.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle null error_description", () => {
|
||||
const params = {
|
||||
successful: false as const,
|
||||
error: "access_denied",
|
||||
error_description: null,
|
||||
error_uri: "https://example.com/error",
|
||||
};
|
||||
|
||||
const description = generateOAuthErrorDescription(params);
|
||||
|
||||
expect(description).toContain("Error: access_denied.");
|
||||
expect(description).not.toContain("Details:");
|
||||
expect(description).toContain("More info: https://example.com/error.");
|
||||
});
|
||||
|
||||
it("should handle null error_uri", () => {
|
||||
const params = {
|
||||
successful: false as const,
|
||||
error: "invalid_client",
|
||||
error_description: "Invalid client credentials",
|
||||
error_uri: null,
|
||||
};
|
||||
|
||||
const description = generateOAuthErrorDescription(params);
|
||||
|
||||
expect(description).toContain("Error: invalid_client.");
|
||||
expect(description).toContain("Details: Invalid client credentials.");
|
||||
expect(description).not.toContain("More info:");
|
||||
});
|
||||
});
|
||||
});
|
||||
172
clients/web/src/test/core/helpers/oauth-client-fixtures.ts
Normal file
172
clients/web/src/test/core/helpers/oauth-client-fixtures.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/**
|
||||
* OAuth client test fixtures for InspectorClient OAuth tests.
|
||||
* These produce InspectorClient OAuth configuration and simulate OAuth flows.
|
||||
*/
|
||||
|
||||
import type {
|
||||
OAuthNavigation,
|
||||
RedirectUrlProvider,
|
||||
} from "@inspector/core/auth/providers.js";
|
||||
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||
import { ConsoleNavigation } from "@inspector/core/auth/providers.js";
|
||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||
|
||||
/** Creates a static RedirectUrlProvider for tests. Single URL for both modes. */
|
||||
function createStaticRedirectUrlProvider(
|
||||
redirectUrl: string,
|
||||
): RedirectUrlProvider {
|
||||
return {
|
||||
getRedirectUrl: () => redirectUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates OAuth configuration for InspectorClient tests
|
||||
*/
|
||||
export function createOAuthClientConfig(options: {
|
||||
mode: "static" | "dcr" | "cimd";
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
clientMetadataUrl?: string;
|
||||
redirectUrl: string;
|
||||
scope?: string;
|
||||
}): {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
clientMetadataUrl?: string;
|
||||
redirectUrlProvider: RedirectUrlProvider;
|
||||
scope?: string;
|
||||
storage: OAuthStorage;
|
||||
navigation: OAuthNavigation;
|
||||
} {
|
||||
const config: {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
clientMetadataUrl?: string;
|
||||
redirectUrlProvider: RedirectUrlProvider;
|
||||
scope?: string;
|
||||
storage: OAuthStorage;
|
||||
navigation: OAuthNavigation;
|
||||
} = {
|
||||
redirectUrlProvider: createStaticRedirectUrlProvider(options.redirectUrl),
|
||||
storage: new NodeOAuthStorage(),
|
||||
navigation: new ConsoleNavigation(),
|
||||
};
|
||||
|
||||
if (options.mode === "static") {
|
||||
if (!options.clientId) {
|
||||
throw new Error("clientId is required for static mode");
|
||||
}
|
||||
config.clientId = options.clientId;
|
||||
if (options.clientSecret) {
|
||||
config.clientSecret = options.clientSecret;
|
||||
}
|
||||
} else if (options.mode === "dcr") {
|
||||
// DCR mode - no clientId needed, will be registered
|
||||
if (options.clientId) {
|
||||
config.clientId = options.clientId;
|
||||
}
|
||||
} else if (options.mode === "cimd") {
|
||||
if (!options.clientMetadataUrl) {
|
||||
throw new Error("clientMetadataUrl is required for CIMD mode");
|
||||
}
|
||||
config.clientMetadataUrl = options.clientMetadataUrl;
|
||||
}
|
||||
|
||||
if (options.scope) {
|
||||
config.scope = options.scope;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client metadata document for CIMD testing
|
||||
*/
|
||||
export interface ClientMetadataDocument {
|
||||
redirect_uris: string[];
|
||||
token_endpoint_auth_method?: string;
|
||||
grant_types?: string[];
|
||||
response_types?: string[];
|
||||
client_name?: string;
|
||||
client_uri?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Express server that serves a client metadata document for CIMD testing
|
||||
* The server runs on a different port and serves the metadata at the root path
|
||||
*
|
||||
* @param metadata - The client metadata document to serve
|
||||
* @returns Object with server URL and cleanup function
|
||||
*/
|
||||
export async function createClientMetadataServer(
|
||||
metadata: ClientMetadataDocument,
|
||||
): Promise<{ url: string; stop: () => Promise<void> }> {
|
||||
const express = await import("express");
|
||||
const app = express.default();
|
||||
|
||||
app.get("/", (_req, res) => {
|
||||
res.json(metadata);
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
reject(new Error("Failed to get server address"));
|
||||
return;
|
||||
}
|
||||
const port = address.port;
|
||||
const url = `http://localhost:${port}`;
|
||||
|
||||
resolve({
|
||||
url,
|
||||
stop: async () => {
|
||||
return new Promise<void>((resolveStop) => {
|
||||
server.close(() => {
|
||||
resolveStop();
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to programmatically complete OAuth authorization
|
||||
* Makes HTTP GET request to authorization URL and extracts authorization code
|
||||
* The test server's authorization endpoint auto-approves and redirects with code
|
||||
*
|
||||
* @param authorizationUrl - The authorization URL from oauthAuthorizationRequired event
|
||||
* @returns Authorization code extracted from redirect URL
|
||||
*/
|
||||
export async function completeOAuthAuthorization(
|
||||
authorizationUrl: URL,
|
||||
): Promise<string> {
|
||||
const response = await fetch(authorizationUrl.toString(), {
|
||||
redirect: "manual",
|
||||
});
|
||||
|
||||
if (response.status !== 302 && response.status !== 301) {
|
||||
throw new Error(
|
||||
`Expected redirect (302/301), got ${response.status}: ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const redirectUrl = response.headers.get("location");
|
||||
if (!redirectUrl) {
|
||||
throw new Error("No Location header in redirect response");
|
||||
}
|
||||
|
||||
const redirectUrlObj = new URL(redirectUrl);
|
||||
const code = redirectUrlObj.searchParams.get("code");
|
||||
if (!code) {
|
||||
throw new Error(`No authorization code in redirect URL: ${redirectUrl}`);
|
||||
}
|
||||
|
||||
return code;
|
||||
}
|
||||
1880
clients/web/src/test/core/inspectorClient-oauth-e2e.test.ts
Normal file
1880
clients/web/src/test/core/inspectorClient-oauth-e2e.test.ts
Normal file
File diff suppressed because it is too large
Load diff
200
clients/web/src/test/core/inspectorClient-oauth-fetchFn.test.ts
Normal file
200
clients/web/src/test/core/inspectorClient-oauth-fetchFn.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||
import { createOAuthClientConfig } from "./helpers/oauth-client-fixtures.js";
|
||||
import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js";
|
||||
|
||||
const oauthTestStatePath = path.join(
|
||||
os.tmpdir(),
|
||||
`mcp-oauth-${process.pid}-inspectorClient-oauth-fetchFn.json`,
|
||||
);
|
||||
|
||||
function createTestOAuthConfig(
|
||||
options: Parameters<typeof createOAuthClientConfig>[0],
|
||||
) {
|
||||
return {
|
||||
...createOAuthClientConfig(options),
|
||||
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||
};
|
||||
}
|
||||
|
||||
const mockAuth = vi.fn();
|
||||
vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
||||
auth: (...args: unknown[]) => mockAuth(...args),
|
||||
}));
|
||||
|
||||
describe("InspectorClient OAuth fetchFn", () => {
|
||||
let client: InspectorClient;
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await fs.unlink(oauthTestStatePath);
|
||||
} catch {
|
||||
// Ignore if file does not exist or already removed
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
mockAuth.mockImplementation(
|
||||
async (provider: { redirectToAuthorization: (url: URL) => void }) => {
|
||||
provider.redirectToAuthorization(
|
||||
new URL("http://example.com/oauth/authorize"),
|
||||
);
|
||||
return "REDIRECT";
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should pass fetchFn to auth() when provided", async () => {
|
||||
const mockFetchFn = vi.fn();
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: "test-client",
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
});
|
||||
|
||||
client = new InspectorClient(
|
||||
{ type: "sse", url: "http://localhost:3000/sse" } as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
fetch: mockFetchFn,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const url = await client.authenticate();
|
||||
|
||||
expect(url).toBeInstanceOf(URL);
|
||||
expect(mockAuth).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
fetchFn: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass fetchFn to auth() when not provided (uses default fetch)", async () => {
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: "test-client",
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
});
|
||||
|
||||
client = new InspectorClient(
|
||||
{ type: "sse", url: "http://localhost:3000/sse" } as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
} as InspectorClientOptions,
|
||||
);
|
||||
|
||||
await client.authenticate();
|
||||
|
||||
expect(mockAuth).toHaveBeenCalled();
|
||||
const callArgs = mockAuth.mock.calls[0]!;
|
||||
const options = callArgs[1];
|
||||
expect(options).toHaveProperty("fetchFn");
|
||||
expect(typeof options.fetchFn).toBe("function");
|
||||
});
|
||||
|
||||
it("should pass fetchFn to auth() in completeOAuthFlow when provided", async () => {
|
||||
const mockFetchFn = vi.fn();
|
||||
mockAuth.mockImplementation(
|
||||
async (provider: { saveTokens: (tokens: unknown) => void }) => {
|
||||
provider.saveTokens({
|
||||
access_token: "test-token",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
return "AUTHORIZED";
|
||||
},
|
||||
);
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: "test-client",
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
});
|
||||
|
||||
client = new InspectorClient(
|
||||
{ type: "sse", url: "http://localhost:3000/sse" } as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
fetch: mockFetchFn,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await client.completeOAuthFlow("test-authorization-code");
|
||||
|
||||
expect(mockAuth).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
authorizationCode: "test-authorization-code",
|
||||
fetchFn: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,515 @@
|
|||
/**
|
||||
* End-to-end OAuth tests for InspectorClient using RemoteOAuthStorage.
|
||||
* Tests OAuth flows with remote storage (HTTP API) instead of file storage.
|
||||
* These tests verify that OAuth state persists correctly via the remote storage API.
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { mkdtempSync, rmSync, unlinkSync } 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 { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createRemoteTransport } from "@inspector/core/mcp/remote/createRemoteTransport.js";
|
||||
import { createRemoteFetch } from "@inspector/core/mcp/remote/createRemoteFetch.js";
|
||||
import { RemoteOAuthStorage } from "@inspector/core/auth/remote/storage-remote.js";
|
||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||
import {
|
||||
TestServerHttp,
|
||||
waitForOAuthWellKnown,
|
||||
waitForRemoteStore,
|
||||
getDefaultServerConfig,
|
||||
createOAuthTestServerConfig,
|
||||
clearOAuthTestData,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import {
|
||||
createOAuthClientConfig,
|
||||
completeOAuthAuthorization,
|
||||
} from "./helpers/oauth-client-fixtures.js";
|
||||
import { ConsoleNavigation } from "@inspector/core/auth/providers.js";
|
||||
import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
const oauthTestStatePath = join(
|
||||
tmpdir(),
|
||||
`mcp-oauth-${process.pid}-inspectorClient-oauth-remote-storage-e2e.json`,
|
||||
);
|
||||
|
||||
function createTestOAuthConfig(
|
||||
options: Parameters<typeof createOAuthClientConfig>[0],
|
||||
) {
|
||||
return {
|
||||
...createOAuthClientConfig(options),
|
||||
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||
};
|
||||
}
|
||||
|
||||
interface TransportConfig {
|
||||
name: string;
|
||||
serverType: "sse" | "streamable-http";
|
||||
clientType: "sse" | "streamable-http";
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
const transports: TransportConfig[] = [
|
||||
{
|
||||
name: "SSE",
|
||||
serverType: "sse",
|
||||
clientType: "sse",
|
||||
endpoint: "/sse",
|
||||
},
|
||||
{
|
||||
name: "Streamable HTTP",
|
||||
serverType: "streamable-http",
|
||||
clientType: "streamable-http",
|
||||
endpoint: "/mcp",
|
||||
},
|
||||
];
|
||||
|
||||
interface StartRemoteServerOptions {
|
||||
storageDir?: string;
|
||||
}
|
||||
|
||||
async function startRemoteServer(
|
||||
port: number,
|
||||
options: StartRemoteServerOptions = {},
|
||||
): Promise<{
|
||||
baseUrl: string;
|
||||
server: ServerType;
|
||||
authToken: string;
|
||||
}> {
|
||||
const { app, authToken } = createRemoteApp({
|
||||
storageDir: options.storageDir,
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = serve(
|
||||
{ fetch: app.fetch, port, hostname: "127.0.0.1" },
|
||||
(info) => {
|
||||
const actualPort =
|
||||
info && typeof info === "object" && "port" in info
|
||||
? (info as { port: number }).port
|
||||
: port;
|
||||
resolve({
|
||||
baseUrl: `http://127.0.0.1:${actualPort}`,
|
||||
server,
|
||||
authToken,
|
||||
});
|
||||
},
|
||||
);
|
||||
server.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe("InspectorClient OAuth E2E with Remote Storage", () => {
|
||||
let mcpServer: TestServerHttp;
|
||||
let remoteServer: ServerType | null = null;
|
||||
let remoteBaseUrl: string | null = null;
|
||||
let remoteAuthToken: string | null = null;
|
||||
let client: InspectorClient;
|
||||
let tempDir: string | null = null;
|
||||
const testRedirectUrl = "http://localhost:3001/oauth/callback";
|
||||
|
||||
beforeEach(() => {
|
||||
clearOAuthTestData();
|
||||
tempDir = mkdtempSync(join(tmpdir(), "inspector-remote-storage-test-"));
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
unlinkSync(oauthTestStatePath);
|
||||
} catch {
|
||||
// Ignore if file does not exist or already removed
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
}
|
||||
if (mcpServer) {
|
||||
await mcpServer.stop();
|
||||
}
|
||||
if (remoteServer) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
remoteServer!.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
remoteServer = null;
|
||||
}
|
||||
if (tempDir) {
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
tempDir = null;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function setupRemoteServer(): Promise<void> {
|
||||
const { baseUrl, server, authToken } = await startRemoteServer(0, {
|
||||
storageDir: tempDir!,
|
||||
});
|
||||
remoteServer = server;
|
||||
remoteBaseUrl = baseUrl;
|
||||
remoteAuthToken = authToken;
|
||||
}
|
||||
|
||||
describe.each(transports)(
|
||||
"Static/Preregistered Client Mode ($name)",
|
||||
(transport) => {
|
||||
it("should complete OAuth flow with static client using remote storage", async () => {
|
||||
await setupRemoteServer();
|
||||
|
||||
const staticClientId = "test-static-client";
|
||||
const staticClientSecret = "test-static-secret";
|
||||
|
||||
// Create test server with OAuth enabled and static client
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: transport.serverType,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
// Create client with remote transport and remote OAuth storage
|
||||
const createTransport = createRemoteTransport({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
});
|
||||
const remoteFetch = createRemoteFetch({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
});
|
||||
const remoteStorage = new RemoteOAuthStorage({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
storeId: "oauth",
|
||||
authToken: remoteAuthToken!,
|
||||
});
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransport,
|
||||
fetch: remoteFetch,
|
||||
oauth: {
|
||||
storage: remoteStorage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: transport.clientType,
|
||||
url: `${serverUrl}${transport.endpoint}`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.runGuidedAuth();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
expect(authUrl.href).toContain("/oauth/authorize");
|
||||
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
await client.connect();
|
||||
|
||||
// Verify tokens are stored
|
||||
const tokens = await client.getOAuthTokens();
|
||||
expect(tokens).toBeDefined();
|
||||
expect(tokens?.access_token).toBeDefined();
|
||||
expect(tokens?.token_type).toBe("Bearer");
|
||||
|
||||
// Connection should now be successful
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
});
|
||||
|
||||
it("should persist OAuth state and reload on new client instance", async () => {
|
||||
await setupRemoteServer();
|
||||
|
||||
const staticClientId = "test-static-client-reload";
|
||||
const staticClientSecret = "test-static-secret-reload";
|
||||
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: transport.serverType,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const createTransport = createRemoteTransport({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
});
|
||||
const remoteFetch = createRemoteFetch({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
});
|
||||
const remoteStorage = new RemoteOAuthStorage({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
storeId: "oauth",
|
||||
authToken: remoteAuthToken!,
|
||||
});
|
||||
|
||||
// First client: complete OAuth flow
|
||||
const oauthConfig1 = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
const clientConfig1: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransport,
|
||||
fetch: remoteFetch,
|
||||
oauth: {
|
||||
storage: remoteStorage,
|
||||
navigation: oauthConfig1.navigation,
|
||||
redirectUrlProvider: oauthConfig1.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig1.clientId,
|
||||
clientSecret: oauthConfig1.clientSecret,
|
||||
clientMetadataUrl: oauthConfig1.clientMetadataUrl,
|
||||
scope: oauthConfig1.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const client1 = new InspectorClient(
|
||||
{
|
||||
type: transport.clientType,
|
||||
url: `${serverUrl}${transport.endpoint}`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig1,
|
||||
);
|
||||
|
||||
const authUrl = await client1.runGuidedAuth();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client1.completeOAuthFlow(authCode);
|
||||
await client1.connect();
|
||||
|
||||
const tokens1 = await client1.getOAuthTokens();
|
||||
expect(tokens1).toBeDefined();
|
||||
await client1.disconnect();
|
||||
|
||||
// Wait until remote server has persisted state before creating second client
|
||||
await waitForRemoteStore(
|
||||
remoteBaseUrl!,
|
||||
"oauth",
|
||||
remoteAuthToken!,
|
||||
(body) => {
|
||||
const b = body as {
|
||||
state?: {
|
||||
servers?: Record<
|
||||
string,
|
||||
{ tokens?: { access_token?: string } }
|
||||
>;
|
||||
};
|
||||
};
|
||||
return !!(
|
||||
b?.state?.servers &&
|
||||
Object.values(b.state.servers).some(
|
||||
(s) => s?.tokens?.access_token,
|
||||
)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Second client: should load persisted state
|
||||
const remoteStorage2 = new RemoteOAuthStorage({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
storeId: "oauth",
|
||||
authToken: remoteAuthToken!,
|
||||
});
|
||||
|
||||
const oauthConfig2 = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
const clientConfig2: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransport,
|
||||
fetch: remoteFetch,
|
||||
oauth: {
|
||||
storage: remoteStorage2,
|
||||
navigation: oauthConfig2.navigation,
|
||||
redirectUrlProvider: oauthConfig2.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig2.clientId,
|
||||
clientSecret: oauthConfig2.clientSecret,
|
||||
clientMetadataUrl: oauthConfig2.clientMetadataUrl,
|
||||
scope: oauthConfig2.scope,
|
||||
},
|
||||
};
|
||||
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: transport.clientType,
|
||||
url: `${serverUrl}${transport.endpoint}`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig2,
|
||||
);
|
||||
|
||||
// Wait for storage to hydrate and tokens to be available
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
const tokens = await client.getOAuthTokens();
|
||||
if (!tokens) {
|
||||
throw new Error("Tokens not yet loaded from storage");
|
||||
}
|
||||
return tokens;
|
||||
},
|
||||
{ timeout: 2000, interval: 50 },
|
||||
);
|
||||
|
||||
// Should be able to connect without re-authenticating
|
||||
await client.connect();
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
|
||||
// Tokens should be loaded from remote storage
|
||||
const tokens2 = await client.getOAuthTokens();
|
||||
expect(tokens2).toBeDefined();
|
||||
expect(tokens2?.access_token).toBe(tokens1?.access_token);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe.each(transports)(
|
||||
"DCR (Dynamic Client Registration) Mode ($name)",
|
||||
(transport) => {
|
||||
it("should register client and complete OAuth flow using remote storage", async () => {
|
||||
await setupRemoteServer();
|
||||
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: transport.serverType,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportDCR: true,
|
||||
}),
|
||||
};
|
||||
|
||||
mcpServer = new TestServerHttp(serverConfig);
|
||||
const port = await mcpServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
await waitForOAuthWellKnown(serverUrl);
|
||||
|
||||
const createTransport = createRemoteTransport({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
});
|
||||
const remoteFetch = createRemoteFetch({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
authToken: remoteAuthToken!,
|
||||
});
|
||||
const remoteStorage = new RemoteOAuthStorage({
|
||||
baseUrl: remoteBaseUrl!,
|
||||
storeId: "oauth",
|
||||
authToken: remoteAuthToken!,
|
||||
});
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "dcr",
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransport,
|
||||
fetch: remoteFetch,
|
||||
oauth: {
|
||||
storage: remoteStorage,
|
||||
navigation: new ConsoleNavigation(),
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
client = new InspectorClient(
|
||||
{
|
||||
type: transport.clientType,
|
||||
url: `${serverUrl}${transport.endpoint}`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
const authUrl = await client.runGuidedAuth();
|
||||
if (!authUrl) throw new Error("Expected authorization URL");
|
||||
expect(authUrl.href).toContain("/oauth/authorize");
|
||||
|
||||
const authCode = await completeOAuthAuthorization(authUrl);
|
||||
await client.completeOAuthFlow(authCode);
|
||||
await client.connect();
|
||||
|
||||
// Verify tokens are stored
|
||||
const tokens = await client.getOAuthTokens();
|
||||
expect(tokens).toBeDefined();
|
||||
expect(tokens?.access_token).toBeDefined();
|
||||
|
||||
expect(client.getStatus()).toBe("connected");
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
553
clients/web/src/test/core/inspectorClient-oauth.test.ts
Normal file
553
clients/web/src/test/core/inspectorClient-oauth.test.ts
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
afterAll,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { FetchRequestLogState } from "@inspector/core/mcp/state/index.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
import { NodeOAuthStorage } from "@inspector/core/auth/node/storage-node.js";
|
||||
import {
|
||||
TestServerHttp,
|
||||
waitForEvent,
|
||||
getDefaultServerConfig,
|
||||
createOAuthTestServerConfig,
|
||||
clearOAuthTestData,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import {
|
||||
createOAuthClientConfig,
|
||||
completeOAuthAuthorization,
|
||||
} from "./helpers/oauth-client-fixtures.js";
|
||||
import type { InspectorClientOptions } from "@inspector/core/mcp/inspectorClient.js";
|
||||
|
||||
const oauthTestStatePath = path.join(
|
||||
os.tmpdir(),
|
||||
`mcp-oauth-${process.pid}-inspectorClient-oauth.json`,
|
||||
);
|
||||
|
||||
function createTestOAuthConfig(
|
||||
options: Parameters<typeof createOAuthClientConfig>[0],
|
||||
) {
|
||||
return {
|
||||
...createOAuthClientConfig(options),
|
||||
storage: new NodeOAuthStorage(oauthTestStatePath),
|
||||
};
|
||||
}
|
||||
|
||||
describe("InspectorClient OAuth", () => {
|
||||
let client: InspectorClient;
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await fs.unlink(oauthTestStatePath);
|
||||
} catch {
|
||||
// Ignore if file does not exist or already removed
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
// Create client with HTTP transport (OAuth only works with HTTP transports)
|
||||
const config: MCPServerConfig = {
|
||||
type: "sse",
|
||||
url: "http://localhost:3000/sse",
|
||||
};
|
||||
client = new InspectorClient(config, {
|
||||
environment: { transport: createTransportNode },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch {
|
||||
// Ignore disconnect errors
|
||||
}
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("OAuth Configuration", () => {
|
||||
it("should set OAuth configuration", () => {
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: "test-client-id",
|
||||
clientSecret: "test-secret",
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
scope: "read write",
|
||||
});
|
||||
client = new InspectorClient(
|
||||
{ type: "sse", url: "http://localhost:3000/sse" },
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Configuration should be set (no error thrown)
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it("should set OAuth configuration with clientMetadataUrl for CIMD", () => {
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "cimd",
|
||||
clientMetadataUrl: "https://example.com/client-metadata.json",
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
scope: "read write",
|
||||
});
|
||||
client = new InspectorClient(
|
||||
{ type: "sse", url: "http://localhost:3000/sse" },
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Token Management", () => {
|
||||
beforeEach(() => {
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: "test-client-id",
|
||||
redirectUrl: "http://localhost:3000/callback",
|
||||
});
|
||||
client = new InspectorClient(
|
||||
{ type: "sse", url: "http://localhost:3000/sse" },
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("should return undefined tokens when not authorized", async () => {
|
||||
const tokens = await client.getOAuthTokens();
|
||||
expect(tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should clear OAuth tokens", () => {
|
||||
client.clearOAuthTokens();
|
||||
// Should not throw
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return false for isOAuthAuthorized when not authorized", async () => {
|
||||
const isAuthorized = await client.isOAuthAuthorized();
|
||||
expect(isAuthorized).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth fetch tracking", () => {
|
||||
let testServer: TestServerHttp;
|
||||
const testRedirectUrl = "http://localhost:3001/oauth/callback";
|
||||
|
||||
beforeEach(() => {
|
||||
clearOAuthTestData();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testServer) {
|
||||
await testServer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should track auth fetches with category 'auth' during guided auth", async () => {
|
||||
const staticClientId = "test-auth-fetch-client";
|
||||
const staticClientSecret = "test-auth-fetch-secret";
|
||||
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "sse" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: false,
|
||||
supportDCR: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
testServer = new TestServerHttp(serverConfig);
|
||||
const port = await testServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
const testClient = new InspectorClient(
|
||||
{
|
||||
type: "sse",
|
||||
url: `${serverUrl}/sse`,
|
||||
} as MCPServerConfig,
|
||||
{
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const fetchRequestLogState = new FetchRequestLogState(testClient);
|
||||
// beginGuidedAuth runs metadata_discovery, client_registration, authorization_redirect
|
||||
// (stops at authorization_code awaiting user). Produces auth fetches only (no connect yet).
|
||||
await testClient.beginGuidedAuth();
|
||||
|
||||
const fetchRequests = fetchRequestLogState.getFetchRequests();
|
||||
const authFetches = fetchRequests.filter(
|
||||
(req) => req.category === "auth",
|
||||
);
|
||||
expect(authFetches.length).toBeGreaterThan(0);
|
||||
const hasOAuthUrls = authFetches.some(
|
||||
(req) =>
|
||||
req.url.includes("well-known") ||
|
||||
req.url.includes("/oauth/") ||
|
||||
req.url.includes("token"),
|
||||
);
|
||||
expect(hasOAuthUrls).toBe(true);
|
||||
|
||||
fetchRequestLogState.destroy();
|
||||
await testClient.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth Events", () => {
|
||||
let testServer: TestServerHttp;
|
||||
const testRedirectUrl = "http://localhost:3001/oauth/callback";
|
||||
|
||||
beforeEach(() => {
|
||||
clearOAuthTestData();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testServer) {
|
||||
await testServer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should dispatch oauthAuthorizationRequired event", async () => {
|
||||
const staticClientId = "test-event-client";
|
||||
const staticClientSecret = "test-event-secret";
|
||||
|
||||
// Create test server with OAuth enabled and DCR support (for authenticate() normal mode)
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "sse" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: false, // Don't require auth for this test
|
||||
supportDCR: true, // Enable DCR so authenticate() can work
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
testServer = new TestServerHttp(serverConfig);
|
||||
const port = await testServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
|
||||
// Create client with OAuth config pointing to test server
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new InspectorClient(
|
||||
{
|
||||
type: "sse",
|
||||
url: `${serverUrl}/sse`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
testClient.authenticate().catch(() => {});
|
||||
|
||||
const detail = await waitForEvent<{ url: URL }>(
|
||||
testClient,
|
||||
"oauthAuthorizationRequired",
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
expect(detail).toHaveProperty("url");
|
||||
expect(detail.url).toBeInstanceOf(URL);
|
||||
expect(detail.url.href).toContain("/oauth/authorize");
|
||||
await testClient.disconnect();
|
||||
});
|
||||
|
||||
it("should dispatch oauthError event when OAuth flow fails", async () => {
|
||||
// Create a minimal test server just for metadata discovery
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "sse" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: false,
|
||||
supportDCR: true,
|
||||
}),
|
||||
};
|
||||
|
||||
testServer = new TestServerHttp(serverConfig);
|
||||
const port = await testServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: "test-error-client",
|
||||
clientSecret: "test-error-secret",
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new InspectorClient(
|
||||
{
|
||||
type: "sse",
|
||||
url: `${serverUrl}/sse`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
|
||||
testClient.completeOAuthFlow("invalid-test-code").catch(() => {});
|
||||
|
||||
const detail = await waitForEvent<{ error: Error }>(
|
||||
testClient,
|
||||
"oauthError",
|
||||
{
|
||||
timeout: 3000,
|
||||
},
|
||||
);
|
||||
expect(detail).toHaveProperty("error");
|
||||
expect(detail.error).toBeInstanceOf(Error);
|
||||
await testClient.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Injection in HTTP Transports", () => {
|
||||
let testServer: TestServerHttp;
|
||||
const testRedirectUrl = "http://localhost:3001/oauth/callback";
|
||||
|
||||
beforeEach(() => {
|
||||
clearOAuthTestData();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testServer) {
|
||||
await testServer.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("should inject Bearer token in HTTP requests when OAuth is configured", async () => {
|
||||
const staticClientId = "test-token-injection-client";
|
||||
const staticClientSecret = "test-token-injection-secret";
|
||||
|
||||
// Create test server with OAuth enabled and auth required
|
||||
const serverConfig = {
|
||||
...getDefaultServerConfig(),
|
||||
serverType: "sse" as const,
|
||||
...createOAuthTestServerConfig({
|
||||
requireAuth: true,
|
||||
supportDCR: true,
|
||||
staticClients: [
|
||||
{
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUris: [testRedirectUrl],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
testServer = new TestServerHttp(serverConfig);
|
||||
const port = await testServer.start();
|
||||
const serverUrl = `http://localhost:${port}`;
|
||||
|
||||
const oauthConfig = createTestOAuthConfig({
|
||||
mode: "static",
|
||||
clientId: staticClientId,
|
||||
clientSecret: staticClientSecret,
|
||||
redirectUrl: testRedirectUrl,
|
||||
});
|
||||
const clientConfig: InspectorClientOptions = {
|
||||
environment: {
|
||||
transport: createTransportNode,
|
||||
oauth: {
|
||||
storage: oauthConfig.storage,
|
||||
navigation: oauthConfig.navigation,
|
||||
redirectUrlProvider: oauthConfig.redirectUrlProvider,
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
clientId: oauthConfig.clientId,
|
||||
clientSecret: oauthConfig.clientSecret,
|
||||
clientMetadataUrl: oauthConfig.clientMetadataUrl,
|
||||
scope: oauthConfig.scope,
|
||||
},
|
||||
};
|
||||
|
||||
const testClient = new InspectorClient(
|
||||
{
|
||||
type: "sse",
|
||||
url: `${serverUrl}/sse`,
|
||||
} as MCPServerConfig,
|
||||
clientConfig,
|
||||
);
|
||||
const fetchRequestLogState = new FetchRequestLogState(testClient);
|
||||
|
||||
// Auth-provider flow: authenticate first, complete OAuth, then connect.
|
||||
// connect() creates transport with authProvider; tokens are already in storage.
|
||||
const authorizationUrl = await testClient.authenticate();
|
||||
const authCode = await completeOAuthAuthorization(authorizationUrl);
|
||||
await testClient.completeOAuthFlow(authCode);
|
||||
|
||||
await testClient.connect();
|
||||
|
||||
const tokens = await testClient.getOAuthTokens();
|
||||
expect(tokens).toBeDefined();
|
||||
expect(tokens?.access_token).toBeDefined();
|
||||
|
||||
// listTools() succeeds only if authProvider injects Bearer token
|
||||
const toolsResult = await testClient.listTools();
|
||||
expect(toolsResult).toBeDefined();
|
||||
|
||||
const fetchRequests = fetchRequestLogState.getFetchRequests();
|
||||
expect(fetchRequests.length).toBeGreaterThan(0);
|
||||
|
||||
// Auth fetches (discovery, token exchange) should have category 'auth'
|
||||
const authFetches = fetchRequests.filter(
|
||||
(req) => req.category === "auth",
|
||||
);
|
||||
expect(authFetches.length).toBeGreaterThan(0);
|
||||
const oauthFetches = authFetches.filter(
|
||||
(req) =>
|
||||
req.url.includes("well-known") ||
|
||||
req.url.includes("/oauth/") ||
|
||||
req.url.includes("/token"),
|
||||
);
|
||||
expect(oauthFetches.length).toBeGreaterThan(0);
|
||||
|
||||
// Transport fetches (SSE, MCP) should have category 'transport'
|
||||
const transportFetches = fetchRequests.filter(
|
||||
(req) => req.category === "transport",
|
||||
);
|
||||
expect(transportFetches.length).toBeGreaterThan(0);
|
||||
|
||||
const mcpPostRequests = transportFetches.filter(
|
||||
(req) =>
|
||||
req.method === "POST" &&
|
||||
(req.url.includes("/sse") || req.url.includes("/mcp")) &&
|
||||
!req.url.includes("/oauth"),
|
||||
);
|
||||
if (mcpPostRequests.length > 0) {
|
||||
const hasAuthHeader = mcpPostRequests.some((req) => {
|
||||
const authHeader =
|
||||
req.requestHeaders?.["Authorization"] ||
|
||||
req.requestHeaders?.["authorization"];
|
||||
return authHeader && authHeader.startsWith("Bearer ");
|
||||
});
|
||||
if (hasAuthHeader) {
|
||||
expect(hasAuthHeader).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
await testClient.disconnect();
|
||||
});
|
||||
});
|
||||
});
|
||||
4016
clients/web/src/test/core/inspectorClient.test.ts
Normal file
4016
clients/web/src/test/core/inspectorClient.test.ts
Normal file
File diff suppressed because it is too large
Load diff
124
clients/web/src/test/core/jsonUtils.test.ts
Normal file
124
clients/web/src/test/core/jsonUtils.test.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
convertParameterValue,
|
||||
convertToolParameters,
|
||||
convertPromptArguments,
|
||||
} from "@inspector/core/json/jsonUtils.js";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
describe("JSON Utils", () => {
|
||||
describe("convertParameterValue", () => {
|
||||
it("should convert string to string", () => {
|
||||
expect(convertParameterValue("hello", { type: "string" })).toBe("hello");
|
||||
});
|
||||
|
||||
it("should convert string to number", () => {
|
||||
expect(convertParameterValue("42", { type: "number" })).toBe(42);
|
||||
expect(convertParameterValue("3.14", { type: "number" })).toBe(3.14);
|
||||
});
|
||||
|
||||
it("should convert string to boolean", () => {
|
||||
expect(convertParameterValue("true", { type: "boolean" })).toBe(true);
|
||||
expect(convertParameterValue("false", { type: "boolean" })).toBe(false);
|
||||
});
|
||||
|
||||
it("should parse JSON strings", () => {
|
||||
expect(
|
||||
convertParameterValue('{"key":"value"}', { type: "object" }),
|
||||
).toEqual({
|
||||
key: "value",
|
||||
});
|
||||
expect(convertParameterValue("[1,2,3]", { type: "array" })).toEqual([
|
||||
1, 2, 3,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return string for unknown types", () => {
|
||||
expect(convertParameterValue("hello", { type: "unknown" })).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToolParameters", () => {
|
||||
const tool: Tool = {
|
||||
name: "test-tool",
|
||||
description: "Test tool",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
count: { type: "number" },
|
||||
enabled: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("should convert string parameters", () => {
|
||||
const result = convertToolParameters(tool, {
|
||||
message: "hello",
|
||||
count: "42",
|
||||
enabled: "true",
|
||||
});
|
||||
|
||||
expect(result.message).toBe("hello");
|
||||
expect(result.count).toBe(42);
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("should preserve non-string values", () => {
|
||||
const result = convertToolParameters(tool, {
|
||||
message: "hello",
|
||||
count: "42", // Still pass as string, conversion will handle it
|
||||
enabled: "true", // Still pass as string, conversion will handle it
|
||||
});
|
||||
|
||||
expect(result.message).toBe("hello");
|
||||
expect(result.count).toBe(42);
|
||||
expect(result.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle missing schema", () => {
|
||||
const toolWithoutSchema: Tool = {
|
||||
name: "test-tool",
|
||||
description: "Test tool",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertToolParameters(toolWithoutSchema, {
|
||||
message: "hello",
|
||||
});
|
||||
|
||||
expect(result.message).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertPromptArguments", () => {
|
||||
it("should convert values to strings", () => {
|
||||
const result = convertPromptArguments({
|
||||
name: "John",
|
||||
age: 42,
|
||||
active: true,
|
||||
data: { key: "value" },
|
||||
items: [1, 2, 3],
|
||||
});
|
||||
|
||||
expect(result.name).toBe("John");
|
||||
expect(result.age).toBe("42");
|
||||
expect(result.active).toBe("true");
|
||||
expect(result.data).toBe('{"key":"value"}');
|
||||
expect(result.items).toBe("[1,2,3]");
|
||||
});
|
||||
|
||||
it("should handle null and undefined", () => {
|
||||
const result = convertPromptArguments({
|
||||
value: null,
|
||||
missing: undefined,
|
||||
});
|
||||
|
||||
expect(result.value).toBe("null");
|
||||
expect(result.missing).toBe("undefined");
|
||||
});
|
||||
});
|
||||
});
|
||||
224
clients/web/src/test/core/mcp/oauthManager.test.ts
Normal file
224
clients/web/src/test/core/mcp/oauthManager.test.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* OAuthManager unit tests. Uses mocked getServerUrl, fetch, storage, and
|
||||
* dispatch callbacks to verify config merge, callback invocation, clearOAuthTokens,
|
||||
* error propagation, and getOAuthState/getOAuthStep after beginGuidedAuth.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
OAuthManager,
|
||||
type OAuthManagerConfig,
|
||||
type OAuthManagerParams,
|
||||
} from "@inspector/core/mcp/oauthManager.js";
|
||||
|
||||
const SERVER_URL = "https://example.com/mcp";
|
||||
|
||||
function createMockParams(
|
||||
overrides?: Partial<OAuthManagerParams>,
|
||||
): OAuthManagerParams {
|
||||
const dispatchOAuthStepChange = vi.fn();
|
||||
const dispatchOAuthComplete = vi.fn();
|
||||
const dispatchOAuthAuthorizationRequired = vi.fn();
|
||||
const dispatchOAuthError = vi.fn();
|
||||
|
||||
const storage = {
|
||||
getScope: vi.fn().mockResolvedValue(undefined),
|
||||
getClientInformation: 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"),
|
||||
saveCodeVerifier: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn(),
|
||||
clearClientInformation: vi.fn(),
|
||||
clearTokens: vi.fn(),
|
||||
clearCodeVerifier: vi.fn(),
|
||||
clearScope: vi.fn(),
|
||||
clearServerMetadata: vi.fn(),
|
||||
getServerMetadata: vi.fn().mockReturnValue(null),
|
||||
saveServerMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const redirectUrlProvider = {
|
||||
getRedirectUrl: vi.fn().mockReturnValue("http://localhost/callback"),
|
||||
};
|
||||
|
||||
const navigation = {
|
||||
navigateToAuthorization: vi.fn(),
|
||||
};
|
||||
|
||||
const initialConfig: OAuthManagerConfig = {
|
||||
storage,
|
||||
redirectUrlProvider,
|
||||
navigation,
|
||||
clientId: "test-client",
|
||||
clientSecret: "test-secret",
|
||||
};
|
||||
|
||||
return {
|
||||
getServerUrl: vi.fn().mockReturnValue(SERVER_URL),
|
||||
effectiveAuthFetch: vi.fn().mockResolvedValue(new Response("{}")),
|
||||
getEventTarget: vi.fn().mockReturnValue(new EventTarget()),
|
||||
initialConfig,
|
||||
dispatchOAuthStepChange,
|
||||
dispatchOAuthComplete,
|
||||
dispatchOAuthAuthorizationRequired,
|
||||
dispatchOAuthError,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("OAuthManager", () => {
|
||||
describe("setOAuthConfig", () => {
|
||||
it("merges config without throwing", () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
expect(() => {
|
||||
manager.setOAuthConfig({ scope: "read write" });
|
||||
manager.setOAuthConfig({ clientId: "new-id" });
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getServerUrl propagation", () => {
|
||||
it("createOAuthProviderForTransport throws when getServerUrl throws", async () => {
|
||||
const params = createMockParams({
|
||||
getServerUrl: vi.fn().mockImplementation(() => {
|
||||
throw new Error("OAuth is only supported for HTTP-based transports");
|
||||
}),
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
await expect(manager.createOAuthProviderForTransport()).rejects.toThrow(
|
||||
"OAuth is only supported for HTTP-based transports",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearOAuthTokens", () => {
|
||||
it("calls storage.clear(serverUrl) when storage is configured", () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
manager.clearOAuthTokens();
|
||||
expect(params.initialConfig.storage!.clear).toHaveBeenCalledWith(
|
||||
SERVER_URL,
|
||||
);
|
||||
expect(manager.getOAuthState()).toBeUndefined();
|
||||
expect(manager.getOAuthStep()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("no-ops when storage is not configured", () => {
|
||||
const params = createMockParams({
|
||||
initialConfig: {
|
||||
redirectUrlProvider: {
|
||||
getRedirectUrl: vi.fn().mockReturnValue("http://localhost"),
|
||||
},
|
||||
navigation: { navigateToAuthorization: vi.fn() },
|
||||
} as OAuthManagerConfig,
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
manager.clearOAuthTokens();
|
||||
expect(params.getServerUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOAuthState / getOAuthStep", () => {
|
||||
it("returns undefined before any flow", () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
expect(manager.getOAuthState()).toBeUndefined();
|
||||
expect(manager.getOAuthStep()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatch callbacks", () => {
|
||||
it("completeOAuthFlow calls dispatchOAuthError when normal path throws", async () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
// Normal path (no guided state): auth() will run and fail (no real server), so catch calls dispatchOAuthError
|
||||
await expect(manager.completeOAuthFlow("bad-code")).rejects.toThrow();
|
||||
expect(params.dispatchOAuthError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.any(Error),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOAuthTokens", () => {
|
||||
it("returns undefined when not authorized", async () => {
|
||||
const params = createMockParams();
|
||||
(
|
||||
params.initialConfig.storage as unknown as {
|
||||
getTokens: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
).getTokens.mockResolvedValue(undefined);
|
||||
const manager = new OAuthManager(params);
|
||||
const tokens = await manager.getOAuthTokens();
|
||||
expect(tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns tokens from storage when no in-memory state", async () => {
|
||||
const params = createMockParams();
|
||||
const storedTokens = {
|
||||
access_token: "stored-token",
|
||||
token_type: "Bearer",
|
||||
};
|
||||
(
|
||||
params.initialConfig.storage as unknown as {
|
||||
getTokens: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
).getTokens.mockResolvedValue(storedTokens);
|
||||
const manager = new OAuthManager(params);
|
||||
const tokens = await manager.getOAuthTokens();
|
||||
expect(tokens).toEqual(storedTokens);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOAuthAuthorized", () => {
|
||||
it("returns false when getOAuthTokens returns undefined", async () => {
|
||||
const params = createMockParams();
|
||||
(
|
||||
params.initialConfig.storage as unknown as {
|
||||
getTokens: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
).getTokens.mockResolvedValue(undefined);
|
||||
const manager = new OAuthManager(params);
|
||||
expect(await manager.isOAuthAuthorized()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when getOAuthTokens returns tokens", async () => {
|
||||
const params = createMockParams();
|
||||
(
|
||||
params.initialConfig.storage as unknown as {
|
||||
getTokens: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
).getTokens.mockResolvedValue({
|
||||
access_token: "x",
|
||||
token_type: "Bearer",
|
||||
});
|
||||
const manager = new OAuthManager(params);
|
||||
expect(await manager.isOAuthAuthorized()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setGuidedAuthorizationCode", () => {
|
||||
it("throws when not in guided flow", async () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
await expect(
|
||||
manager.setGuidedAuthorizationCode("code", true),
|
||||
).rejects.toThrow("Not in guided OAuth flow");
|
||||
});
|
||||
});
|
||||
|
||||
describe("proceedOAuthStep", () => {
|
||||
it("throws when not in guided flow", async () => {
|
||||
const params = createMockParams();
|
||||
const manager = new OAuthManager(params);
|
||||
await expect(manager.proceedOAuthStep()).rejects.toThrow(
|
||||
"Not in guided OAuth flow",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
clients/web/src/test/core/remote-server-config.test.ts
Normal file
58
clients/web/src/test/core/remote-server-config.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||
|
||||
describe("createRemoteApp GET /api/config", () => {
|
||||
it("includes sandboxUrl in response when option is set", async () => {
|
||||
const sandboxUrl = "http://localhost:9123/sandbox";
|
||||
const { app } = createRemoteApp({
|
||||
dangerouslyOmitAuth: true,
|
||||
allowedOrigins: ["http://127.0.0.1:6274"],
|
||||
sandboxUrl,
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
});
|
||||
const res = await app.request(new Request("http://test/api/config"));
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { sandboxUrl?: string };
|
||||
expect(data.sandboxUrl).toBe(sandboxUrl);
|
||||
});
|
||||
|
||||
it("omits sandboxUrl when option is not set", async () => {
|
||||
const { app } = createRemoteApp({
|
||||
dangerouslyOmitAuth: true,
|
||||
allowedOrigins: ["http://127.0.0.1:6274"],
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
});
|
||||
const res = await app.request(new Request("http://test/api/config"));
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as { sandboxUrl?: string };
|
||||
expect(data).not.toHaveProperty("sandboxUrl");
|
||||
});
|
||||
|
||||
it("uses initialConfig when provided instead of env", async () => {
|
||||
const { app } = createRemoteApp({
|
||||
dangerouslyOmitAuth: true,
|
||||
allowedOrigins: ["http://127.0.0.1:6274"],
|
||||
initialConfig: {
|
||||
defaultCommand: "my-server",
|
||||
defaultArgs: ["--foo"],
|
||||
defaultTransport: "stdio",
|
||||
defaultCwd: "/tmp",
|
||||
defaultEnvironment: { PATH: "/usr/bin" },
|
||||
},
|
||||
});
|
||||
const res = await app.request(new Request("http://test/api/config"));
|
||||
expect(res.status).toBe(200);
|
||||
const data = (await res.json()) as {
|
||||
defaultCommand?: string;
|
||||
defaultArgs?: string[];
|
||||
defaultTransport?: string;
|
||||
defaultCwd?: string;
|
||||
defaultEnvironment?: Record<string, string>;
|
||||
};
|
||||
expect(data.defaultCommand).toBe("my-server");
|
||||
expect(data.defaultArgs).toEqual(["--foo"]);
|
||||
expect(data.defaultTransport).toBe("stdio");
|
||||
expect(data.defaultCwd).toBe("/tmp");
|
||||
expect(data.defaultEnvironment).toEqual({ PATH: "/usr/bin" });
|
||||
});
|
||||
});
|
||||
1003
clients/web/src/test/core/remote-transport.test.ts
Normal file
1003
clients/web/src/test/core/remote-transport.test.ts
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue