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:
Cliff Hall 2026-07-25 22:24:30 -04:00 • committed by GitHub
commit a82a70f4f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 60 additions and 19 deletions

View file

@ -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.

View file

@ -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:

View file

@ -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 =

View file

@ -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";

View file

@ -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`

View file

@ -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,

View 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();
});
});

View 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);
}

View 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);
});
});

View 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;
}

View 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();
});
});

View 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);
});
});

View 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();
}

View 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([]);
});
});

View 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)]
: [];
};

View file

@ -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;

View 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&amp;b&quot;c&#39;d&lt;e&gt;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&quot;&gt;&lt;script&gt;"');
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);
});
});

View 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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
* 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>`;
}

View file

@ -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. -->

View file

@ -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