feat(web): honor _meta.ui.domain with a dedicated app origin

An MCP App is rendered by handing its HTML to the sandbox proxy as
`srcdoc`, in a frame sandboxed without `allow-same-origin` — the #1565
isolation model. That gives the app document an opaque origin, so every
request it makes carries `Origin: null` and no CORS / OAuth-callback /
API-key allowlist can admit it. `_meta.ui.domain` is the spec field a
server uses to ask its host for a stable, dedicated origin, and it was
being dropped on the floor.

The field is explicitly host-dependent and the Inspector owns no domain
infrastructure, so it treats any non-empty value as a request rather than
an address, and answers with a real loopback origin on its own listener
(MCP_APP_ORIGIN_PORT, default 6276). One shared origin, path-keyed per
document: that delivers what the field is for without minting a port or
DNS name per app, and is therefore not a per-app isolation boundary.

The inner frame is granted `allow-same-origin` on this path only, which
is what makes the origin real. It is not a #1565 regression: 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 the grant is never reachable from the server-supplied `sandbox`
string, which is still stripped. The per-app CSP is delivered as a real
response header there rather than only as a <meta>.

Every failure path falls back to the default srcdoc render with a console
warning naming the field, rather than blanking the app.

Also fixes a pre-existing `tsc -b` break on v2/main (useServers.test.tsx
passed `metadata: []` where `RequestMetadata` is expected), which has had
CI red since #2087 and blocks the pre-push gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 23:03:55 -04:00 • committed by GitHub
commit ec40cf5531
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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,58 @@ 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 **`6276`** (beside the web
port `6274` and the sandbox port `6275`, so the three forward together).
- 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 sandbox proxy.
- 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.
**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.
**Forward `6276` 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.