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:
parent
5de0b8a5ca
commit
ec40cf5531
1 changed files with 1554 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue