Merge pull request #2100 from modelcontextprotocol/v2/fix/2056-app-ui-domain
fix(web): honor _meta.ui.domain with a dedicated app origin
This commit is contained in:
commit
66763e85ee
31 changed files with 2274 additions and 44 deletions
28
AGENTS.md
28
AGENTS.md
|
|
@ -15,6 +15,34 @@ v2/main/
|
|||
│ │ │ # start-vite-dev-server.ts (in-process Vite starter for the launcher),
|
||||
│ │ │ # web-server-config.ts (env parsing + initial-config payload + banner),
|
||||
│ │ │ # sandbox-controller.ts (MCP Apps sandbox HTTP server),
|
||||
│ │ # app-origin-controller.ts (the DEDICATED APP ORIGIN for
|
||||
│ │ # `_meta.ui.domain` — #2056. The default render hands an
|
||||
│ │ # app's HTML to the proxy as `srcdoc` in a frame with no
|
||||
│ │ # `allow-same-origin`, so the document has an OPAQUE origin
|
||||
│ │ # and every request it makes sends `Origin: null`; no CORS /
|
||||
│ │ # OAuth-callback / API-key allowlist can admit that, which is
|
||||
│ │ # what the spec field exists to fix. `domain` is explicitly
|
||||
│ │ # HOST-DEPENDENT and the Inspector owns no domain
|
||||
│ │ # infrastructure, so it treats any non-empty value as a
|
||||
│ │ # REQUEST, not an address, and answers with a real loopback
|
||||
│ │ # origin on its own port (MCP_APP_ORIGIN_PORT, default 6278).
|
||||
│ │ # ONE SHARED origin, path-keyed per document — a real,
|
||||
│ │ # allowlistable origin without a port or DNS name per app,
|
||||
│ │ # and therefore NOT a per-app isolation boundary (two such
|
||||
│ │ # apps share its localStorage/cookies). The inner frame is
|
||||
│ │ # granted `allow-same-origin` on THIS path only, which is
|
||||
│ │ # what makes the origin real; that is not a #1565 regression
|
||||
│ │ # because the listener is on its own port, so the app stays
|
||||
│ │ # cross-origin to both the proxy and the Inspector — the
|
||||
│ │ # proxy refuses the grant if the URL's origin equals its own,
|
||||
│ │ # and it is never reachable from the server-supplied
|
||||
│ │ # `sandbox` string, which is still stripped. The browser
|
||||
│ │ # hands the wrapped bytes back through the authenticated
|
||||
│ │ # `POST /api/app-document` (core/mcp/remote/node/server.ts)
|
||||
│ │ # because only it holds the MCP connection. EVERY failure —
|
||||
│ │ # no listener, port never bound, older backend, network
|
||||
│ │ # error — falls back to the srcdoc render with a warning
|
||||
│ │ # rather than blanking the app),
|
||||
│ │ │ # inject-auth-token.ts (embeds the API token into served index.html),
|
||||
│ │ │ # vite-base-config.ts (shared optimizeDeps exclusions),
|
||||
│ │ │ # resolve-bind-host.ts (bind-host POLICY: defaults to
|
||||
|
|
|
|||
|
|
@ -32,11 +32,14 @@ ENV HOST=0.0.0.0 \
|
|||
DANGEROUSLY_BIND_ALL_INTERFACES=true \
|
||||
CLIENT_PORT=6274 \
|
||||
MCP_SANDBOX_PORT=6275 \
|
||||
MCP_APP_ORIGIN_PORT=6278 \
|
||||
MCP_AUTO_OPEN_ENABLED=false
|
||||
# 6275 is the MCP Apps sandbox, a second listener the browser reaches directly.
|
||||
# It is only needed for the Apps tab, so it is EXPOSEd but publishing it is
|
||||
# 6275 is the MCP Apps sandbox and 6278 the dedicated app origin (#2056) — two
|
||||
# further listeners the browser reaches DIRECTLY, so neither works through the
|
||||
# 6274 publish alone. Both are only needed for the Apps tab (6278 only for an
|
||||
# App declaring `_meta.ui.domain`), so they are EXPOSEd but publishing them is
|
||||
# optional — see the Docker section of the root README.
|
||||
EXPOSE 6274 6275
|
||||
EXPOSE 6274 6275 6278
|
||||
|
||||
# Run as the non-root `node` user the base image ships. The inspector resolves
|
||||
# its runtime-state dir (default catalog, OAuth token storage) from `HOME`
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -143,6 +143,7 @@ Each config below is a ready-made server for exercising one feature by hand. Loa
|
|||
| ----------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `mcp-app-http.json` **(legacy era)** | An MCP App (UI resource + app tool) in the Apps tab | [#1859](https://github.com/modelcontextprotocol/inspector/issues/1859) |
|
||||
| `app-elicitation-http.json` **(legacy era)** | An MCP App rendering a form elicitation | [#1854](https://github.com/modelcontextprotocol/inspector/issues/1854) |
|
||||
| `mcp-app-domain-http.json` **(legacy era)** | An MCP App asking for a dedicated origin (`_meta.ui.domain`) | [#2056](https://github.com/modelcontextprotocol/inspector/issues/2056) |
|
||||
| `modern-mrtr-http.json` | A single MRTR round-trip | — |
|
||||
| `mrtr-showcase-http.json` | Every MRTR preset in one server | [#1860](https://github.com/modelcontextprotocol/inspector/issues/1860) |
|
||||
| `modern-network-http.json` | Network tab: `Mcp-*` headers + error taxonomy | [#1628](https://github.com/modelcontextprotocol/inspector/issues/1628) |
|
||||
|
|
@ -165,6 +166,16 @@ Open the Apps tab, select `mcp_app_demo`, give it a title and click **Open App**
|
|||
|
||||
For the scripted version of the same flow (`--app-info` probe → deep link → rendered widget), see [Reviewing an MCP App](./docs/mcp-app-review.md).
|
||||
|
||||
#### An App's dedicated origin
|
||||
|
||||
`mcp-app-domain-http.json` serves the same `mcp_app_demo` widget as above, with one addition: its UI resource declares `_meta.ui.domain`. Plain streamable-HTTP; connect with the **default (legacy)** protocol era.
|
||||
|
||||
That field is how a server asks its host for a stable, dedicated origin. Without one, an App renders into a `srcdoc` frame sandboxed without `allow-same-origin`, so its document has an *opaque* origin and every request it makes carries `Origin: null` — which no CORS policy, OAuth callback, or API-key allowlist can admit ([#2056](https://github.com/modelcontextprotocol/inspector/issues/2056)).
|
||||
|
||||
Open the Apps tab and run `mcp_app_demo`. The widget renders identically to `mcp-app-http.json` — the difference is not visual. Inspect the inner iframe in devtools: on this server it is served from `http://127.0.0.1:6278/app-document/<id>` and `location.origin` is that real origin, where on `mcp-app-http.json` it is `about:srcdoc` with an origin of `null`. (The host is whatever the Inspector bound to — `127.0.0.1` by default, an *address* rather than the name `localhost`, for the reason `resolve-bind-host.ts` documents.)
|
||||
|
||||
The spec makes `domain`'s format **host-dependent**, and the Inspector owns no domain infrastructure — so it reads any non-empty value as a *request* rather than an address, and answers with a real loopback origin of its own. See [MCP App dedicated origins](./clients/web/README.md#mcp-app-dedicated-origins-metauidomain) for the full contract, including what the one shared origin does and does not isolate, and how every failure falls back to the default render rather than blanking the app.
|
||||
|
||||
#### App-rendered form elicitations
|
||||
|
||||
`app-elicitation-http.json` serves `app_choose_option` alongside the `choose_option_app` UI resource (`ui://demo/choose-option.html`). The tool sends a completely ordinary form `elicitation/create` — the only thing added is `_meta.ui.resourceUri` naming that app. Plain streamable-HTTP; connect with the **default (legacy)** protocol era.
|
||||
|
|
@ -466,7 +477,14 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 \
|
|||
ghcr.io/modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
Publish it on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:<container port>/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead.
|
||||
**And `6278` if your app declares `_meta.ui.domain`.** That is the spec field a server uses to ask its host for a stable, dedicated origin — without one the app runs at an opaque origin and its requests carry `Origin: null`, which no CORS / OAuth-callback / API-key allowlist can admit. The Inspector answers the request with a real loopback origin on a third listener, `MCP_APP_ORIGIN_PORT` (default `6278`); apps that declare no `domain` never touch it. **Publish it if you use one** — this is the one failure that does not fall back: the backend publishes fine (its listener bound inside the container), so it hands the browser a URL on a port the browser cannot reach, and that app's frame stays **blank**. The cross-origin navigation failure is not observable from the page, so there is no opaque-origin fallback and no console warning here; those cover the failures the *backend* can see (no listener, a port that never bound, an older backend). See [MCP App dedicated origins](./clients/web/README.md#mcp-app-dedicated-origins-metauidomain) for the host-specific contract and its isolation trade-offs.
|
||||
|
||||
```bash
|
||||
docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 -p 127.0.0.1:6278:6278 \
|
||||
ghcr.io/modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
Publish each on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:<container port>/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead. The same holds for the app origin: the URL a published app document is served from is built from the port the container bound, so remap with `-e MCP_APP_ORIGIN_PORT=9001 -p 127.0.0.1:9001:9001` rather than with `-p` alone.
|
||||
|
||||
**Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one.
|
||||
|
||||
|
|
|
|||
|
|
@ -554,6 +554,7 @@ describe("--print-handoff", () => {
|
|||
MCP_INSPECTOR_API_TOKEN: "tok123",
|
||||
CLIENT_PORT: "16274",
|
||||
MCP_SANDBOX_PORT: "16275",
|
||||
MCP_APP_ORIGIN_PORT: "16278",
|
||||
MCP_STORAGE_DIR: "/tmp/inspector-storage",
|
||||
MCP_INSPECTOR_OAUTH_STATE_PATH: "",
|
||||
},
|
||||
|
|
@ -574,6 +575,9 @@ describe("--print-handoff", () => {
|
|||
expect(out.deepLink).toContain("transport=http");
|
||||
expect(out.portForwardCmd).toContain("--tcp 16274:16274");
|
||||
expect(out.portForwardCmd).toContain("--tcp 16275:16275");
|
||||
// The dedicated app origin (#2056) forwards with the other two — an app
|
||||
// declaring `_meta.ui.domain` is unreachable without it.
|
||||
expect(out.portForwardCmd).toContain("--tcp 16278:16278");
|
||||
expect(out.oauthStatePath).toBe(
|
||||
join("/tmp/inspector-storage", "oauth.json"),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -452,6 +452,11 @@ function buildHandoff(
|
|||
: canonicalUrlHost(host);
|
||||
const clientPort = process.env.CLIENT_PORT || "6274";
|
||||
const sandboxPort = process.env.MCP_SANDBOX_PORT || "6275";
|
||||
// The dedicated app origin (#2056). Forwarded alongside the other two: an App
|
||||
// whose UI resource declares `_meta.ui.domain` is served from this port and
|
||||
// the browser reaches it DIRECTLY, so a handoff that forwards only 6274/6275
|
||||
// renders that app from an unreachable origin.
|
||||
const appOriginPort = process.env.MCP_APP_ORIGIN_PORT || "6278";
|
||||
// Treat an empty MCP_INSPECTOR_API_TOKEN the same as unset — an empty token
|
||||
// can't satisfy the deep-link autoConnect gate.
|
||||
const apiToken = process.env.MCP_INSPECTOR_API_TOKEN || undefined;
|
||||
|
|
@ -469,7 +474,7 @@ function buildHandoff(
|
|||
return {
|
||||
serverUrl: normalizedUrl,
|
||||
deepLink: `http://${linkHost}:${clientPort}/?${params.toString()}`,
|
||||
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort}`,
|
||||
portForwardCmd: `coder port-forward <workspace> --tcp ${clientPort}:${clientPort} --tcp ${sandboxPort}:${sandboxPort} --tcp ${appOriginPort}:${appOriginPort}`,
|
||||
oauthStatePath: statePath,
|
||||
apiToken: apiToken ?? null,
|
||||
note:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -135,6 +135,97 @@ Three further params extend the deep link to pre-select — and optionally auto-
|
|||
|
||||
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.
|
||||
|
|
|
|||
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(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -391,6 +391,24 @@ vi.mock(
|
|||
},
|
||||
);
|
||||
|
||||
// --- 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 /
|
||||
|
|
@ -3463,4 +3481,40 @@ describe("App MCP App listed-resource metadata wiring (#2055)", () => {
|
|||
).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) }),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -148,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,
|
||||
|
|
@ -808,9 +809,23 @@ function App() {
|
|||
[],
|
||||
);
|
||||
|
||||
// `_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) => {
|
||||
|
|
@ -832,7 +847,7 @@ function App() {
|
|||
});
|
||||
},
|
||||
}),
|
||||
[inspectorClient, getListedResourceMeta],
|
||||
[inspectorClient, getListedResourceMeta, publishDocument],
|
||||
);
|
||||
|
||||
// App-rendered form elicitations (#1854). The controller is created once and
|
||||
|
|
@ -885,6 +900,7 @@ function App() {
|
|||
() =>
|
||||
createAppBridgeFactory({
|
||||
advertiseElicitation: true,
|
||||
publishAppDocument: publishDocument,
|
||||
getClient: () => inspectorClient?.getAppRendererClient() ?? null,
|
||||
getListedResourceMeta,
|
||||
readResource: async (uri) => {
|
||||
|
|
@ -903,7 +919,7 @@ function App() {
|
|||
});
|
||||
},
|
||||
}),
|
||||
[inspectorClient, getListedResourceMeta],
|
||||
[inspectorClient, getListedResourceMeta, publishDocument],
|
||||
);
|
||||
/**
|
||||
* Close the previous client's session and open one for the client being
|
||||
|
|
|
|||
|
|
@ -550,17 +550,26 @@ describe("AppRenderer", () => {
|
|||
// 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.
|
||||
await waitFor(() =>
|
||||
expect(bridge.sendHostContextChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
theme: "dark",
|
||||
styles: expect.objectContaining({
|
||||
variables: expect.objectContaining({
|
||||
"--color-background-primary": "#101113",
|
||||
//
|
||||
// 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();
|
||||
|
|
|
|||
|
|
@ -483,6 +483,141 @@ describe("createAppBridgeFactory", () => {
|
|||
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 {
|
||||
|
|
@ -108,6 +109,21 @@ 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -174,6 +190,20 @@ function extractHtmlAndMeta(
|
|||
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>`
|
||||
|
|
@ -359,10 +389,43 @@ export function createAppBridgeFactory(
|
|||
permissions: approvedPermissions,
|
||||
csp: approvedCsp,
|
||||
};
|
||||
await bridge.sendSandboxResourceReady({
|
||||
html: wrapSandboxedHtml(html, buildSandboxCspPolicy(approvedCsp)),
|
||||
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(
|
||||
|
|
|
|||
121
clients/web/src/lib/publishAppDocument.test.ts
Normal file
121
clients/web/src/lib/publishAppDocument.test.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* `publishAppDocument` (#2056) — the browser half of dedicated-origin hosting.
|
||||
*
|
||||
* The contract worth pinning is the failure behavior: every way this can fail
|
||||
* must resolve `null` rather than throw, because the caller's response to all
|
||||
* of them is the same (render the app the default way), and a throw there
|
||||
* would cost the user the app itself.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { publishAppDocument } from "./publishAppDocument";
|
||||
|
||||
/**
|
||||
* A real `Response`, not a shaped literal. The platform implementation is
|
||||
* available here, so building one keeps `ok`/`status`/`json()` honest rather
|
||||
* than asserting a hand-written object is a Response.
|
||||
*/
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("publishAppDocument", () => {
|
||||
it("POSTs the document with the auth header and returns the URL", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ url: "http://localhost:6278/app-document/abc" }),
|
||||
);
|
||||
const url = await publishAppDocument(
|
||||
{ html: "<p>app</p>", csp: "default-src 'none'" },
|
||||
{ baseUrl: "http://localhost:6274", authToken: "tok", fetchFn },
|
||||
);
|
||||
expect(url).toBe("http://localhost:6278/app-document/abc");
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
"http://localhost:6274/api/app-document",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-mcp-remote-auth": "Bearer tok",
|
||||
},
|
||||
body: JSON.stringify({ html: "<p>app</p>", csp: "default-src 'none'" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the auth header when there is no token, and trims a trailing slash", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(jsonResponse({ url: "http://x/y" }));
|
||||
await publishAppDocument(
|
||||
{ html: "<p>app</p>" },
|
||||
{ baseUrl: "http://localhost:6274/", fetchFn },
|
||||
);
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
"http://localhost:6274/api/app-document",
|
||||
expect.objectContaining({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a 503 (no app-origin listener)", jsonResponse({}, 503)],
|
||||
["a 404 (backend predating the route)", jsonResponse({}, 404)],
|
||||
["a body with no url", jsonResponse({})],
|
||||
["a body whose url is not a string", jsonResponse({ url: 42 })],
|
||||
["a body whose url is empty", jsonResponse({ url: "" })],
|
||||
])("resolves null for %s", async (_label, response) => {
|
||||
const fetchFn = vi.fn<typeof fetch>().mockResolvedValue(response);
|
||||
await expect(
|
||||
publishAppDocument(
|
||||
{ html: "<p>app</p>" },
|
||||
{ baseUrl: "http://b", fetchFn },
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("resolves null on a network error rather than rejecting", async () => {
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockRejectedValue(new Error("offline"));
|
||||
await expect(
|
||||
publishAppDocument(
|
||||
{ html: "<p>app</p>" },
|
||||
{ baseUrl: "http://b", fetchFn },
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("resolves null when the response body is not JSON", async () => {
|
||||
// A real 200 whose body is not JSON, so `json()` rejects for the reason
|
||||
// the production path has to survive rather than because a stub threw.
|
||||
const fetchFn = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValue(new Response("not json", { status: 200 }));
|
||||
await expect(
|
||||
publishAppDocument(
|
||||
{ html: "<p>app</p>" },
|
||||
{ baseUrl: "http://b", fetchFn },
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("defaults to globalThis.fetch when none is injected", async () => {
|
||||
const spy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(jsonResponse({ url: "http://x/y" }));
|
||||
try {
|
||||
await expect(
|
||||
publishAppDocument({ html: "<p>app</p>" }, { baseUrl: "http://b" }),
|
||||
).resolves.toBe("http://x/y");
|
||||
expect(spy).toHaveBeenCalled();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
67
clients/web/src/lib/publishAppDocument.ts
Normal file
67
clients/web/src/lib/publishAppDocument.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
/**
|
||||
* Publish a wrapped MCP App document to the backend's dedicated app-origin
|
||||
* listener and get back the URL to load it from (#2056).
|
||||
*
|
||||
* This is `lib`, not `utils`: it does network I/O against the authenticated
|
||||
* `/api/*` surface.
|
||||
*
|
||||
* ## Why the browser hands the bytes back
|
||||
*
|
||||
* The app's HTML arrives over the MCP connection, which only the browser
|
||||
* holds — the backend has no client of its own to read the `ui://` resource
|
||||
* with. So the one way an app can be served from a real HTTP origin is for the
|
||||
* browser to POST the document it already wrapped. The backend stores it under
|
||||
* an unguessable id and serves it from a separate listener on its own port,
|
||||
* which is what makes the app's origin real instead of opaque.
|
||||
*
|
||||
* Only apps whose UI resource declares `_meta.ui.domain` take this path; every
|
||||
* other app keeps the default `srcdoc` render under an opaque origin.
|
||||
*/
|
||||
|
||||
/** Backend response shape for a successful publish. */
|
||||
interface PublishResponse {
|
||||
url?: unknown;
|
||||
}
|
||||
|
||||
export interface PublishAppDocumentOptions {
|
||||
/** Base URL of the backend (typically `window.location.origin`). */
|
||||
baseUrl: string;
|
||||
/** Optional auth token for the `x-mcp-remote-auth` header. */
|
||||
authToken?: string;
|
||||
/** Fetch function to use (default: `globalThis.fetch`). Useful in tests. */
|
||||
fetchFn?: typeof fetch;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST the document and return the absolute URL it is served from, or `null`
|
||||
* when the backend cannot host it.
|
||||
*
|
||||
* Never throws. Every failure — an old backend with no route, a backend with no
|
||||
* app-origin listener (503), a network error, a malformed body — is a `null`,
|
||||
* because the caller's response to all of them is the same: render the app the
|
||||
* default way. Losing the dedicated origin degrades what the app can reach; it
|
||||
* must not cost the user the app itself.
|
||||
*/
|
||||
export async function publishAppDocument(
|
||||
doc: { html: string; csp?: string },
|
||||
opts: PublishAppDocumentOptions,
|
||||
): Promise<string | null> {
|
||||
const doFetch = opts.fetchFn ?? globalThis.fetch;
|
||||
const base = opts.baseUrl.replace(/\/$/, "");
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (opts.authToken) headers["x-mcp-remote-auth"] = `Bearer ${opts.authToken}`;
|
||||
try {
|
||||
const res = await doFetch(`${base}/api/app-document`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(doc),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as PublishResponse;
|
||||
return typeof body.url === "string" && body.url ? body.url : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* `POST /api/app-document` (#2056) — the authenticated seam the browser hands a
|
||||
* wrapped MCP App document back through so the backend can serve it from a
|
||||
* dedicated origin.
|
||||
*
|
||||
* The route owns validation and the two "can't host it" answers; the listener
|
||||
* that actually serves the bytes is `clients/web/server/app-origin-controller`
|
||||
* and is tested separately.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
||||
|
||||
function post(body: unknown): Request {
|
||||
return new Request("http://test/api/app-document", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function appWith(
|
||||
publishAppDocument?: (doc: {
|
||||
html: string;
|
||||
csp?: string;
|
||||
}) => { url: string } | null,
|
||||
) {
|
||||
return createRemoteApp({
|
||||
dangerouslyOmitAuth: true,
|
||||
allowedOrigins: ["http://127.0.0.1:6274"],
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
publishAppDocument,
|
||||
}).app;
|
||||
}
|
||||
|
||||
describe("createRemoteApp POST /api/app-document", () => {
|
||||
it("publishes the document and returns the URL", async () => {
|
||||
const publish = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||
.mockReturnValue({ url: "http://localhost:6278/app-document/abc" });
|
||||
const res = await appWith(publish).request(
|
||||
post({ html: "<h1>hi</h1>", csp: "default-src 'none'" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
url: "http://localhost:6278/app-document/abc",
|
||||
});
|
||||
expect(publish).toHaveBeenCalledWith({
|
||||
html: "<h1>hi</h1>",
|
||||
csp: "default-src 'none'",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a document with no csp", async () => {
|
||||
const publish = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||
.mockReturnValue({ url: "http://localhost:6278/app-document/abc" });
|
||||
const res = await appWith(publish).request(post({ html: "<h1>hi</h1>" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(publish).toHaveBeenCalledWith({
|
||||
html: "<h1>hi</h1>",
|
||||
csp: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers 503 when the backend supplies no publisher", async () => {
|
||||
// An embedder with no app-origin listener at all — the browser reads this
|
||||
// as "no dedicated origin available" and renders the app the default way.
|
||||
const res = await appWith().request(post({ html: "<h1>hi</h1>" }));
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("answers 503 when the publisher declines (listener never bound)", async () => {
|
||||
const res = await appWith(() => null).request(
|
||||
post({ html: "<h1>hi</h1>" }),
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
|
||||
it("rejects a malformed body", async () => {
|
||||
const res = await appWith(() => ({ url: "http://x/y" })).request(
|
||||
post("{not json"),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a JSON null body", "null"],
|
||||
["a JSON array body", "[1,2]"],
|
||||
])("rejects %s with 400 rather than a 500", async (_label, raw) => {
|
||||
// Both parse cleanly, so the try/catch above sees nothing — destructuring
|
||||
// them is what used to throw, outside it, turning a bad shape into a 500.
|
||||
const publish = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||
.mockReturnValue({ url: "http://x/y" });
|
||||
const res = await appWith(publish).request(post(raw));
|
||||
expect(res.status).toBe(400);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing html", {}],
|
||||
["empty html", { html: "" }],
|
||||
["non-string html", { html: 42 }],
|
||||
["non-string csp", { html: "<p>x</p>", csp: 42 }],
|
||||
])("rejects %s with 400", async (_label, body) => {
|
||||
const publish = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||
.mockReturnValue({ url: "http://x/y" });
|
||||
const res = await appWith(publish).request(post(body));
|
||||
expect(res.status).toBe(400);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a CR/LF-carrying csp", "default-src 'none'\r\nX-Evil: 1"],
|
||||
["a NUL-carrying csp", "default-src 'none'\u0000"],
|
||||
])("rejects %s with 400", async (_label, csp) => {
|
||||
// The controller hands `csp` to `res.writeHead()` verbatim. Node throws
|
||||
// ERR_INVALID_CHAR there SYNCHRONOUSLY, inside the handler for a later,
|
||||
// unrelated request — so accepting one here turns a bad publish into a
|
||||
// thrown listener rather than a 400 anyone can see.
|
||||
const publish = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||
.mockReturnValue({ url: "http://x/y" });
|
||||
const res = await appWith(publish).request(post({ html: "<p>x</p>", csp }));
|
||||
expect(res.status).toBe(400);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized csp with 413 — it is retained per document", async () => {
|
||||
const publish = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||
.mockReturnValue({ url: "http://x/y" });
|
||||
const res = await appWith(publish).request(
|
||||
post({ html: "<p>x</p>", csp: "a".repeat(8 * 1024 + 1) }),
|
||||
);
|
||||
expect(res.status).toBe(413);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized BODY before parsing it", async () => {
|
||||
// Distinct from the document check below, which only runs once the whole
|
||||
// request has been buffered and parsed — and only bounds `html`.
|
||||
const publish = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||
.mockReturnValue({ url: "http://x/y" });
|
||||
const res = await appWith(publish).request(
|
||||
post(`{"junk":"${"x".repeat(24 * 1024 * 1024 + 1024)}"}`),
|
||||
);
|
||||
expect(res.status).toBe(413);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized document with 413 rather than pinning it in memory", async () => {
|
||||
const publish = vi
|
||||
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
|
||||
.mockReturnValue({ url: "http://x/y" });
|
||||
const res = await appWith(publish).request(
|
||||
post({ html: "x".repeat(8 * 1024 * 1024 + 1) }),
|
||||
);
|
||||
expect(res.status).toBe(413);
|
||||
expect(publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires the auth token like every other /api route", async () => {
|
||||
const { app } = createRemoteApp({
|
||||
authToken: "s3cret",
|
||||
allowedOrigins: ["http://127.0.0.1:6274"],
|
||||
initialConfig: { defaultEnvironment: {} },
|
||||
publishAppDocument: () => ({ url: "http://x/y" }),
|
||||
});
|
||||
const unauthorized = await app.request(post({ html: "<p>x</p>" }));
|
||||
expect(unauthorized.status).toBe(401);
|
||||
|
||||
const authorized = await app.request(
|
||||
new Request("http://test/api/app-document", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-mcp-remote-auth": "Bearer s3cret",
|
||||
},
|
||||
body: JSON.stringify({ html: "<p>x</p>" }),
|
||||
}),
|
||||
);
|
||||
expect(authorized.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,482 @@
|
|||
/**
|
||||
* The dedicated app origin (#2056): the listener that serves an MCP App whose
|
||||
* UI resource declares `_meta.ui.domain`, so the app document has a real origin
|
||||
* and its requests carry a real `Origin` instead of `null`.
|
||||
*
|
||||
* The behaviors pinned here are the ones the feature's value rests on: the
|
||||
* document is reachable at the URL `publish` hands back, it is served with the
|
||||
* per-app CSP as a real response header plus a `frame-ancestors` naming the
|
||||
* sandbox proxy, an id nobody published is a 404, and every degradation path
|
||||
* (no listener, port taken, unusable bind host) leaves the caller able to fall
|
||||
* back to the default opaque-origin render rather than losing the app.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
createAppOriginController,
|
||||
DEFAULT_APP_ORIGIN_PORT,
|
||||
parseDocumentId,
|
||||
resolveAppOriginPort,
|
||||
appDocumentEmbedders,
|
||||
} from "../../../../server/app-origin-controller.js";
|
||||
import { RUNNER_OAUTH_CALLBACK_DEFAULT_PORT } from "@inspector/core/auth/node/runner-oauth-callback.js";
|
||||
|
||||
describe("resolveAppOriginPort", () => {
|
||||
const saved = process.env.MCP_APP_ORIGIN_PORT;
|
||||
afterEach(() => {
|
||||
if (saved === undefined) delete process.env.MCP_APP_ORIGIN_PORT;
|
||||
else process.env.MCP_APP_ORIGIN_PORT = saved;
|
||||
});
|
||||
|
||||
it("defaults to the fixed port so it can be forwarded", () => {
|
||||
delete process.env.MCP_APP_ORIGIN_PORT;
|
||||
expect(resolveAppOriginPort()).toBe(DEFAULT_APP_ORIGIN_PORT);
|
||||
});
|
||||
|
||||
it("does not default to the runners' fixed OAuth callback port", () => {
|
||||
// Both listeners are fixed-by-default and both live in the 627x family, so
|
||||
// the next free number is the obvious pick for each — which is how they
|
||||
// collided. This one can move (it warns and retries on a dynamic port);
|
||||
// the OAuth callback cannot, because apps pre-register
|
||||
// `http://127.0.0.1:<that port>/oauth/callback`. A `--web` holding the
|
||||
// port would therefore break a later `--cli`/`--tui` OAuth flow, and the
|
||||
// handoff recipe runs those side by side.
|
||||
expect(DEFAULT_APP_ORIGIN_PORT).not.toBe(
|
||||
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT,
|
||||
);
|
||||
});
|
||||
|
||||
it("honors a valid MCP_APP_ORIGIN_PORT, including an explicit 0", () => {
|
||||
process.env.MCP_APP_ORIGIN_PORT = "9200";
|
||||
expect(resolveAppOriginPort()).toBe(9200);
|
||||
process.env.MCP_APP_ORIGIN_PORT = "0";
|
||||
expect(resolveAppOriginPort()).toBe(0);
|
||||
});
|
||||
|
||||
it.each(["6278abc", "70000", "-1", "nope", " "])(
|
||||
"warns and falls back for an invalid value %j",
|
||||
(value) => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
process.env.MCP_APP_ORIGIN_PORT = value;
|
||||
expect(resolveAppOriginPort()).toBe(DEFAULT_APP_ORIGIN_PORT);
|
||||
// A blank value is "unset", not "invalid" — nothing to warn about.
|
||||
if (value.trim()) {
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("MCP_APP_ORIGIN_PORT"),
|
||||
);
|
||||
} else {
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("parseDocumentId", () => {
|
||||
const id = "a".repeat(32);
|
||||
|
||||
it("reads the id out of a document path, query string and all", () => {
|
||||
expect(parseDocumentId(`/app-document/${id}`)).toBe(id);
|
||||
expect(parseDocumentId(`/app-document/${id}?x=1`)).toBe(id);
|
||||
expect(parseDocumentId(`/app-document/${id}#frag`)).toBe(id);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["undefined target", undefined],
|
||||
["another path", "/sandbox"],
|
||||
["no id", "/app-document/"],
|
||||
["a non-hex id", "/app-document/" + "z".repeat(32)],
|
||||
["a short id", "/app-document/abc"],
|
||||
["a traversal attempt", "/app-document/../../etc/passwd"],
|
||||
["a nested path", `/app-document/${id}/extra`],
|
||||
])("rejects %s", (_label, url) => {
|
||||
expect(parseDocumentId(url as string | undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("appDocumentEmbedders", () => {
|
||||
it("admits the sandbox proxy AND the Inspector page", () => {
|
||||
// `frame-ancestors` is checked against EVERY ancestor, not just the parent:
|
||||
// the document is framed by the proxy, which is framed by the app page.
|
||||
// Omitting the app page's origin blocks the frame outright (it renders as a
|
||||
// chrome-error frame that never reaches the bridge).
|
||||
expect(
|
||||
appDocumentEmbedders("http://127.0.0.1:6275/sandbox", [
|
||||
"http://127.0.0.1:6274",
|
||||
"http://localhost:6274",
|
||||
]),
|
||||
).toEqual([
|
||||
"http://127.0.0.1:6275",
|
||||
"http://127.0.0.1:6274",
|
||||
"http://localhost:6274",
|
||||
]);
|
||||
});
|
||||
|
||||
it("still returns the allow-list when the sandbox never bound", () => {
|
||||
expect(appDocumentEmbedders(null, ["http://127.0.0.1:6274"])).toEqual([
|
||||
"http://127.0.0.1:6274",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([[null], [undefined], [""]])(
|
||||
"returns undefined for %j with no allow-list, so frame-ancestors falls back to loopback",
|
||||
(value) => {
|
||||
// Not `[]`: an empty list would make the directive `'none'` and block the
|
||||
// app frame outright, which is worse than a permissive loopback fallback.
|
||||
expect(
|
||||
appDocumentEmbedders(value as string | null | undefined),
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("createAppOriginController", () => {
|
||||
let controller: ReturnType<typeof createAppOriginController> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
controller = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await controller?.close();
|
||||
});
|
||||
|
||||
it("serves a published document at the URL it hands back, with the CSP as a header", async () => {
|
||||
controller = createAppOriginController({
|
||||
port: 0,
|
||||
host: "127.0.0.1",
|
||||
embedderOrigins: ["http://127.0.0.1:6275"],
|
||||
});
|
||||
const { url } = await controller.start();
|
||||
expect(url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
|
||||
expect(controller.getOrigin()).toBe(url);
|
||||
|
||||
const published = controller.publish({
|
||||
html: "<!doctype html><p>app</p>",
|
||||
csp: "default-src 'none'; connect-src https://api.example.com",
|
||||
});
|
||||
expect(published).not.toBeNull();
|
||||
expect(published!.url.startsWith(`${url}/app-document/`)).toBe(true);
|
||||
|
||||
const res = await fetch(published!.url);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
// `nosniff` matters more here than on a page we authored: these bytes are
|
||||
// server-supplied and untrusted.
|
||||
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(res.headers.get("cache-control")).toContain("no-store");
|
||||
// The per-app policy, plus a frame-ancestors naming the sandbox proxy —
|
||||
// the only page allowed to frame this document.
|
||||
expect(res.headers.get("content-security-policy")).toBe(
|
||||
"default-src 'none'; connect-src https://api.example.com; frame-ancestors http://127.0.0.1:6275",
|
||||
);
|
||||
await expect(res.text()).resolves.toBe("<!doctype html><p>app</p>");
|
||||
});
|
||||
|
||||
it("serves only frame-ancestors when the document carries no csp", async () => {
|
||||
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
await controller.start();
|
||||
const published = controller.publish({ html: "<p>x</p>" })!;
|
||||
const res = await fetch(published.url);
|
||||
// No embedderOrigins supplied → the loopback fallback, same as the sandbox
|
||||
// proxy's own directive.
|
||||
expect(res.headers.get("content-security-policy")).toBe(
|
||||
"frame-ancestors http://127.0.0.1:* http://localhost:*",
|
||||
);
|
||||
});
|
||||
|
||||
it("mints a distinct, unguessable id per document", async () => {
|
||||
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
const { url } = await controller.start();
|
||||
const a = controller.publish({ html: "<p>a</p>" })!;
|
||||
const b = controller.publish({ html: "<p>b</p>" })!;
|
||||
expect(a.url).not.toBe(b.url);
|
||||
expect(a.url.slice(`${url}/app-document/`.length)).toMatch(
|
||||
/^[0-9a-f]{32}$/,
|
||||
);
|
||||
await expect((await fetch(a.url)).text()).resolves.toBe("<p>a</p>");
|
||||
await expect((await fetch(b.url)).text()).resolves.toBe("<p>b</p>");
|
||||
});
|
||||
|
||||
it("404s an unknown id and any non-document request", async () => {
|
||||
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
const { url } = await controller.start();
|
||||
expect((await fetch(`${url}/app-document/${"b".repeat(32)}`)).status).toBe(
|
||||
404,
|
||||
);
|
||||
expect((await fetch(`${url}/`)).status).toBe(404);
|
||||
expect((await fetch(`${url}/sandbox`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s a non-GET request to a real document", async () => {
|
||||
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
await controller.start();
|
||||
const published = controller.publish({ html: "<p>x</p>" })!;
|
||||
expect((await fetch(published.url, { method: "POST" })).status).toBe(404);
|
||||
expect((await fetch(published.url)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns null from publish before start, so the caller falls back to srcdoc", async () => {
|
||||
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
expect(controller.publish({ html: "<p>x</p>" })).toBeNull();
|
||||
expect(controller.getOrigin()).toBeNull();
|
||||
});
|
||||
|
||||
it("evicts the oldest document past the cap rather than growing unbounded", async () => {
|
||||
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
await controller.start();
|
||||
const first = controller.publish({ html: "<p>first</p>" })!;
|
||||
// 32 is the cap; publishing 32 more must have pushed the first out.
|
||||
for (let i = 0; i < 32; i++) {
|
||||
controller.publish({ html: `<p>${i}</p>` });
|
||||
}
|
||||
expect((await fetch(first.url)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("evicts against a retained-size budget, not just the entry count", async () => {
|
||||
// The count bound alone permits MAX_DOCUMENTS * 8Mi retained for an hour,
|
||||
// on documents a server under test chooses the size of. Publish a few
|
||||
// large ones — well under the 32-entry cap — and the earliest must stop
|
||||
// being served.
|
||||
const c = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
controller = c;
|
||||
const { url } = await c.start();
|
||||
// 24Mi each: three fit under no count bound, but the third pushes total
|
||||
// retained past the 64Mi budget and the first has to go.
|
||||
const big = "x".repeat(24 * 1024 * 1024);
|
||||
const first = c.publish({ html: big });
|
||||
const second = c.publish({ html: big });
|
||||
expect(first).not.toBeNull();
|
||||
expect(second).not.toBeNull();
|
||||
const third = c.publish({ html: big });
|
||||
expect(third).not.toBeNull();
|
||||
|
||||
expect((await fetch(first!.url)).status).toBe(404);
|
||||
// The newest is always served — its frame is about to fetch it.
|
||||
expect((await fetch(third!.url)).status).toBe(200);
|
||||
expect(url).toBe(c.getOrigin());
|
||||
});
|
||||
|
||||
it("404s a document past its TTL", async () => {
|
||||
// Fake `Date` ONLY — the TTL is the sole thing that reads it, and faking
|
||||
// the timer queue too would deadlock the real HTTP round-trip below.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
await controller.start();
|
||||
const published = controller.publish({ html: "<p>x</p>" })!;
|
||||
expect((await fetch(published.url)).status).toBe(200);
|
||||
vi.setSystemTime(Date.now() + 61 * 60 * 1000);
|
||||
expect((await fetch(published.url)).status).toBe(404);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the same handle when start() is called twice", async () => {
|
||||
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
const first = await controller.start();
|
||||
const second = await controller.start();
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
it("drops published documents on close so nothing is served after teardown", async () => {
|
||||
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
await controller.start();
|
||||
const published = controller.publish({ html: "<p>x</p>" })!;
|
||||
await controller.close();
|
||||
expect(controller.getOrigin()).toBeNull();
|
||||
expect(controller.publish({ html: "<p>y</p>" })).toBeNull();
|
||||
await expect(fetch(published.url)).rejects.toThrow();
|
||||
controller = null;
|
||||
});
|
||||
|
||||
it("advertises localhost for a wildcard bind", async () => {
|
||||
controller = createAppOriginController({ port: 0, host: "0.0.0.0" });
|
||||
const { url } = await controller.start();
|
||||
// `http://0.0.0.0:PORT` isn't reachable from a browser, but a wildcard bind
|
||||
// does serve loopback.
|
||||
expect(url).toMatch(/^http:\/\/localhost:\d+$/);
|
||||
});
|
||||
|
||||
/** Claim a port so a subsequent listen on it fails with EADDRINUSE. */
|
||||
async function claimPort(): Promise<{
|
||||
port: number;
|
||||
release: () => Promise<void>;
|
||||
}> {
|
||||
const blocker: Server = createServer();
|
||||
await new Promise<void>((resolve) =>
|
||||
blocker.listen(0, "127.0.0.1", () => resolve()),
|
||||
);
|
||||
const addr = blocker.address();
|
||||
const port =
|
||||
typeof addr === "object" && addr !== null && "port" in addr
|
||||
? addr.port
|
||||
: 0;
|
||||
expect(port).toBeGreaterThan(0);
|
||||
return {
|
||||
port,
|
||||
release: () =>
|
||||
new Promise<void>((resolve) => blocker.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
it("falls back to an OS-assigned port when the fixed port is taken, loudly", async () => {
|
||||
const { port, release } = await claimPort();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
controller = createAppOriginController({ port, host: "127.0.0.1" });
|
||||
try {
|
||||
const result = await controller.start();
|
||||
expect(result.port).toBeGreaterThan(0);
|
||||
expect(result.port).not.toBe(port);
|
||||
const published = controller.publish({ html: "<p>x</p>" })!;
|
||||
expect((await fetch(published.url)).status).toBe(200);
|
||||
// Loud: an app's backend may allowlist this origin, so a port that
|
||||
// silently moved is the failure the fixed default exists to prevent.
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`App origin: port ${port} in use`),
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("MCP_APP_ORIGIN_PORT"),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it("degrades to publish()===null when listen fails for a non-EADDRINUSE reason", async () => {
|
||||
// The backends await start() during boot; if it ever stopped resolving the
|
||||
// whole web server would hang. Instead it resolves empty and every publish
|
||||
// answers null, which the renderer reads as "no dedicated origin".
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
controller = createAppOriginController({
|
||||
port: 0,
|
||||
host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface
|
||||
});
|
||||
try {
|
||||
const result = await controller.start();
|
||||
expect(result).toEqual({ port: 0, url: "" });
|
||||
expect(controller.getOrigin()).toBeNull();
|
||||
expect(controller.publish({ html: "<p>x</p>" })).toBeNull();
|
||||
await expect(controller.close()).resolves.toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
"App origin server error:",
|
||||
expect.objectContaining({ code: expect.any(String) }),
|
||||
);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
controller = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("stops minting URLs when the listener errors AFTER it bound", async () => {
|
||||
// `publish` gates on `origin`, not on `server`. An error arriving once the
|
||||
// listener is live must therefore clear `origin` too — otherwise the
|
||||
// browser keeps being handed URLs on a dead port and never takes the
|
||||
// srcdoc fallback the caller is promised.
|
||||
vi.resetModules();
|
||||
let emitter!: EventEmitter;
|
||||
vi.doMock("node:http", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("node:http")>("node:http");
|
||||
return {
|
||||
...actual,
|
||||
createServer: () => {
|
||||
emitter = new EventEmitter();
|
||||
return Object.assign(emitter, {
|
||||
listen: (_p: number, _h: string, cb?: () => void) =>
|
||||
setImmediate(() => cb?.()),
|
||||
address: () => ({
|
||||
address: "127.0.0.1",
|
||||
family: "IPv4",
|
||||
port: 6278,
|
||||
}),
|
||||
close: (cb?: () => void) => cb?.(),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const mod = await import("../../../../server/app-origin-controller.js");
|
||||
const c = mod.createAppOriginController({
|
||||
port: 6278,
|
||||
host: "127.0.0.1",
|
||||
});
|
||||
await c.start();
|
||||
expect(c.getOrigin()).toBe("http://127.0.0.1:6278");
|
||||
expect(c.publish({ html: "<p>x</p>" })).not.toBeNull();
|
||||
|
||||
emitter.emit(
|
||||
"error",
|
||||
Object.assign(new Error("boom"), { code: "EPIPE" }),
|
||||
);
|
||||
|
||||
expect(c.getOrigin()).toBeNull();
|
||||
expect(c.publish({ html: "<p>x</p>" })).toBeNull();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
vi.doUnmock("node:http");
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives up after one retry when the dynamic fallback also fails", async () => {
|
||||
// Guards the `retriedDynamic` latch. A real `listen(0)` effectively always
|
||||
// succeeds, so a claimed port only ever produces ONE EADDRINUSE — under
|
||||
// which this test would pass with the latch deleted. Mock `node:http` so
|
||||
// every listen fails: without the latch this recurses until timeout.
|
||||
vi.resetModules();
|
||||
const listenCalls: number[] = [];
|
||||
vi.doMock("node:http", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("node:http")>("node:http");
|
||||
return {
|
||||
...actual,
|
||||
createServer: () => {
|
||||
const emitter = new EventEmitter();
|
||||
return Object.assign(emitter, {
|
||||
listen: (p: number) => {
|
||||
listenCalls.push(p);
|
||||
setImmediate(() =>
|
||||
emitter.emit(
|
||||
"error",
|
||||
Object.assign(new Error("in use"), { code: "EADDRINUSE" }),
|
||||
),
|
||||
);
|
||||
},
|
||||
address: () => null,
|
||||
close: (cb?: () => void) => cb?.(),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
try {
|
||||
const mod = await import("../../../../server/app-origin-controller.js");
|
||||
const c = mod.createAppOriginController({
|
||||
port: DEFAULT_APP_ORIGIN_PORT,
|
||||
host: "127.0.0.1",
|
||||
});
|
||||
const result = await c.start();
|
||||
expect(result).toEqual({ port: 0, url: "" });
|
||||
// Exactly two listens: the fixed port, then one dynamic retry.
|
||||
expect(listenCalls).toEqual([DEFAULT_APP_ORIGIN_PORT, 0]);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("in use. Apps declaring _meta.ui.domain"),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
vi.doUnmock("node:http");
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue