refactor(web): sharpen the src/lib vs src/utils rule and move misfiled modules (#1784)
* refactor(web): sharpen src/lib vs src/utils rule and move misfiled modules (#1776) Codify the empirical rule the web client already followed loosely — utils = pure functions that compute; lib = things that instantiate, adapt, or touch the environment — in AGENTS.md and clients/web/README.md, and move the modules that sat on the wrong side of it. Moves (source + co-located test): - lib/sandbox-csp -> utils (pure CSP-string construction; type-only import) - utils/browserTabVisibility -> lib (registers visibilitychange listeners) - utils/oauthResume -> lib (reads/writes sessionStorage) - utils/clearServerOAuthState -> lib (drives live InspectorClient / OAuthStorage) Borderline audits (kept in place): pendingReauth (pure type declarations), deepLink / correlateTransportErrors / mcpNetworkHeaders / oauthUx / oauthFlow / oauthNetworkPhase (pure transforms / type-only core imports) stay in utils; downloadFile stays whole in lib (dominant side is DOM side effects). Also relocate the lone src/test/lib/downloadFile.test.ts straggler beside its source at src/lib/downloadFile.test.ts, and document the test-placement rule (side-by-side by default; src/test/ only for core/ tests, the integration project, and shared scaffolding). Pure move — no behavior change; no build/coverage config keys off the boundary. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * refactor(web): address review — fix stale paths, move customHeaders, one-way arrows (#1776) Responds to the @claude review on PR #1784: 1. Stale path references (fixed): update the four in-repo pointers left at pre-move paths — specification/v2_auth_mid_session.md (oauthResume, browserTabVisibility → lib/), and the two load-bearing sandbox security comments (sandbox_proxy.html, sandbox-controller.ts → src/utils/sandbox-csp). 2. lib/types/customHeaders (moved): it's pure (types + pure transforms), the rule's clearest counterexample. Moved lib/types/customHeaders.{ts,test.ts} → utils/ (no external importers; only its own test) and removed the undocumented lib/types/ subdir. Documented that utils/ is the home for pure shared domain types + their constructors — no separate types/ bucket. 3. One-way arrows (item 3): declare OAuthResumeAuthKind in utils/pendingReauth and re-export it from lib/oauthResume, so the only cross-directory arrow now points lib → utils. Codified that invariant in AGENTS.md. Wording nit: AGENTS.md + README now state diagnostic logging (console.warn/error) doesn't count as a side effect for the utils rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs: address review round 2 — stale customHeaders refs, rule wording (#1776) Doc-only follow-up to the round-2 @claude review on PR #1784 (no code changed): 1. Stale paths (same class as round-1 item 1): the customHeaders move left three references to the deleted lib/types/customHeaders.ts path in specification/ (v2_ux_interfaces_phase_0_4_inventory.md, v2_ux_interfaces_plan.md ×2) — updated to utils/customHeaders.ts. 2. customHeaders anchor: note in AGENTS.md that its shape is staged for ServerSettingsForm (per v2_ux_interfaces_plan.md), so its zero-importer state reads as intentional rather than rot; kept as the no-types/-bucket illustration and led the anchor list with load-bearing modules. 3. Type-only-import clause: split the conflated clause — a type-only import (pendingReauth) and re-exporting core's pure functions/constants (oauthUx/oauthFlow) are distinct reasons to stay in utils; what makes a module lib is wrapping core's stateful runtime. Fixed in both AGENTS.md and the web README. Cosmetic: re-padded the v2_auth_mid_session.md table row to column width. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs: address review round 3 — scope the "no types/ bucket" claim (#1776) Doc-only follow-up to the round-3 @claude review on PR #1784 (no code changed): 1. "no separate types/ bucket" overreached: clients/web/src/types/ does exist (navigation.ts + react-syntax-highlighter .d.ts ambient stubs wired via tsconfig.app.json paths). Scoped the claim in both AGENTS.md and the web README to "no types/ sub-bucket INSIDE lib/utils", and noted src/types/ is for ambient .d.ts stubs, not domain types. 2. README example list drifted from AGENTS.md's round-2 load-bearing anchors — realigned it (inspectorTabs/mcpNetworkHeaders, dropped the zero-importer customHeaders from the headline list). 3. Clarified v2_ux_interfaces_plan.md:185 so the v2 path reads as the copy's destination, not as the v1.5 source location. Follow-up filed as #1785 (board Todo): src/types/navigation.ts InspectorTab is dead and duplicates utils/inspectorTabs.ts — kept out of this pure-move PR. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs: address review round 4 — reconcile src/types/ claim with #1785 (#1776) Doc-only follow-up to the round-4 @claude review on PR #1784 (no code changed): The round-3 scope fix overshot: it asserted src/types/ holds "not domain types at all", but the dead navigation.ts InspectorTab is a plain-.ts domain type still living there, and specification/v2_ux_interfaces_plan.md:181-182 actively prescribes placing InspectorTab there — so AGENTS.md and that spec doc gave opposite guidance about the same directory. - AGENTS.md + web README: softened to "not the place for new domain types" and added a parenthetical that the one lingering domain type (dead navigation.ts) is tracked for removal in #1785; until then a pure domain type belongs in utils/, not src/types/. - v2_ux_interfaces_plan.md:181: marked the tab-enum placement guidance as superseded by #1785 (live type is InspectorTabId in utils/inspectorTabs.ts). Self-heals on merge of #1785. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs: address review round 5 — fix garbled #1785 clause, restructure utils rule (#1776) Doc-only follow-up to the round-5 @claude review on PR #1784 (no code changed): 1. Grammar (item 1): the round-4 #1785 clause read "superseding [#1785] will remove it" — a participle with no subject. Reworded to "the dead navigation.ts InspectorTab, which #1785 will remove". 2. Readability (item 2): the src/utils/ bullet had accreted into a single ~1,400-char blob across five review rounds. Promoted the carve-outs to scannable sub-bullets (Domain types / Diagnostic logging / Importing from core) and lifted the src/types/ note out to its own line, since it's a statement about a sibling directory, not about utils. AGENTS.md only; the README's parallel sentence was already clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * docs+config: address review round 6 — correct the coverage-glob claim (#1776) Responds to the round-6 @claude review on PR #1784: 1. "no build/coverage glob" was factually wrong (should-fix): the web coverage include in clients/web/vite.config.ts DOES enumerate src/lib/** and src/utils/**. The conclusion (a move between them is coverage-neutral) survives, but the reason was wrong. Reworded both AGENTS.md and the web README to say nothing *enforces* the boundary — no path alias, and the include lists both dirs so a move is coverage-neutral — plus the important whitelist caveat: a module outside components/lib/utils/server falls out of the ≥90 gate entirely. 2. Removed a pre-existing duplicate glob in vite.config.ts (src/lib/**/*.{ts,tsx} appeared twice, lines 161 and 163). Inert (vitest de-dupes) but it sat three lines from the config the doc now describes. Nits: gave the README bullet the same round-5 sub-bullet restructure as AGENTS.md, and made its #1785 reference a real markdown link (bare #N doesn't autolink in rendered repo markdown). npm run ci green (config change re-verified through coverage + smokes + Storybook). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d009fcca09
commit
a82a70f4f5
23 changed files with 60 additions and 19 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -202,6 +202,9 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i
|
||||||
- **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it.
|
- **TUI** (`clients/tui`): the gate covers the **non-React logic** only — `logger.ts`, `components/tabsConfig.ts`, and `utils/*` (server resolution lives in `core/` and is measured by the web suite). The Ink components, `App.tsx`, and `hooks/` are an **interim exclusion** in `clients/tui/vitest.config.ts` pending the renderer-based follow-up (#1501). When adding new **non-React** logic under `clients/tui/src`, it falls under the gate automatically — add tests for it.
|
||||||
- Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`.
|
- Run `npm run test:integration` (also from `clients/web/`) for the InspectorClient + transport + auth integration suite. It runs under a separate `integration` vitest project in node env (no happy-dom) with 30s timeouts. The script builds `test-servers/` first via `tsc -p ../../test-servers --noCheck` so the stdio MCP test server can be spawned as a real subprocess. CI does not run `test:integration` as its own step — the integration project is covered by the CI `coverage` gate, whose web `test:coverage` runs `--project=unit --project=integration --coverage`.
|
||||||
- Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`.
|
- Test files live alongside the source as `<Name>.test.tsx` (or `.test.ts` for non-React modules). Integration tests live under `clients/web/src/test/integration/`, mirroring the `core/` source layout (`mcp/`, `mcp/node/`, `mcp/remote/`, `auth/`, `auth/node/`, `storage/`). Any test file under that folder is automatically picked up by the `integration` vitest project (node env, 30s timeouts) via the folder glob in `vite.config.ts` — placement is the manifest, there is no enumeration to keep in sync. Tests outside the folder run in the `unit` project (happy-dom). When adding a new test for, e.g., `core/mcp/remote/foo.ts`, put it at `src/test/integration/mcp/remote/foo.test.ts`.
|
||||||
|
- **Test placement: side-by-side by default, `src/test/` only for what can't be co-located.** These look like competing conventions but aren't — the split is: *tests live beside their source, **except** tests for the repo-root `core/` package (which lives outside `clients/web/`) and shared test scaffolding — both of which live under `src/test/`, with `core/` tests mirroring the `core/` layout and integration tests under `src/test/integration/`.*
|
||||||
|
- **Side-by-side (`<Name>.test.tsx` next to the source) — the default for web's own `src/` code.** Components, hooks, `lib/`, `utils/`. This is the overwhelming majority; a web-owned test living under `src/test/` instead of beside its source is a bug (fixed one such straggler, `downloadFile.test.ts`, in #1776).
|
||||||
|
- **`src/test/` — the three things that *cannot* be co-located:** (1) tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` folder layout — `core/` physically lives at `/core` outside `clients/web/`, is consumed via the `@inspector/core` alias, and has no test harness of its own, so co-locating would pollute the shared isomorphic package with web-only test infra); (2) the **`integration`** vitest project (`src/test/integration/…`, node env, 30s — placement *is* the manifest, see above); (3) **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`, `scrollAreaStoryAssertions.ts`) — not tests *of* a source file, so nothing to sit beside.
|
||||||
- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). Only when a test must assert *mid-flight* transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions) — and that test MUST drive the transition to completion (`await waitFor`/`findBy`) so its timer resolves before teardown.
|
- Use `renderWithMantine` from `src/test/renderWithMantine.tsx` to render components — it wraps in `MantineProvider` with the project theme. It sets `env="test"` so Mantine renders transitions synchronously (no internal `setTimeout`); this prevents a `Transition`/`Modal` timer from firing after happy-dom tears down `window` at end-of-run and failing the whole run with an uncaught `ReferenceError: window is not defined` (#1760). **Always render through `renderWithMantine`; do not hand-roll a bare `MantineProvider` in a test** (that reintroduces the leak class). Only when a test must assert *mid-flight* transition state (e.g. a `data-anim="out"` cell during an exit crossfade) use `renderWithMantineTransitions` (real transitions) — and that test MUST drive the transition to completion (`await waitFor`/`findBy`) so its timer resolves before teardown.
|
||||||
|
|
||||||
### Responding to Code Reviews
|
### Responding to Code Reviews
|
||||||
|
|
@ -238,6 +241,22 @@ gh project item-edit --project-id PVT_kwDOCt2Azc4BJVxt --id "$ITEM_ID" --field-i
|
||||||
- Take advantage of TypeScript's advanced features like generics, type aliases, and conditional types to write more expressive and reusable code
|
- Take advantage of TypeScript's advanced features like generics, type aliases, and conditional types to write more expressive and reusable code
|
||||||
- Regularly review and refactor TypeScript code to ensure it remains well-structured and adheres to evolving best practices
|
- Regularly review and refactor TypeScript code to ensure it remains well-structured and adheres to evolving best practices
|
||||||
|
|
||||||
|
## Web source layout: `src/lib` vs `src/utils`
|
||||||
|
|
||||||
|
The web client keeps two grab-bag directories under `clients/web/src`, split by a real (now codified) rule — **`utils` = functions that compute; `lib` = things that instantiate, adapt, or touch the environment.** If it does I/O or wraps a subsystem, it's `lib`; if it's a pure transform, it's `utils`.
|
||||||
|
|
||||||
|
- **`src/utils/`** — pure, side-effect-free functions. Input → output, no DOM/browser/storage I/O, no subsystem ownership. Trivially unit-testable with no mocks. (Anchors: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`.) Carve-outs that are still `utils`:
|
||||||
|
- _Domain types._ Pure **shared domain types plus their pure constructors/transforms** live here (`customHeaders` — `CustomHeader` + `headersToRecord`/`migrateFromLegacyAuth`, a shape staged for `ServerSettingsForm`, see `specification/v2_ux_interfaces_plan.md`, so it currently has no importer but its own test). There is no `types/` sub-bucket **inside** `lib`/`utils` — removing `lib/types/` is what the `customHeaders` move settles.
|
||||||
|
- _Diagnostic logging._ `console.warn`/`console.error` does **not** count as a side effect for this rule — a validator that warns on bad input is still "pure" here (`sandbox-csp`, `jsonUtils`, `schemaUtils` all warn).
|
||||||
|
- _Importing from `@inspector/core`._ Two forms are fine: a **type-only** import is not a subsystem dependency (`pendingReauth` is pure type declarations), and **re-exporting pure functions or constants** from core is not subsystem ownership either (`oauthUx`/`oauthFlow` re-export core copy/predicates). What makes a module `lib` is wrapping core's *stateful runtime*, not merely importing from it.
|
||||||
|
- **`src/lib/`** — infrastructure / integration / stateful adapters. Modules that instantiate or compose subsystems, wrap the `@inspector/core` **runtime** (not just its types), touch the DOM / `window` / `sessionStorage`, or otherwise produce side effects. (Anchors: `environmentFactory` composes `InspectorClientEnvironment`; `remoteOAuthStorage` is an adapter class over `core/auth`; `oauthResume` reads/writes `sessionStorage`; `browserTabVisibility` registers `visibilitychange` listeners; `clearServerOAuthState` drives the live `InspectorClient` / `OAuthStorage`; `downloadFile` triggers browser downloads.)
|
||||||
|
|
||||||
|
The top-level **`src/types/`** is a sibling of both and is not the place for new domain types — it's essentially the home for ambient `.d.ts` module stubs (e.g. the `react-syntax-highlighter` shims wired through `tsconfig.app.json` `paths`). One plain-`.ts` domain type still lingers there, the dead `navigation.ts` `InspectorTab`, which [#1785](https://github.com/modelcontextprotocol/inspector/issues/1785) will remove; until then a pure domain type belongs in `utils/`, not `src/types/`.
|
||||||
|
|
||||||
|
Cross-directory imports point **one way, `lib → utils`** (infra depends on pure helpers, never the reverse). Keep it that way: if a `utils/` module needs a type currently exported from a `lib/` module, declare the type in `utils/` and re-export it from `lib/` (as `pendingReauth` owns `OAuthResumeAuthKind` and `oauthResume` re-exports it), rather than importing "up" from `utils` into `lib`.
|
||||||
|
|
||||||
|
Nothing **enforces** the boundary: no path alias keys off it, and the coverage `include` in `clients/web/vite.config.ts` lists **both** `src/lib/**` and `src/utils/**`, so a move between them is coverage-neutral (this is why the refactor was gate-safe). It's a human-legible signal at import time, valuable in a codebase this test-heavy (the ≥90% per-file gate). Note that `include` is a **whitelist** — it names `components`/`lib`/`utils`/`server` (plus the `core/*` runtime), so a module placed **outside** those directories (`hooks/`, `theme/`, `types/`, `App.tsx`, or a brand-new grab-bag) falls out of the ≥90 gate entirely, silently. When adding a module, place it by the rule and keep it inside a gated directory; when it genuinely mixes both (e.g. `downloadFile` bundles DOM-side-effect helpers with a couple of pure ones), keep it whole on its dominant side (`lib`) rather than splitting hairs.
|
||||||
|
|
||||||
## React instructions
|
## React instructions
|
||||||
- UI Components
|
- UI Components
|
||||||
- We are using the Mantine component library for UI.
|
- We are using the Mantine component library for UI.
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,20 @@ Components live under `src/components/` in four layers, smallest to largest:
|
||||||
|
|
||||||
Every screen and element has a `*.stories.tsx` (see [Storybook](#storybook)). Styling follows the Mantine-first rules in [`AGENTS.md`](../../AGENTS.md) — theme variants and component props over CSS, `--inspector-*` tokens over raw colors.
|
Every screen and element has a `*.stories.tsx` (see [Storybook](#storybook)). Styling follows the Mantine-first rules in [`AGENTS.md`](../../AGENTS.md) — theme variants and component props over CSS, `--inspector-*` tokens over raw colors.
|
||||||
|
|
||||||
|
## Non-component code: `src/lib` vs `src/utils`
|
||||||
|
|
||||||
|
Two grab-bag directories, split by one rule: **`utils` = functions that compute; `lib` = things that instantiate, adapt, or touch the environment.** If it does I/O or wraps a subsystem, it's `lib`; if it's a pure transform, it's `utils`.
|
||||||
|
|
||||||
|
- **`src/utils/`** — pure, side-effect-free functions (no DOM/`window`/`sessionStorage` I/O, no subsystem ownership), trivially unit-testable with no mocks. Examples: `jsonUtils`, `schemaUtils`, `toolUtils`, `maskSecrets`, `inspectorTabs`, `deepLink`, `mcpNetworkHeaders`. Carve-outs that stay `utils`:
|
||||||
|
- _Diagnostic logging_ (`console.warn`/`console.error`) doesn't count as a side effect.
|
||||||
|
- _Importing from `@inspector/core`_ — neither a type-only import nor re-exporting core's pure functions/constants is a subsystem dependency (what makes a module `lib` is wrapping core's stateful runtime).
|
||||||
|
- _Pure domain types + their constructors_ (`customHeaders`) — there is no `types/` sub-bucket inside `lib`/`utils`.
|
||||||
|
- **`src/lib/`** — infrastructure / stateful adapters: modules that compose subsystems, wrap the `@inspector/core` **runtime**, or produce side effects. Examples: `environmentFactory`, `remoteOAuthStorage`, `oauthResume` (sessionStorage), `browserTabVisibility` (DOM listeners), `clearServerOAuthState`, `downloadFile`.
|
||||||
|
|
||||||
|
The top-level `src/types/` is a separate sibling — ambient `.d.ts` module stubs, not the place for new domain types (the one that lingers there, dead `navigation.ts`, is tracked for removal in [#1785](https://github.com/modelcontextprotocol/inspector/issues/1785)).
|
||||||
|
|
||||||
|
Nothing _enforces_ the boundary — no path alias keys off it, and the coverage `include` in `vite.config.ts` lists both directories, so a move between them is coverage-neutral. It's a human-legible import-time signal. See [`AGENTS.md`](../../AGENTS.md) for the full rule (including the whitelist caveat — a module placed outside `components`/`lib`/`utils`/`server` falls out of the ≥90 gate).
|
||||||
|
|
||||||
## MCP Apps screen automation contract
|
## MCP Apps screen automation contract
|
||||||
|
|
||||||
The Apps screen exposes a small, stable set of `data-testid` / `data-*` attributes so an automated driver (deep-link auto-open, CI review harness) can `waitForSelector` on a deterministic signal instead of sleeping. Treat these as a public contract — drivers depend on them staying stable:
|
The Apps screen exposes a small, stable set of `data-testid` / `data-*` attributes so an automated driver (deep-link auto-open, CI review harness) can `waitForSelector` on a deterministic signal instead of sleeping. Treat these as a public contract — drivers depend on them staying stable:
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export function createSandboxController(
|
||||||
// policy container: any fetch directive on this header would be inherited by
|
// policy container: any fetch directive on this header would be inherited by
|
||||||
// the inner app document and, since multiple CSPs intersect, would override
|
// the inner app document and, since multiple CSPs intersect, would override
|
||||||
// the per-app `connect-src`/`img-src` allowlists the host bakes into the
|
// the per-app `connect-src`/`img-src` allowlists the host bakes into the
|
||||||
// wrapped HTML (see src/lib/sandbox-csp.ts). The opaque-origin sandbox on
|
// wrapped HTML (see src/utils/sandbox-csp.ts). The opaque-origin sandbox on
|
||||||
// the inner frame is the structural boundary; `frame-ancestors` ensures the
|
// the inner frame is the structural boundary; `frame-ancestors` ensures the
|
||||||
// proxy can only be embedded by the local inspector itself.
|
// proxy can only be embedded by the local inspector itself.
|
||||||
const SANDBOX_PROXY_CSP =
|
const SANDBOX_PROXY_CSP =
|
||||||
|
|
|
||||||
|
|
@ -556,7 +556,7 @@ import {
|
||||||
readOAuthResumeSnapshot,
|
readOAuthResumeSnapshot,
|
||||||
writeOAuthResumeSnapshot,
|
writeOAuthResumeSnapshot,
|
||||||
type OAuthResumeSnapshot,
|
type OAuthResumeSnapshot,
|
||||||
} from "./utils/oauthResume.js";
|
} from "./lib/oauthResume.js";
|
||||||
import * as McpIndex from "@inspector/core/mcp/index.js";
|
import * as McpIndex from "@inspector/core/mcp/index.js";
|
||||||
import * as FetchLogModule from "@inspector/core/mcp/state/fetchRequestLogState.js";
|
import * as FetchLogModule from "@inspector/core/mcp/state/fetchRequestLogState.js";
|
||||||
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
|
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ import {
|
||||||
oauthResumeToastMessage,
|
oauthResumeToastMessage,
|
||||||
writeOAuthResumeSnapshot,
|
writeOAuthResumeSnapshot,
|
||||||
type OAuthResumeAuthKind,
|
type OAuthResumeAuthKind,
|
||||||
} from "./utils/oauthResume";
|
} from "./lib/oauthResume";
|
||||||
import { createWebEnvironment } from "./lib/environmentFactory";
|
import { createWebEnvironment } from "./lib/environmentFactory";
|
||||||
import { OAUTH_CALLBACK_PATH, isUnauthorizedError } from "./utils/oauthFlow";
|
import { OAUTH_CALLBACK_PATH, isUnauthorizedError } from "./utils/oauthFlow";
|
||||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||||
|
|
@ -193,7 +193,7 @@ import {
|
||||||
isEmaStepUp as isCoreEmaStepUp,
|
isEmaStepUp as isCoreEmaStepUp,
|
||||||
isStepUpConfirmation as isCoreStepUpConfirmation,
|
isStepUpConfirmation as isCoreStepUpConfirmation,
|
||||||
} from "@inspector/core/auth/oauthUx.js";
|
} from "@inspector/core/auth/oauthUx.js";
|
||||||
import { clearServerOAuthState } from "./utils/clearServerOAuthState";
|
import { clearServerOAuthState } from "./lib/clearServerOAuthState";
|
||||||
import {
|
import {
|
||||||
authRecoveryRestoredMessage,
|
authRecoveryRestoredMessage,
|
||||||
isReAuthBannerReason,
|
isReAuthBannerReason,
|
||||||
|
|
@ -206,7 +206,7 @@ import {
|
||||||
import {
|
import {
|
||||||
isBrowserTabVisible,
|
isBrowserTabVisible,
|
||||||
onBrowserTabVisible,
|
onBrowserTabVisible,
|
||||||
} from "./utils/browserTabVisibility";
|
} from "./lib/browserTabVisibility";
|
||||||
import type { PendingReauth } from "./utils/pendingReauth";
|
import type { PendingReauth } from "./utils/pendingReauth";
|
||||||
|
|
||||||
// OAuth redirect URL provider — points at the dev backend's `/oauth/callback`
|
// OAuth redirect URL provider — points at the dev backend's `/oauth/callback`
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
approveCspSources,
|
approveCspSources,
|
||||||
buildSandboxCspPolicy,
|
buildSandboxCspPolicy,
|
||||||
wrapSandboxedHtml,
|
wrapSandboxedHtml,
|
||||||
} from "../../../lib/sandbox-csp";
|
} from "../../../utils/sandbox-csp";
|
||||||
import {
|
import {
|
||||||
downloadBlob,
|
downloadBlob,
|
||||||
fileNameFromUri,
|
fileNameFromUri,
|
||||||
|
|
|
||||||
123
clients/web/src/lib/browserTabVisibility.test.ts
Normal file
123
clients/web/src/lib/browserTabVisibility.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
isBrowserTabVisible,
|
||||||
|
onBrowserTabVisible,
|
||||||
|
} from "./browserTabVisibility.js";
|
||||||
|
|
||||||
|
describe("browserTabVisibility", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("document", {
|
||||||
|
visibilityState: "visible",
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isBrowserTabVisible reflects document.visibilityState", () => {
|
||||||
|
expect(isBrowserTabVisible()).toBe(true);
|
||||||
|
(document as { visibilityState: string }).visibilityState = "hidden";
|
||||||
|
expect(isBrowserTabVisible()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("onBrowserTabVisible invokes callback when becoming visible", () => {
|
||||||
|
let handler: (() => void) | undefined;
|
||||||
|
vi.mocked(document.addEventListener).mockImplementation((_event, fn) => {
|
||||||
|
handler = fn as () => void;
|
||||||
|
});
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
onBrowserTabVisible(callback);
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
(document as { visibilityState: string }).visibilityState = "hidden";
|
||||||
|
handler!();
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
(document as { visibilityState: string }).visibilityState = "visible";
|
||||||
|
handler!();
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returned unsubscribe removes the visibilitychange listener", () => {
|
||||||
|
let handler: (() => void) | undefined;
|
||||||
|
vi.mocked(document.addEventListener).mockImplementation((_event, fn) => {
|
||||||
|
handler = fn as () => void;
|
||||||
|
});
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const unsubscribe = onBrowserTabVisible(callback);
|
||||||
|
expect(handler).toBeDefined();
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
expect(document.removeEventListener).toHaveBeenCalledWith(
|
||||||
|
"visibilitychange",
|
||||||
|
handler,
|
||||||
|
);
|
||||||
|
|
||||||
|
// After unsubscribing the stored handler must no longer fire the callback
|
||||||
|
// through the (now removed) listener path.
|
||||||
|
vi.mocked(document.addEventListener).mockClear();
|
||||||
|
expect(document.addEventListener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("integrates with a real document: dispatch, hidden/visible, and unsubscribe", () => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
const original = Object.getOwnPropertyDescriptor(
|
||||||
|
Document.prototype,
|
||||||
|
"visibilityState",
|
||||||
|
);
|
||||||
|
const setVisibility = (value: string): void => {
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
get: () => value,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
setVisibility("visible");
|
||||||
|
expect(isBrowserTabVisible()).toBe(true);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const unsubscribe = onBrowserTabVisible(callback);
|
||||||
|
|
||||||
|
// Hidden branch: listener fires but callback is not invoked.
|
||||||
|
setVisibility("hidden");
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
expect(isBrowserTabVisible()).toBe(false);
|
||||||
|
|
||||||
|
// Visible branch: callback invoked.
|
||||||
|
setVisibility("visible");
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
// Unsubscribe: further events must not re-invoke the callback.
|
||||||
|
unsubscribe();
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
expect(callback).toHaveBeenCalledOnce();
|
||||||
|
} finally {
|
||||||
|
if (original) {
|
||||||
|
Object.defineProperty(document, "visibilityState", original);
|
||||||
|
} else {
|
||||||
|
delete (document as { visibilityState?: string }).visibilityState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-ops safely when document is undefined (SSR guard)", () => {
|
||||||
|
vi.stubGlobal("document", undefined);
|
||||||
|
|
||||||
|
expect(isBrowserTabVisible()).toBe(false);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const unsubscribe = onBrowserTabVisible(callback);
|
||||||
|
expect(typeof unsubscribe).toBe("function");
|
||||||
|
// The returned no-op must be callable without throwing.
|
||||||
|
expect(() => unsubscribe()).not.toThrow();
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
clients/web/src/lib/browserTabVisibility.ts
Normal file
20
clients/web/src/lib/browserTabVisibility.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
/** True when this browser tab is the foreground tab (Page Visibility API). */
|
||||||
|
export function isBrowserTabVisible(): boolean {
|
||||||
|
return (
|
||||||
|
typeof document !== "undefined" && document.visibilityState === "visible"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to this browser tab becoming visible. Returns an unsubscribe function. */
|
||||||
|
export function onBrowserTabVisible(callback: () => void): () => void {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const handler = (): void => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", handler);
|
||||||
|
return () => document.removeEventListener("visibilitychange", handler);
|
||||||
|
}
|
||||||
56
clients/web/src/lib/clearServerOAuthState.test.ts
Normal file
56
clients/web/src/lib/clearServerOAuthState.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { BrowserOAuthStorage } from "@inspector/core/auth/browser/storage.js";
|
||||||
|
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||||
|
import { clearServerOAuthState } from "./clearServerOAuthState";
|
||||||
|
|
||||||
|
describe("clearServerOAuthState", () => {
|
||||||
|
let storage: BrowserOAuthStorage;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storage = new BrowserOAuthStorage();
|
||||||
|
await storage.clear("https://mcp.example.com/mcp");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears storage by server URL when not the active connection", async () => {
|
||||||
|
await storage.saveTokens("https://mcp.example.com/mcp", {
|
||||||
|
access_token: "tok",
|
||||||
|
token_type: "Bearer",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleared = await clearServerOAuthState({
|
||||||
|
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
|
||||||
|
isActiveConnection: false,
|
||||||
|
oauthStorage: storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cleared).toBe(true);
|
||||||
|
expect(
|
||||||
|
await storage.getTokens("https://mcp.example.com/mcp"),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the live client when clearing the active connection", async () => {
|
||||||
|
const clearOAuthTokens = vi.fn<InspectorClient["clearOAuthTokens"]>();
|
||||||
|
const inspectorClient = { clearOAuthTokens };
|
||||||
|
|
||||||
|
const cleared = await clearServerOAuthState({
|
||||||
|
config: { type: "streamable-http", url: "https://mcp.example.com/mcp" },
|
||||||
|
inspectorClient,
|
||||||
|
isActiveConnection: true,
|
||||||
|
oauthStorage: storage,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(cleared).toBe(true);
|
||||||
|
expect(clearOAuthTokens).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for stdio servers", async () => {
|
||||||
|
await expect(
|
||||||
|
clearServerOAuthState({
|
||||||
|
config: { type: "stdio", command: "node", args: [] },
|
||||||
|
isActiveConnection: false,
|
||||||
|
oauthStorage: storage,
|
||||||
|
}),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
34
clients/web/src/lib/clearServerOAuthState.ts
Normal file
34
clients/web/src/lib/clearServerOAuthState.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import type { OAuthStorage } from "@inspector/core/auth/storage.js";
|
||||||
|
import { getOAuthServerUrl } from "@inspector/core/mcp/config.js";
|
||||||
|
import type { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||||
|
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||||
|
|
||||||
|
export interface ClearServerOAuthStateParams {
|
||||||
|
config: MCPServerConfig;
|
||||||
|
/** When set and this server is the active connection, clear via the live client. */
|
||||||
|
inspectorClient?: Pick<InspectorClient, "clearOAuthTokens"> | null;
|
||||||
|
isActiveConnection: boolean;
|
||||||
|
/** Shared web OAuth store; required so clear hits the same blob as connect. */
|
||||||
|
oauthStorage: OAuthStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear persisted OAuth state (tokens, DCR/CIMD client id, PKCE, etc.) for an
|
||||||
|
* HTTP MCP server. When clearing the active connection, uses the live client so
|
||||||
|
* in-memory flow state is reset too.
|
||||||
|
*/
|
||||||
|
export async function clearServerOAuthState(
|
||||||
|
params: ClearServerOAuthStateParams,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const serverUrl = getOAuthServerUrl(params.config);
|
||||||
|
if (!serverUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.isActiveConnection && params.inspectorClient) {
|
||||||
|
await params.inspectorClient.clearOAuthTokens();
|
||||||
|
} else {
|
||||||
|
await params.oauthStorage.clear(serverUrl);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
187
clients/web/src/lib/downloadFile.test.ts
Normal file
187
clients/web/src/lib/downloadFile.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
/**
|
||||||
|
* Tests for the download helpers. Mocks URL.createObjectURL +
|
||||||
|
* URL.revokeObjectURL (happy-dom doesn't ship them) and asserts the temp
|
||||||
|
* anchor's attributes + click + deferred cleanup sequence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
downloadBlob,
|
||||||
|
downloadJsonFile,
|
||||||
|
buildExportFilename,
|
||||||
|
fileNameFromUri,
|
||||||
|
isHttpUrl,
|
||||||
|
} from "./downloadFile";
|
||||||
|
|
||||||
|
describe("downloadBlob / downloadJsonFile", () => {
|
||||||
|
const originalCreate = URL.createObjectURL;
|
||||||
|
const originalRevoke = URL.revokeObjectURL;
|
||||||
|
let createMock: ReturnType<typeof vi.fn>;
|
||||||
|
let revokeMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
createMock = vi.fn().mockReturnValue("blob:mock-url");
|
||||||
|
revokeMock = vi.fn();
|
||||||
|
// happy-dom doesn't implement URL.createObjectURL/revokeObjectURL.
|
||||||
|
URL.createObjectURL = createMock as unknown as typeof URL.createObjectURL;
|
||||||
|
URL.revokeObjectURL = revokeMock as unknown as typeof URL.revokeObjectURL;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
URL.createObjectURL = originalCreate;
|
||||||
|
URL.revokeObjectURL = originalRevoke;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates an anchor with the right href + download attributes and clicks it", () => {
|
||||||
|
const clickedAnchors: HTMLAnchorElement[] = [];
|
||||||
|
const origCreateElement = document.createElement.bind(document);
|
||||||
|
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
|
||||||
|
const el = origCreateElement(tag) as HTMLAnchorElement;
|
||||||
|
if (tag === "a") {
|
||||||
|
el.click = function click() {
|
||||||
|
clickedAnchors.push(el);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}) as typeof document.createElement);
|
||||||
|
|
||||||
|
downloadJsonFile("mcp.json", '{"hello":"world"}');
|
||||||
|
|
||||||
|
expect(createMock).toHaveBeenCalledOnce();
|
||||||
|
const blob = createMock.mock.calls[0]?.[0] as Blob;
|
||||||
|
expect(blob).toBeInstanceOf(Blob);
|
||||||
|
expect(blob.type).toBe("application/json");
|
||||||
|
|
||||||
|
expect(clickedAnchors).toHaveLength(1);
|
||||||
|
const anchor = clickedAnchors[0]!;
|
||||||
|
expect(anchor.getAttribute("href")).toBe("blob:mock-url");
|
||||||
|
expect(anchor.getAttribute("download")).toBe("mcp.json");
|
||||||
|
|
||||||
|
// After the call, the anchor must be removed and the URL revoked.
|
||||||
|
expect(document.body.contains(anchor)).toBe(false);
|
||||||
|
// Revoke is deferred so the scheduled download can read the blob first.
|
||||||
|
expect(revokeMock).not.toHaveBeenCalled();
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(revokeMock).toHaveBeenCalledWith("blob:mock-url");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still cleans up the anchor + URL when click() throws (try/finally guard)", () => {
|
||||||
|
const removed: Node[] = [];
|
||||||
|
const origCreateElement = document.createElement.bind(document);
|
||||||
|
vi.spyOn(document, "createElement").mockImplementation(((tag: string) => {
|
||||||
|
const el = origCreateElement(tag) as HTMLAnchorElement;
|
||||||
|
if (tag === "a") {
|
||||||
|
el.click = () => {
|
||||||
|
throw new Error("simulated click failure");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}) as typeof document.createElement);
|
||||||
|
// Stub removeChild outright so the throw doesn't double up with a
|
||||||
|
// "not a child" error from a partially-mocked DOM.
|
||||||
|
vi.spyOn(document.body, "removeChild").mockImplementation(((node: Node) => {
|
||||||
|
removed.push(node);
|
||||||
|
return node;
|
||||||
|
}) as typeof document.body.removeChild);
|
||||||
|
|
||||||
|
expect(() => downloadJsonFile("oops.json", '{"oops":true}')).toThrow(
|
||||||
|
/simulated click failure/,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup still ran despite the throw.
|
||||||
|
expect(removed).toHaveLength(1);
|
||||||
|
vi.runAllTimers();
|
||||||
|
expect(revokeMock).toHaveBeenCalledWith("blob:mock-url");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the JSON string into the Blob constructor verbatim", async () => {
|
||||||
|
downloadJsonFile("test.json", '{"x":1}');
|
||||||
|
const blob = createMock.mock.calls[0]?.[0] as Blob;
|
||||||
|
const text = await blob.text();
|
||||||
|
expect(text).toBe('{"x":1}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("downloadBlob passes the given blob through and honours its type", async () => {
|
||||||
|
const blob = new Blob(["plain text"], { type: "text/plain" });
|
||||||
|
downloadBlob("note.txt", blob);
|
||||||
|
expect(createMock).toHaveBeenCalledWith(blob);
|
||||||
|
const passed = createMock.mock.calls[0]?.[0] as Blob;
|
||||||
|
expect(passed.type).toBe("text/plain");
|
||||||
|
expect(await passed.text()).toBe("plain text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildExportFilename", () => {
|
||||||
|
const fixedNow = new Date("2026-03-17T10:00:42.123Z");
|
||||||
|
|
||||||
|
it("includes kind, server id, and ISO timestamp with `:` swapped for `-`", () => {
|
||||||
|
expect(buildExportFilename("protocol", "alpha", fixedNow)).toBe(
|
||||||
|
"inspector-protocol-alpha-2026-03-17T10-00-42.123Z.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the server-id segment when serverId is undefined", () => {
|
||||||
|
expect(buildExportFilename("logs", undefined, fixedNow)).toBe(
|
||||||
|
"inspector-logs-2026-03-17T10-00-42.123Z.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits the server-id segment when serverId is an empty string", () => {
|
||||||
|
expect(buildExportFilename("logs", "", fixedNow)).toBe(
|
||||||
|
"inspector-logs-2026-03-17T10-00-42.123Z.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("encodes server ids that contain filesystem-unsafe characters", () => {
|
||||||
|
expect(buildExportFilename("network", "my server/v2", fixedNow)).toBe(
|
||||||
|
"inspector-network-my%20server%2Fv2-2026-03-17T10-00-42.123Z.json",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults `now` to the current time when not provided", () => {
|
||||||
|
const name = buildExportFilename("protocol", "alpha");
|
||||||
|
expect(name).toMatch(
|
||||||
|
/^inspector-protocol-alpha-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z\.json$/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fileNameFromUri", () => {
|
||||||
|
it("returns the last path segment", () => {
|
||||||
|
expect(fileNameFromUri("https://x/y/z/report.pdf")).toBe("report.pdf");
|
||||||
|
});
|
||||||
|
it("splits on backslashes as well as forward slashes", () => {
|
||||||
|
expect(fileNameFromUri("C:\\Users\\me\\report.pdf")).toBe("report.pdf");
|
||||||
|
});
|
||||||
|
it("strips control/format chars and disallowed filename chars", () => {
|
||||||
|
// U+200B (zero-width space, Cf) and U+202E (RTL override, Cf) are
|
||||||
|
// stripped; the disallowed `*?` run collapses to a single `_`.
|
||||||
|
const uri = "https://x/a\u200bb\u202ec*?.txt";
|
||||||
|
expect(fileNameFromUri(uri)).toBe("abc_.txt");
|
||||||
|
});
|
||||||
|
it("truncates very long names to 255 characters", () => {
|
||||||
|
const long = "a".repeat(300) + ".txt";
|
||||||
|
expect(fileNameFromUri(`https://x/${long}`)).toHaveLength(255);
|
||||||
|
});
|
||||||
|
it("falls back to 'download' when nothing usable remains", () => {
|
||||||
|
expect(fileNameFromUri("https://x/")).toBe("download");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isHttpUrl", () => {
|
||||||
|
it("accepts http and https", () => {
|
||||||
|
expect(isHttpUrl("https://example.com")?.href).toBe("https://example.com/");
|
||||||
|
expect(isHttpUrl("http://localhost:3000/a")?.href).toBe(
|
||||||
|
"http://localhost:3000/a",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("rejects other schemes and unparsable input", () => {
|
||||||
|
expect(isHttpUrl("javascript:alert(1)")).toBeNull();
|
||||||
|
expect(isHttpUrl("data:text/html,<b>")).toBeNull();
|
||||||
|
expect(isHttpUrl("file:///etc/passwd")).toBeNull();
|
||||||
|
expect(isHttpUrl("not a url")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
495
clients/web/src/lib/oauthResume.test.ts
Normal file
495
clients/web/src/lib/oauthResume.test.ts
Normal file
|
|
@ -0,0 +1,495 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
applyOAuthResumeUi,
|
||||||
|
buildTabUiSnapshot,
|
||||||
|
clearOAuthResumeSnapshot,
|
||||||
|
consumeOAuthResumeSnapshot,
|
||||||
|
oauthResumeInsufficientScopeMessage,
|
||||||
|
oauthResumeToastMessage,
|
||||||
|
OAUTH_PENDING_SERVER_KEY,
|
||||||
|
OAUTH_RESUME_KEY,
|
||||||
|
readOAuthResumeSnapshot,
|
||||||
|
restoreTabUiFromSnapshot,
|
||||||
|
writeOAuthResumeSnapshot,
|
||||||
|
type OAuthResumeSnapshot,
|
||||||
|
} from "./oauthResume.js";
|
||||||
|
import {
|
||||||
|
EMPTY_TOOLS_UI,
|
||||||
|
EMPTY_PROMPTS_UI,
|
||||||
|
EMPTY_RESOURCES_UI,
|
||||||
|
EMPTY_APPS_UI,
|
||||||
|
EMPTY_TASKS_UI,
|
||||||
|
EMPTY_LOGS_UI,
|
||||||
|
EMPTY_PROTOCOL_UI,
|
||||||
|
EMPTY_NETWORK_UI,
|
||||||
|
} from "../components/screens/screenUiState.js";
|
||||||
|
|
||||||
|
describe("oauthResume", () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage.clear();
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: (key: string) => storage.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => {
|
||||||
|
storage.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key: string) => {
|
||||||
|
storage.delete(key);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumeOAuthResumeSnapshot reads once then clears storage", () => {
|
||||||
|
const snapshot: OAuthResumeSnapshot = {
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: {},
|
||||||
|
};
|
||||||
|
writeOAuthResumeSnapshot(snapshot);
|
||||||
|
expect(consumeOAuthResumeSnapshot()).toEqual(snapshot);
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
expect(consumeOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearOAuthResumeSnapshot removes pending redirect state (explicit disconnect)", () => {
|
||||||
|
writeOAuthResumeSnapshot({
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: {},
|
||||||
|
});
|
||||||
|
clearOAuthResumeSnapshot();
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
expect(consumeOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips OAuthResumeSnapshot", () => {
|
||||||
|
const snapshot: OAuthResumeSnapshot = {
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "step_up",
|
||||||
|
tabUi: {
|
||||||
|
Tools: {
|
||||||
|
...EMPTY_TOOLS_UI,
|
||||||
|
selectedToolName: "echo",
|
||||||
|
formValues: { message: "hi" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
remoteSessionId: "remote-abc",
|
||||||
|
};
|
||||||
|
writeOAuthResumeSnapshot(snapshot);
|
||||||
|
expect(readOAuthResumeSnapshot()).toEqual(snapshot);
|
||||||
|
expect(storage.get(OAUTH_PENDING_SERVER_KEY)).toBeUndefined();
|
||||||
|
clearOAuthResumeSnapshot();
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds and restores tab ui snapshots", () => {
|
||||||
|
const toolsUi = {
|
||||||
|
...EMPTY_TOOLS_UI,
|
||||||
|
selectedToolName: "get_temp",
|
||||||
|
formValues: { city: "NYC" },
|
||||||
|
};
|
||||||
|
const tabUi = buildTabUiSnapshot({
|
||||||
|
toolsUi,
|
||||||
|
promptsUi: EMPTY_PROMPTS_UI,
|
||||||
|
resourcesUi: EMPTY_RESOURCES_UI,
|
||||||
|
appsUi: EMPTY_APPS_UI,
|
||||||
|
tasksUi: EMPTY_TASKS_UI,
|
||||||
|
logsUi: EMPTY_LOGS_UI,
|
||||||
|
protocolUi: EMPTY_PROTOCOL_UI,
|
||||||
|
networkUi: EMPTY_NETWORK_UI,
|
||||||
|
});
|
||||||
|
const setToolsUi = vi.fn();
|
||||||
|
restoreTabUiFromSnapshot(tabUi, {
|
||||||
|
setToolsUi,
|
||||||
|
setPromptsUi: vi.fn(),
|
||||||
|
setResourcesUi: vi.fn(),
|
||||||
|
setAppsUi: vi.fn(),
|
||||||
|
setTasksUi: vi.fn(),
|
||||||
|
setLogsUi: vi.fn(),
|
||||||
|
setProtocolUi: vi.fn(),
|
||||||
|
setNetworkUi: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(setToolsUi).toHaveBeenCalledWith(toolsUi);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to legacy pending server key", () => {
|
||||||
|
storage.set(OAUTH_PENDING_SERVER_KEY, "legacy-srv");
|
||||||
|
const snapshot = readOAuthResumeSnapshot();
|
||||||
|
expect(snapshot?.serverId).toBe("legacy-srv");
|
||||||
|
expect(snapshot?.activeTab).toBe("Servers");
|
||||||
|
expect(snapshot?.authKind).toBe("reauth");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns toast copy by auth kind", () => {
|
||||||
|
expect(oauthResumeToastMessage("step_up", { recoverySource: "tool" })).toBe(
|
||||||
|
"Step-up authorization succeeded. Retry your action.",
|
||||||
|
);
|
||||||
|
expect(oauthResumeToastMessage("step_up")).toBe(
|
||||||
|
"Step-up authorization succeeded.",
|
||||||
|
);
|
||||||
|
expect(oauthResumeToastMessage("reauth", { recoverySource: "tool" })).toBe(
|
||||||
|
"Authentication succeeded. Retry your action.",
|
||||||
|
);
|
||||||
|
expect(oauthResumeToastMessage("reauth")).toBe("Authentication succeeded.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns insufficient-scope message with tool context", () => {
|
||||||
|
expect(
|
||||||
|
oauthResumeInsufficientScopeMessage({
|
||||||
|
reason: "insufficient_scope",
|
||||||
|
requiredScopes: ["weather:read"],
|
||||||
|
context: { toolName: "get_temp" },
|
||||||
|
}),
|
||||||
|
).toMatch(/get_temp/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips authChallenge on step-up snapshot", () => {
|
||||||
|
const challenge = {
|
||||||
|
reason: "insufficient_scope" as const,
|
||||||
|
requiredScopes: ["weather:read"],
|
||||||
|
context: { toolName: "get_temp" },
|
||||||
|
};
|
||||||
|
const snapshot: OAuthResumeSnapshot = {
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "step_up",
|
||||||
|
tabUi: {},
|
||||||
|
authChallenge: challenge,
|
||||||
|
};
|
||||||
|
writeOAuthResumeSnapshot(snapshot);
|
||||||
|
expect(readOAuthResumeSnapshot()?.authChallenge).toEqual(challenge);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects snapshots with invalid tabUi keys", () => {
|
||||||
|
writeOAuthResumeSnapshot({
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: { NotATab: {} },
|
||||||
|
} as OAuthResumeSnapshot);
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applyOAuthResumeUi restores tab ui, active tab, and clears in-flight panels", () => {
|
||||||
|
const toolsUi = {
|
||||||
|
...EMPTY_TOOLS_UI,
|
||||||
|
selectedToolName: "get_temp",
|
||||||
|
formValues: { city: "NYC" },
|
||||||
|
};
|
||||||
|
const snapshot: OAuthResumeSnapshot = {
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "step_up",
|
||||||
|
tabUi: { Tools: toolsUi },
|
||||||
|
};
|
||||||
|
const setToolsUi = vi.fn();
|
||||||
|
const setActiveTab = vi.fn();
|
||||||
|
const clearToolCallState = vi.fn();
|
||||||
|
const clearGetPromptState = vi.fn();
|
||||||
|
const clearReadResourceState = vi.fn();
|
||||||
|
|
||||||
|
applyOAuthResumeUi(snapshot, {
|
||||||
|
setToolsUi,
|
||||||
|
setPromptsUi: vi.fn(),
|
||||||
|
setResourcesUi: vi.fn(),
|
||||||
|
setAppsUi: vi.fn(),
|
||||||
|
setTasksUi: vi.fn(),
|
||||||
|
setLogsUi: vi.fn(),
|
||||||
|
setProtocolUi: vi.fn(),
|
||||||
|
setNetworkUi: vi.fn(),
|
||||||
|
setActiveTab,
|
||||||
|
clearToolCallState,
|
||||||
|
clearGetPromptState,
|
||||||
|
clearReadResourceState,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setToolsUi).toHaveBeenCalledWith(toolsUi);
|
||||||
|
expect(setActiveTab).toHaveBeenCalledWith("Tools");
|
||||||
|
expect(clearToolCallState).toHaveBeenCalledOnce();
|
||||||
|
expect(clearGetPromptState).toHaveBeenCalledOnce();
|
||||||
|
expect(clearReadResourceState).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readOAuthResumeSnapshot returns undefined for a non-JSON string", () => {
|
||||||
|
storage.set(OAUTH_RESUME_KEY, "not-json{");
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readOAuthResumeSnapshot returns undefined when parsed JSON is null", () => {
|
||||||
|
storage.set(OAUTH_RESUME_KEY, "null");
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readOAuthResumeSnapshot rejects a wrong version", () => {
|
||||||
|
storage.set(
|
||||||
|
OAUTH_RESUME_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readOAuthResumeSnapshot rejects a non-string serverId", () => {
|
||||||
|
storage.set(
|
||||||
|
OAUTH_RESUME_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
serverId: 42,
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readOAuthResumeSnapshot rejects an unknown authKind", () => {
|
||||||
|
storage.set(
|
||||||
|
OAUTH_RESUME_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "bogus",
|
||||||
|
tabUi: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readOAuthResumeSnapshot rejects a non-string activeTab", () => {
|
||||||
|
storage.set(
|
||||||
|
OAUTH_RESUME_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: 7,
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readOAuthResumeSnapshot accepts a snapshot with tabUi absent", () => {
|
||||||
|
storage.set(
|
||||||
|
OAUTH_RESUME_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "reauth",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const snapshot = readOAuthResumeSnapshot();
|
||||||
|
expect(snapshot?.serverId).toBe("srv-1");
|
||||||
|
expect(snapshot?.tabUi).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readOAuthResumeSnapshot rejects non-object tabUi (string, array, null)", () => {
|
||||||
|
for (const tabUi of ['"nope"', "[]", "null"]) {
|
||||||
|
storage.set(
|
||||||
|
OAUTH_RESUME_KEY,
|
||||||
|
`{"version":1,"serverId":"srv-1","activeTab":"Tools","authKind":"reauth","tabUi":${tabUi}}`,
|
||||||
|
);
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writeOAuthResumeSnapshot swallows setItem failures", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {
|
||||||
|
throw new Error("quota exceeded");
|
||||||
|
},
|
||||||
|
removeItem: () => {},
|
||||||
|
});
|
||||||
|
expect(() =>
|
||||||
|
writeOAuthResumeSnapshot({
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: {},
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearOAuthResumeSnapshot swallows removeItem failures", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {
|
||||||
|
throw new Error("blocked");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(() => clearOAuthResumeSnapshot()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy fallback returns undefined when the pending-key read throws", () => {
|
||||||
|
vi.stubGlobal("sessionStorage", {
|
||||||
|
getItem: (key: string) => {
|
||||||
|
if (key === OAUTH_PENDING_SERVER_KEY) {
|
||||||
|
throw new Error("blocked");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
setItem: () => {},
|
||||||
|
removeItem: () => {},
|
||||||
|
});
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legacy fallback returns undefined when no pending server is stored", () => {
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("without a window global", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("window", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writeOAuthResumeSnapshot is a no-op", () => {
|
||||||
|
expect(() =>
|
||||||
|
writeOAuthResumeSnapshot({
|
||||||
|
version: 1,
|
||||||
|
serverId: "srv-1",
|
||||||
|
activeTab: "Tools",
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: {},
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(storage.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readOAuthResumeSnapshot returns undefined", () => {
|
||||||
|
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearOAuthResumeSnapshot is a no-op", () => {
|
||||||
|
expect(() => clearOAuthResumeSnapshot()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restoreTabUiFromSnapshot returns early when tabUi is undefined", () => {
|
||||||
|
const setToolsUi = vi.fn();
|
||||||
|
restoreTabUiFromSnapshot(undefined, {
|
||||||
|
setToolsUi,
|
||||||
|
setPromptsUi: vi.fn(),
|
||||||
|
setResourcesUi: vi.fn(),
|
||||||
|
setAppsUi: vi.fn(),
|
||||||
|
setTasksUi: vi.fn(),
|
||||||
|
setLogsUi: vi.fn(),
|
||||||
|
setProtocolUi: vi.fn(),
|
||||||
|
setNetworkUi: vi.fn(),
|
||||||
|
});
|
||||||
|
expect(setToolsUi).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restoreTabUiFromSnapshot skips keys that are not inspector tabs", () => {
|
||||||
|
const setters = {
|
||||||
|
setToolsUi: vi.fn(),
|
||||||
|
setPromptsUi: vi.fn(),
|
||||||
|
setResourcesUi: vi.fn(),
|
||||||
|
setAppsUi: vi.fn(),
|
||||||
|
setTasksUi: vi.fn(),
|
||||||
|
setLogsUi: vi.fn(),
|
||||||
|
setProtocolUi: vi.fn(),
|
||||||
|
setNetworkUi: vi.fn(),
|
||||||
|
};
|
||||||
|
restoreTabUiFromSnapshot(
|
||||||
|
{ NotATab: {} } as Record<string, unknown>,
|
||||||
|
setters,
|
||||||
|
);
|
||||||
|
for (const setter of Object.values(setters)) {
|
||||||
|
expect(setter).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restoreTabUiFromSnapshot restores every tab with a present value", () => {
|
||||||
|
const setters = {
|
||||||
|
setToolsUi: vi.fn(),
|
||||||
|
setPromptsUi: vi.fn(),
|
||||||
|
setResourcesUi: vi.fn(),
|
||||||
|
setAppsUi: vi.fn(),
|
||||||
|
setTasksUi: vi.fn(),
|
||||||
|
setLogsUi: vi.fn(),
|
||||||
|
setProtocolUi: vi.fn(),
|
||||||
|
setNetworkUi: vi.fn(),
|
||||||
|
};
|
||||||
|
restoreTabUiFromSnapshot(
|
||||||
|
{
|
||||||
|
Tools: EMPTY_TOOLS_UI,
|
||||||
|
Prompts: EMPTY_PROMPTS_UI,
|
||||||
|
Resources: EMPTY_RESOURCES_UI,
|
||||||
|
Apps: EMPTY_APPS_UI,
|
||||||
|
Tasks: EMPTY_TASKS_UI,
|
||||||
|
Logs: EMPTY_LOGS_UI,
|
||||||
|
Protocol: EMPTY_PROTOCOL_UI,
|
||||||
|
Network: EMPTY_NETWORK_UI,
|
||||||
|
},
|
||||||
|
setters,
|
||||||
|
);
|
||||||
|
expect(setters.setToolsUi).toHaveBeenCalledWith(EMPTY_TOOLS_UI);
|
||||||
|
expect(setters.setPromptsUi).toHaveBeenCalledWith(EMPTY_PROMPTS_UI);
|
||||||
|
expect(setters.setResourcesUi).toHaveBeenCalledWith(EMPTY_RESOURCES_UI);
|
||||||
|
expect(setters.setAppsUi).toHaveBeenCalledWith(EMPTY_APPS_UI);
|
||||||
|
expect(setters.setTasksUi).toHaveBeenCalledWith(EMPTY_TASKS_UI);
|
||||||
|
expect(setters.setLogsUi).toHaveBeenCalledWith(EMPTY_LOGS_UI);
|
||||||
|
expect(setters.setProtocolUi).toHaveBeenCalledWith(EMPTY_PROTOCOL_UI);
|
||||||
|
expect(setters.setNetworkUi).toHaveBeenCalledWith(EMPTY_NETWORK_UI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restoreTabUiFromSnapshot falls back to EMPTY state for undefined tab values", () => {
|
||||||
|
const setters = {
|
||||||
|
setToolsUi: vi.fn(),
|
||||||
|
setPromptsUi: vi.fn(),
|
||||||
|
setResourcesUi: vi.fn(),
|
||||||
|
setAppsUi: vi.fn(),
|
||||||
|
setTasksUi: vi.fn(),
|
||||||
|
setLogsUi: vi.fn(),
|
||||||
|
setProtocolUi: vi.fn(),
|
||||||
|
setNetworkUi: vi.fn(),
|
||||||
|
};
|
||||||
|
restoreTabUiFromSnapshot(
|
||||||
|
{
|
||||||
|
Tools: undefined,
|
||||||
|
Prompts: undefined,
|
||||||
|
Resources: undefined,
|
||||||
|
Apps: undefined,
|
||||||
|
Tasks: undefined,
|
||||||
|
Logs: undefined,
|
||||||
|
Protocol: undefined,
|
||||||
|
Network: undefined,
|
||||||
|
},
|
||||||
|
setters,
|
||||||
|
);
|
||||||
|
expect(setters.setToolsUi).toHaveBeenCalledWith(EMPTY_TOOLS_UI);
|
||||||
|
expect(setters.setPromptsUi).toHaveBeenCalledWith(EMPTY_PROMPTS_UI);
|
||||||
|
expect(setters.setResourcesUi).toHaveBeenCalledWith(EMPTY_RESOURCES_UI);
|
||||||
|
expect(setters.setAppsUi).toHaveBeenCalledWith(EMPTY_APPS_UI);
|
||||||
|
expect(setters.setTasksUi).toHaveBeenCalledWith(EMPTY_TASKS_UI);
|
||||||
|
expect(setters.setLogsUi).toHaveBeenCalledWith(EMPTY_LOGS_UI);
|
||||||
|
expect(setters.setProtocolUi).toHaveBeenCalledWith(EMPTY_PROTOCOL_UI);
|
||||||
|
expect(setters.setNetworkUi).toHaveBeenCalledWith(EMPTY_NETWORK_UI);
|
||||||
|
});
|
||||||
|
});
|
||||||
279
clients/web/src/lib/oauthResume.ts
Normal file
279
clients/web/src/lib/oauthResume.ts
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
/**
|
||||||
|
* Persist inspector shell state across full-page OAuth redirects.
|
||||||
|
* Serializes only liftable `*UiState` shells — not message logs, fetch bodies,
|
||||||
|
* tool results, or managed primitive lists.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
EMPTY_APPS_UI,
|
||||||
|
EMPTY_PROTOCOL_UI,
|
||||||
|
EMPTY_LOGS_UI,
|
||||||
|
EMPTY_NETWORK_UI,
|
||||||
|
EMPTY_PROMPTS_UI,
|
||||||
|
EMPTY_RESOURCES_UI,
|
||||||
|
EMPTY_TASKS_UI,
|
||||||
|
EMPTY_TOOLS_UI,
|
||||||
|
} from "../components/screens/screenUiState.js";
|
||||||
|
import type { AppsUiState } from "../components/screens/AppsScreen/AppsScreen.js";
|
||||||
|
import type { ProtocolUiState } from "../components/screens/ProtocolScreen/ProtocolScreen.js";
|
||||||
|
import type { LogsUiState } from "../components/screens/LoggingScreen/LoggingScreen.js";
|
||||||
|
import type { NetworkUiState } from "../components/screens/NetworkScreen/NetworkScreen.js";
|
||||||
|
import type { PromptsUiState } from "../components/screens/PromptsScreen/PromptsScreen.js";
|
||||||
|
import type { ResourcesUiState } from "../components/screens/ResourcesScreen/ResourcesScreen.js";
|
||||||
|
import type { TasksUiState } from "../components/screens/TasksScreen/TasksScreen.js";
|
||||||
|
import type { ToolsUiState } from "../components/screens/ToolsScreen/ToolsScreen.js";
|
||||||
|
import {
|
||||||
|
INSPECTOR_SERVERS_TAB,
|
||||||
|
type InspectorTabId,
|
||||||
|
isInspectorTabId,
|
||||||
|
} from "../utils/inspectorTabs.js";
|
||||||
|
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||||
|
import {
|
||||||
|
oauthResumeSuccessMessage,
|
||||||
|
stepUpInsufficientScopeMessage,
|
||||||
|
type OAuthRecoverySource,
|
||||||
|
} from "@inspector/core/auth/oauthUx.js";
|
||||||
|
import { OAUTH_PENDING_SERVER_KEY } from "../utils/oauthFlow.js";
|
||||||
|
import type { OAuthResumeAuthKind } from "../utils/pendingReauth.js";
|
||||||
|
|
||||||
|
export const OAUTH_RESUME_KEY = "mcp-inspector:oauth-resume";
|
||||||
|
|
||||||
|
export { OAUTH_PENDING_SERVER_KEY };
|
||||||
|
|
||||||
|
export type { OAuthResumeAuthKind };
|
||||||
|
|
||||||
|
export interface OAuthResumeSnapshot {
|
||||||
|
version: 1;
|
||||||
|
serverId: string;
|
||||||
|
activeTab: string;
|
||||||
|
authKind: OAuthResumeAuthKind;
|
||||||
|
/**
|
||||||
|
* Per-tab lifted UI state (`*UiState` only). Keys are {@link InspectorTabId}.
|
||||||
|
*/
|
||||||
|
tabUi: Partial<Record<InspectorTabId, unknown>>;
|
||||||
|
/** Hono remote session id for auth-state push after callback. */
|
||||||
|
remoteSessionId?: string;
|
||||||
|
/** Step-up challenge at redirect time; used to verify scope satisfaction after callback. */
|
||||||
|
authChallenge?: AuthChallenge;
|
||||||
|
/** Command-scoped recovery source when redirect was triggered by a user action. */
|
||||||
|
recoverySource?: OAuthRecoverySource;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiftedTabUiState {
|
||||||
|
toolsUi: ToolsUiState;
|
||||||
|
promptsUi: PromptsUiState;
|
||||||
|
resourcesUi: ResourcesUiState;
|
||||||
|
appsUi: AppsUiState;
|
||||||
|
tasksUi: TasksUiState;
|
||||||
|
logsUi: LogsUiState;
|
||||||
|
protocolUi: ProtocolUiState;
|
||||||
|
networkUi: NetworkUiState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabUiSetters {
|
||||||
|
setToolsUi: (next: ToolsUiState) => void;
|
||||||
|
setPromptsUi: (next: PromptsUiState) => void;
|
||||||
|
setResourcesUi: (next: ResourcesUiState) => void;
|
||||||
|
setAppsUi: (next: AppsUiState) => void;
|
||||||
|
setTasksUi: (next: TasksUiState) => void;
|
||||||
|
setLogsUi: (next: LogsUiState) => void;
|
||||||
|
setProtocolUi: (next: ProtocolUiState) => void;
|
||||||
|
setNetworkUi: (next: NetworkUiState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTabUiSnapshot(
|
||||||
|
state: LiftedTabUiState,
|
||||||
|
): Partial<Record<InspectorTabId, unknown>> {
|
||||||
|
return {
|
||||||
|
Apps: state.appsUi,
|
||||||
|
Tools: state.toolsUi,
|
||||||
|
Prompts: state.promptsUi,
|
||||||
|
Resources: state.resourcesUi,
|
||||||
|
Tasks: state.tasksUi,
|
||||||
|
Logs: state.logsUi,
|
||||||
|
Protocol: state.protocolUi,
|
||||||
|
Network: state.networkUi,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreTabUiFromSnapshot(
|
||||||
|
tabUi: Partial<Record<InspectorTabId, unknown>> | undefined,
|
||||||
|
setters: TabUiSetters,
|
||||||
|
): void {
|
||||||
|
if (!tabUi) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const tabId of Object.keys(tabUi) as InspectorTabId[]) {
|
||||||
|
if (!isInspectorTabId(tabId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const value = tabUi[tabId];
|
||||||
|
switch (tabId) {
|
||||||
|
case "Tools":
|
||||||
|
setters.setToolsUi(
|
||||||
|
(value as ToolsUiState | undefined) ?? EMPTY_TOOLS_UI,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "Prompts":
|
||||||
|
setters.setPromptsUi(
|
||||||
|
(value as PromptsUiState | undefined) ?? EMPTY_PROMPTS_UI,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "Resources":
|
||||||
|
setters.setResourcesUi(
|
||||||
|
(value as ResourcesUiState | undefined) ?? EMPTY_RESOURCES_UI,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "Apps":
|
||||||
|
setters.setAppsUi((value as AppsUiState | undefined) ?? EMPTY_APPS_UI);
|
||||||
|
break;
|
||||||
|
case "Tasks":
|
||||||
|
setters.setTasksUi(
|
||||||
|
(value as TasksUiState | undefined) ?? EMPTY_TASKS_UI,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "Logs":
|
||||||
|
setters.setLogsUi((value as LogsUiState | undefined) ?? EMPTY_LOGS_UI);
|
||||||
|
break;
|
||||||
|
case "Protocol":
|
||||||
|
setters.setProtocolUi(
|
||||||
|
(value as ProtocolUiState | undefined) ?? EMPTY_PROTOCOL_UI,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "Network":
|
||||||
|
setters.setNetworkUi(
|
||||||
|
(value as NetworkUiState | undefined) ?? EMPTY_NETWORK_UI,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = tabId;
|
||||||
|
void _exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeOAuthResumeSnapshot(snapshot: OAuthResumeSnapshot): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.sessionStorage.setItem(OAUTH_RESUME_KEY, JSON.stringify(snapshot));
|
||||||
|
} catch {
|
||||||
|
// Best-effort — privacy mode / quota.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = window.sessionStorage.getItem(OAUTH_RESUME_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return readLegacyPendingServerSnapshot();
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as OAuthResumeSnapshot;
|
||||||
|
if (
|
||||||
|
parsed?.version !== 1 ||
|
||||||
|
typeof parsed.serverId !== "string" ||
|
||||||
|
!isOAuthResumeAuthKind(parsed.authKind) ||
|
||||||
|
typeof parsed.activeTab !== "string" ||
|
||||||
|
!isValidTabUiSnapshot(parsed.tabUi)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the pending snapshot and remove it from storage (one-shot). */
|
||||||
|
export function consumeOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined {
|
||||||
|
const snapshot = readOAuthResumeSnapshot();
|
||||||
|
if (snapshot) {
|
||||||
|
clearOAuthResumeSnapshot();
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLegacyPendingServerSnapshot(): OAuthResumeSnapshot | undefined {
|
||||||
|
try {
|
||||||
|
const serverId = window.sessionStorage.getItem(OAUTH_PENDING_SERVER_KEY);
|
||||||
|
if (!serverId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
serverId,
|
||||||
|
activeTab: INSPECTOR_SERVERS_TAB,
|
||||||
|
authKind: "reauth",
|
||||||
|
tabUi: {},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearOAuthResumeSnapshot(): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
window.sessionStorage.removeItem(OAUTH_RESUME_KEY);
|
||||||
|
window.sessionStorage.removeItem(OAUTH_PENDING_SERVER_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oauthResumeToastMessage(
|
||||||
|
authKind: OAuthResumeAuthKind,
|
||||||
|
options?: { recoverySource?: OAuthRecoverySource },
|
||||||
|
): string {
|
||||||
|
return oauthResumeSuccessMessage(authKind, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Post-callback copy when step-up OAuth completed but scopes still do not satisfy the challenge. */
|
||||||
|
export function oauthResumeInsufficientScopeMessage(
|
||||||
|
challenge: AuthChallenge,
|
||||||
|
): string {
|
||||||
|
return stepUpInsufficientScopeMessage(challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOAuthResumeAuthKind(value: unknown): value is OAuthResumeAuthKind {
|
||||||
|
return value === "step_up" || value === "reauth";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTabUiSnapshot(
|
||||||
|
tabUi: unknown,
|
||||||
|
): tabUi is Partial<Record<InspectorTabId, unknown>> {
|
||||||
|
if (tabUi === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof tabUi !== "object" || tabUi === null || Array.isArray(tabUi)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Object.keys(tabUi).every((key) => isInspectorTabId(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setters used when restoring App shell state after `/oauth/callback`. */
|
||||||
|
export interface OAuthResumeUiSetters extends TabUiSetters {
|
||||||
|
setActiveTab: (tab: string) => void;
|
||||||
|
clearToolCallState: () => void;
|
||||||
|
clearGetPromptState: () => void;
|
||||||
|
clearReadResourceState: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore tab selection, per-tab UI, and clear in-flight result panels. One-shot: callers must not invoke twice for the same redirect. */
|
||||||
|
export function applyOAuthResumeUi(
|
||||||
|
snapshot: OAuthResumeSnapshot,
|
||||||
|
setters: OAuthResumeUiSetters,
|
||||||
|
): void {
|
||||||
|
restoreTabUiFromSnapshot(snapshot.tabUi, setters);
|
||||||
|
setters.setActiveTab(snapshot.activeTab);
|
||||||
|
setters.clearToolCallState();
|
||||||
|
setters.clearGetPromptState();
|
||||||
|
setters.clearReadResourceState();
|
||||||
|
}
|
||||||
98
clients/web/src/utils/customHeaders.test.ts
Normal file
98
clients/web/src/utils/customHeaders.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* Tests for the custom-headers value helpers: constructing headers, filtering
|
||||||
|
* enabled/non-blank ones, converting to/from a plain record, and the legacy
|
||||||
|
* bearer-token migration.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
createEmptyHeader,
|
||||||
|
createHeaderFromBearerToken,
|
||||||
|
getEnabledHeaders,
|
||||||
|
headersToRecord,
|
||||||
|
recordToHeaders,
|
||||||
|
migrateFromLegacyAuth,
|
||||||
|
type CustomHeaders,
|
||||||
|
} from "./customHeaders";
|
||||||
|
|
||||||
|
describe("createEmptyHeader", () => {
|
||||||
|
it("returns an enabled header with blank name and value", () => {
|
||||||
|
expect(createEmptyHeader()).toEqual({
|
||||||
|
name: "",
|
||||||
|
value: "",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createHeaderFromBearerToken", () => {
|
||||||
|
it("defaults to an Authorization: Bearer header when no name is given", () => {
|
||||||
|
expect(createHeaderFromBearerToken("abc")).toEqual({
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer abc",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefixes Bearer when the header name is Authorization (any case)", () => {
|
||||||
|
expect(createHeaderFromBearerToken("abc", "authorization")).toEqual({
|
||||||
|
name: "authorization",
|
||||||
|
value: "Bearer abc",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the raw token for non-Authorization header names", () => {
|
||||||
|
expect(createHeaderFromBearerToken("abc", "X-Api-Key")).toEqual({
|
||||||
|
name: "X-Api-Key",
|
||||||
|
value: "abc",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getEnabledHeaders", () => {
|
||||||
|
it("keeps only enabled headers with non-blank name and value", () => {
|
||||||
|
const headers: CustomHeaders = [
|
||||||
|
{ name: "A", value: "1", enabled: true },
|
||||||
|
{ name: "B", value: "2", enabled: false },
|
||||||
|
{ name: "", value: "3", enabled: true },
|
||||||
|
{ name: "C", value: " ", enabled: true },
|
||||||
|
];
|
||||||
|
expect(getEnabledHeaders(headers)).toEqual([
|
||||||
|
{ name: "A", value: "1", enabled: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("headersToRecord", () => {
|
||||||
|
it("trims names/values and drops disabled or blank entries", () => {
|
||||||
|
const headers: CustomHeaders = [
|
||||||
|
{ name: " X-One ", value: " a ", enabled: true },
|
||||||
|
{ name: "X-Two", value: "b", enabled: false },
|
||||||
|
];
|
||||||
|
expect(headersToRecord(headers)).toEqual({ "X-One": "a" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recordToHeaders", () => {
|
||||||
|
it("maps each record entry to an enabled header", () => {
|
||||||
|
expect(recordToHeaders({ "X-A": "1", "X-B": "2" })).toEqual([
|
||||||
|
{ name: "X-A", value: "1", enabled: true },
|
||||||
|
{ name: "X-B", value: "2", enabled: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("migrateFromLegacyAuth", () => {
|
||||||
|
it("returns a single header for a bearer token", () => {
|
||||||
|
expect(migrateFromLegacyAuth("abc", "X-Api-Key")).toEqual([
|
||||||
|
{ name: "X-Api-Key", value: "abc", enabled: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there is no bearer token", () => {
|
||||||
|
expect(migrateFromLegacyAuth()).toEqual([]);
|
||||||
|
expect(migrateFromLegacyAuth("")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
clients/web/src/utils/customHeaders.ts
Normal file
64
clients/web/src/utils/customHeaders.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
export interface CustomHeader {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomHeaders = CustomHeader[];
|
||||||
|
|
||||||
|
export const createEmptyHeader = (): CustomHeader => ({
|
||||||
|
name: "",
|
||||||
|
value: "",
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createHeaderFromBearerToken = (
|
||||||
|
bearerToken: string,
|
||||||
|
headerName?: string,
|
||||||
|
): CustomHeader => ({
|
||||||
|
name: headerName || "Authorization",
|
||||||
|
value:
|
||||||
|
headerName?.toLowerCase() === "authorization" || !headerName
|
||||||
|
? `Bearer ${bearerToken}`
|
||||||
|
: bearerToken,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getEnabledHeaders = (headers: CustomHeaders): CustomHeaders => {
|
||||||
|
return headers.filter(
|
||||||
|
(header) => header.enabled && header.name.trim() && header.value.trim(),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const headersToRecord = (
|
||||||
|
headers: CustomHeaders,
|
||||||
|
): Record<string, string> => {
|
||||||
|
const enabledHeaders = getEnabledHeaders(headers);
|
||||||
|
const record: Record<string, string> = {};
|
||||||
|
|
||||||
|
enabledHeaders.forEach((header) => {
|
||||||
|
record[header.name.trim()] = header.value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
return record;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recordToHeaders = (
|
||||||
|
record: Record<string, string>,
|
||||||
|
): CustomHeaders => {
|
||||||
|
return Object.entries(record).map(([name, value]) => ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
enabled: true,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Migration helper for backward compatibility
|
||||||
|
export const migrateFromLegacyAuth = (
|
||||||
|
bearerToken?: string,
|
||||||
|
headerName?: string,
|
||||||
|
): CustomHeaders => {
|
||||||
|
return bearerToken
|
||||||
|
? [createHeaderFromBearerToken(bearerToken, headerName)]
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
import type { AuthChallenge } from "@inspector/core/auth/challenge.js";
|
||||||
import type { OAuthRecoverySource } from "@inspector/core/auth/oauthUx.js";
|
import type { OAuthRecoverySource } from "@inspector/core/auth/oauthUx.js";
|
||||||
import type { OAuthResumeAuthKind } from "./oauthResume.js";
|
|
||||||
|
/**
|
||||||
|
* Which interactive auth flow a resume/recovery represents. Declared here in the
|
||||||
|
* pure `utils/` layer (rather than in `lib/oauthResume`) so the only arrow
|
||||||
|
* between the two directories points `lib → utils`; `lib/oauthResume`
|
||||||
|
* re-exports it for its existing consumers.
|
||||||
|
*/
|
||||||
|
export type OAuthResumeAuthKind = "step_up" | "reauth";
|
||||||
|
|
||||||
/** Origin of a deferred or resumed auth recovery flow (matches web `StepUpSource`). */
|
/** Origin of a deferred or resumed auth recovery flow (matches web `StepUpSource`). */
|
||||||
export type PendingReauthSource = OAuthRecoverySource;
|
export type PendingReauthSource = OAuthRecoverySource;
|
||||||
|
|
|
||||||
170
clients/web/src/utils/sandbox-csp.test.ts
Normal file
170
clients/web/src/utils/sandbox-csp.test.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
SAFE_CSP_SOURCE,
|
||||||
|
approveCspSources,
|
||||||
|
buildSandboxCspPolicy,
|
||||||
|
escapeHtmlAttr,
|
||||||
|
wrapSandboxedHtml,
|
||||||
|
} from "./sandbox-csp";
|
||||||
|
|
||||||
|
describe("SAFE_CSP_SOURCE", () => {
|
||||||
|
it.each([
|
||||||
|
"*",
|
||||||
|
"https://api.example.com",
|
||||||
|
"https://api.example.com:8443",
|
||||||
|
"https://api.example.com/path/seg",
|
||||||
|
"wss://realtime.service.com",
|
||||||
|
"*.example.com",
|
||||||
|
"https://*.cloudflare.com",
|
||||||
|
"data:",
|
||||||
|
"blob:",
|
||||||
|
"example.com",
|
||||||
|
// Schemes are case-insensitive per the URL spec, so a capitalized or
|
||||||
|
// mixed-case scheme is still a valid source (over-rejecting it would
|
||||||
|
// silently drop a restriction a server asked for).
|
||||||
|
"HTTPS://api.example.com",
|
||||||
|
"WSS://realtime.service.com",
|
||||||
|
"Https://*.cloudflare.com",
|
||||||
|
])("accepts %s", (s) => {
|
||||||
|
expect(SAFE_CSP_SOURCE.test(s)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"https://a.com; script-src *",
|
||||||
|
'https://a.com" onload=',
|
||||||
|
"https://a.com>",
|
||||||
|
"<script>",
|
||||||
|
" https://a.com",
|
||||||
|
"https://a.com ",
|
||||||
|
"",
|
||||||
|
"javascript:alert(1)//;x",
|
||||||
|
])("rejects %s", (s) => {
|
||||||
|
expect(SAFE_CSP_SOURCE.test(s)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("approveCspSources", () => {
|
||||||
|
it("returns an empty object for undefined / empty input", () => {
|
||||||
|
expect(approveCspSources(undefined)).toEqual({});
|
||||||
|
expect(approveCspSources({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps safe entries per key and drops unsafe ones with a warning", () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const approved = approveCspSources({
|
||||||
|
connectDomains: ["https://api.ok.com", "https://x.com; script-src *"],
|
||||||
|
resourceDomains: ["data:", '">'],
|
||||||
|
frameDomains: [],
|
||||||
|
baseUriDomains: ["https://b.ok.com"],
|
||||||
|
});
|
||||||
|
expect(approved).toEqual({
|
||||||
|
connectDomains: ["https://api.ok.com"],
|
||||||
|
resourceDomains: ["data:"],
|
||||||
|
baseUriDomains: ["https://b.ok.com"],
|
||||||
|
});
|
||||||
|
expect(warn).toHaveBeenCalledTimes(2);
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits a key when every entry is rejected", () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
expect(approveCspSources({ connectDomains: ['"; x'] })).toEqual({});
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores non-array values without throwing", () => {
|
||||||
|
expect(
|
||||||
|
approveCspSources({
|
||||||
|
connectDomains: "https://x.com" as unknown as string[],
|
||||||
|
}),
|
||||||
|
).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildSandboxCspPolicy", () => {
|
||||||
|
it("emits locked-down defaults for an empty approved csp", () => {
|
||||||
|
const policy = buildSandboxCspPolicy({});
|
||||||
|
expect(policy).toContain("default-src 'none'");
|
||||||
|
expect(policy).toContain("connect-src 'none'");
|
||||||
|
expect(policy).toContain("script-src 'unsafe-inline'");
|
||||||
|
expect(policy).toContain("style-src 'unsafe-inline'");
|
||||||
|
expect(policy).toContain("img-src 'none'");
|
||||||
|
expect(policy).toContain("font-src 'none'");
|
||||||
|
expect(policy).toContain("media-src 'none'");
|
||||||
|
expect(policy).toContain("frame-src 'none'");
|
||||||
|
expect(policy).toContain("base-uri 'self'");
|
||||||
|
expect(policy).toContain("form-action 'none'");
|
||||||
|
expect(policy).toContain("object-src 'none'");
|
||||||
|
expect(policy).toContain("worker-src 'none'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps approved domains to the correct directives", () => {
|
||||||
|
const policy = buildSandboxCspPolicy({
|
||||||
|
connectDomains: ["https://api.a.com", "wss://rt.a.com"],
|
||||||
|
resourceDomains: ["https://cdn.a.com"],
|
||||||
|
frameDomains: ["https://embed.a.com"],
|
||||||
|
baseUriDomains: ["https://a.com"],
|
||||||
|
});
|
||||||
|
expect(policy).toContain("connect-src https://api.a.com wss://rt.a.com");
|
||||||
|
expect(policy).toContain("script-src 'unsafe-inline' https://cdn.a.com");
|
||||||
|
expect(policy).toContain("style-src 'unsafe-inline' https://cdn.a.com");
|
||||||
|
expect(policy).toContain("img-src https://cdn.a.com");
|
||||||
|
expect(policy).toContain("font-src https://cdn.a.com");
|
||||||
|
expect(policy).toContain("media-src https://cdn.a.com");
|
||||||
|
expect(policy).toContain("frame-src https://embed.a.com");
|
||||||
|
expect(policy).toContain("base-uri https://a.com");
|
||||||
|
// catch-all directives stay locked down regardless of approved domains
|
||||||
|
expect(policy).toContain("default-src 'none'");
|
||||||
|
expect(policy).toContain("form-action 'none'");
|
||||||
|
expect(policy).toContain("object-src 'none'");
|
||||||
|
expect(policy).toContain("worker-src 'none'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("escapeHtmlAttr", () => {
|
||||||
|
it("encodes &, quotes, and angle brackets", () => {
|
||||||
|
expect(escapeHtmlAttr(`a&b"c'd<e>f`)).toBe(
|
||||||
|
"a&b"c'd<e>f",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("wrapSandboxedHtml", () => {
|
||||||
|
it("places the CSP meta as the literal first <head> child before any untrusted bytes", () => {
|
||||||
|
const wrapped = wrapSandboxedHtml("<p>hi</p>", "default-src 'none'");
|
||||||
|
const metaIdx = wrapped.indexOf("Content-Security-Policy");
|
||||||
|
const bodyIdx = wrapped.indexOf("<body>");
|
||||||
|
expect(metaIdx).toBeGreaterThan(-1);
|
||||||
|
expect(metaIdx).toBeLessThan(bodyIdx);
|
||||||
|
expect(wrapped.startsWith("<!DOCTYPE html><html><head><meta")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attribute-encodes the policy value", () => {
|
||||||
|
const wrapped = wrapSandboxedHtml("", `x"><script>`);
|
||||||
|
expect(wrapped).toContain('content="x"><script>"');
|
||||||
|
expect(wrapped).not.toContain('"><script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places untrusted full-document HTML inside <body> so its own <head> token cannot precede the policy", () => {
|
||||||
|
const evil = `<!-- <head> --><!DOCTYPE html><html><head><script src="x"></script></head><body>app</body></html>`;
|
||||||
|
const wrapped = wrapSandboxedHtml(evil, "default-src 'none'");
|
||||||
|
// The first <head> in the output is OURS; the untrusted <head> token only
|
||||||
|
// appears after <body>.
|
||||||
|
const ourHead = wrapped.indexOf("<head>");
|
||||||
|
const body = wrapped.indexOf("<body>");
|
||||||
|
const evilHead = wrapped.indexOf("<head>", ourHead + 1);
|
||||||
|
expect(ourHead).toBeLessThan(body);
|
||||||
|
expect(evilHead).toBeGreaterThan(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not introduce a host-authored <iframe>, so the widget's window.parent remains the sandbox proxy", () => {
|
||||||
|
// The proxy assigns the wrap output to the inner iframe's srcdoc.
|
||||||
|
// If the wrap inserted its own <iframe>, the widget's window.parent would
|
||||||
|
// point at the wrap's intermediate frame instead of the proxy, breaking the
|
||||||
|
// postMessage relay (and the proxy's event.source === inner.contentWindow
|
||||||
|
// check). The only <iframe>s in the output must be ones the untrusted app
|
||||||
|
// brought itself.
|
||||||
|
const wrapped = wrapSandboxedHtml("<p>no frames here</p>", "default-src *");
|
||||||
|
expect(wrapped).not.toMatch(/<iframe/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
146
clients/web/src/utils/sandbox-csp.ts
Normal file
146
clients/web/src/utils/sandbox-csp.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import type { McpUiResourceCsp } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed shapes for a CSP source-expression supplied by an app's
|
||||||
|
* `_meta.ui.csp`. Each entry is server-supplied and untrusted: it MUST NOT
|
||||||
|
* inject extra directives (`;`) or break out of the meta attribute
|
||||||
|
* (`"`, `<`, `>`). Only common source forms are accepted —
|
||||||
|
* `scheme://host[:port][/path]`, scheme-only (`data:`, `blob:`), `*`, and
|
||||||
|
* wildcard hosts (`*.example.com`, `https://*.example.com`); anything else is
|
||||||
|
* dropped by {@link approveCspSources}.
|
||||||
|
*/
|
||||||
|
export const SAFE_CSP_SOURCE =
|
||||||
|
/^(?:\*|[a-zA-Z][a-zA-Z0-9+.-]*:(?:\/\/(?:\*\.)?[A-Za-z0-9._~%!$&'()*+,=@:-]+(?::\d+)?(?:\/[A-Za-z0-9._~%!$&'()*+,=@:/-]*)?)?|(?:\*\.)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identity helper that pins `CSP_KEYS` to an exhaustive, valid key list at
|
||||||
|
* compile time. `satisfies readonly (keyof McpUiResourceCsp)[]` alone only
|
||||||
|
* proves every *listed* key is valid; it does NOT prove the list is *complete*.
|
||||||
|
* The intersected conditional adds the missing half: when `CSP_KEYS` covers
|
||||||
|
* every key, `keyof McpUiResourceCsp extends T[number]` holds and the parameter
|
||||||
|
* type is just `T`; if the upstream ext-apps type ever gains a new domain key,
|
||||||
|
* the conditional collapses the parameter type to `never` and the call fails to
|
||||||
|
* compile — forcing the key to be added here rather than being silently dropped
|
||||||
|
* (a vanished restriction) by {@link approveCspSources}.
|
||||||
|
*/
|
||||||
|
function exhaustiveCspKeys<const T extends readonly (keyof McpUiResourceCsp)[]>(
|
||||||
|
keys: T & (keyof McpUiResourceCsp extends T[number] ? unknown : never),
|
||||||
|
): T {
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSP_KEYS = exhaustiveCspKeys([
|
||||||
|
"connectDomains",
|
||||||
|
"resourceDomains",
|
||||||
|
"frameDomains",
|
||||||
|
"baseUriDomains",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter an app-supplied {@link McpUiResourceCsp} down to the entries the host
|
||||||
|
* will actually enforce. Unsafe values are dropped (and warned), and the
|
||||||
|
* resulting object contains only keys with at least one accepted source. The
|
||||||
|
* return value is what the host echoes back to the view via
|
||||||
|
* `hostCapabilities.sandbox.csp` so the app sees what was granted, not what it
|
||||||
|
* asked for.
|
||||||
|
*
|
||||||
|
* NOTE: this screens each source for *injection safety* only — it does NOT
|
||||||
|
* bound the *breadth* of a grant. A bare `*` (and a scheme-wildcard host) is a
|
||||||
|
* syntactically safe source, so `resourceDomains: ["*"]` is "approved" and maps
|
||||||
|
* to `script-src 'unsafe-inline' *` (scripts from anywhere), just as
|
||||||
|
* `connectDomains: ["*"]` maps to `connect-src *`. That breadth is acceptable
|
||||||
|
* here because the enforced document runs in an opaque-origin sandbox with no
|
||||||
|
* ambient credentials and nothing to exfiltrate beyond what the app already
|
||||||
|
* received; "approved" therefore means "cannot break out of the meta
|
||||||
|
* attribute," not "restrictive."
|
||||||
|
*/
|
||||||
|
export function approveCspSources(
|
||||||
|
csp: McpUiResourceCsp | undefined,
|
||||||
|
): McpUiResourceCsp {
|
||||||
|
const approved: McpUiResourceCsp = {};
|
||||||
|
if (!csp) return approved;
|
||||||
|
for (const key of CSP_KEYS) {
|
||||||
|
const requested = csp[key];
|
||||||
|
if (!Array.isArray(requested)) continue;
|
||||||
|
const accepted: string[] = [];
|
||||||
|
for (const entry of requested) {
|
||||||
|
if (typeof entry === "string" && SAFE_CSP_SOURCE.test(entry)) {
|
||||||
|
accepted.push(entry);
|
||||||
|
} else {
|
||||||
|
console.warn("[mcp-app sandbox] dropping unsafe CSP source:", entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (accepted.length > 0) approved[key] = accepted;
|
||||||
|
}
|
||||||
|
return approved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinSources(list: string[] | undefined, fallback: string): string {
|
||||||
|
return list && list.length > 0 ? list.join(" ") : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate an approved {@link McpUiResourceCsp} into the Content-Security-Policy
|
||||||
|
* string enforced on the inner sandboxed document. `default-src 'none'` is the
|
||||||
|
* catch-all so any fetch type not explicitly mapped is denied. `script-src` /
|
||||||
|
* `style-src` carry `'unsafe-inline'` because the app's own inline code ships
|
||||||
|
* with the inline-delivered HTML and has no origin to allowlist; external loads
|
||||||
|
* stay restricted to `resourceDomains`.
|
||||||
|
*
|
||||||
|
* `resourceDomains` intentionally feeds `script-src` (and `style-src`) in
|
||||||
|
* addition to `img-src`/`font-src`/`media-src`: the `McpUiResourceCsp` contract
|
||||||
|
* defines it as a single "static resources" allowlist that "Maps to CSP
|
||||||
|
* `img-src`, `script-src`, `style-src`, `font-src`, `media-src` directives," so
|
||||||
|
* an app that lists a CDN there is granted script execution from that origin by
|
||||||
|
* design. There is no narrower per-directive key in the contract; if the spec
|
||||||
|
* ever splits scripts out, update the mapping here accordingly.
|
||||||
|
*/
|
||||||
|
export function buildSandboxCspPolicy(approved: McpUiResourceCsp): string {
|
||||||
|
const resourceSrc = joinSources(approved.resourceDomains, "'none'");
|
||||||
|
const inlineResource =
|
||||||
|
resourceSrc === "'none'"
|
||||||
|
? "'unsafe-inline'"
|
||||||
|
: `'unsafe-inline' ${resourceSrc}`;
|
||||||
|
return [
|
||||||
|
"default-src 'none'",
|
||||||
|
`connect-src ${joinSources(approved.connectDomains, "'none'")}`,
|
||||||
|
`script-src ${inlineResource}`,
|
||||||
|
`style-src ${inlineResource}`,
|
||||||
|
`img-src ${resourceSrc}`,
|
||||||
|
`font-src ${resourceSrc}`,
|
||||||
|
`media-src ${resourceSrc}`,
|
||||||
|
`frame-src ${joinSources(approved.frameDomains, "'none'")}`,
|
||||||
|
`base-uri ${joinSources(approved.baseUriDomains, "'self'")}`,
|
||||||
|
"form-action 'none'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"worker-src 'none'",
|
||||||
|
].join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTML-attribute-encode a string (defense-in-depth for the CSP meta value). */
|
||||||
|
export function escapeHtmlAttr(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an app's untrusted HTML in a host-authored document whose first
|
||||||
|
* `<head>` child is the CSP `<meta>`. The wrapper bytes are fixed — the
|
||||||
|
* untrusted content lands inside `<body>` and never precedes the policy, so a
|
||||||
|
* `<head>`/`<!-- -->` token in the app's HTML cannot push the meta inert or
|
||||||
|
* load resources before the policy applies. If the app's HTML is itself a full
|
||||||
|
* document, the second `<!doctype>`/`<html>`/`<head>` are parsed inside
|
||||||
|
* `<body>` (the HTML parser ignores duplicate document-structure tags) while
|
||||||
|
* its scripts and styles still run — governed by the already-applied policy.
|
||||||
|
*/
|
||||||
|
export function wrapSandboxedHtml(
|
||||||
|
untrustedHtml: string,
|
||||||
|
policy: string,
|
||||||
|
): string {
|
||||||
|
const meta = `<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttr(policy)}">`;
|
||||||
|
return `<!DOCTYPE html><html><head>${meta}<meta charset="utf-8"></head><body>${untrustedHtml}</body></html>`;
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
Network/resource egress from the inner document is constrained by a
|
Network/resource egress from the inner document is constrained by a
|
||||||
Content-Security-Policy that the HOST builds and bakes into the
|
Content-Security-Policy that the HOST builds and bakes into the
|
||||||
delivered HTML before it reaches this proxy (see
|
delivered HTML before it reaches this proxy (see
|
||||||
src/lib/sandbox-csp.ts). The proxy assigns that pre-wrapped document
|
src/utils/sandbox-csp.ts). The proxy assigns that pre-wrapped document
|
||||||
to `srcdoc` verbatim — it never parses or injects into the untrusted
|
to `srcdoc` verbatim — it never parses or injects into the untrusted
|
||||||
bytes — so the CSP `<meta>` is guaranteed to be the first `<head>`
|
bytes — so the CSP `<meta>` is guaranteed to be the first `<head>`
|
||||||
child of the inner document. -->
|
child of the inner document. -->
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,6 @@ export default defineConfig(({ command }) => {
|
||||||
'src/components/**/*.{ts,tsx}',
|
'src/components/**/*.{ts,tsx}',
|
||||||
'src/lib/**/*.{ts,tsx}',
|
'src/lib/**/*.{ts,tsx}',
|
||||||
'src/utils/**/*.{ts,tsx}',
|
'src/utils/**/*.{ts,tsx}',
|
||||||
'src/lib/**/*.{ts,tsx}',
|
|
||||||
'clients/web/server/**/*.{ts,tsx}',
|
'clients/web/server/**/*.{ts,tsx}',
|
||||||
path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'),
|
path.join(repoRoot, 'core/mcp/**/*.{ts,tsx}'),
|
||||||
path.join(repoRoot, 'core/json/**/*.{ts,tsx}'),
|
path.join(repoRoot, 'core/json/**/*.{ts,tsx}'),
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue