Merge remote-tracking branch 'origin/v2/main' into v2/fix/2071-oauth-resource-metadata
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # core/mcp/node/transport.ts
This commit is contained in:
commit
baf4fe954f
76 changed files with 9048 additions and 1271 deletions
|
|
@ -1,14 +1,11 @@
|
|||
import type { Preview } from "@storybook/react-vite";
|
||||
import {
|
||||
MantineProvider,
|
||||
useMantineColorScheme,
|
||||
type CSSVariablesResolver,
|
||||
} from "@mantine/core";
|
||||
import { MantineProvider, useMantineColorScheme } from "@mantine/core";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import "../src/App.css";
|
||||
import { theme } from "../src/theme/theme";
|
||||
import { cssVariablesResolver } from "../src/theme/cssVariables";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
|
|
@ -28,14 +25,6 @@ function ColorSchemeWrapper({
|
|||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const resolver: CSSVariablesResolver = () => ({
|
||||
variables: {},
|
||||
light: {},
|
||||
dark: {
|
||||
"--mantine-color-body": "var(--mantine-color-dark-9)",
|
||||
},
|
||||
});
|
||||
|
||||
const preview: Preview = {
|
||||
globalTypes: {
|
||||
colorScheme: {
|
||||
|
|
@ -61,7 +50,7 @@ const preview: Preview = {
|
|||
<MantineProvider
|
||||
theme={theme}
|
||||
defaultColorScheme="light"
|
||||
cssVariablesResolver={resolver}
|
||||
cssVariablesResolver={cssVariablesResolver}
|
||||
>
|
||||
<ColorSchemeWrapper
|
||||
colorScheme={context.globals.colorScheme ?? "light"}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ This README covers what's specific to the web client. For the repo-wide picture
|
|||
|
||||
## Two halves: `src/` (browser) and `server/` (Node)
|
||||
|
||||
| Path | Runs in | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `src/` | browser | The React SPA — components, hooks, theme, entry (`main.tsx`). |
|
||||
| `server/` | Node | The dev/prod backend wiring (never imported by the browser). |
|
||||
| Path | Runs in | Purpose |
|
||||
| --------- | ------- | ------------------------------------------------------------- |
|
||||
| `src/` | browser | The React SPA — components, hooks, theme, entry (`main.tsx`). |
|
||||
| `server/` | Node | The dev/prod backend wiring (never imported by the browser). |
|
||||
|
||||
The `server/` directory holds the Node-only backend:
|
||||
|
||||
|
|
@ -19,7 +19,7 @@ The `server/` directory holds the Node-only backend:
|
|||
- **`web-server-config.ts`** — env parsing, the `GET /api/config` payload, the startup banner, the default origin allow-list.
|
||||
- **`resolve-bind-host.ts`** — the shared bind-host guard (refuses an all-interfaces `HOST` unless `DANGEROUSLY_BIND_ALL_INTERFACES`), used by both bind points (`web-server-config.ts` + `vite.config.ts`); see [Host binding & the origin allow-list](#host-binding--the-origin-allow-list).
|
||||
- **`inject-auth-token.ts`** — embeds the API token into the served `index.html` (see [Auth token](#auth-token)).
|
||||
- **`sandbox-controller.ts`** — the MCP Apps sandbox HTTP server; **`ensure-web-build.ts`** — builds `dist/` on demand for prod `--web`; **`vite-base-config.ts`** — shared `optimizeDeps` exclusions.
|
||||
- **`sandbox-controller.ts`** — the MCP Apps sandbox HTTP server; **`app-origin-controller.ts`** — the dedicated app-origin server for `_meta.ui.domain` (see [MCP App dedicated origins](#mcp-app-dedicated-origins-metauidomain)); **`ensure-web-build.ts`** — builds `dist/` on demand for prod `--web`; **`vite-base-config.ts`** — shared `optimizeDeps` exclusions.
|
||||
- **`browser-externalized-builtin-gate.ts`** — Vite-agnostic build-gate logic that fails `vite build` when a Node built-in reaches the browser bundle (#1769); the thin Vite plugin wiring lives in `vite.config.ts`. It sits under `server/` (rather than `src/`) as the home for Node-only, build-time tooling — it's imported by the Vite config, never by the browser — alongside the other `vite-*` config helpers here.
|
||||
|
||||
## Development
|
||||
|
|
@ -47,12 +47,12 @@ Two artifacts come out, both of which ship in the published package:
|
|||
|
||||
Components live under `src/components/` in four layers, smallest to largest:
|
||||
|
||||
| Layer | Count | What it is |
|
||||
| --- | --- | --- |
|
||||
| `elements/` | ~31 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
|
||||
| `groups/` | ~63 | Composite pieces (cards, panels, modals, control bars). |
|
||||
| `screens/` | ~11 | Full tab screens (Tools, Resources, Servers, monitoring screens…). |
|
||||
| `views/` | 1 | `InspectorView` — the top-level layout that composes the screens. |
|
||||
| Layer | Count | What it is |
|
||||
| ----------- | ----- | ------------------------------------------------------------------------------ |
|
||||
| `elements/` | ~31 | Leaf presentational pieces (badges, buttons, toggles) over Mantine primitives. |
|
||||
| `groups/` | ~63 | Composite pieces (cards, panels, modals, control bars). |
|
||||
| `screens/` | ~11 | Full tab screens (Tools, Resources, Servers, monitoring screens…). |
|
||||
| `views/` | 1 | `InspectorView` — the top-level layout that composes the screens. |
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -74,23 +74,23 @@ Nothing _enforces_ the boundary — no path alias keys off it, and the coverage
|
|||
|
||||
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:
|
||||
|
||||
| Attribute | Where | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `data-testid="apps-form"` | Apps content card | The container that carries the status/error attributes below. |
|
||||
| `data-app-status` | on `apps-form` | Renderer lifecycle: `idle` (nothing running) → `loading` (bridge building / `ui/initialize` in flight) → `ready` (view fired `notifications/initialized`) → `error` (bridge factory threw/rejected). Poll for `ready`. |
|
||||
| `data-app-error` | on `apps-form` | The failure reason string when `data-app-status="error"` (e.g. no connected client); absent otherwise. |
|
||||
| `data-testid="apps-error"` | error panel | Rendered below the frame when the app fails to load (factory throw/reject); shows the reason so the failure isn't a silent blank frame. |
|
||||
| `data-testid="open-app"` | Open App button | Launches the selected app. |
|
||||
| `data-testid="apps-stage"` | Stage-partial button | Snapshots the current form values for progressive-render testing. |
|
||||
| `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. |
|
||||
| `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). |
|
||||
| Attribute | Where | Meaning |
|
||||
| ----------------------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `data-testid="apps-form"` | Apps content card | The container that carries the status/error attributes below. |
|
||||
| `data-app-status` | on `apps-form` | Renderer lifecycle: `idle` (nothing running) → `loading` (bridge building / `ui/initialize` in flight) → `ready` (view fired `notifications/initialized`) → `error` (bridge factory threw/rejected). Poll for `ready`. |
|
||||
| `data-app-error` | on `apps-form` | The failure reason string when `data-app-status="error"` (e.g. no connected client); absent otherwise. |
|
||||
| `data-testid="apps-error"` | error panel | Rendered below the frame when the app fails to load (factory throw/reject); shows the reason so the failure isn't a silent blank frame. |
|
||||
| `data-testid="open-app"` | Open App button | Launches the selected app. |
|
||||
| `data-testid="apps-stage"` | Stage-partial button | Snapshots the current form values for progressive-render testing. |
|
||||
| `data-testid="apps-messages"` | messages panel | `ui/message` submissions from the running view. |
|
||||
| `data-testid="apps-logs"` | app-logs panel | `notifications/message` log entries (default-expanded). |
|
||||
|
||||
App-rendered **elicitations** (#1854) render through the same `AppRenderer` but outside the Apps screen — one modal per request, from `AppElicitationHost` — and carry their own pair:
|
||||
|
||||
| Attribute | Where | Meaning |
|
||||
| --- | --- | --- |
|
||||
| Attribute | Where | Meaning |
|
||||
| ------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `data-testid="app-elicitation"` | the elicitation modal | One per in-flight app-rendered elicitation. **Absent** means the request was answered by the native elicitation form instead, which is what a driver asserts to prove the negotiation gate held. |
|
||||
| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. |
|
||||
| `data-app-elicitation-status` | on `app-elicitation` | The same `AppRendererStatus` for that modal's app. `ready` is when the host forwards the `elicitation/create` through its bridge. |
|
||||
|
||||
`scripts/smoke-web-elicitation.mjs` drives both halves against the public fixture (`test-servers/configs/app-elicitation-http.json` and its `-native-` sibling).
|
||||
|
||||
|
|
@ -104,20 +104,20 @@ A driver (launcher, CLI `--print-handoff`, CI review harness) can reach a **conn
|
|||
http://127.0.0.1:6274/?serverUrl=<url>&transport=http|sse&autoConnect=<token>
|
||||
```
|
||||
|
||||
| Param | Meaning |
|
||||
| --- | --- |
|
||||
| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. |
|
||||
| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. |
|
||||
| Param | Meaning |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `serverUrl` | The MCP server URL. Restricted to `http:` / `https:` (a crafted `javascript:` / `data:` / `file:` value is rejected). Canonicalized via `URL.href` so it matches the OAuth store's key form. |
|
||||
| `transport` | `http` (streamable-HTTP, the default) or `sse`. Unknown values fall back to `http`. |
|
||||
| `autoConnect` | **CSRF gate.** Must equal the per-launch `MCP_INSPECTOR_API_TOKEN`. The token is random per launch and only known to whatever started the server, so a third-party-minted link cannot satisfy it — this is the same exposure surface as the existing `?MCP_INSPECTOR_API_TOKEN=` param. Without a match the link is ignored. |
|
||||
|
||||
The deep link upserts a stable `deep-link` catalog row (so a reload reconnects to the same row instead of accumulating duplicates) and connects. Connection-level outcomes are surfaced on the `AppShell.Header` as a machine-readable contract, so a driver can `waitForSelector` and read the failure reason without scraping a transient toast:
|
||||
|
||||
| Attribute | Where | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `data-testid="connection-status"` | header | The element carrying the attributes below. |
|
||||
| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. |
|
||||
| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. |
|
||||
| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. |
|
||||
| Attribute | Where | Meaning |
|
||||
| --------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `data-testid="connection-status"` | header | The element carrying the attributes below. |
|
||||
| `data-status` | on `connection-status` | The live `ConnectionStatus` (`disconnected` → `connecting` → `connected` / `error`). Poll for `connected`. |
|
||||
| `data-error-message` | on `connection-status` | Why the last connect failed (handshake error, OAuth-start failure, deep-link automation failure); absent when there is no error. |
|
||||
| `data-deeplink` | on `connection-status` | `parsed` (a valid deep link drove this load), `rejected` (deep-link params present but the token/serverUrl gate failed), or `none`. Lets a driver distinguish "no deep link" from "rejected" — both otherwise leave `data-status` idle. |
|
||||
|
||||
### Landing on a rendered app
|
||||
|
||||
|
|
@ -127,27 +127,132 @@ Three further params extend the deep link to pre-select — and optionally auto-
|
|||
…&openApp=<toolName>&appArgs=<base64url(JSON)>&autoOpen=<token>
|
||||
```
|
||||
|
||||
| Param | Meaning |
|
||||
| --- | --- |
|
||||
| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. |
|
||||
| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. |
|
||||
| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. |
|
||||
| Param | Meaning |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `openApp` | The app-tool name. Once the connection is up and the tool appears in the app list, the inspector switches to the Apps tab and pre-selects it. |
|
||||
| `appArgs` | `base64url(JSON)` object of form values. Merged **over** the tool's schema defaults (`collectSchemaDefaults`) so a required-with-default field isn't left blank — which would otherwise disable "Open App". Malformed / non-object values fall back to `{}`. |
|
||||
| `autoOpen` | **Same CSRF gate as `autoConnect`** — must equal the session token. When set, "Open App" fires automatically (a tool call from a URL), so the token gate is mandatory. Without a match the app is pre-selected but not opened. |
|
||||
|
||||
The app-side render lifecycle is observable through the [MCP Apps screen automation contract](#mcp-apps-screen-automation-contract) above (`data-app-status="ready"`), so a driver can `waitForSelector` the whole `connect → open → ready` chain deterministically.
|
||||
|
||||
## MCP App dedicated origins (`_meta.ui.domain`)
|
||||
|
||||
By default an MCP App is rendered by handing its HTML to the sandbox proxy as
|
||||
`srcdoc`, inside an iframe sandboxed **without** `allow-same-origin`. That is
|
||||
the isolation model from #1565 and it stays the default — but it gives the app
|
||||
document an *opaque* origin, so every request it makes carries `Origin: null`.
|
||||
An app whose backend allowlists origins (CORS, an OAuth callback, an API-key
|
||||
allowlist) can't work that way, which is what the spec's `_meta.ui.domain`
|
||||
exists to solve: a server uses it to ask its host for a stable, dedicated
|
||||
origin.
|
||||
|
||||
**The Inspector's host-specific contract.** The spec makes `domain`'s format
|
||||
host-dependent ("servers MUST consult host-specific documentation"), and the
|
||||
Inspector owns no domain infrastructure — it cannot serve
|
||||
`my-app.example.com`. So it treats the field as a **request, not an address**:
|
||||
|
||||
- Any **non-empty** `domain` string opts the resource in. The value itself is
|
||||
not parsed, matched, or reserved — declare whatever your production host
|
||||
expects.
|
||||
- The Inspector answers with a real HTTP origin on loopback:
|
||||
`http://<host>:<MCP_APP_ORIGIN_PORT>`, default **`6278`** — in the same
|
||||
`627x` family as the web port `6274` and the sandbox port `6275`, so the
|
||||
three forward together. It is **not** `6276`: that is the fixed loopback
|
||||
OAuth callback the CLI and TUI listen on, which OAuth apps pre-register and
|
||||
which therefore cannot move. `6277` is skipped as v1's retired proxy port.
|
||||
- The app document is served from there under an unguessable path, its
|
||||
per-app CSP delivered as a real response **header** (stronger than the
|
||||
`<meta>` the `srcdoc` path relies on) plus a `frame-ancestors` that admits
|
||||
only the two origins in its real ancestor chain — the sandbox proxy that
|
||||
frames it, **and** the Inspector page that frames the proxy. Both are
|
||||
required, not belt-and-braces: `frame-ancestors` is checked against every
|
||||
ancestor, so omitting the Inspector's own origin blocks the frame outright
|
||||
(see `appDocumentEmbedders`).
|
||||
- The inner iframe is granted `allow-same-origin` on this path **only** —
|
||||
that is what makes the origin real rather than opaque. It is not a
|
||||
weakening of #1565: the listener is on its own port, so the app is
|
||||
cross-origin to both the sandbox proxy and the Inspector, and same-origin
|
||||
policy blocks the reach either way. The proxy refuses the grant outright if
|
||||
the URL's origin equals its own, and the grant is never reachable from the
|
||||
server-supplied `sandbox` string, which is still stripped.
|
||||
|
||||
**It is one shared origin, not one per app.** Every domain-declaring app is
|
||||
served from the same port, keyed by path. That delivers the property the field
|
||||
is *for* — a real, allowlistable origin — without minting a port or a DNS name
|
||||
per app, at the cost of not being a per-app isolation boundary: two such apps
|
||||
share `localStorage`, `sessionStorage`, and cookies for that origin.
|
||||
|
||||
**And the separate port does not isolate cookies from the rest of loopback.**
|
||||
A distinct port makes a distinct *origin*, so the origin-scoped surfaces —
|
||||
DOM/scripting access, `localStorage`, `sessionStorage`, IndexedDB — are isolated
|
||||
from the sandbox proxy and from the Inspector page. **Cookies are not
|
||||
origin-scoped**: they are keyed by host and path, ignoring port. An app served
|
||||
here therefore shares the `127.0.0.1` cookie jar with the Inspector on `6274`
|
||||
and with anything else on loopback — it can read any non-`HttpOnly` cookie set
|
||||
for that host, and set `Path=/` cookies those services will receive.
|
||||
|
||||
This is not a hole in the Inspector's own auth: the API token travels in the
|
||||
`x-mcp-remote-auth` header and lives in a `window` global and `sessionStorage`,
|
||||
neither of which another origin can read. Closing the cookie gap properly needs
|
||||
a distinct *host*, which this listener cannot mint (the Inspector owns no DNS,
|
||||
and a second loopback address is not portable) — so it is stated rather than
|
||||
papered over. Don't run the Inspector beside a loopback service whose session
|
||||
cookie you would not hand to an app under test.
|
||||
|
||||
**Every failure falls back rather than blanking the app.** No app-origin
|
||||
listener, a port that never bound, an older backend with no
|
||||
`POST /api/app-document` route, a network error — each renders the app the
|
||||
default (opaque-origin) way and logs a console warning naming `_meta.ui.domain`.
|
||||
Losing the real origin degrades what the app can reach; losing the app itself
|
||||
would be worse.
|
||||
|
||||
**One failure is outside that guarantee, deliberately: a published document the
|
||||
browser cannot reach.** Every case above is one the *host* observes — publishing
|
||||
returned nothing, so it falls back before choosing a render path. If publishing
|
||||
succeeds and the browser then cannot load the URL (the port is not forwarded, or
|
||||
a collision moved the listener to a dynamic port that is not), the frame stays
|
||||
blank instead. There is no honest signal to fall back on: the navigation is
|
||||
cross-origin, so `onerror` never fires for an HTTP error, `onload` fires for the
|
||||
error page too, and nothing about the document is readable. The alternatives are
|
||||
a timeout — which races an app that is merely slow, and "recovers" it by
|
||||
re-running it at an opaque origin, executing its side effects twice — or a
|
||||
reachability probe on every render, which proves the port answers rather than
|
||||
that this fetch will. So the cause is removed instead of guessed at: every
|
||||
documented remote workflow forwards the port (see below, the Docker section of
|
||||
the root README, and the SSH recipe in `docs/mcp-app-review.md`).
|
||||
|
||||
**Forward `6278` too** if you need this off loopback (a container, a tunnel).
|
||||
As with the sandbox port, a taken port falls back to an OS-assigned one with a
|
||||
loud warning — the app still renders, but the origin an app's backend was told
|
||||
to allowlist is then wrong, which is what the warning tells you.
|
||||
|
||||
## Theme (`src/theme/`)
|
||||
|
||||
Each customized Mantine component has a `Theme<Name>.ts` file (`Button.ts`, `Text.ts`, …, ~21 total) exporting a `Theme<Name>` constant; the barrel `index.ts` re-exports them and `theme.ts` assembles the `MantineProvider` theme. Theme files hold app-wide defaults and **variants** (flat CSS-in-JS); only pseudo-selectors, nested child selectors, keyframes, and native-HTML styling belong in `App.css`. Element components import from `@mantine/core` (never from `theme/`) — the theme layer is applied transparently by the provider.
|
||||
|
||||
**`cssVariables.ts` is the third piece, beside the component files and `App.css`.** It holds overrides for the CSS variables `MantineProvider` injects at runtime, which `App.css` cannot reach: the provider appends its generated `<style>` after the stylesheet imports, so a `:root` rule there loses on source order at equal specificity. `cssVariablesResolver` is the supported seam. It is passed at **all three** `MantineProvider` sites — the app (`main.tsx`), the Storybook preview, and `renderWithMantine` — so the running app, the stories, and the tests cannot disagree about a token's value. It currently corrects `--mantine-color-error`, whose Mantine defaults fail WCAG AA in both schemes at the size input error text renders.
|
||||
|
||||
## Code editing (`JsonObjectInput`)
|
||||
|
||||
Payloads whose _values_ may be arbitrary JSON — `_meta` is the case that forced it ([#1910](https://github.com/modelcontextprotocol/inspector/issues/1910)) — are edited with **Ace** (`react-ace` + `ace-builds`, declared in this client because they render React) rather than the key/value rows used for headers and env, which cannot express an object value. Ace brings code folding, brace auto-closing, and per-line error annotation from its JSON worker.
|
||||
|
||||
Three integration details are load-bearing:
|
||||
|
||||
- **The worker is imported as `?url`** so Vite emits it as an asset. Without it Ace fetches `worker-json.js` from a path that does not exist in a bundled app and silently loses its annotations.
|
||||
- **The gutter's colors are overridden in `App.css`**, keyed off Ace's cssClass (`ace-github` / `ace-github-dark` — _not_ the `theme-github_dark` module name). Ace's own themes are 1.89:1 and 4.13:1 there, both under AA, and folding needs the gutter so it cannot simply be hidden.
|
||||
- **The label and error are wired to Ace's hidden textarea by hand.** `Input.Wrapper` associates a _Mantine_ input through context; Ace renders its own DOM, so the id, `aria-invalid` and `aria-describedby` are set on the textarea in an effect.
|
||||
|
||||
**Testing it is split by necessity.** Ace's input path does not work under happy-dom — `userEvent.type` reaches the textarea and produces no edit — so a keyboard test in the unit project passes while asserting nothing. Unit tests drive the editor instance through `src/test/aceEditor.ts`; real keyboard behaviour lives in the Storybook play functions, which run in Chromium.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests run under three Vitest **projects** (configured in `vite.config.ts`), each in the right environment:
|
||||
|
||||
| Project | Env | Scope | Script |
|
||||
| --- | --- | --- | --- |
|
||||
| `unit` | happy-dom | Components, hooks, utils (`*.test.tsx` beside the source) | `npm test` |
|
||||
| `integration` | node | `@inspector/core` + transports + auth, spawning the real stdio test server (`src/test/integration/**`) | `npm run test:integration` |
|
||||
| `storybook` | real Chromium | Story **play functions** as interaction tests | `npm run test:storybook` |
|
||||
| Project | Env | Scope | Script |
|
||||
| ------------- | ------------- | ------------------------------------------------------------------------------------------------------ | -------------------------- |
|
||||
| `unit` | happy-dom | Components, hooks, utils (`*.test.tsx` beside the source) | `npm test` |
|
||||
| `integration` | node | `@inspector/core` + transports + auth, spawning the real stdio test server (`src/test/integration/**`) | `npm run test:integration` |
|
||||
| `storybook` | real Chromium | Story **play functions** as interaction tests | `npm run test:storybook` |
|
||||
|
||||
- `npm test` runs the fast **unit** project (happy-dom). `test:watch` for the loop.
|
||||
- **Integration** tests run in a real Node env (no happy-dom, 30s timeouts) and spawn `test-servers/build/test-server-stdio.js` as a subprocess, so `pretest`/the coverage script build the test servers first (`test-servers:build`).
|
||||
|
|
@ -193,4 +298,6 @@ In every case, exposing the Inspector beyond loopback also means anyone who can
|
|||
|
||||
## HTTP proxy support
|
||||
|
||||
The web backend connects to remote MCP servers through the shared Node transport (`core/mcp/node/transport.ts`), which honors the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. Routing is powered by [`undici`](https://www.npmjs.com/package/undici)'s `EnvHttpProxyAgent`, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost. See the CLI README for more detail.
|
||||
The web backend connects to remote MCP servers through the shared Node fetch (`core/mcp/node/proxyFetch.ts`), which honors the conventional proxy environment variables: `HTTPS_PROXY` / `HTTP_PROXY` (and their lowercase forms) select the proxy, and `NO_PROXY` exempts hosts. Routing uses [`undici`](https://www.npmjs.com/package/undici)'s own `fetch` bound to its `EnvHttpProxyAgent`, imported lazily only when a proxy variable is set, so runs without a proxy configured pay no cost.
|
||||
|
||||
The proxy sits at the **bottom** of the fetch stack rather than wrapping the transport's fetch, and both undici halves must come from the same copy — see [HTTP proxy support](../cli/README.md#http-proxy-support) in the CLI README for why ([#2067](https://github.com/modelcontextprotocol/inspector/issues/2067)).
|
||||
|
|
|
|||
39
clients/web/package-lock.json
generated
39
clients/web/package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
|||
"@mantine/hooks": "^8.3.17",
|
||||
"@mantine/notifications": "^8.3.17",
|
||||
"@napi-rs/keyring": "^1.3.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
"chokidar": "^4.0.3",
|
||||
|
|
@ -25,6 +26,7 @@
|
|||
"papaparse": "^5.5.4",
|
||||
"pino": "^9.14.0",
|
||||
"react": "^19.2.4",
|
||||
"react-ace": "^15.0.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
@ -4383,6 +4385,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ace-builds": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.44.0.tgz",
|
||||
"integrity": "sha512-PFNMSYqFdEUkul2Ntud0HvA09AgY+F1ag0UYdpMH60wNI/qOA8cB8tlTgoALMEwIdUPJK2CjrIQ7OnbiSS/ugQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||
|
|
@ -5061,6 +5069,12 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/diff-match-patch": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz",
|
||||
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/doctrine": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||
|
|
@ -5487,6 +5501,15 @@
|
|||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
|
||||
"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-json-stable-stringify": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
|
|
@ -8294,6 +8317,22 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-ace": {
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-ace/-/react-ace-15.0.0.tgz",
|
||||
"integrity": "sha512-gdmS5ftF0hsbkcrAjZQqYbXwFg5JrHuxjki8mP6Bn9kfa6lHKfZk9sU9EfS0ifQ1CpGCmRxF/VC7GRvlJMBuZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ace-builds": "^1.36.3",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"fast-equals": "^5.3.3",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-docgen": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.3.tgz",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
"@mantine/hooks": "^8.3.17",
|
||||
"@mantine/notifications": "^8.3.17",
|
||||
"@napi-rs/keyring": "^1.3.0",
|
||||
"ace-builds": "^1.44.0",
|
||||
"ajv": "^8.17.1",
|
||||
"atomically": "^2.1.1",
|
||||
"chokidar": "^4.0.3",
|
||||
|
|
@ -51,6 +52,7 @@
|
|||
"papaparse": "^5.5.4",
|
||||
"pino": "^9.14.0",
|
||||
"react": "^19.2.4",
|
||||
"react-ace": "^15.0.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
|
|||
414
clients/web/server/app-origin-controller.ts
Normal file
414
clients/web/server/app-origin-controller.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
/**
|
||||
* App-origin server: the dedicated origin an MCP App gets when its UI resource
|
||||
* declares `_meta.ui.domain` (#2056).
|
||||
*
|
||||
* ## Why a third listener exists
|
||||
*
|
||||
* By default the Inspector renders an App by handing its HTML to the sandbox
|
||||
* proxy as `srcdoc`, in an iframe sandboxed **without** `allow-same-origin`.
|
||||
* That is the isolation model #1565 chose, and it is the right default — but it
|
||||
* gives the app document an *opaque* origin, so every request it makes carries
|
||||
* `Origin: null`. An app whose backend allowlists origins (CORS, an OAuth
|
||||
* callback, an API-key allowlist) therefore cannot work, which is exactly what
|
||||
* `_meta.ui.domain` exists to solve: the spec lets a server ask its host for a
|
||||
* stable, dedicated origin.
|
||||
*
|
||||
* `domain` is explicitly **host-dependent** — "the format and validation rules
|
||||
* for this field are determined by each host" — and the Inspector owns no
|
||||
* domain infrastructure. What it *can* provide is a real, stable HTTP origin on
|
||||
* loopback. So a UI resource that declares any non-empty `domain` opts into
|
||||
* being served from this listener instead of `srcdoc`, and its requests carry a
|
||||
* real `Origin: http://<host>:<port>`.
|
||||
*
|
||||
* ## One shared origin, not one per domain
|
||||
*
|
||||
* Every domain-declaring app is served from this single port, path-keyed by an
|
||||
* unguessable document id. That is a deliberate trade: it delivers the property
|
||||
* the spec field is *for* (a real, allowlistable origin) without minting a port
|
||||
* — or a DNS name — per app. The consequence is that this origin is **not** a
|
||||
* per-app isolation boundary: two apps served here share `localStorage`,
|
||||
* `sessionStorage`, and cookies for it.
|
||||
*
|
||||
* ## What the separate port does and does not isolate
|
||||
*
|
||||
* A distinct port makes a distinct **origin**, so the usual origin-scoped
|
||||
* surfaces are isolated from the sandbox proxy and from the Inspector page:
|
||||
* DOM and scripting access, `localStorage`, `sessionStorage`, IndexedDB.
|
||||
*
|
||||
* **Cookies are the exception, and they are not isolated.** Cookies are scoped
|
||||
* by host (and path), not by port — so an app served here shares the
|
||||
* `127.0.0.1` cookie jar with the Inspector on `6274` and with every other
|
||||
* service on loopback. It can read any non-`HttpOnly` cookie set for that host
|
||||
* and set `Path=/` cookies those services will receive.
|
||||
*
|
||||
* That is a real caveat, but it is not a hole in the Inspector's own auth: the
|
||||
* API token travels in the `x-mcp-remote-auth` header and is held in a
|
||||
* `window` global and `sessionStorage`, both of which a different origin
|
||||
* cannot read. Closing the cookie gap properly needs a distinct *host*, which
|
||||
* this listener cannot mint — the Inspector owns no DNS, and a second loopback
|
||||
* address is not portable. So it is documented rather than papered over; do
|
||||
* not run the Inspector beside a loopback service whose session cookie you
|
||||
* would not hand to an app under test.
|
||||
*
|
||||
* ## Why granting `allow-same-origin` here is not a regression
|
||||
*
|
||||
* The inner frame is sandboxed with `allow-same-origin` only on this path,
|
||||
* which is what makes the origin real rather than opaque. The #1565 rationale —
|
||||
* "the app cannot touch the proxy's DOM, so it cannot bypass its own CSP by
|
||||
* executing in the parent's realm" — still holds, because this listener is on
|
||||
* its own port and is therefore cross-origin to both the proxy and the app
|
||||
* page. Same-origin policy blocks the reach either way.
|
||||
*
|
||||
* The per-app CSP is additionally delivered as a real response **header** here,
|
||||
* which is stronger than the `<meta>` the `srcdoc` path must rely on: a header
|
||||
* applies to the document before any of its bytes are parsed and cannot be
|
||||
* displaced by anything in the app's own markup.
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
canonicalUrlHost,
|
||||
isAllInterfacesHost,
|
||||
} from "../../../core/node/hostUrl.ts";
|
||||
import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js";
|
||||
import { frameAncestorsDirective } from "./sandbox-controller.js";
|
||||
|
||||
/**
|
||||
* The default app-origin listen port — **fixed**, for the same reason
|
||||
* `DEFAULT_SANDBOX_PORT` is: a port that changes per run can never be named in
|
||||
* a dev container's `forwardPorts`, a `docker run -p`, or an SSH tunnel.
|
||||
*
|
||||
* **Not 6276, and not 6277.** `6276` is already taken, by the fixed loopback
|
||||
* OAuth callback the CLI and TUI listen on
|
||||
* (`RUNNER_OAUTH_CALLBACK_DEFAULT_PORT`) — fixed precisely so an OAuth app can
|
||||
* pre-register `http://127.0.0.1:6276/oauth/callback`, which means it is the
|
||||
* one port here that CANNOT move when something else takes it first. Binding it
|
||||
* would let a running `--web` (started at login, holding the port) break a
|
||||
* later `--cli`/`--tui` OAuth flow at its registered redirect URI — and the
|
||||
* handoff recipe in `docs/mcp-app-review.md` has those two running side by
|
||||
* side. Of the two listeners this listener is the one with a documented
|
||||
* dynamic fallback, so it is the one that yields. `6277` is skipped because it
|
||||
* was v1's MCP proxy port, which the migration guide tells people to stop
|
||||
* forwarding; reusing it would make that instruction wrong.
|
||||
*/
|
||||
export const DEFAULT_APP_ORIGIN_PORT = 6278;
|
||||
|
||||
/** Documents kept at once. Oldest is evicted past this; see {@link publish}. */
|
||||
const MAX_DOCUMENTS = 32;
|
||||
|
||||
/**
|
||||
* Total retained document size, in UTF-16 code units, across every live entry.
|
||||
*
|
||||
* {@link MAX_DOCUMENTS} alone bounds the *count*, and the route admits up to
|
||||
* 8Mi code units per document — so the count bound permits ~256Mi retained for
|
||||
* a full hour, enough to OOM a memory-limited Inspector on documents a server
|
||||
* chose the size of. Both bounds apply; whichever binds first evicts.
|
||||
*
|
||||
* 64Mi is roughly a full complement of ordinary widgets (a real app document is
|
||||
* tens to hundreds of KiB) or a couple of pathological ones. An app large
|
||||
* enough to evict its predecessors still renders — the frame fetches its `src`
|
||||
* immediately, long before anything could push it out.
|
||||
*/
|
||||
const MAX_RETAINED_CHARS = 64 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* How long a published document stays fetchable. An iframe fetches its `src`
|
||||
* once, so this only has to outlive the gap between publishing and the frame
|
||||
* loading — but a generous window keeps a reload (React StrictMode, a devtools
|
||||
* "reload frame") working rather than blanking the app.
|
||||
*/
|
||||
const DOCUMENT_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The origins that may appear in a published document's ancestor chain, for
|
||||
* {@link AppOriginControllerOptions.embedderOrigins}.
|
||||
*
|
||||
* There are **two**, and getting this wrong blocks the frame outright: CSP's
|
||||
* `frame-ancestors` is checked against *every* ancestor, not just the immediate
|
||||
* parent. A published document is framed by the sandbox proxy, which is itself
|
||||
* framed by the Inspector app page — so the directive has to admit the proxy's
|
||||
* origin **and** the app's, which is exactly the backend's origin allow-list.
|
||||
*
|
||||
* `undefined` when neither is known, which makes the directive fall back to the
|
||||
* loopback family rather than to `'none'`.
|
||||
*/
|
||||
export function appDocumentEmbedders(
|
||||
sandboxUrl: string | null | undefined,
|
||||
allowedOrigins?: string[],
|
||||
): string[] | undefined {
|
||||
const origins: string[] = [];
|
||||
if (sandboxUrl) {
|
||||
try {
|
||||
origins.push(new URL(sandboxUrl).origin);
|
||||
} catch {
|
||||
/* v8 ignore next -- the sandbox controller only ever emits a URL it built
|
||||
itself; an unparseable value would be a bug there, not input. */
|
||||
}
|
||||
}
|
||||
origins.push(...(allowedOrigins ?? []));
|
||||
return origins.length > 0 ? origins : undefined;
|
||||
}
|
||||
|
||||
export interface AppOriginControllerOptions {
|
||||
/** Port to bind (0 = dynamic). */
|
||||
port: number;
|
||||
/** Host to bind (default {@link DEFAULT_BIND_HOST}). */
|
||||
host?: string;
|
||||
/**
|
||||
* The origin(s) allowed to frame a published document — in practice the
|
||||
* sandbox proxy's own origin, since the proxy is what embeds the inner
|
||||
* iframe. Malformed entries are dropped and an empty result falls back to
|
||||
* loopback, exactly as the sandbox proxy's own `frame-ancestors` does.
|
||||
*/
|
||||
embedderOrigins?: string[];
|
||||
}
|
||||
|
||||
/** A document handed to {@link AppOriginController.publish}. */
|
||||
export interface AppDocument {
|
||||
/** The fully-wrapped HTML the sandbox would otherwise have received. */
|
||||
html: string;
|
||||
/** The per-app CSP policy string, delivered as a response header. */
|
||||
csp?: string;
|
||||
}
|
||||
|
||||
export interface AppOriginController {
|
||||
start(): Promise<{ port: number; url: string }>;
|
||||
close(): Promise<void>;
|
||||
/** The origin this listener serves on, or null when it never bound. */
|
||||
getOrigin(): string | null;
|
||||
/**
|
||||
* Store a document and return the absolute URL the sandbox proxy should load
|
||||
* it from, or `null` when this listener isn't running (the caller then falls
|
||||
* back to the default `srcdoc` path rather than failing the render).
|
||||
*/
|
||||
publish(doc: AppDocument): { url: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A usable listen port from a raw env value, or `undefined` if it isn't a plain
|
||||
* integer in `0`–`65535`. Mirrors `sandbox-controller`'s parser: `^\d+$` rejects
|
||||
* `parseInt`'s partial parses (`6278abc`) and the bound keeps an out-of-range
|
||||
* value from reaching `server.listen`, which throws synchronously.
|
||||
*/
|
||||
function parseListenPort(raw: string | undefined): number | undefined {
|
||||
const v = raw?.trim();
|
||||
if (!v || !/^\d+$/.test(v)) return undefined;
|
||||
const n = parseInt(v, 10);
|
||||
return n <= 65535 ? n : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the app-origin port from env: `MCP_APP_ORIGIN_PORT` →
|
||||
* {@link DEFAULT_APP_ORIGIN_PORT}. A set-but-invalid value is warned and falls
|
||||
* through rather than crashing the boot, matching `resolveSandboxPort`.
|
||||
*/
|
||||
export function resolveAppOriginPort(): number {
|
||||
const parsed = parseListenPort(process.env.MCP_APP_ORIGIN_PORT);
|
||||
if (parsed === undefined && process.env.MCP_APP_ORIGIN_PORT?.trim()) {
|
||||
console.warn(
|
||||
`Ignoring invalid MCP_APP_ORIGIN_PORT="${process.env.MCP_APP_ORIGIN_PORT}" (need an integer 0–65535); falling back.`,
|
||||
);
|
||||
}
|
||||
return parsed ?? DEFAULT_APP_ORIGIN_PORT;
|
||||
}
|
||||
|
||||
/** The path a published document is served at, given its id. */
|
||||
const DOC_PATH_PREFIX = "/app-document/";
|
||||
|
||||
/** `/app-document/<id>` → `<id>`, or null for any other request target. */
|
||||
export function parseDocumentId(url: string | undefined): string | null {
|
||||
if (!url || !url.startsWith(DOC_PATH_PREFIX)) return null;
|
||||
// Drop a query string: an app's own URL may carry one and it is not part of
|
||||
// the id. Anything with a further path segment is not a document target.
|
||||
const id = url.slice(DOC_PATH_PREFIX.length).split(/[?#]/)[0];
|
||||
return /^[0-9a-f]{32}$/.test(id ?? "") ? (id ?? null) : null;
|
||||
}
|
||||
|
||||
interface StoredDocument extends AppDocument {
|
||||
expiresAt: number;
|
||||
/** Retained size in UTF-16 code units, against {@link MAX_RETAINED_CHARS}. */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function createAppOriginController(
|
||||
options: AppOriginControllerOptions,
|
||||
): AppOriginController {
|
||||
// Same defaulting rationale as the sandbox controller: never the *name*
|
||||
// `localhost`, which resolves to a single address family and would put this
|
||||
// listener on a different family than the web server (#1951).
|
||||
const { port, host = DEFAULT_BIND_HOST, embedderOrigins } = options;
|
||||
let server: Server | null = null;
|
||||
let origin: string | null = null;
|
||||
|
||||
// Insertion-ordered, so the first key is the oldest entry.
|
||||
const documents = new Map<string, StoredDocument>();
|
||||
// Running sum of every live entry's `size`, kept in step with the map on
|
||||
// every insert and every delete. Tracked rather than recomputed: eviction
|
||||
// runs per publish and summing the map there would be O(n) on a hot path.
|
||||
let retainedChars = 0;
|
||||
|
||||
/** The single removal seam, so the running total cannot drift from the map. */
|
||||
function drop(id: string): void {
|
||||
const doc = documents.get(id);
|
||||
if (!doc) return;
|
||||
retainedChars -= doc.size;
|
||||
documents.delete(id);
|
||||
}
|
||||
|
||||
const FRAME_ANCESTORS = frameAncestorsDirective(embedderOrigins);
|
||||
|
||||
/** Drop everything past its TTL. Cheap: the map is bounded by MAX_DOCUMENTS. */
|
||||
function evictExpired(now: number): void {
|
||||
for (const [id, doc] of documents) {
|
||||
if (doc.expiresAt <= now) drop(id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async start(): Promise<{ port: number; url: string }> {
|
||||
if (server && origin) {
|
||||
return { port: parseInt(new URL(origin).port, 10), url: origin };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (value: { port: number; url: string }) => {
|
||||
/* v8 ignore next -- defensive double-settle guard: `error` and
|
||||
`listening` are mutually exclusive for a TCP listen. */
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
server = createServer((req, res) => {
|
||||
const id = req.method === "GET" ? parseDocumentId(req.url) : null;
|
||||
const doc = id ? documents.get(id) : undefined;
|
||||
if (!doc || doc.expiresAt <= Date.now()) {
|
||||
if (id) drop(id);
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not Found");
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate",
|
||||
Pragma: "no-cache",
|
||||
// `nosniff` matters more here than on a page we authored: these
|
||||
// bytes are server-supplied and untrusted.
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
// The per-app policy as a real header. It is the same policy baked
|
||||
// into the document's <meta>; delivering both is deliberate —
|
||||
// multiple policies intersect, and two copies of one policy
|
||||
// intersect to itself, so this can only ever be at least as strict.
|
||||
"Content-Security-Policy": doc.csp
|
||||
? `${doc.csp}; ${FRAME_ANCESTORS}`
|
||||
: FRAME_ANCESTORS,
|
||||
});
|
||||
res.end(doc.html);
|
||||
});
|
||||
|
||||
// Same fallback as the sandbox controller: a fixed default port can
|
||||
// collide (a second Inspector), and losing the listener would take
|
||||
// dedicated-origin rendering down. Retry once on an OS-assigned port,
|
||||
// loudly — whoever pinned the port to forward it needs to know.
|
||||
let retriedDynamic = false;
|
||||
server.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "EADDRINUSE") {
|
||||
if (!retriedDynamic && port !== 0) {
|
||||
retriedDynamic = true;
|
||||
console.warn(
|
||||
`App origin: port ${port} in use; falling back to an OS-assigned port. ` +
|
||||
`Apps declaring _meta.ui.domain will still render, but the origin ` +
|
||||
`they are served from is no longer predictable — set ` +
|
||||
`MCP_APP_ORIGIN_PORT to a free one if your app's backend ` +
|
||||
`allowlists it.`,
|
||||
);
|
||||
server!.listen(0, host);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`App origin: port ${port || "dynamic"} in use. Apps declaring _meta.ui.domain will fall back to an opaque origin.`,
|
||||
);
|
||||
} else {
|
||||
console.error("App origin server error:", err);
|
||||
}
|
||||
// Degrade rather than reject: `publish` then returns null and the
|
||||
// renderer keeps working on the default srcdoc path. Clearing
|
||||
// `origin` is what makes that true — `publish` gates on it, not on
|
||||
// `server`, so an error arriving AFTER a successful listen would
|
||||
// otherwise keep minting URLs on a dead listener and the browser
|
||||
// would never take the promised fallback. Dropping the documents
|
||||
// with it releases their memory, since nothing can fetch them now.
|
||||
server = null;
|
||||
origin = null;
|
||||
documents.clear();
|
||||
retainedChars = 0;
|
||||
settle({ port: 0, url: "" });
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
const addr = server!.address();
|
||||
/* v8 ignore next 4 -- unreachable for a TCP listen: inside the
|
||||
`listening` callback `address()` returns an AddressInfo. The guard
|
||||
is a narrowing for the string (pipe/socket) and null members of the
|
||||
return type, not a case this listener can produce. */
|
||||
if (typeof addr !== "object" || addr === null) {
|
||||
settle({ port: 0, url: "" });
|
||||
return;
|
||||
}
|
||||
const actualPort = addr.port;
|
||||
// A wildcard bind isn't reachable as `http://0.0.0.0:PORT`, but it
|
||||
// does serve loopback — advertise `localhost` there, and otherwise
|
||||
// the same canonical host the origin allow-list emits.
|
||||
const canonicalHost = canonicalUrlHost(host);
|
||||
const urlHost = isAllInterfacesHost(canonicalHost)
|
||||
? "localhost"
|
||||
: canonicalHost;
|
||||
origin = `http://${urlHost}:${actualPort}`;
|
||||
settle({ port: actualPort, url: origin });
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async close(): Promise<void> {
|
||||
documents.clear();
|
||||
retainedChars = 0;
|
||||
if (!server) return;
|
||||
return new Promise((resolve) => {
|
||||
server!.close(() => {
|
||||
server = null;
|
||||
origin = null;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
getOrigin(): string | null {
|
||||
return origin;
|
||||
},
|
||||
|
||||
publish(doc: AppDocument): { url: string } | null {
|
||||
if (!origin) return null;
|
||||
const now = Date.now();
|
||||
evictExpired(now);
|
||||
const size = doc.html.length + (doc.csp?.length ?? 0);
|
||||
// Bound the store even if nothing has expired: drop oldest-first until
|
||||
// BOTH the count and the retained-size budget have room. An evicted
|
||||
// document's frame has long since loaded, and the alternative is a store
|
||||
// fed — in count and in bytes — by whatever the servers under test send.
|
||||
while (
|
||||
documents.size >= MAX_DOCUMENTS ||
|
||||
(documents.size > 0 && retainedChars + size > MAX_RETAINED_CHARS)
|
||||
) {
|
||||
const oldest = documents.keys().next();
|
||||
/* v8 ignore next -- both loop conditions require a non-empty map. */
|
||||
if (oldest.done) break;
|
||||
drop(oldest.value);
|
||||
}
|
||||
const id = randomBytes(16).toString("hex");
|
||||
documents.set(id, { ...doc, expiresAt: now + DOCUMENT_TTL_MS, size });
|
||||
retainedChars += size;
|
||||
return { url: `${origin}${DOC_PATH_PREFIX}${id}` };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -70,7 +70,23 @@ const CSP_HOST_SOURCE = /^[a-z][a-z0-9+.-]*:\/\/[^\s;,'"[\]*]+$/i;
|
|||
* header can't be corrupted.
|
||||
*/
|
||||
export function sandboxFrameAncestors(allowedOrigins?: string[]): string {
|
||||
const valid = (allowedOrigins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
|
||||
return frameAncestorsDirective(allowedOrigins);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `frame-ancestors` directive from a caller-supplied origin list,
|
||||
* dropping malformed entries ({@link CSP_HOST_SOURCE}) and falling back to
|
||||
* {@link LOOPBACK_FRAME_ANCESTORS} when nothing valid remains.
|
||||
*
|
||||
* Shared with the app-origin server (#2056), which restricts a published app
|
||||
* document to being framed by the sandbox proxy and the Inspector page above
|
||||
* it. Same grammar, same fallback — an all-invalid list yields the loopback
|
||||
* family rather than an empty source list, since a `frame-ancestors` with no
|
||||
* sources means `'none'` and would block the frame outright — so the two
|
||||
* derive it here rather than each rolling one.
|
||||
*/
|
||||
export function frameAncestorsDirective(origins?: string[]): string {
|
||||
const valid = (origins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
|
||||
const sources = valid.length > 0 ? valid : LOOPBACK_FRAME_ANCESTORS;
|
||||
return `frame-ancestors ${sources.join(" ")}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import type { Context } from "hono";
|
|||
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
||||
import { formatHostForUrl } from "../../../core/node/hostUrl.ts";
|
||||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import {
|
||||
createAppOriginController,
|
||||
appDocumentEmbedders,
|
||||
} from "./app-origin-controller.js";
|
||||
import { injectAuthToken } from "./inject-auth-token.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts";
|
||||
|
|
@ -44,6 +48,18 @@ export async function startHonoServer(
|
|||
allowedOrigins: config.allowedOrigins,
|
||||
});
|
||||
await sandboxController.start();
|
||||
// The dedicated origin apps declaring `_meta.ui.domain` are served from
|
||||
// (#2056). Started after the sandbox because its `frame-ancestors` names the
|
||||
// sandbox proxy's origin — the proxy is what embeds the app frame.
|
||||
const appOriginController = createAppOriginController({
|
||||
port: config.appOriginPort,
|
||||
host: config.sandboxHost,
|
||||
embedderOrigins: appDocumentEmbedders(
|
||||
sandboxController.getUrl(),
|
||||
config.allowedOrigins,
|
||||
),
|
||||
});
|
||||
await appOriginController.start();
|
||||
|
||||
const resolvedAuthToken =
|
||||
config.authToken ||
|
||||
|
|
@ -65,6 +81,7 @@ export async function startHonoServer(
|
|||
initialServers: config.initialServers ?? undefined,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
||||
publishAppDocument: (doc) => appOriginController.publish(doc),
|
||||
logger: config.logger,
|
||||
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
||||
// The startup value above is what the banner printed; the route
|
||||
|
|
@ -159,6 +176,7 @@ export async function startHonoServer(
|
|||
async close(): Promise<void> {
|
||||
await closeApi();
|
||||
await sandboxController.close();
|
||||
await appOriginController.close();
|
||||
if ("closeAllConnections" in httpServer) {
|
||||
httpServer.closeAllConnections();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,10 @@ import type { Plugin } from "vite";
|
|||
// spurious "could not resolve" warnings during build.
|
||||
import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
||||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import {
|
||||
createAppOriginController,
|
||||
appDocumentEmbedders,
|
||||
} from "./app-origin-controller.js";
|
||||
import { injectAuthToken } from "./inject-auth-token.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
import { getSecretStorageInfo } from "../../../core/auth/node/secret-store-selection.ts";
|
||||
|
|
@ -67,6 +71,18 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
allowedOrigins: config.allowedOrigins,
|
||||
});
|
||||
await sandboxController.start();
|
||||
// The dedicated origin apps declaring `_meta.ui.domain` are served from
|
||||
// (#2056). Started after the sandbox because its `frame-ancestors` names
|
||||
// the sandbox proxy's origin — the proxy is what embeds the app frame.
|
||||
const appOriginController = createAppOriginController({
|
||||
port: config.appOriginPort,
|
||||
host: config.sandboxHost,
|
||||
embedderOrigins: appDocumentEmbedders(
|
||||
sandboxController.getUrl(),
|
||||
config.allowedOrigins,
|
||||
),
|
||||
});
|
||||
await appOriginController.start();
|
||||
// Resolved before the API is built so `/api/config` and the banner
|
||||
// report the same store (the selection itself is cached process-wide).
|
||||
const secretStorage = await getSecretStorageInfo();
|
||||
|
|
@ -84,6 +100,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
initialServers: config.initialServers ?? undefined,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
sandboxUrl: sandboxController.getUrl() ?? undefined,
|
||||
publishAppDocument: (doc) => appOriginController.publish(doc),
|
||||
logger: config.logger,
|
||||
initialConfig: webServerConfigToInitialPayload(config, secretStorage),
|
||||
// The startup value above is what the banner printed; the route
|
||||
|
|
@ -103,6 +120,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
server.close = async () => {
|
||||
await closeApi();
|
||||
await sandboxController.close();
|
||||
await appOriginController.close();
|
||||
return originalClose();
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type { SecretStorageInfo } from "../../../core/auth/secret-storage-info.t
|
|||
import { secretStorageSummary } from "../../../core/auth/secret-storage-info.ts";
|
||||
import { readInspectorVersionSafe } from "../../../core/node/version.ts";
|
||||
import { resolveSandboxPort } from "./sandbox-controller.js";
|
||||
import { resolveAppOriginPort } from "./app-origin-controller.js";
|
||||
import { resolveBindHostname } from "./resolve-bind-host.js";
|
||||
import {
|
||||
canonicalUrlHost,
|
||||
|
|
@ -66,6 +67,13 @@ export interface WebServerConfig {
|
|||
/** Sandbox port (0 = dynamic). */
|
||||
sandboxPort: number;
|
||||
sandboxHost: string;
|
||||
/**
|
||||
* Port for the dedicated app origin (#2056) — the listener that serves an
|
||||
* MCP App declaring `_meta.ui.domain` so its requests carry a real `Origin`
|
||||
* instead of `null`. `0` = dynamic. Bound on {@link sandboxHost}, the same
|
||||
* address the sandbox uses.
|
||||
*/
|
||||
appOriginPort: number;
|
||||
logger: Logger | undefined;
|
||||
/** When true, open browser after server starts. */
|
||||
autoOpen: boolean;
|
||||
|
|
@ -369,6 +377,35 @@ export function buildWebServerConfig(
|
|||
"");
|
||||
|
||||
const sandboxPort = resolveSandboxPort();
|
||||
// The app-origin listener starts BEFORE the web server binds (both in prod
|
||||
// `startHonoServer` and in the Vite plugin), so a configured collision is not
|
||||
// a harmless race — the app origin wins the port and the Inspector itself
|
||||
// then dies with EADDRINUSE. `CLIENT_PORT=6278` is now such a config, and it
|
||||
// was perfectly valid before this feature existed.
|
||||
//
|
||||
// Resolved to a dynamic port rather than by refusing to boot: of the three
|
||||
// listeners this is the only one that can move (the sandbox URL and the web
|
||||
// port are both advertised to the browser as fixed), and its own EADDRINUSE
|
||||
// path already degrades this way. Losing a predictable app origin costs an
|
||||
// app declaring `_meta.ui.domain` its forwarded port; refusing to boot costs
|
||||
// the user the whole Inspector over a feature they may not be using.
|
||||
const requestedAppOriginPort = resolveAppOriginPort();
|
||||
const appOriginCollidesWith =
|
||||
requestedAppOriginPort !== 0 &&
|
||||
(requestedAppOriginPort === port
|
||||
? "the web server (CLIENT_PORT)"
|
||||
: requestedAppOriginPort === sandboxPort
|
||||
? "the MCP Apps sandbox (MCP_SANDBOX_PORT)"
|
||||
: null);
|
||||
if (appOriginCollidesWith) {
|
||||
console.warn(
|
||||
`App origin: port ${requestedAppOriginPort} is already ${appOriginCollidesWith}; ` +
|
||||
`using an OS-assigned port instead. Apps declaring _meta.ui.domain will still ` +
|
||||
`render, but the origin they are served from is no longer predictable — set ` +
|
||||
`MCP_APP_ORIGIN_PORT to a free one if your app's backend allowlists it.`,
|
||||
);
|
||||
}
|
||||
const appOriginPort = appOriginCollidesWith ? 0 : requestedAppOriginPort;
|
||||
|
||||
let logger: Logger | undefined;
|
||||
if (process.env.MCP_LOG_FILE) {
|
||||
|
|
@ -439,6 +476,7 @@ export function buildWebServerConfig(
|
|||
: defaultAllowedOrigins(hostname, port),
|
||||
sandboxPort,
|
||||
sandboxHost: hostname,
|
||||
appOriginPort,
|
||||
logger,
|
||||
autoOpen: resolveAutoOpen(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -73,6 +73,16 @@
|
|||
ScrollArea would break flex sizing, e.g. the Resources accordion) blend in. */
|
||||
--inspector-scrollbar-thumb: rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* ── Code editor (Ace) ─────────────────────────────────── */
|
||||
/* Ace's own `github` themes fail WCAG AA on the gutter: `#AAA` on the light
|
||||
gutter's `#e8e8e8` is 1.89:1, and `#7388b5` on the dark gutter's `#24292e`
|
||||
is 4.13:1 — both under 4.5:1 at the 12px the line numbers render. The
|
||||
numbers are `aria-hidden`, which exempts nothing here: contrast is a
|
||||
concern for sighted users, and axe flags them accordingly.
|
||||
`gray-7` is 5.82:1 on the light gutter; the dark block lifts it to
|
||||
`gray-5`, 4.88:1 on the dark one. */
|
||||
--inspector-code-gutter-text: var(--mantine-color-gray-7);
|
||||
|
||||
/* ── Connection status ─────────────────────────────────── */
|
||||
--inspector-status-connected: var(--mantine-color-green-6);
|
||||
--inspector-status-connecting: var(--mantine-color-yellow-6);
|
||||
|
|
@ -161,6 +171,9 @@
|
|||
schemes, which sits too dark on charcoal — lift them one to two steps so
|
||||
they stay legible in dark mode. Light-mode `:root` keeps the `*-6` values. */
|
||||
|
||||
/* Code editor (Ace) — see the note in `:root`. */
|
||||
--inspector-code-gutter-text: var(--mantine-color-gray-5);
|
||||
|
||||
/* Status */
|
||||
--inspector-status-connected: var(--mantine-color-green-4);
|
||||
--inspector-status-connecting: var(--mantine-color-yellow-4);
|
||||
|
|
@ -593,3 +606,12 @@ body.resizing-col {
|
|||
user-select: none;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Ace renders its own DOM, so its gutter can only be reached by selector — it
|
||||
is not a Mantine component with a theme entry. The doubled class raises
|
||||
specificity above Ace's own `.ace-github .ace_gutter`, which is injected into
|
||||
<head> at runtime and therefore wins on source order otherwise. */
|
||||
.ace_editor.ace-github .ace_gutter,
|
||||
.ace_editor.ace-github-dark .ace_gutter {
|
||||
color: var(--inspector-code-gutter-text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -220,16 +220,29 @@ const SERVER_A = {
|
|||
const { updateServerSettingsSpy } = vi.hoisted(() => ({
|
||||
updateServerSettingsSpy: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
// Same idea for the edit-modal rename path (#1914): the test needs to make the
|
||||
// PUT's *reload* fail, which only a spy it controls can do.
|
||||
const { updateServerSpy, addServerSpy } = vi.hoisted(() => ({
|
||||
updateServerSpy: vi.fn(() => Promise.resolve()),
|
||||
addServerSpy: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
// Stable spy for the tools list-changed acknowledgement, so a test can assert
|
||||
// the paginated Refresh clears the indicator (#1721).
|
||||
const { clearToolsListChangedSpy } = vi.hoisted(() => ({
|
||||
clearToolsListChangedSpy: vi.fn(),
|
||||
}));
|
||||
vi.mock("@inspector/core/react/useServers.js", () => ({
|
||||
vi.mock("@inspector/core/react/useServers.js", async (importOriginal) => ({
|
||||
// `ServerListReloadError` is a real class the pagination toggle branches on
|
||||
// with `instanceof`, so it must be the genuine one — a stub would make the
|
||||
// check vacuously false and the #1914 branch untestable (#1914 review r1).
|
||||
...(await importOriginal<
|
||||
typeof import("@inspector/core/react/useServers.js")
|
||||
>()),
|
||||
useServers: vi.fn(() => ({
|
||||
servers: [SERVER_A],
|
||||
addServer: vi.fn(),
|
||||
updateServer: vi.fn(),
|
||||
addServer: addServerSpy,
|
||||
updateServer: updateServerSpy,
|
||||
updateServerSettings: updateServerSettingsSpy,
|
||||
removeServer: vi.fn(),
|
||||
})),
|
||||
|
|
@ -355,6 +368,47 @@ vi.mock("@inspector/core/react/useSettingsDraft.js", () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
// --- App bridge factory spy (#2055) -----------------------------------------
|
||||
// Passes through to the real factory but records the deps App hands it, so a
|
||||
// test can drive `getListedResourceMeta` — the wiring that carries a
|
||||
// `resources/list` entry's `_meta.ui` into the sandbox CSP. Without this the
|
||||
// bridge-factory unit tests would still pass while App stopped supplying it.
|
||||
const appBridgeFactoryDeps: AppBridgeFactoryDeps[] = [];
|
||||
vi.mock(
|
||||
"./components/elements/AppRenderer/createAppBridgeFactory",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("./components/elements/AppRenderer/createAppBridgeFactory")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
createAppBridgeFactory: (deps: AppBridgeFactoryDeps) => {
|
||||
appBridgeFactoryDeps.push(deps);
|
||||
return actual.createAppBridgeFactory(deps);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// --- publishAppDocument spy (#2056) -----------------------------------------
|
||||
// The dedicated-origin publisher App hands both bridge factories. Mocked at the
|
||||
// module so a test can assert the wiring is live — the factory's own tests
|
||||
// inject the dep and cannot see App failing to supply it.
|
||||
const publishAppDocumentMock =
|
||||
vi.fn<
|
||||
(
|
||||
doc: { html: string; csp?: string },
|
||||
opts: { baseUrl: string; authToken?: string },
|
||||
) => Promise<string | null>
|
||||
>();
|
||||
vi.mock("./lib/publishAppDocument", () => ({
|
||||
publishAppDocument: (
|
||||
doc: { html: string; csp?: string },
|
||||
opts: { baseUrl: string; authToken?: string },
|
||||
) => publishAppDocumentMock(doc, opts),
|
||||
}));
|
||||
|
||||
// --- InspectorView double ---------------------------------------------------
|
||||
// Surfaces each piece of session-scoped state under test and exposes buttons
|
||||
// that invoke the App's connect / call-tool / get-prompt / read-resource /
|
||||
|
|
@ -412,6 +466,9 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
onClearCompletedTasks: () => void;
|
||||
onRefreshTasks: () => void;
|
||||
onServerSettings: (id: string) => void;
|
||||
onServerEdit: (id: string) => void;
|
||||
onServerAdd: () => void;
|
||||
highlightedServerIds?: string[];
|
||||
onClearProtocol: () => void;
|
||||
onReplayProtocol: (id: string) => void;
|
||||
onTogglePinProtocol: (id: string) => void;
|
||||
|
|
@ -537,6 +594,14 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
|
|||
</button>
|
||||
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
|
||||
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
|
||||
{/* The real server grid (and its Add / Edit controls) lives inside this
|
||||
mocked view, so the config modal is only reachable through these
|
||||
callbacks — and the highlight batch only observable through this prop. */}
|
||||
<button onClick={() => props.onServerEdit("A")}>edit-server</button>
|
||||
<button onClick={() => props.onServerAdd()}>add-server</button>
|
||||
<span data-testid="highlighted-servers">
|
||||
{(props.highlightedServerIds ?? []).join(",") || "none"}
|
||||
</span>
|
||||
<span data-testid="pinned-history">
|
||||
{Array.from(props.pinnedProtocolIds ?? []).join(",")}
|
||||
</span>
|
||||
|
|
@ -584,12 +649,19 @@ import { usePagedTools } from "@inspector/core/react/usePagedTools.js";
|
|||
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
|
||||
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
|
||||
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
||||
import { useServers } from "@inspector/core/react/useServers.js";
|
||||
import {
|
||||
ServerListReloadError,
|
||||
useServers,
|
||||
} from "@inspector/core/react/useServers.js";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MessageEntry,
|
||||
ServerEntry,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import type { AppBridgeFactoryDeps } from "./components/elements/AppRenderer/createAppBridgeFactory";
|
||||
import { useManagedResources } from "@inspector/core/react/useManagedResources.js";
|
||||
import type { UseManagedResourcesResult } from "@inspector/core/react/useManagedResources.js";
|
||||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
|
||||
// Default useInspectorClient return — capabilities empty (no task tool calls).
|
||||
// Individual tests override via vi.mocked(...).mockReturnValue(...).
|
||||
|
|
@ -1638,7 +1710,7 @@ const settingsWithRoots = (
|
|||
): InspectorServerSettings => ({
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: 60000,
|
||||
|
|
@ -2963,6 +3035,236 @@ describe("App paginated list pagination toggle (#1721)", () => {
|
|||
};
|
||||
expect(lastPush?.paginatedLists).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a modal save whose list reload failed as landed (#1914 + #2089)", async () => {
|
||||
// A `ServerListReloadError` rejects *after* the PUT landed, so the save is
|
||||
// on disk. It has to be recorded as the last landed write and applied like
|
||||
// any other settled one — the failure path would roll the UI, the live
|
||||
// client and the modal's seed back to a value disk no longer holds.
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
const draftOptions = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0];
|
||||
if (!draftOptions) throw new Error("useSettingsDraft was never called");
|
||||
updateServerSettingsSpy.mockRejectedValueOnce(
|
||||
new ServerListReloadError(
|
||||
"The server was saved, but the server list could not be reloaded: disk full",
|
||||
),
|
||||
);
|
||||
const saved: InspectorServerSettings = {
|
||||
...settingsWithRoots([]),
|
||||
paginatedLists: true,
|
||||
};
|
||||
await act(async () => {
|
||||
await draftOptions.onPersist("A", saved).catch(() => undefined);
|
||||
});
|
||||
|
||||
// `servers` is mocked to a fixed entry here — exactly what a failed reload
|
||||
// leaves — so both of these read the record rather than the entry.
|
||||
expect(draftOptions.resolveInitial("A")).toEqual(
|
||||
expect.objectContaining({ paginatedLists: true }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the optimistic toggle when only the list reload failed (#1914)", async () => {
|
||||
// The mirror of the test above. `refreshAfterWrite` rejects with a
|
||||
// `ServerListReloadError` when the PUT landed and only reading the list
|
||||
// back failed — the setting IS on disk, so the #1721 rollback would put
|
||||
// the UI and the live client on the value the user just changed away
|
||||
// from, and contradict what a reload would show.
|
||||
const user = userEvent.setup();
|
||||
updateServerSettingsSpy.mockRejectedValueOnce(
|
||||
new ServerListReloadError(
|
||||
"The server was saved, but the server list could not be reloaded: disk full",
|
||||
),
|
||||
);
|
||||
renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
await user.click(screen.getByText("paginated-on"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true"),
|
||||
);
|
||||
// Give the rejection a turn to land, then confirm nothing reverted it.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(screen.getByTestId("tools-paginated")).toHaveTextContent("true");
|
||||
// Single intersection assertion rather than the `as unknown as` this file
|
||||
// uses elsewhere: the fake client really is an EventTarget, and widening
|
||||
// it keeps that relationship instead of erasing it (#1914 r2).
|
||||
const client = clientInstances[0] as EventTarget & {
|
||||
setServerSettings: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const lastPush = client.setServerSettings.mock.calls.at(-1)?.[0] as {
|
||||
paginatedLists?: boolean;
|
||||
};
|
||||
expect(lastPush?.paginatedLists).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// A post-write reload failure rejects *after* the write landed, so every piece
|
||||
// of state `onConfigSubmit` used to apply on the resolve path has to be applied
|
||||
// on the reject path too — the rename target and the highlight batch both
|
||||
// describe a row that really is on disk (#1914 review r2 / r3).
|
||||
describe("App config submit with a failed list reload (#1914)", () => {
|
||||
// Earlier describes install their own `useServers` return value, which
|
||||
// outlives them — so this block installs its own and puts the previous
|
||||
// implementation back, rather than trusting whatever leaked in.
|
||||
let restoreUseServers: (() => void) | undefined;
|
||||
|
||||
const serversResult = (ids: string[]): ReturnType<typeof useServers> => ({
|
||||
servers: ids.map((id) => ({ ...SERVER_A, id }) as ServerEntry),
|
||||
loading: false,
|
||||
error: undefined,
|
||||
refresh: vi.fn().mockResolvedValue(undefined),
|
||||
addServer: addServerSpy,
|
||||
updateServer: updateServerSpy,
|
||||
updateServerSettings: updateServerSettingsSpy,
|
||||
removeServer: vi.fn().mockResolvedValue(undefined),
|
||||
reorderServers: vi.fn().mockResolvedValue(undefined),
|
||||
importSource: vi.fn().mockResolvedValue({ servers: {} }),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clientInstances.length = 0;
|
||||
const previous = vi.mocked(useServers).getMockImplementation();
|
||||
restoreUseServers = previous
|
||||
? () => vi.mocked(useServers).mockImplementation(previous)
|
||||
: undefined;
|
||||
updateServerSpy.mockClear();
|
||||
addServerSpy.mockClear();
|
||||
updateServerSettingsSpy.mockClear();
|
||||
vi.mocked(useServers).mockReturnValue(serversResult(["A"]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreUseServers?.();
|
||||
});
|
||||
|
||||
it("follows the rename so the active id isn't orphaned (#1914)", async () => {
|
||||
// `activeServerId` isn't rendered anywhere, so it's read back through the
|
||||
// one handler that passes it straight to a spy: the pagination toggle
|
||||
// calls `updateServerSettings(activeServerId, …)`.
|
||||
const user = userEvent.setup();
|
||||
updateServerSpy.mockRejectedValueOnce(
|
||||
new ServerListReloadError(
|
||||
"The server was saved, but the server list could not be reloaded: disk full",
|
||||
),
|
||||
);
|
||||
const { rerender } = renderWithMantine(<App />);
|
||||
await user.click(screen.getByText("connect"));
|
||||
await waitFor(() => expect(clientInstances).toHaveLength(1));
|
||||
|
||||
await user.click(screen.getByText("edit-server"));
|
||||
const idField = await screen.findByLabelText(/Server ID/);
|
||||
await user.clear(idField);
|
||||
await user.type(idField, "A-renamed");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateServerSpy).toHaveBeenCalledWith(
|
||||
"A",
|
||||
"A-renamed",
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
// The rejection still reaches the modal, which stays open showing it.
|
||||
expect(
|
||||
await screen.findByText(/could not be reloaded: disk full/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Now let the row land, as the SSE refresh the write itself triggers
|
||||
// would. The modal's target ("A") is gone, so it closes rather than
|
||||
// blanking its own form — `initialId`/`initialConfig` would go undefined
|
||||
// and ServerConfigModal's reset would wipe the open form and the error
|
||||
// it is showing (#1914 r3).
|
||||
vi.mocked(useServers).mockReturnValue(serversResult(["A-renamed"]));
|
||||
rerender(<App />);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText(/Server ID/)).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
// With the modal gone, read the active id back off the toggle.
|
||||
await user.click(screen.getByText("paginated-on"));
|
||||
await waitFor(() =>
|
||||
expect(updateServerSettingsSpy).toHaveBeenCalledWith(
|
||||
"A-renamed",
|
||||
expect.objectContaining({ paginatedLists: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("labels a settings save whose reload failed as saved, not failed (#1914)", () => {
|
||||
// The settings draft debounces and flushes on close, so this toast is the
|
||||
// user's only signal — a flush that rejects usually does so after the
|
||||
// modal is gone. `useSettingsDraft` is mocked here, so the App's `onError`
|
||||
// is invoked directly with the options it was handed.
|
||||
renderWithMantine(<App />);
|
||||
const onError = vi.mocked(useSettingsDraft).mock.calls.at(-1)?.[0].onError;
|
||||
expect(onError).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
onError!(
|
||||
"A",
|
||||
new ServerListReloadError(
|
||||
"The server was saved, but the server list could not be reloaded: disk full",
|
||||
),
|
||||
);
|
||||
});
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'Saved settings for "A", but the server list did not reload',
|
||||
message: expect.stringContaining("disk full"),
|
||||
}),
|
||||
);
|
||||
|
||||
// A genuinely failed write still reads as a failure.
|
||||
act(() => {
|
||||
onError!("A", new Error("disk full"));
|
||||
});
|
||||
expect(notificationsMock.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Failed to save settings for "A"' }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a successful add in the highlight batch when the reload failed (#1914)", async () => {
|
||||
// `addServerHighlighted` marked the new id only after `addServer`
|
||||
// resolved. The row is on disk either way, so on a reload failure it has
|
||||
// to be marked from the catch — the next successful refresh is what
|
||||
// renders it, and it would otherwise arrive unhighlighted.
|
||||
const user = userEvent.setup();
|
||||
addServerSpy.mockRejectedValueOnce(
|
||||
new ServerListReloadError(
|
||||
"The server was added, but the server list could not be reloaded: disk full",
|
||||
),
|
||||
);
|
||||
renderWithMantine(<App />);
|
||||
expect(screen.getByTestId("highlighted-servers")).toHaveTextContent("none");
|
||||
|
||||
await user.click(screen.getByText("add-server"));
|
||||
await user.type(await screen.findByLabelText(/Server ID/), "brand-new");
|
||||
await user.type(screen.getByLabelText(/^Command/), "node");
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(addServerSpy).toHaveBeenCalledWith("brand-new", expect.anything()),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/could not be reloaded: disk full/),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("highlighted-servers")).toHaveTextContent(
|
||||
"brand-new",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A background command runs through `runCommandInBackground`, which owns the
|
||||
|
|
@ -3117,3 +3419,102 @@ describe("App background command rejections (#2049)", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** A complete `useManagedResources` return, so the mock stays type-checked against the hook's contract. */
|
||||
function managedResourcesResult(
|
||||
resources: Resource[],
|
||||
): UseManagedResourcesResult {
|
||||
return {
|
||||
error: null,
|
||||
resources,
|
||||
listChanged: false,
|
||||
refresh: vi.fn().mockResolvedValue(resources),
|
||||
clearListChanged: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("App MCP App listed-resource metadata wiring (#2055)", () => {
|
||||
beforeEach(() => {
|
||||
appBridgeFactoryDeps.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// The hook mock is module-level and shared, so put the empty-list default
|
||||
// back rather than leaving a populated list for whatever runs next.
|
||||
vi.mocked(useManagedResources).mockReturnValue(managedResourcesResult([]));
|
||||
});
|
||||
|
||||
it("hands the bridge factory a getListedResourceMeta reading the resources/list entries", async () => {
|
||||
// ext-apps treats a listing entry's `_meta.ui` as the static default for a
|
||||
// UI resource, so the App has to surface the listing to the bridge — the
|
||||
// factory's own tests inject the dep and cannot see this wiring break.
|
||||
const listedMeta = {
|
||||
ui: { csp: { connectDomains: ["https://api.example.com"] } },
|
||||
};
|
||||
vi.mocked(useManagedResources).mockReturnValue(
|
||||
managedResourcesResult([
|
||||
{ uri: "ui://weather/app.html", name: "app", _meta: listedMeta },
|
||||
]),
|
||||
);
|
||||
|
||||
renderWithMantine(<App />);
|
||||
|
||||
// App builds two factories, differing only in `advertiseElicitation`. Wait
|
||||
// for BOTH by that flag rather than for a count: waiting on "at least one"
|
||||
// would still pass with the wiring stripped from either call site, since
|
||||
// the other's deps sit in the same array.
|
||||
const appsFactory = () =>
|
||||
appBridgeFactoryDeps.find((d) => !d.advertiseElicitation);
|
||||
const elicitationFactory = () =>
|
||||
appBridgeFactoryDeps.find((d) => d.advertiseElicitation === true);
|
||||
await waitFor(() => {
|
||||
expect(appsFactory()).toBeDefined();
|
||||
expect(elicitationFactory()).toBeDefined();
|
||||
});
|
||||
|
||||
for (const deps of [appsFactory(), elicitationFactory()]) {
|
||||
expect(deps?.getListedResourceMeta?.("ui://weather/app.html")).toEqual(
|
||||
listedMeta,
|
||||
);
|
||||
expect(
|
||||
deps?.getListedResourceMeta?.("ui://other/app.html"),
|
||||
).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("hands BOTH bridge factories a working publishAppDocument (#2056)", async () => {
|
||||
// Both call sites pass the same `publishDocument`, and the end-to-end smoke
|
||||
// only covers the Apps-screen one — so without this the elicitation
|
||||
// factory's `publishAppDocument` could be dropped and every test stays
|
||||
// green while a domain-declaring elicitation app silently loses its real
|
||||
// origin. Assert it is not merely present but actually reaches the lib.
|
||||
publishAppDocumentMock.mockResolvedValue(
|
||||
"http://127.0.0.1:6278/app-document/deadbeef",
|
||||
);
|
||||
|
||||
renderWithMantine(<App />);
|
||||
|
||||
const appsFactory = () =>
|
||||
appBridgeFactoryDeps.find((d) => !d.advertiseElicitation);
|
||||
const elicitationFactory = () =>
|
||||
appBridgeFactoryDeps.find((d) => d.advertiseElicitation === true);
|
||||
await waitFor(() => {
|
||||
expect(appsFactory()).toBeDefined();
|
||||
expect(elicitationFactory()).toBeDefined();
|
||||
});
|
||||
|
||||
for (const deps of [appsFactory(), elicitationFactory()]) {
|
||||
publishAppDocumentMock.mockClear();
|
||||
await expect(
|
||||
deps?.publishAppDocument?.({
|
||||
html: "<p>app</p>",
|
||||
csp: "default-src 'none'",
|
||||
}),
|
||||
).resolves.toBe("http://127.0.0.1:6278/app-document/deadbeef");
|
||||
expect(publishAppDocumentMock).toHaveBeenCalledWith(
|
||||
{ html: "<p>app</p>", csp: "default-src 'none'" },
|
||||
expect.objectContaining({ baseUrl: expect.any(String) }),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type {
|
|||
LoggingMessageNotification,
|
||||
Progress,
|
||||
ProgressToken,
|
||||
Resource,
|
||||
Task,
|
||||
Tool,
|
||||
} from "@modelcontextprotocol/client";
|
||||
|
|
@ -98,7 +99,10 @@ import {
|
|||
} from "@inspector/core/auth/index.js";
|
||||
import { RemoteInspectorClientStorage } from "@inspector/core/mcp/remote/index.js";
|
||||
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
|
||||
import { useServers } from "@inspector/core/react/useServers.js";
|
||||
import {
|
||||
ServerListReloadError,
|
||||
useServers,
|
||||
} from "@inspector/core/react/useServers.js";
|
||||
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
|
||||
import { useClientSettingsDraft } from "@inspector/core/react/useClientSettingsDraft.js";
|
||||
import { useEmaIdpLoginState } from "@inspector/core/react/useEmaIdpLoginState.js";
|
||||
|
|
@ -144,6 +148,7 @@ import {
|
|||
import { clearScrollMemory } from "./hooks/useScrollMemory";
|
||||
import type { AppRendererHandle } from "./components/elements/AppRenderer/AppRenderer";
|
||||
import { createAppBridgeFactory } from "./components/elements/AppRenderer/createAppBridgeFactory";
|
||||
import { publishAppDocument } from "./lib/publishAppDocument";
|
||||
import { AppElicitationHost } from "./components/elements/AppElicitation/AppElicitationHost";
|
||||
import {
|
||||
AppElicitationController,
|
||||
|
|
@ -386,7 +391,7 @@ async function replayProtocolRequest(
|
|||
const EMPTY_SETTINGS: InspectorServerSettings = {
|
||||
headers: [],
|
||||
env: [],
|
||||
metadata: [],
|
||||
metadata: {},
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
|
|
@ -784,10 +789,45 @@ function App() {
|
|||
});
|
||||
}, [configBaseUrl]);
|
||||
|
||||
// The `resources/list` entries, read lazily by the App bridge factories below.
|
||||
// ext-apps treats a listing entry's `_meta.ui` as the static default for its
|
||||
// UI resource (a read content item's own `_meta.ui` wins), so the sandbox CSP
|
||||
// has to be able to see it. A ref rather than a dependency: `resources` is
|
||||
// derived further down this component, and the factories only read it inside
|
||||
// an async sandboxready handler, long after any render that produced it.
|
||||
const listedResourcesRef = useRef<Resource[]>([]);
|
||||
// Best-effort by construction: this reads the list as it stands when the app
|
||||
// opens, and does not distinguish "no entry for this URI" from "the list
|
||||
// hasn't arrived yet". Both yield no hints, which is the same outcome as
|
||||
// having no listing carrier at all. Blocking the render on list readiness
|
||||
// instead would mean waiting on a request that, for a server advertising no
|
||||
// `resources` capability or whose list errored, never resolves — trading a
|
||||
// missing default for an app that never renders.
|
||||
const getListedResourceMeta = useCallback(
|
||||
(uri: string) =>
|
||||
listedResourcesRef.current.find((r) => r.uri === uri)?._meta,
|
||||
[],
|
||||
);
|
||||
|
||||
// `_meta.ui.domain` support (#2056): hand a wrapped app document to the
|
||||
// backend so it can serve it from a dedicated origin, giving the app's
|
||||
// requests a real `Origin`. Resolves `null` on any backend that can't, and
|
||||
// the factory then renders the app the default (opaque-origin) way.
|
||||
const publishDocument = useCallback(
|
||||
(doc: { html: string; csp?: string }) =>
|
||||
publishAppDocument(doc, {
|
||||
baseUrl: configBaseUrl,
|
||||
authToken: getAuthToken(),
|
||||
}),
|
||||
[configBaseUrl],
|
||||
);
|
||||
|
||||
const sandboxBridgeFactory = useMemo(
|
||||
() =>
|
||||
createAppBridgeFactory({
|
||||
publishAppDocument: publishDocument,
|
||||
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
||||
getListedResourceMeta,
|
||||
readResource: async (uri) => {
|
||||
if (!inspectorClient) throw new Error("No MCP client connected.");
|
||||
const invocation = await inspectorClient.readResource(uri);
|
||||
|
|
@ -807,7 +847,7 @@ function App() {
|
|||
});
|
||||
},
|
||||
}),
|
||||
[inspectorClient],
|
||||
[inspectorClient, getListedResourceMeta, publishDocument],
|
||||
);
|
||||
|
||||
// App-rendered form elicitations (#1854). The controller is created once and
|
||||
|
|
@ -860,7 +900,9 @@ function App() {
|
|||
() =>
|
||||
createAppBridgeFactory({
|
||||
advertiseElicitation: true,
|
||||
publishAppDocument: publishDocument,
|
||||
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
||||
getListedResourceMeta,
|
||||
readResource: async (uri) => {
|
||||
if (!inspectorClient) throw new Error("No MCP client connected.");
|
||||
const invocation = await inspectorClient.readResource(uri);
|
||||
|
|
@ -877,7 +919,7 @@ function App() {
|
|||
});
|
||||
},
|
||||
}),
|
||||
[inspectorClient],
|
||||
[inspectorClient, getListedResourceMeta, publishDocument],
|
||||
);
|
||||
/**
|
||||
* Close the previous client's session and open one for the client being
|
||||
|
|
@ -1238,6 +1280,14 @@ function App() {
|
|||
const tools = toolsPagination.items;
|
||||
const prompts = promptsPagination.items;
|
||||
const resources = resourcesPagination.items;
|
||||
// Whatever the resource list currently holds — every page in the default
|
||||
// aggregate mode, only the pages fetched so far under `paginatedLists`. The
|
||||
// listing is a documented *default* that a read content item overrides, and
|
||||
// an app whose entry sits on an unfetched page simply falls back to no hints
|
||||
// (`connect-src 'none'`), exactly as before this wiring existed. Walking the
|
||||
// whole list to close that would issue the very requests the user opted out
|
||||
// of by turning pagination on, so the setting wins.
|
||||
listedResourcesRef.current = resources;
|
||||
const {
|
||||
tasks,
|
||||
refresh: refreshTasks,
|
||||
|
|
@ -2536,13 +2586,9 @@ function App() {
|
|||
const activeCimdUrl = getActiveCimdClientMetadataUrl(clientConfig);
|
||||
// Flatten the persisted settings into the InspectorClient options shape.
|
||||
// Empty / zero values stay unset so the SDK defaults apply.
|
||||
const defaultMetadata = savedSettings?.metadata
|
||||
? Object.fromEntries(
|
||||
savedSettings.metadata
|
||||
.filter((m) => m.key.trim() !== "")
|
||||
.map((m) => [m.key, m.value]),
|
||||
)
|
||||
: undefined;
|
||||
// Per-server default `_meta` is already a JSON object (#1910) — no
|
||||
// pair-array flattening left to do; `{}` means "no defaults".
|
||||
const defaultMetadata = savedSettings?.metadata;
|
||||
const serverAuthorizationParams = savedSettings
|
||||
? oauthAuthorizationParamsFromSettings(savedSettings)
|
||||
: undefined;
|
||||
|
|
@ -3835,30 +3881,46 @@ function App() {
|
|||
// are ordered by when they were issued rather than by which one's list
|
||||
// reload finished first (#2089).
|
||||
const write = lastPersistedSettings.begin(activeServerId);
|
||||
// This value is on disk now. Remember it as the rollback baseline for
|
||||
// whatever is written next, since the `servers` entry it was derived
|
||||
// from will keep describing the old value if the reload behind this
|
||||
// write — or any later one — fails (#2089).
|
||||
//
|
||||
// Re-apply it when this write is the settled one: an overlapping toggle
|
||||
// that failed *first* rolled the UI and the live client back to a
|
||||
// baseline this write has since replaced, and if the list read behind
|
||||
// this write failed too, nothing else would ever correct them. Through
|
||||
// the *current* client, not this continuation's closure: a reconnect to
|
||||
// the same server passes the id check while the captured instance is
|
||||
// already destroyed.
|
||||
const settlePaginationWrite = () => {
|
||||
const settled = write.landed(next);
|
||||
if (settled && activeServerIdRef.current === activeServerId) {
|
||||
setPaginatedListsOverride(next.paginatedLists ?? false);
|
||||
applyLiveServerSettings(next);
|
||||
}
|
||||
};
|
||||
void refreshingPersist(updateServerSettings, refreshInitialConfig)(
|
||||
activeServerId,
|
||||
next,
|
||||
)
|
||||
.then(() => {
|
||||
// This value is on disk now. Remember it as the rollback baseline for
|
||||
// whatever is written next, since the `servers` entry it was derived
|
||||
// from will keep describing the old value if the reload behind this
|
||||
// write — or any later one — fails (#2089).
|
||||
//
|
||||
// Re-apply it when this write is the settled one: an overlapping
|
||||
// toggle that failed *first* rolled the UI and the live client back
|
||||
// to a baseline this write has since replaced, and if the list read
|
||||
// behind this write failed too, nothing else would ever correct them.
|
||||
// Through the *current* client, not this continuation's closure: a
|
||||
// reconnect to the same server passes the id check while the captured
|
||||
// instance is already destroyed.
|
||||
const settled = write.landed(next);
|
||||
if (settled && activeServerIdRef.current === activeServerId) {
|
||||
setPaginatedListsOverride(next.paginatedLists ?? false);
|
||||
applyLiveServerSettings(next);
|
||||
}
|
||||
})
|
||||
.then(settlePaginationWrite)
|
||||
.catch((err: unknown) => {
|
||||
// A `ServerListReloadError` means the PUT landed and only reading the
|
||||
// list back failed, so the new setting IS on disk (#1914). That is a
|
||||
// landed write, not a failed one: rolling back would put the UI and
|
||||
// the live client on the *old* value and contradict disk. Settle it
|
||||
// exactly as the success path does and report only the failed reload.
|
||||
if (err instanceof ServerListReloadError) {
|
||||
settlePaginationWrite();
|
||||
notifications.show({
|
||||
title:
|
||||
"Pagination setting saved, but the server list did not reload",
|
||||
message: err.message,
|
||||
color: "red",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// This write is over and never reached disk, so it stops counting as
|
||||
// in flight: an earlier write still running is the settled state once
|
||||
// it lands, and is what re-applies the UI this rollback is about to
|
||||
|
|
@ -4186,8 +4248,20 @@ function App() {
|
|||
// opens (see the menu handlers).
|
||||
const addServerHighlighted = useCallback(
|
||||
async (id: string, config: MCPServerConfig) => {
|
||||
await addServer(id, config);
|
||||
setHighlightedServerIds((ids) => (ids.includes(id) ? ids : [...ids, id]));
|
||||
const markAdded = () =>
|
||||
setHighlightedServerIds((ids) =>
|
||||
ids.includes(id) ? ids : [...ids, id],
|
||||
);
|
||||
try {
|
||||
await addServer(id, config);
|
||||
} catch (err) {
|
||||
// A failed list reload still means the row is on disk, so it belongs
|
||||
// in the highlight batch — the next successful refresh is what
|
||||
// renders it, and it would otherwise arrive unmarked (#1914 r2).
|
||||
if (err instanceof ServerListReloadError) markAdded();
|
||||
throw err;
|
||||
}
|
||||
markAdded();
|
||||
},
|
||||
[addServer],
|
||||
);
|
||||
|
|
@ -4197,10 +4271,24 @@ function App() {
|
|||
async (id: string, config: MCPServerConfig) => {
|
||||
if (configModal?.mode === "edit" && configModal.targetId) {
|
||||
const originalId = configModal.targetId;
|
||||
await updateServer(originalId, id, config);
|
||||
if (originalId === activeServerId && id !== originalId) {
|
||||
setActiveServerId(id);
|
||||
const followRename = () => {
|
||||
if (originalId === activeServerId && id !== originalId) {
|
||||
setActiveServerId(id);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await updateServer(originalId, id, config);
|
||||
} catch (err) {
|
||||
// A `ServerListReloadError` means the PUT landed and only reading
|
||||
// the list back failed — the rename IS on disk. The active
|
||||
// selection has to follow it anyway, or the next successful
|
||||
// refresh leaves `activeServerId` pointing at an id that no longer
|
||||
// exists (#1914 r2). Still rethrow, so the modal shows the reload
|
||||
// error rather than closing as if nothing happened.
|
||||
if (err instanceof ServerListReloadError) followRename();
|
||||
throw err;
|
||||
}
|
||||
followRename();
|
||||
return;
|
||||
}
|
||||
// add or clone
|
||||
|
|
@ -4225,6 +4313,30 @@ function App() {
|
|||
return servers.find((s) => s.id === configModal.targetId);
|
||||
}, [configModal, servers]);
|
||||
|
||||
// An edit/clone modal whose target has left the list can no longer render a
|
||||
// coherent form: `initialId`/`initialConfig` go undefined, and
|
||||
// ServerConfigModal's own `useValueChange` reset then blanks the open form
|
||||
// *and* clears the error it is showing.
|
||||
//
|
||||
// Only reachable since #1914 made the modal stay open on a failed post-write
|
||||
// reload: rename A → B, the PUT lands, the reload fails, and the modal sits
|
||||
// there targeting A. The write itself is what triggers the backend's change
|
||||
// broadcast, so the SSE-driven background refresh that lands B (and drops A)
|
||||
// is the *likely* next event, not a remote one. Closing is right — the edit
|
||||
// is on disk and the list now agrees — and it covers the ordinary case too,
|
||||
// where an external mcp.json edit removes the server being edited.
|
||||
//
|
||||
// Adjusted during render rather than in an effect (see `useValueChange`), so
|
||||
// no frame ever paints the blanked form.
|
||||
useValueChange(
|
||||
configModal?.mode === "add" || configModal?.targetId === undefined
|
||||
? true
|
||||
: servers.some((s) => s.id === configModal.targetId),
|
||||
(targetPresent) => {
|
||||
if (!targetPresent) setConfigModal(null);
|
||||
},
|
||||
);
|
||||
|
||||
const settingsModalTarget = useMemo(() => {
|
||||
if (!settingsModalTargetId) return undefined;
|
||||
return servers.find((s) => s.id === settingsModalTargetId);
|
||||
|
|
@ -4275,12 +4387,27 @@ function App() {
|
|||
// flush can put two saves for one server in flight, and the later-issued
|
||||
// one describes disk however the two happen to finish (#2089).
|
||||
const write = lastPersistedSettings.begin(id);
|
||||
const settleSettingsWrite = () => {
|
||||
const settled = write.landed(value);
|
||||
if (settled && activeServerIdRef.current === id) {
|
||||
setPaginatedListsOverride(value.paginatedLists ?? false);
|
||||
applyLiveServerSettings(value);
|
||||
}
|
||||
};
|
||||
try {
|
||||
await refreshingPersist(updateServerSettings, refreshInitialConfig)(
|
||||
id,
|
||||
value,
|
||||
);
|
||||
} catch (err) {
|
||||
// A `ServerListReloadError` means the PUT landed and only reading the
|
||||
// list back failed, so this write did reach disk (#1914): record it
|
||||
// like the success path below instead of rolling anything back, and
|
||||
// rethrow so `onError` can say the reload — not the save — failed.
|
||||
if (err instanceof ServerListReloadError) {
|
||||
settleSettingsWrite();
|
||||
throw err;
|
||||
}
|
||||
// Stop counting as in flight before the rejection goes on to
|
||||
// `onError` — an earlier write still running settles the state once it
|
||||
// lands, and cannot know that while this one looks pending (#2089).
|
||||
|
|
@ -4307,11 +4434,7 @@ function App() {
|
|||
// toggle failing while this save is in flight rolls the UI and the live
|
||||
// client back to a baseline this save then replaces on disk, so a settled
|
||||
// save has to re-apply itself exactly as a settled toggle does (#2089).
|
||||
const settled = write.landed(value);
|
||||
if (settled && activeServerIdRef.current === id) {
|
||||
setPaginatedListsOverride(value.paginatedLists ?? false);
|
||||
applyLiveServerSettings(value);
|
||||
}
|
||||
settleSettingsWrite();
|
||||
},
|
||||
// Surface failures via toast — the modal usually closes
|
||||
// immediately on user dismiss, so a silent fail-on-flush would
|
||||
|
|
@ -4319,7 +4442,14 @@ function App() {
|
|||
// didn't (especially painful for the OAuth client secret).
|
||||
onError: (id, err) => {
|
||||
notifications.show({
|
||||
title: `Failed to save settings for "${id}"`,
|
||||
// A `ServerListReloadError` means the PUT landed and only reading the
|
||||
// list back failed, so "Failed to save" would be false — and this
|
||||
// toast is the user's only signal here, since the modal has usually
|
||||
// closed by the time a flush rejects (#1914 r3).
|
||||
title:
|
||||
err instanceof ServerListReloadError
|
||||
? `Saved settings for "${id}", but the server list did not reload`
|
||||
: `Failed to save settings for "${id}"`,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
color: "red",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createRef, StrictMode } from "react";
|
||||
import { act } from "@testing-library/react";
|
||||
import { act, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { CallToolResult, Tool } from "@modelcontextprotocol/client";
|
||||
|
|
@ -546,15 +546,30 @@ describe("AppRenderer", () => {
|
|||
// MutationObserver callbacks are delivered on a microtask.
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
theme: "dark",
|
||||
styles: expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
"--color-background-primary": "#101113",
|
||||
// `waitFor`, not a bare assertion after the single microtask above: how
|
||||
// many ticks happy-dom takes to deliver the MutationObserver callback is
|
||||
// load-dependent, so under a full parallel run the observer can still be
|
||||
// pending here. Observed failing exactly once that way, with 0 calls.
|
||||
//
|
||||
// The default 1000ms then turned out not to be enough either — it failed
|
||||
// twice in a row in `npm run ci` (at ~1021ms, still 0 calls) while
|
||||
// passing every time this file runs alone, which is the signature of a
|
||||
// budget that is too tight rather than of a broken observer. Raised
|
||||
// rather than retried: a `waitFor` that is generous costs nothing on the
|
||||
// passing path, since it returns as soon as the assertion holds.
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
theme: "dark",
|
||||
styles: expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
"--color-background-primary": "#101113",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
} finally {
|
||||
getComputedStyleSpy.mockRestore();
|
||||
|
|
|
|||
|
|
@ -83,21 +83,37 @@ function makeIframe(hasWindow = true): HTMLIFrameElement {
|
|||
|
||||
const fakeClient = { name: "sdk-client" } as unknown as Client;
|
||||
|
||||
/**
|
||||
* A UI resource read result. `meta` is the MCP Apps sandbox metadata, which the
|
||||
* spec nests under the `_meta` bag's `ui` key — the helper wraps it so every
|
||||
* test drives the real wire shape. `opts.at` places the bag on the result
|
||||
* envelope (`McpUiReadResourceResult`) instead of the content item, and
|
||||
* `opts.flat` writes it unnested (the non-conforming shape the host ignores).
|
||||
*/
|
||||
function uiResource(
|
||||
text: string | undefined,
|
||||
meta?: Record<string, unknown>,
|
||||
opts: { at?: "content" | "result"; flat?: boolean } = {},
|
||||
): ReadResourceResult {
|
||||
const bag = meta ? { _meta: opts.flat ? meta : { ui: meta } } : {};
|
||||
const onResult = opts.at === "result";
|
||||
return {
|
||||
...(onResult ? bag : {}),
|
||||
contents: [
|
||||
{
|
||||
uri: "ui://weather/app.html",
|
||||
...(text === undefined ? {} : { text }),
|
||||
...(meta ? { _meta: meta } : {}),
|
||||
...(onResult ? {} : bag),
|
||||
},
|
||||
],
|
||||
} as ReadResourceResult;
|
||||
}
|
||||
|
||||
/** The `_meta` of a `resources/list` entry, as `getListedResourceMeta` returns it. */
|
||||
function listedMeta(meta: Record<string, unknown>): { ui: unknown } {
|
||||
return { ui: meta };
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
|
@ -230,6 +246,378 @@ describe("createAppBridgeFactory", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("falls back to the read result's _meta.ui when the content item carries none", async () => {
|
||||
// `McpUiReadResourceResult` — what a registerAppResource callback returns —
|
||||
// types `_meta.ui` at the envelope level, so a server stamping it there
|
||||
// must have it honored.
|
||||
const readResource = vi.fn().mockResolvedValue(
|
||||
uiResource(
|
||||
"<h1>x</h1>",
|
||||
{
|
||||
permissions: { microphone: {} },
|
||||
csp: { connectDomains: ["https://envelope.example.com"] },
|
||||
},
|
||||
{ at: "result" },
|
||||
),
|
||||
);
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
|
||||
html: string;
|
||||
permissions: unknown;
|
||||
};
|
||||
expect(call.permissions).toEqual({ microphone: {} });
|
||||
expect(call.html).toContain("connect-src https://envelope.example.com");
|
||||
});
|
||||
|
||||
it("prefers the read result's _meta.ui over the listing entry's", async () => {
|
||||
// Precedence runs most-specific first: content item, then the result
|
||||
// envelope, then the listing default.
|
||||
const readResource = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
uiResource(
|
||||
"<h1>x</h1>",
|
||||
{ csp: { connectDomains: ["https://envelope.example.com"] } },
|
||||
{ at: "result" },
|
||||
),
|
||||
);
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
getListedResourceMeta: () =>
|
||||
listedMeta({ csp: { connectDomains: ["https://listed.example.com"] } }),
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
|
||||
html: string;
|
||||
};
|
||||
expect(call.html).toContain("connect-src https://envelope.example.com");
|
||||
expect(call.html).not.toContain("listed.example.com");
|
||||
});
|
||||
|
||||
it("falls back to the resources/list entry's _meta.ui when the content block carries none", async () => {
|
||||
// ext-apps documents the listing-level `_meta.ui` as the static default for
|
||||
// a UI resource, so an app declaring its CSP only there must still have it
|
||||
// honored.
|
||||
const readResource = vi.fn().mockResolvedValue(uiResource("<h1>x</h1>"));
|
||||
const getListedResourceMeta = vi.fn().mockReturnValue(
|
||||
listedMeta({
|
||||
permissions: { camera: {} },
|
||||
csp: { connectDomains: ["https://api.example.com"] },
|
||||
}),
|
||||
);
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
getListedResourceMeta,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
expect(getListedResourceMeta).toHaveBeenCalledWith("ui://weather/app.html");
|
||||
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
|
||||
html: string;
|
||||
permissions: unknown;
|
||||
};
|
||||
expect(call.permissions).toEqual({ camera: {} });
|
||||
expect(call.html).toContain("connect-src https://api.example.com");
|
||||
});
|
||||
|
||||
it("prefers the read content item's _meta.ui over the listing entry's", async () => {
|
||||
// The listing value is only a default: a content item that carries its own
|
||||
// `_meta.ui` wins outright, rather than being merged with it.
|
||||
const readResource = vi.fn().mockResolvedValue(
|
||||
uiResource("<h1>x</h1>", {
|
||||
csp: { connectDomains: ["https://read.example.com"] },
|
||||
}),
|
||||
);
|
||||
const getListedResourceMeta = vi.fn().mockReturnValue(
|
||||
listedMeta({
|
||||
permissions: { camera: {} },
|
||||
csp: { connectDomains: ["https://listed.example.com"] },
|
||||
}),
|
||||
);
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
getListedResourceMeta,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
|
||||
html: string;
|
||||
permissions: unknown;
|
||||
};
|
||||
expect(call.html).toContain("connect-src https://read.example.com");
|
||||
expect(call.html).not.toContain("listed.example.com");
|
||||
expect(call.permissions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders with no sandbox hints when neither the content item nor the listing has _meta.ui", async () => {
|
||||
// A host with no resource listing (or one not covering this URI) is
|
||||
// unaffected: the optional dep is simply absent.
|
||||
const readResource = vi.fn().mockResolvedValue(uiResource("<h1>x</h1>"));
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
getListedResourceMeta: () => undefined,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
|
||||
html: string;
|
||||
permissions: unknown;
|
||||
};
|
||||
expect(call.permissions).toBeUndefined();
|
||||
expect(call.html).toContain("connect-src 'none'");
|
||||
});
|
||||
|
||||
it("prefers the content item's _meta.ui over both broader carriers", async () => {
|
||||
// Pins the full three-way order — content item > result envelope > listing
|
||||
// — which no single-fallback case can: swapping the first two operands
|
||||
// would still satisfy them.
|
||||
const result = uiResource("<h1>x</h1>", {
|
||||
csp: { connectDomains: ["https://content.example.com"] },
|
||||
});
|
||||
(result as { _meta?: unknown })._meta = {
|
||||
ui: { csp: { connectDomains: ["https://envelope.example.com"] } },
|
||||
};
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi.fn().mockResolvedValue(result),
|
||||
getListedResourceMeta: () =>
|
||||
listedMeta({ csp: { connectDomains: ["https://listed.example.com"] } }),
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
|
||||
html: string;
|
||||
};
|
||||
expect(call.html).toContain("connect-src https://content.example.com");
|
||||
expect(call.html).not.toContain("envelope.example.com");
|
||||
expect(call.html).not.toContain("listed.example.com");
|
||||
});
|
||||
|
||||
it("stops at a carrier whose _meta.ui is malformed rather than falling through", async () => {
|
||||
// A declared-but-unusable `ui` is that carrier's answer. Falling through
|
||||
// would grant whatever a BROADER carrier asked for — wrong precedence, and
|
||||
// the wrong direction to fail in.
|
||||
const readResource = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
uiResource("<h1>x</h1>", { ui: null }, { flat: true }),
|
||||
);
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
getListedResourceMeta: () =>
|
||||
listedMeta({
|
||||
permissions: { camera: {} },
|
||||
csp: { connectDomains: ["https://listed.example.com"] },
|
||||
}),
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
|
||||
html: string;
|
||||
permissions: unknown;
|
||||
};
|
||||
expect(call.permissions).toBeUndefined();
|
||||
expect(call.html).not.toContain("listed.example.com");
|
||||
expect(call.html).toContain("connect-src 'none'");
|
||||
});
|
||||
|
||||
it("ignores sandbox metadata written unnested on _meta", async () => {
|
||||
// `McpUiResourceMeta` describes the value of `_meta.ui`, not `_meta`. A bag
|
||||
// whose keys sit at the top level is not the spec shape, so it must not be
|
||||
// read as one — reading it there is what produced #2055 in reverse.
|
||||
const readResource = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
uiResource(
|
||||
"<h1>x</h1>",
|
||||
{ csp: { connectDomains: ["https://api.example.com"] } },
|
||||
{ flat: true },
|
||||
),
|
||||
);
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
const bridge = bridgeInstances[0];
|
||||
bridge.emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
|
||||
html: string;
|
||||
};
|
||||
expect(call.html).not.toContain("api.example.com");
|
||||
// The policy is rendered into an HTML attribute, so quotes arrive escaped.
|
||||
expect(call.html).toContain("connect-src 'none'");
|
||||
});
|
||||
|
||||
describe("_meta.ui.domain — dedicated origin (#2056)", () => {
|
||||
// An app rendered under the default opaque origin sends `Origin: null`, so
|
||||
// no CORS / OAuth-callback / API-key allowlist can admit it. `domain` is
|
||||
// how a server asks its host for a real one; the Inspector answers with a
|
||||
// URL on its own app-origin listener and passes it as `src`.
|
||||
|
||||
it("publishes the wrapped document and passes its URL as `src`", async () => {
|
||||
const readResource = vi.fn().mockResolvedValue(
|
||||
uiResource("<h1>weather</h1>", {
|
||||
domain: "my-app.example.com",
|
||||
csp: { connectDomains: ["https://api.example.com"] },
|
||||
}),
|
||||
);
|
||||
const publishAppDocument = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
||||
.mockResolvedValue("http://localhost:6278/app-document/abc");
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource,
|
||||
publishAppDocument,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
|
||||
// What is published is the SAME wrapped document the srcdoc path would
|
||||
// have rendered, plus the policy as a separate string so the backend can
|
||||
// serve it as a real response header.
|
||||
const published = publishAppDocument.mock.calls[0][0];
|
||||
expect(published.html).toContain("<body><h1>weather</h1></body>");
|
||||
expect(published.csp).toContain("connect-src https://api.example.com");
|
||||
|
||||
const call = bridgeInstances[0].sendSandboxResourceReady.mock
|
||||
.calls[0][0] as { html: string; src?: string };
|
||||
expect(call.src).toBe("http://localhost:6278/app-document/abc");
|
||||
// `html` still rides along: a proxy that ignores `src` renders the app
|
||||
// rather than a blank frame.
|
||||
expect(call.html).toBe(published.html);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an empty domain", ""],
|
||||
["a whitespace-only domain", " "],
|
||||
["a non-string domain", 42],
|
||||
])("does not take the dedicated path for %s", async (_label, domain) => {
|
||||
const publishAppDocument = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
||||
.mockResolvedValue("http://localhost:6278/app-document/abc");
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi
|
||||
.fn()
|
||||
.mockResolvedValue(uiResource("<h1>x</h1>", { domain })),
|
||||
publishAppDocument,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
expect(publishAppDocument).not.toHaveBeenCalled();
|
||||
expect(
|
||||
bridgeInstances[0].sendSandboxResourceReady.mock.calls[0][0],
|
||||
).not.toHaveProperty("src");
|
||||
});
|
||||
|
||||
it("never publishes a resource that declares no domain", async () => {
|
||||
const publishAppDocument = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
|
||||
.mockResolvedValue("http://localhost:6278/app-document/abc");
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),
|
||||
publishAppDocument,
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
expect(publishAppDocument).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to srcdoc, warning, when the backend cannot host the document", async () => {
|
||||
// A backend with no app-origin listener. Losing the real origin degrades
|
||||
// what the app can reach and the developer needs to see why — losing the
|
||||
// app itself would be worse.
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
uiResource("<h1>x</h1>", { domain: "my-app.example.com" }),
|
||||
),
|
||||
publishAppDocument: vi.fn().mockResolvedValue(null),
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
const call = bridgeInstances[0].sendSandboxResourceReady.mock
|
||||
.calls[0][0] as { html: string; src?: string };
|
||||
expect(call.src).toBeUndefined();
|
||||
expect(call.html).toContain("<body><h1>x</h1></body>");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("_meta.ui.domain"),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to srcdoc when the factory was given no publisher at all", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const factory = createAppBridgeFactory({
|
||||
getClient: () => fakeClient,
|
||||
readResource: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
uiResource("<h1>x</h1>", { domain: "my-app.example.com" }),
|
||||
),
|
||||
});
|
||||
await factory(makeIframe(), { kind: "tool", tool });
|
||||
bridgeInstances[0].emit("sandboxready");
|
||||
await flush();
|
||||
expect(
|
||||
bridgeInstances[0].sendSandboxResourceReady.mock.calls[0][0],
|
||||
).not.toHaveProperty("src");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("_meta.ui.domain"),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mutate the shared HOST_CAPABILITIES when echoing the approved sandbox", async () => {
|
||||
// The factory builds a per-app copy ({ ...HOST_CAPABILITIES }) so the
|
||||
// sandbox echo never leaks across apps/renders. Lock that in: after a
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
McpUiDisplayMode,
|
||||
McpUiHostCapabilities,
|
||||
McpUiResourceMeta,
|
||||
McpUiSandboxResourceReadyNotification,
|
||||
} from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
|
|
@ -17,6 +18,7 @@ import type {
|
|||
} from "@modelcontextprotocol/client";
|
||||
import {
|
||||
approveCspSources,
|
||||
approveSandboxPermissions,
|
||||
buildSandboxCspPolicy,
|
||||
wrapSandboxedHtml,
|
||||
} from "../../../utils/sandbox-csp";
|
||||
|
|
@ -88,6 +90,15 @@ export interface AppBridgeFactoryDeps {
|
|||
* frame; the error is also always console.error'd.
|
||||
*/
|
||||
onResourceError?: (err: Error) => void;
|
||||
/**
|
||||
* The `_meta` of the `resources/list` entry for `uri`, when the host has that
|
||||
* listing. ext-apps treats the listing-level `_meta.ui` as the static default
|
||||
* for a UI resource, so an app that declares its CSP only there must still
|
||||
* get it honored; a read content item's own `_meta.ui` wins over it. Omitted
|
||||
* (or returning undefined) simply means no default — the host that has no
|
||||
* resource listing, or whose listing does not include this URI, is unaffected.
|
||||
*/
|
||||
getListedResourceMeta?: (uri: string) => object | undefined;
|
||||
/**
|
||||
* Advertise `hostCapabilities.elicitation` — "this host can forward a
|
||||
* form-mode `elicitation/create` to the app and return its result to the
|
||||
|
|
@ -98,25 +109,101 @@ export interface AppBridgeFactoryDeps {
|
|||
* there would tell the app something untrue about what its host will do.
|
||||
*/
|
||||
advertiseElicitation?: boolean;
|
||||
/**
|
||||
* Publish a wrapped app document to the backend's dedicated app-origin
|
||||
* listener and resolve with the URL to load it from, or `null` when the
|
||||
* backend can't host one (#2056).
|
||||
*
|
||||
* Only consulted for a UI resource that declares `_meta.ui.domain` — the
|
||||
* spec field by which a server asks its host for a stable, dedicated origin
|
||||
* so its requests carry a real `Origin` instead of `null`. Optional: a
|
||||
* factory without it (and a `null` result from one with it) renders every
|
||||
* app the default way, under an opaque origin.
|
||||
*/
|
||||
publishAppDocument?: (doc: {
|
||||
html: string;
|
||||
csp?: string;
|
||||
}) => Promise<string | null>;
|
||||
}
|
||||
|
||||
/** First text content block of a UI resource, plus its `_meta` (sandbox hints). */
|
||||
function extractHtmlAndMeta(result: ReadResourceResult): {
|
||||
/**
|
||||
* The MCP Apps sandbox hints (`csp`, `permissions`, …) a single `_meta` bag
|
||||
* declares under its `ui` key, or undefined when that bag declares none.
|
||||
*
|
||||
* The two "no hints" cases are deliberately distinguished by the return type:
|
||||
* a bag with no `ui` key at all returns undefined (this carrier is silent —
|
||||
* keep looking), while a bag whose `ui` is malformed returns `{ meta:
|
||||
* undefined }` (this carrier spoke, and what it said is unusable — stop). Were
|
||||
* they conflated, a content item declaring `ui: null` would fall through to a
|
||||
* broader carrier and be granted whatever *that* one asked for, which is both
|
||||
* the wrong precedence and the wrong direction to fail in.
|
||||
*
|
||||
* `McpUiResourceMeta` describes the value of `_meta.ui`, NOT `_meta` itself —
|
||||
* reading `_meta` directly leaves every field undefined, so a spec-conforming
|
||||
* app's declared `connectDomains` never reach the generated CSP and it renders
|
||||
* under `connect-src 'none'` (#2055). This mirrors the tool side, where
|
||||
* `getToolUiResourceUri` already reads `_meta.ui.resourceUri`.
|
||||
*/
|
||||
function uiMeta(
|
||||
bag: object | undefined,
|
||||
): { meta: McpUiResourceMeta | undefined } | undefined {
|
||||
if (bag === undefined || !("ui" in bag)) return undefined;
|
||||
const ui: unknown = (bag as { ui: unknown }).ui;
|
||||
const wellFormed =
|
||||
ui !== null && typeof ui === "object" && !Array.isArray(ui);
|
||||
return { meta: wellFormed ? (ui as McpUiResourceMeta) : undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* First text content block of a UI resource, plus its `_meta.ui` sandbox hints.
|
||||
*
|
||||
* ext-apps exposes three carriers, and they are consulted most-specific first:
|
||||
*
|
||||
* 1. the **read content item**'s own `_meta.ui`;
|
||||
* 2. the **read result envelope**'s — `McpUiReadResourceResult` (what a
|
||||
* `registerAppResource` callback returns) types `_meta.ui` at that level;
|
||||
* 3. the matching **`resources/list` entry**'s, which `McpUiAppResourceConfig`
|
||||
* documents as the static default a host reviews at connection time, and
|
||||
* which a read content item explicitly takes precedence over.
|
||||
*
|
||||
* The first carrier that *declares* a `ui` key decides, and its value is used
|
||||
* outright — the levels are not merged, since a server that restates only
|
||||
* `csp` at the more specific level means that to be the whole grant, not a
|
||||
* patch over the broader one. A carrier declaring a malformed `ui` decides
|
||||
* too, for "no hints"; see {@link uiMeta}.
|
||||
*/
|
||||
function extractHtmlAndMeta(
|
||||
result: ReadResourceResult,
|
||||
listedMeta?: object,
|
||||
): {
|
||||
html: string;
|
||||
meta: McpUiResourceMeta | undefined;
|
||||
} {
|
||||
for (const content of result.contents) {
|
||||
const text = (content as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
return {
|
||||
html: text,
|
||||
meta: content._meta as McpUiResourceMeta | undefined,
|
||||
};
|
||||
const carried =
|
||||
uiMeta(content._meta) ?? uiMeta(result._meta) ?? uiMeta(listedMeta);
|
||||
return { html: text, meta: carried?.meta };
|
||||
}
|
||||
}
|
||||
throw new Error("UI resource has no text (HTML) content");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a UI resource asked for a dedicated origin (#2056).
|
||||
*
|
||||
* Any non-empty `domain` string counts. The spec is explicit that the field's
|
||||
* format is host-dependent — hosts publish their own rules, and the examples it
|
||||
* gives (`{hash}.claudemcpcontent.com`, `www-example-com.oaiusercontent.com`)
|
||||
* are each one host's convention. The Inspector's convention is that the value
|
||||
* is a *request*, not an address: it grants a real loopback origin and does not
|
||||
* try to honor the specific name, which it could not serve anyway.
|
||||
*/
|
||||
function wantsDedicatedOrigin(meta: McpUiResourceMeta | undefined): boolean {
|
||||
return typeof meta?.domain === "string" && meta.domain.trim() !== "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64-encoded blob resource into bytes for download. Allocates the
|
||||
* backing store explicitly so the return type is `Uint8Array<ArrayBuffer>`
|
||||
|
|
@ -271,7 +358,10 @@ export function createAppBridgeFactory(
|
|||
const uri = resolveSourceUri(source);
|
||||
if (!uri) return;
|
||||
const result = await deps.readResource(uri);
|
||||
const { html, meta } = extractHtmlAndMeta(result);
|
||||
const { html, meta } = extractHtmlAndMeta(
|
||||
result,
|
||||
deps.getListedResourceMeta?.(uri),
|
||||
);
|
||||
// Build the per-app CSP host-side: filter the requested sources to
|
||||
// ones the host accepts, render the policy string, and wrap the
|
||||
// app's HTML in a fixed shell whose first <head> child is the CSP
|
||||
|
|
@ -283,24 +373,59 @@ export function createAppBridgeFactory(
|
|||
// sendSandboxResourceReady: the view only sends ui/initialize once it
|
||||
// has the HTML, so the bridge reflects this in the initialize result.
|
||||
const approvedCsp = approveCspSources(meta?.csp);
|
||||
// NOTE on the CSP-vs-permissions asymmetry: `csp` is injection-filtered
|
||||
// by approveCspSources because its sources are interpolated into the
|
||||
// CSP <meta> content string. `permissions` is NOT filtered here — it is
|
||||
// a structured object (camera/microphone/geolocation/clipboardWrite
|
||||
// booleans), and its only consumer is the sandbox proxy's
|
||||
// buildAllowAttribute(), which maps each known key to a fixed
|
||||
// Permissions-Policy token and ignores anything else. Untrusted values
|
||||
// therefore can't reach the iframe `sandbox`/`allow` attribute as raw
|
||||
// text (that layer, and the allow-same-origin strip, is owned by the
|
||||
// sandbox-hardening work in #1565), so no source-style allowlist applies.
|
||||
// Both halves of the app-supplied sandbox config are screened, for
|
||||
// different reasons. `csp` is injection-filtered by approveCspSources
|
||||
// because its sources are interpolated into the CSP <meta> content
|
||||
// string. `permissions` can't reach the `sandbox`/`allow` attribute as
|
||||
// raw text — the proxy maps each known key to a fixed Permissions-Policy
|
||||
// token — but it tests those keys for TRUTHINESS, so a non-boolean
|
||||
// (`camera: "false"`) would read as a grant; approveSandboxPermissions
|
||||
// reduces the bag to the keys actually requested. The `allow-same-origin`
|
||||
// strip and the rest of that layer stay owned by #1565.
|
||||
const approvedPermissions = approveSandboxPermissions(
|
||||
meta?.permissions,
|
||||
);
|
||||
hostCapabilities.sandbox = {
|
||||
permissions: meta?.permissions,
|
||||
permissions: approvedPermissions,
|
||||
csp: approvedCsp,
|
||||
};
|
||||
await bridge.sendSandboxResourceReady({
|
||||
html: wrapSandboxedHtml(html, buildSandboxCspPolicy(approvedCsp)),
|
||||
permissions: meta?.permissions,
|
||||
});
|
||||
const policy = buildSandboxCspPolicy(approvedCsp);
|
||||
const wrapped = wrapSandboxedHtml(html, policy);
|
||||
// `_meta.ui.domain` (#2056): the resource is asking to be served
|
||||
// from a stable, dedicated origin rather than rendered into the
|
||||
// default opaque-origin srcdoc frame, because an opaque origin sends
|
||||
// `Origin: null` and no CORS / OAuth-callback / API-key allowlist can
|
||||
// admit that. The spec makes the field's format host-dependent and
|
||||
// the Inspector owns no domain infrastructure, so what it honors the
|
||||
// request WITH is a real loopback origin — any non-empty domain opts
|
||||
// in, and the string itself is not interpreted (see the app-origin
|
||||
// controller for the host-specific contract).
|
||||
//
|
||||
// A `null` back means the backend has no such listener (an older
|
||||
// backend, or one whose port never bound). Fall back to srcdoc: the
|
||||
// app loses its real origin, which is a degradation the developer
|
||||
// can see and act on — losing the app entirely is not.
|
||||
const dedicated = wantsDedicatedOrigin(meta);
|
||||
const src = dedicated
|
||||
? await deps.publishAppDocument?.({ html: wrapped, csp: policy })
|
||||
: undefined;
|
||||
if (dedicated && !src) {
|
||||
console.warn(
|
||||
"[mcp-app] resource declares _meta.ui.domain but no dedicated app origin is available; rendering under an opaque origin (requests will send `Origin: null`).",
|
||||
);
|
||||
}
|
||||
// `src` is an Inspector-specific extension to the (internal)
|
||||
// sandbox-resource-ready params, which ext-apps types but does not
|
||||
// validate — the bridge forwards the params object verbatim. Declared
|
||||
// as an intersection rather than cast so the extra key stays typed.
|
||||
const readyParams: McpUiSandboxResourceReadyNotification["params"] & {
|
||||
src?: string;
|
||||
} = {
|
||||
html: wrapped,
|
||||
permissions: approvedPermissions,
|
||||
...(src ? { src } : {}),
|
||||
};
|
||||
await bridge.sendSandboxResourceReady(readyParams);
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
console.error(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { EnlargeButton } from "./EnlargeButton";
|
||||
|
||||
const meta: Meta<typeof EnlargeButton> = {
|
||||
title: "Elements/EnlargeButton",
|
||||
component: EnlargeButton,
|
||||
args: { onClick: () => {} },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EnlargeButton>;
|
||||
|
||||
// Default: labelled "Enlarge", reachable by keyboard, and clickable.
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const button = await canvas.findByRole("button", { name: "Enlarge" });
|
||||
await expect(button).not.toHaveAttribute("tabindex", "-1");
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
// A per-field accessible name, for a form where several fields each carry one.
|
||||
export const CustomLabel: Story = {
|
||||
args: { ariaLabel: "Enlarge Query" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByRole("button", { name: "Enlarge Query" }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { EnlargeButton } from "./EnlargeButton";
|
||||
|
||||
describe("EnlargeButton", () => {
|
||||
it('renders with the "Enlarge" accessible name', () => {
|
||||
renderWithMantine(<EnlargeButton onClick={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Enlarge" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays in the keyboard tab order", () => {
|
||||
renderWithMantine(<EnlargeButton onClick={vi.fn()} />);
|
||||
expect(screen.getByRole("button", { name: "Enlarge" })).not.toHaveAttribute(
|
||||
"tabindex",
|
||||
"-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("invokes onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
renderWithMantine(<EnlargeButton onClick={onClick} />);
|
||||
await user.click(screen.getByRole("button", { name: "Enlarge" }));
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows the accessible name to be overridden", () => {
|
||||
renderWithMantine(
|
||||
<EnlargeButton onClick={vi.fn()} ariaLabel="Enlarge Query" />,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Enlarge Query" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is inert when the field it belongs to is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClick = vi.fn();
|
||||
renderWithMantine(<EnlargeButton onClick={onClick} disabled />);
|
||||
const button = screen.getByRole("button", { name: "Enlarge" });
|
||||
expect(button).toBeDisabled();
|
||||
await user.click(button);
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { ImEnlarge2 } from "react-icons/im";
|
||||
|
||||
export interface EnlargeButtonProps {
|
||||
/** Swap the single-line input this sits in for a multiline text area. */
|
||||
onClick: () => void;
|
||||
/**
|
||||
* Overrides the accessible name (aria-label), which defaults to the tooltip
|
||||
* text ("Enlarge"). Pass a per-field name (e.g. including the field's label)
|
||||
* when several inputs in one form each carry a button, so assistive tech can
|
||||
* tell them apart; the visible tooltip stays the plain verb.
|
||||
*/
|
||||
ariaLabel?: string;
|
||||
/**
|
||||
* Mirrors the disabled state of the input this sits in. A live button on a
|
||||
* disabled field would still swap in the text area — which mounts disabled and
|
||||
* cannot take focus, dropping keyboard focus to the document.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const EnlargeActionIcon = ActionIcon.withProps({
|
||||
variant: "subtle",
|
||||
color: "gray",
|
||||
size: "sm",
|
||||
});
|
||||
|
||||
/**
|
||||
* The enlarge affordance shown to the left of a text input's ClearButton
|
||||
* (#2042). Clicking it replaces the single-line input with a multiline text
|
||||
* area, so a value containing newlines can be typed — a plain `<input>` swallows
|
||||
* Enter, which left string arguments effectively single-line.
|
||||
*
|
||||
* Deliberately not a toggle: shrinking back would have to decide what to do with
|
||||
* the newlines already typed, and either answer (silently discard them, or keep
|
||||
* a value the field can no longer display) is worse than staying enlarged.
|
||||
*
|
||||
* Unlike ClearButton it stays in the keyboard tab order. Clearing a field has a
|
||||
* keyboard equivalent (select-all, delete), so #1487 could take that button out
|
||||
* of the tab order without cost; entering multiline mode has none, so removing
|
||||
* this one would put the feature out of reach of keyboard users entirely.
|
||||
*/
|
||||
export function EnlargeButton({
|
||||
onClick,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
}: EnlargeButtonProps) {
|
||||
return (
|
||||
<Tooltip label="Enlarge">
|
||||
<EnlargeActionIcon
|
||||
aria-label={ariaLabel ?? "Enlarge"}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ImEnlarge2 size={12} />
|
||||
</EnlargeActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import { JsonObjectInput } from "./JsonObjectInput";
|
||||
|
||||
const LABEL = "Request metadata JSON";
|
||||
|
||||
/**
|
||||
* The component is controlled, so every story drives it through a small
|
||||
* stateful wrapper — otherwise the editor would not accept a keystroke and the
|
||||
* play functions could not exercise the draft behavior at all.
|
||||
*
|
||||
* These stories run in real Chromium, which is what makes them the *only*
|
||||
* place Ace's keyboard behavior can be tested: the unit suite runs in
|
||||
* happy-dom, where Ace's hidden-textarea input path does not work at all.
|
||||
*/
|
||||
function ControlledJsonObjectInput({ initial }: { initial: StrictJsonObject }) {
|
||||
const [value, setValue] = useState<StrictJsonObject>(initial);
|
||||
return (
|
||||
<JsonObjectInput
|
||||
label="Request metadata"
|
||||
ariaLabel={LABEL}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ControlledJsonObjectInput> = {
|
||||
title: "Elements/JsonObjectInput",
|
||||
component: ControlledJsonObjectInput,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ControlledJsonObjectInput>;
|
||||
|
||||
/** The live editor instance, which Ace hangs off its container as `env.editor`. */
|
||||
function aceEditor(canvasElement: HTMLElement): {
|
||||
focus(): void;
|
||||
selectAll(): void;
|
||||
getValue(): string;
|
||||
session: { getAnnotations(): { row: number; type: string }[] };
|
||||
} {
|
||||
const container = canvasElement.querySelector(".ace_editor");
|
||||
const editor = (
|
||||
container as
|
||||
| (HTMLElement & {
|
||||
env?: {
|
||||
editor?: {
|
||||
focus(): void;
|
||||
selectAll(): void;
|
||||
getValue(): string;
|
||||
session: { getAnnotations(): { row: number; type: string }[] };
|
||||
};
|
||||
};
|
||||
})
|
||||
| null
|
||||
)?.env?.editor;
|
||||
if (!editor) throw new Error("Ace editor not mounted");
|
||||
return editor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the editor and empty it, so the next keystrokes are the whole document
|
||||
* and the typing itself — the thing under test — is left to `userEvent`.
|
||||
*
|
||||
* Emptied rather than merely selected: typing an opening brace or quote with a
|
||||
* selection makes Ace **wrap** the selection instead of replacing it, so typing
|
||||
* `{` over a selected `{}` yields `{{}}` and the assertion stops being about
|
||||
* auto-closing at all.
|
||||
*
|
||||
* Focus and select go through Ace's API on purpose. `userEvent.click` does not
|
||||
* focus the editor — Ace's input is a 1px offscreen textarea, so the click
|
||||
* lands nowhere and the keystrokes reach the body instead. And select-all is
|
||||
* not `Control+A` everywhere (Ace binds `Cmd-A` on mac), so pressing it would
|
||||
* make these stories pass or fail by host OS.
|
||||
*/
|
||||
async function focusAndClear(canvasElement: HTMLElement): Promise<void> {
|
||||
const editor = aceEditor(canvasElement);
|
||||
editor.focus();
|
||||
editor.selectAll();
|
||||
await userEvent.keyboard("{Backspace}");
|
||||
}
|
||||
|
||||
/** Ace renders each token in its own element, so read the document, not text. */
|
||||
function editorText(canvasElement: HTMLElement): string {
|
||||
return aceEditor(canvasElement).getValue();
|
||||
}
|
||||
|
||||
/** A structured payload renders as formatted JSON, nesting intact. */
|
||||
export const Populated: Story = {
|
||||
args: {
|
||||
initial: {
|
||||
tenant: "acme",
|
||||
trace: { id: "abc123", sampled: true },
|
||||
features: ["apps", "tasks"],
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(JSON.parse(editorText(canvasElement))).toEqual({
|
||||
tenant: "acme",
|
||||
trace: { id: "abc123", sampled: true },
|
||||
features: ["apps", "tasks"],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/** An empty payload opens as `{}` and carries no error. */
|
||||
export const Empty: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(editorText(canvasElement)).toBe("{}");
|
||||
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Opening a brace closes it — one of the two reasons this is an editor rather
|
||||
* than a textarea, and the thing that makes hand-writing a nested `_meta`
|
||||
* payload bearable.
|
||||
*
|
||||
* Deliberately one keystroke: Ace also auto-pairs quotes and skips over a
|
||||
* closing character you type yourself, so a longer sequence asserts the sum of
|
||||
* several behaviours and stops being a readable statement about this one.
|
||||
*/
|
||||
export const ClosesBracesWhileTyping: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await focusAndClear(canvasElement);
|
||||
// `{{` is userEvent's escape for a literal `{`.
|
||||
await userEvent.keyboard("{{");
|
||||
// Ace does not close it on the keystroke — an opening brace at the end of a
|
||||
// line is often the start of a block the user is about to fill in.
|
||||
await expect(editorText(canvasElement)).toBe("{");
|
||||
// Enter is what completes the pair, leaving the cursor indented between
|
||||
// them. This is the behaviour that makes writing nested `_meta` by hand
|
||||
// bearable, and the reason this is an editor rather than a textarea.
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await expect(editorText(canvasElement)).toBe("{\n \n}");
|
||||
// The result parses, so nothing is flagged.
|
||||
await expect(canvas.queryByText(/Not valid JSON/)).toBeNull();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Half-typed text is displayed as typed and flagged, rather than being
|
||||
* re-escaped back into the box each keystroke (the #1928 failure this draft
|
||||
* split exists to avoid).
|
||||
*/
|
||||
export const InvalidDraft: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await focusAndClear(canvasElement);
|
||||
// Genuinely half-typed — the state a user is in partway through writing an
|
||||
// entry, rather than a contrived bad string.
|
||||
await userEvent.keyboard('{{"a":');
|
||||
// Shown exactly as typed. The old textarea re-escaped its own contents on
|
||||
// every keystroke here, which is what made it unusable (#1928).
|
||||
await expect(editorText(canvasElement)).toBe('{"a":');
|
||||
await expect(await canvas.findByText(/Not valid JSON/)).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Valid JSON that is not an object is rejected with its own message, distinct
|
||||
* from unparseable text.
|
||||
*
|
||||
* A bare number rather than an array: brackets are auto-paired too, so typing
|
||||
* one would test the pairing as much as the rejection.
|
||||
*/
|
||||
export const NotAnObject: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await focusAndClear(canvasElement);
|
||||
await userEvent.keyboard("42");
|
||||
await expect(editorText(canvasElement)).toBe("42");
|
||||
await expect(
|
||||
await canvas.findByText(/Must be a JSON object/),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The JSON worker marks the offending line in the gutter — the reason this
|
||||
* component registers `worker-json` through `ace.config.setModuleUrl` with a
|
||||
* Vite `?url` asset.
|
||||
*
|
||||
* Asserted because nothing else covers it: the worker runs out-of-process, so
|
||||
* if its URL registration breaks, Ace fails to start it and silently falls back
|
||||
* to no annotations. Every other test here — including the wrapper's own
|
||||
* "Not valid JSON" message, which comes from our parser, not from Ace — would
|
||||
* still pass while the gutter diagnostics quietly disappeared.
|
||||
*/
|
||||
export const AnnotatesTheOffendingLine: Story = {
|
||||
args: { initial: {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
await focusAndClear(canvasElement);
|
||||
// A trailing comma on line 2: valid-looking until the closing brace, so the
|
||||
// worker has to locate it rather than just reject the document.
|
||||
await userEvent.keyboard('{{"a": 1,{Enter}}');
|
||||
await waitFor(
|
||||
async () => {
|
||||
const annotations = aceEditor(canvasElement).session.getAnnotations();
|
||||
await expect(annotations.length).toBeGreaterThan(0);
|
||||
await expect(annotations.some((a) => a.type === "error")).toBe(true);
|
||||
},
|
||||
// The worker is asynchronous and debounced, so this needs a real wait
|
||||
// rather than a tick.
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import { useState } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
|
||||
import { renderWithMantine } from "../../../test/renderWithMantine";
|
||||
import { aceLabel, getAceText, setAceText } from "../../../test/aceEditor";
|
||||
import { JsonObjectInput } from "./JsonObjectInput";
|
||||
|
||||
const LABEL = "Payload JSON";
|
||||
|
||||
/** Controlled harness: the component never owns the value, only the draft. */
|
||||
function Harness({
|
||||
initial,
|
||||
onChange,
|
||||
}: {
|
||||
initial: StrictJsonObject;
|
||||
onChange?: (v: StrictJsonObject) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState<StrictJsonObject>(initial);
|
||||
return (
|
||||
<>
|
||||
<JsonObjectInput
|
||||
ariaLabel={LABEL}
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange?.(next);
|
||||
}}
|
||||
/>
|
||||
<output data-testid="emitted">{JSON.stringify(value)}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("JsonObjectInput", () => {
|
||||
it("names the editor for assistive tech", () => {
|
||||
renderWithMantine(<Harness initial={{}} />);
|
||||
// Ace composes "<label>, Cursor at row N", so the match is by substring —
|
||||
// the position readout is Ace's, the name is ours.
|
||||
expect(screen.getByLabelText(aceLabel(LABEL))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses Ace's dark theme under a dark color scheme", () => {
|
||||
// The editor picks its Ace theme from the computed scheme rather than
|
||||
// inheriting Mantine tokens, so the dark branch is only exercised by
|
||||
// actually rendering dark.
|
||||
renderWithMantine(<Harness initial={{}} />, { colorScheme: "dark" });
|
||||
// `ace-github-dark`, hyphenated — Ace's cssClass, not the module name
|
||||
// (`theme-github_dark`). The App.css gutter-contrast override keys off this
|
||||
// same class, so getting it wrong silently drops the fix in dark mode.
|
||||
expect(document.querySelector(".ace-github-dark")).not.toBeNull();
|
||||
expect(document.querySelector(".ace-github")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses Ace's light theme under a light color scheme", () => {
|
||||
renderWithMantine(<Harness initial={{}} />, { colorScheme: "light" });
|
||||
expect(document.querySelector(".ace-github")).not.toBeNull();
|
||||
expect(document.querySelector(".ace-github_dark")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens with the value serialized two-space, nesting intact", () => {
|
||||
renderWithMantine(<Harness initial={{ a: { b: [1, 2] } }} />);
|
||||
expect(getAceText()).toBe(
|
||||
'{\n "a": {\n "b": [\n 1,\n 2\n ]\n }\n}',
|
||||
);
|
||||
});
|
||||
|
||||
it("emits the parsed object — a structured value stays structured", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(<Harness initial={{}} onChange={onChange} />);
|
||||
await setAceText('{"trace":{"id":"x"},"n":3}');
|
||||
expect(onChange).toHaveBeenLastCalledWith({ trace: { id: "x" }, n: 3 });
|
||||
});
|
||||
|
||||
// Also the regression test for the paired-event bug `handleChange` coalesces:
|
||||
// `setValue` is a remove followed by an insert, and acting on the remove
|
||||
// emitted `{}` and wiped `keep` before the insert had landed.
|
||||
it("shows the text as typed while it is invalid and does not emit", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
|
||||
await setAceText('{"a":');
|
||||
// Displayed verbatim — not re-escaped back through JSON.stringify.
|
||||
expect(getAceText()).toBe('{"a":');
|
||||
expect(
|
||||
screen.getByText("Not valid JSON — changes are not applied"),
|
||||
).toBeInTheDocument();
|
||||
// Nothing emitted, so the caller still holds the last valid object.
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("emitted").textContent).toBe('{"keep":"me"}');
|
||||
});
|
||||
|
||||
it("flags valid JSON that is not an object without emitting", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(<Harness initial={{ keep: "me" }} onChange={onChange} />);
|
||||
await setAceText("[1]");
|
||||
expect(screen.getByText(/Must be a JSON object/)).toBeInTheDocument();
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats a cleared editor as the empty object", async () => {
|
||||
const onChange = vi.fn();
|
||||
renderWithMantine(<Harness initial={{ a: 1 }} onChange={onChange} />);
|
||||
await setAceText("");
|
||||
expect(onChange).toHaveBeenLastCalledWith({});
|
||||
expect(screen.queryByText(/Not valid JSON/)).toBeNull();
|
||||
});
|
||||
|
||||
it("re-syncs when the parent's value diverges from the draft", async () => {
|
||||
// An external reset (switching servers, a reloaded catalog) must reach the
|
||||
// editor; an in-progress edit must not be overwritten by what it emitted.
|
||||
function Externally() {
|
||||
const [value, setValue] = useState<StrictJsonObject>({ a: 1 });
|
||||
return (
|
||||
<>
|
||||
<JsonObjectInput
|
||||
ariaLabel={LABEL}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<button onClick={() => setValue({ b: 2 })}>reset</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
renderWithMantine(<Externally />);
|
||||
await act(async () => {
|
||||
screen.getByRole("button", { name: "reset" }).click();
|
||||
});
|
||||
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
|
||||
});
|
||||
|
||||
it("applies an external value it showed before (A → B → A)", async () => {
|
||||
// Regression: `echoed` used to hold only what this component last emitted,
|
||||
// so returning to a previously-shown value matched the stale entry and was
|
||||
// dismissed as our own echo, stranding the editor on B.
|
||||
function Externally({ value }: { value: StrictJsonObject }) {
|
||||
return (
|
||||
<JsonObjectInput ariaLabel={LABEL} value={value} onChange={() => {}} />
|
||||
);
|
||||
}
|
||||
const { rerender } = renderWithMantine(<Externally value={{ a: 1 }} />);
|
||||
expect(JSON.parse(getAceText())).toEqual({ a: 1 });
|
||||
|
||||
rerender(<Externally value={{ b: 2 }} />);
|
||||
expect(JSON.parse(getAceText())).toEqual({ b: 2 });
|
||||
|
||||
// Back to the first value — a distinct object, same content.
|
||||
rerender(<Externally value={{ a: 1 }} />);
|
||||
expect(JSON.parse(getAceText())).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
describe("wiring to the Input.Wrapper label and error", () => {
|
||||
/** Ace's hidden textarea — the element the wrapper has to point at. */
|
||||
function textarea(): HTMLTextAreaElement {
|
||||
const el = document.querySelector<HTMLTextAreaElement>(
|
||||
"textarea.ace_text-input",
|
||||
);
|
||||
if (!el) throw new Error("Ace text input not mounted");
|
||||
return el;
|
||||
}
|
||||
|
||||
it("gives two instances distinct DOM ids", () => {
|
||||
// react-ace uses `name` as the container id; a fixed value would put
|
||||
// duplicate ids in the document as soon as a second editor renders.
|
||||
renderWithMantine(
|
||||
<>
|
||||
<JsonObjectInput ariaLabel="First" value={{}} onChange={() => {}} />
|
||||
<JsonObjectInput ariaLabel="Second" value={{}} onChange={() => {}} />
|
||||
</>,
|
||||
);
|
||||
const containerIds = Array.from(
|
||||
document.querySelectorAll(".ace_editor"),
|
||||
).map((n) => n.id);
|
||||
const inputIds = Array.from(
|
||||
document.querySelectorAll("textarea.ace_text-input"),
|
||||
).map((n) => n.id);
|
||||
expect(containerIds).toHaveLength(2);
|
||||
expect(new Set([...containerIds, ...inputIds]).size).toBe(4);
|
||||
expect(containerIds.concat(inputIds).every((id) => id !== "")).toBe(true);
|
||||
});
|
||||
|
||||
it("lets the visible label focus the editor", () => {
|
||||
renderWithMantine(
|
||||
<JsonObjectInput
|
||||
label="Request metadata"
|
||||
ariaLabel={LABEL}
|
||||
value={{}}
|
||||
onChange={() => {}}
|
||||
/>,
|
||||
);
|
||||
// `Input.Wrapper` renders `<label for={id}>`; the editor is only
|
||||
// reachable from it if Ace's textarea carries that same id.
|
||||
const label = screen.getByText("Request metadata");
|
||||
expect(label.getAttribute("for")).toBe(textarea().id);
|
||||
expect(textarea().id).not.toBe("");
|
||||
});
|
||||
|
||||
it("marks the editor invalid and points it at the error text", async () => {
|
||||
renderWithMantine(<Harness initial={{}} />);
|
||||
expect(textarea().getAttribute("aria-invalid")).toBeNull();
|
||||
|
||||
await setAceText("[1]");
|
||||
expect(textarea().getAttribute("aria-invalid")).toBe("true");
|
||||
const describedBy = textarea().getAttribute("aria-describedby");
|
||||
expect(describedBy).not.toBeNull();
|
||||
// The id must resolve to the message actually on screen.
|
||||
expect(document.getElementById(describedBy!)?.textContent).toMatch(
|
||||
/Must be a JSON object/,
|
||||
);
|
||||
});
|
||||
|
||||
it("announces a supplied description, and keeps it when the draft breaks", async () => {
|
||||
function WithDescription() {
|
||||
const [value, setValue] = useState<StrictJsonObject>({});
|
||||
return (
|
||||
<JsonObjectInput
|
||||
ariaLabel={LABEL}
|
||||
description="Sent with every request"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderWithMantine(<WithDescription />);
|
||||
|
||||
const described = () =>
|
||||
(textarea().getAttribute("aria-describedby") ?? "")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((id) => document.getElementById(id)?.textContent);
|
||||
|
||||
// Valid draft: described by the description alone.
|
||||
expect(described()).toEqual(["Sent with every request"]);
|
||||
|
||||
// Invalid draft: the error joins it rather than replacing it — the
|
||||
// description is still true, and dropping it would silently un-describe
|
||||
// the control at the moment it needs explaining most.
|
||||
await setAceText("[1]");
|
||||
expect(described()).toHaveLength(2);
|
||||
expect(described()[0]).toBe("Sent with every request");
|
||||
expect(described()[1]).toMatch(/Must be a JSON object/);
|
||||
});
|
||||
|
||||
it("clears the invalid marking once the text parses again", async () => {
|
||||
renderWithMantine(<Harness initial={{}} />);
|
||||
await setAceText("[1]");
|
||||
expect(textarea().getAttribute("aria-invalid")).toBe("true");
|
||||
await setAceText('{"a":1}');
|
||||
expect(textarea().getAttribute("aria-invalid")).toBeNull();
|
||||
expect(textarea().getAttribute("aria-describedby")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an in-progress edit alone when the parent echoes it back", async () => {
|
||||
renderWithMantine(<Harness initial={{}} />);
|
||||
// The parent re-renders with the object this text parsed to; the draft must
|
||||
// not be reformatted out from under the cursor.
|
||||
await setAceText('{"a":1}');
|
||||
expect(getAceText()).toBe('{"a":1}');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue