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
20 changed files with 1554 additions and 18 deletions

View file

@ -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 6276).
│ │ # 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

View file

@ -466,7 +466,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 `6276` 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 `6276`); apps that declare no `domain` never touch it. An app that declares one and can't reach it still renders — at an opaque origin, with a console warning. 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:6276:6276 \
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.

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.

View file

@ -0,0 +1,326 @@
/**
* 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. They remain isolated from the sandbox
* proxy and from the Inspector itself, which are different origins.
*
* ## 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. `6276`
* sits next to the web (`6274`) and sandbox (`6275`) ports so the three forward
* together.
*/
export const DEFAULT_APP_ORIGIN_PORT = 6276;
/** Documents kept at once. Oldest is evicted past this; see {@link publish}. */
const MAX_DOCUMENTS = 32;
/**
* 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 sandbox proxy's origin as a one-entry list for
* {@link AppOriginControllerOptions.embedderOrigins}, or `undefined` when the
* sandbox never bound (its controller degrades to a null URL). Passing
* `undefined` is what makes the app-origin `frame-ancestors` fall back to
* loopback rather than to `'none'`, which would block the frame outright.
*/
export function sandboxOriginList(
sandboxUrl: string | null | undefined,
): string[] | undefined {
if (!sandboxUrl) return undefined;
try {
return [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. */
return 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 (`6276abc`) 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;
}
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>();
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) documents.delete(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) documents.delete(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.
server = null;
settle({ port: 0, url: "" });
});
server.listen(port, host, () => {
const addr = server!.address();
const actualPort =
typeof addr === "object" && addr !== null && "port" in addr
? addr.port
: /* v8 ignore next -- unreachable for a TCP listen; `address()`
returns AddressInfo. */
(addr as unknown as number);
// 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();
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);
// Bound the map even if nothing has expired: drop the oldest entry. An
// evicted document's frame has long since loaded, and the alternative is
// an unbounded store fed by every app the user opens in a session.
while (documents.size >= MAX_DOCUMENTS) {
const oldest = documents.keys().next();
/* v8 ignore next -- `size >= MAX_DOCUMENTS` guarantees a first key. */
if (oldest.done) break;
documents.delete(oldest.value);
}
const id = randomBytes(16).toString("hex");
documents.set(id, { ...doc, expiresAt: now + DOCUMENT_TTL_MS });
return { url: `${origin}${DOC_PATH_PREFIX}${id}` };
},
};
}

View file

@ -70,7 +70,21 @@ 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. Same grammar, same failure
* mode — a directive whose only source is invalid degrades to `'none'` and
* blocks the frame — 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(" ")}`;
}

View file

@ -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,
sandboxOriginList,
} 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,15 @@ 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: sandboxOriginList(sandboxController.getUrl()),
});
await appOriginController.start();
const resolvedAuthToken =
config.authToken ||
@ -65,6 +78,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 +173,7 @@ export async function startHonoServer(
async close(): Promise<void> {
await closeApi();
await sandboxController.close();
await appOriginController.close();
if ("closeAllConnections" in httpServer) {
httpServer.closeAllConnections();
}

View file

@ -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,
sandboxOriginList,
} 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,15 @@ 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: sandboxOriginList(sandboxController.getUrl()),
});
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 +97,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 +117,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
server.close = async () => {
await closeApi();
await sandboxController.close();
await appOriginController.close();
return originalClose();
};

View file

@ -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,7 @@ export function buildWebServerConfig(
"");
const sandboxPort = resolveSandboxPort();
const appOriginPort = resolveAppOriginPort();
let logger: Logger | undefined;
if (process.env.MCP_LOG_FILE) {
@ -439,6 +448,7 @@ export function buildWebServerConfig(
: defaultAllowedOrigins(hostname, port),
sandboxPort,
sandboxHost: hostname,
appOriginPort,
logger,
autoOpen: resolveAutoOpen(),
};

View file

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

View file

@ -483,6 +483,141 @@ describe("createAppBridgeFactory", () => {
expect(call.html).toContain("connect-src &#39;none&#39;");
});
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:6276/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:6276/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:6276/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:6276/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

View file

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

View file

@ -0,0 +1,119 @@
/**
* `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";
function jsonResponse(body: unknown, status = 200): Response {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as unknown as Response;
}
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:6276/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:6276/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 () => {
const fetchFn = vi.fn<typeof fetch>().mockResolvedValue({
ok: true,
status: 200,
json: async () => {
throw new Error("not json");
},
} as unknown as Response);
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();
}
});
});

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

View file

@ -0,0 +1,134 @@
/**
* `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:6276/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:6276/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:6276/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([
["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("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);
});
});

View file

@ -0,0 +1,375 @@
/**
* 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,
sandboxOriginList,
} from "../../../../server/app-origin-controller.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("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(["6276abc", "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("sandboxOriginList", () => {
it("reduces the sandbox URL to its origin", () => {
expect(sandboxOriginList("http://127.0.0.1:6275/sandbox")).toEqual([
"http://127.0.0.1:6275",
]);
});
it.each([[null], [undefined], [""]])(
"returns undefined for %j 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(
sandboxOriginList(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("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("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();
}
});
});

View file

@ -123,6 +123,7 @@ describe("startHonoServer autoOpen", () => {
storageDir: undefined,
allowedOrigins: [baseUrl],
sandboxPort: 0,
appOriginPort: 0,
sandboxHost: "127.0.0.1",
logger: undefined,
autoOpen: true,

View file

@ -73,6 +73,7 @@ describe("startHonoServer index.html token injection (/ -> /api/*)", () => {
// Allow the same-origin requests the test issues below.
allowedOrigins: [baseUrl],
sandboxPort: 0,
appOriginPort: 0,
sandboxHost: "127.0.0.1",
logger: undefined,
autoOpen: false,

View file

@ -26,6 +26,7 @@ const MUTATED_ENV_KEYS = [
"MCP_STORAGE_DIR",
"ALLOWED_ORIGINS",
"MCP_SANDBOX_PORT",
"MCP_APP_ORIGIN_PORT",
"SERVER_PORT",
"MCP_LOG_FILE",
"MCP_AUTO_OPEN_ENABLED",
@ -43,6 +44,7 @@ const baseConfig = (): WebServerConfig => ({
storageDir: undefined,
allowedOrigins: ["http://localhost:6274"],
sandboxPort: 0,
appOriginPort: 0,
sandboxHost: "127.0.0.1",
logger: undefined,
autoOpen: false,
@ -301,6 +303,17 @@ describe("buildWebServerConfigFromEnv", () => {
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9100);
});
it("resolves appOriginPort from MCP_APP_ORIGIN_PORT, defaulting to 6276", () => {
// The dedicated app origin (#2056). Deliberately NOT sharing the sandbox's
// SERVER_PORT fallback: it is a different listener and must be pinnable
// (and forwardable) on its own.
delete process.env.MCP_APP_ORIGIN_PORT;
process.env.SERVER_PORT = "9100";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(6276);
process.env.MCP_APP_ORIGIN_PORT = "9300";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(9300);
});
it("sets MCP_STORAGE_DIR when present", () => {
process.env.MCP_STORAGE_DIR = "/tmp/storage";
expect(buildWebServerConfigFromEnv().storageDir).toBe("/tmp/storage");

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="color-scheme" content="light dark" />
<!-- Isolation model: the inner iframe is sandboxed WITHOUT
<!-- Isolation model: by default the inner iframe is sandboxed WITHOUT
`allow-same-origin`, so the untrusted app runs under an opaque
("null") origin. That is the structural boundary — the app cannot
touch this proxy's DOM, window, or cookies, so it cannot bypass its
@ -15,6 +15,18 @@
resource-ready message's `sandbox` field; `allow-same-origin` is
always stripped from a server-supplied value.
Dedicated-origin exception (#2056): an app whose UI resource declares
`_meta.ui.domain` is instead served from the host's own app-origin
listener, handed to us as the resource-ready message's `src`. That
frame IS granted `allow-same-origin` — an opaque origin sends
`Origin: null`, which defeats the CORS / OAuth-callback / API-key
allowlists the field exists to support. The boundary is preserved by
the origin itself rather than by opacity: the listener is on its own
port, so the app is cross-origin to this proxy and to the host, and
the grant is refused outright if the URL's origin equals this page's.
The grant is host-driven — it is never reachable from the server's
own `sandbox` string, which is still stripped.
Network/resource egress from the inner document is constrained by a
Content-Security-Policy that the HOST builds and bakes into the
delivered HTML before it reaches this proxy (see
@ -85,13 +97,21 @@
// This is the origin we expect all parent messages to come from.
const EXPECTED_HOST_ORIGIN = new URL(document.referrer).origin;
// The inner iframe is sandboxed without allow-same-origin, so its
// document has an opaque origin and every postMessage from it arrives
// The inner iframe is sandboxed without allow-same-origin by default, so
// its document has an opaque origin and every postMessage from it arrives
// with event.origin === "null" (the literal string). That value is
// checked below as a defense-in-depth assertion; the actual identity
// proof is `event.source === inner.contentWindow`, which a sibling
// frame cannot forge.
const INNER_ORIGIN = "null";
//
// The dedicated-origin path (#2056) is the one exception: when the host
// supplies a `src` on its own app-origin listener, the frame is granted
// allow-same-origin so the app has a REAL origin — which is the entire
// point, since an opaque origin sends `Origin: null` and defeats any
// CORS / OAuth-callback / API-key allowlist the app's backend applies.
// `innerOrigin` is then that URL's origin, and the assertion above still
// holds against it.
let innerOrigin = "null";
// Security self-test: verify iframe isolation is working correctly.
// This MUST throw a SecurityError -- if `window.top` is accessible, the sandbox
@ -158,7 +178,37 @@
}
if (event.data && event.data.method === RESOURCE_READY_NOTIFICATION) {
const { html, sandbox, permissions } = event.data.params;
const { html, sandbox, permissions, src } = event.data.params;
// The host asked for the dedicated-origin path: it published the
// wrapped document to its own app-origin listener and is handing
// us the URL. Only an absolute http(s) URL is honored — anything
// else (a relative path, a `javascript:` or `data:` URL) falls
// through to the srcdoc path below rather than being navigated to.
let srcUrl = null;
if (typeof src === "string") {
try {
const parsed = new URL(src);
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
srcUrl = parsed;
} else {
console.warn("[Sandbox] ignoring non-http(s) src:", src);
}
} catch (e) {
console.warn("[Sandbox] ignoring unparseable src:", src);
}
}
// The app origin MUST differ from this proxy's own — otherwise
// granting allow-same-origin below would put the untrusted app in
// this realm, exactly the escape the double-iframe model exists to
// prevent. Refuse rather than degrade quietly: this is a host
// misconfiguration, not app input.
if (srcUrl && srcUrl.origin === window.location.origin) {
console.error(
"[Sandbox] refusing a src on the proxy's own origin:",
srcUrl.origin,
);
srcUrl = null;
}
if (typeof sandbox === "string") {
// Never grant allow-same-origin even if the server asks for it —
// that would let the untrusted app reach this proxy's DOM and
@ -179,13 +229,34 @@
}
inner.setAttribute("sandbox", safeTokens.join(" "));
}
if (srcUrl) {
// Re-grant what the strip above removes unconditionally. Safe
// here and only here: the document is served from a DIFFERENT
// origin than this proxy (asserted above), so same-origin policy
// still denies it this realm's DOM — it gains its own origin,
// not ours.
const tokens = (inner.getAttribute("sandbox") || "")
.split(/\s+/)
.filter(Boolean);
if (!tokens.some((t) => t.toLowerCase() === "allow-same-origin")) {
tokens.push("allow-same-origin");
}
inner.setAttribute("sandbox", tokens.join(" "));
}
// Set Permission Policy allow attribute if permissions are requested
const allowAttribute = buildAllowAttribute(permissions);
if (allowAttribute) {
console.log("[Sandbox] Setting allow attribute:", allowAttribute);
inner.setAttribute("allow", allowAttribute);
}
if (typeof html === "string") {
if (srcUrl) {
// Navigate to the host's app-origin URL. The per-app CSP is
// delivered as a real response header there (stronger than the
// <meta> the srcdoc path relies on), so no wrapping applies here
// — this proxy still never parses the untrusted bytes.
innerOrigin = srcUrl.origin;
inner.src = srcUrl.href;
} else if (typeof html === "string") {
// The host has already wrapped the app's HTML in a fixed shell
// whose first <head> child is the CSP <meta>; assign it verbatim.
// srcdoc is the only delivery path that works without
@ -198,7 +269,16 @@
}
} else {
if (inner && inner.contentWindow) {
inner.contentWindow.postMessage(event.data, "*");
// Target the inner frame's real origin on the dedicated-origin
// path, so a frame that navigated itself elsewhere stops
// receiving host messages. The opaque default has no targetable
// origin ("null" is not a valid targetOrigin), so it keeps "*" —
// safe there because the frame is `srcdoc` and cannot navigate
// to another origin's document without allow-top-navigation.
inner.contentWindow.postMessage(
event.data,
innerOrigin === "null" ? "*" : innerOrigin,
);
}
}
} else if (event.source === inner.contentWindow) {
@ -207,12 +287,12 @@
// sandbox means its origin serialises as the literal string "null";
// assert that as defense-in-depth so a future change that
// accidentally re-grants allow-same-origin is caught here.
if (event.origin !== INNER_ORIGIN) {
if (event.origin !== innerOrigin) {
console.error(
"[Sandbox] Rejecting message from inner iframe with unexpected origin:",
event.origin,
"expected opaque:",
INNER_ORIGIN,
"expected:",
innerOrigin,
);
return;
}

View file

@ -256,6 +256,23 @@ export interface RemoteServerOptions {
/** Optional sandbox URL for MCP Apps tab. When set, GET /api/config includes sandboxUrl. */
sandboxUrl?: string;
/**
* Publish a fully-wrapped MCP App document to the backend's dedicated
* app-origin listener and return the URL to load it from (#2056). When set,
* `POST /api/app-document` is served; when absent — or when it returns
* `null`, meaning the listener never bound — the route answers 503 and the
* browser falls back to the default opaque-origin `srcdoc` render.
*
* Supplied by the web backends only. It is an option rather than a route
* built here because the listener is a `clients/web/server` concern; this
* module owns nothing but the authenticated seam the browser reaches it
* through.
*/
publishAppDocument?: (doc: {
html: string;
csp?: string;
}) => { url: string } | null;
/** Initial config for GET /api/config. Caller must pass this (e.g. from webServerConfigToInitialPayload(config)). */
initialConfig: InitialConfigPayload;
@ -280,6 +297,15 @@ export interface RemoteServerOptions {
secretStorageResolver?: () => Promise<SecretStorageInfo | undefined>;
}
/**
* Upper bound on a single MCP App document accepted by `POST /api/app-document`
* (#2056). Counted in UTF-16 code units, which is what `String.length` gives —
* a loose but cheap proxy for bytes, and it only has to keep a runaway or
* hostile payload from being pinned in the backend's memory. Generous: a real
* app inlines its own scripts and styles into this one document.
*/
const MAX_APP_DOCUMENT_CHARS = 8 * 1024 * 1024;
export interface CreateRemoteAppResult {
/** The Hono app */
app: Hono;
@ -707,6 +733,45 @@ export function createRemoteApp(
return c.json(payload);
});
/**
* Hand the backend a wrapped MCP App document and get back the URL its
* dedicated origin serves it from (#2056).
*
* The browser is what holds the document — it read the `ui://` resource over
* the MCP connection and wrapped it — so the only way it can reach a real
* HTTP origin is to hand the bytes back. This route is the authenticated
* seam for that; the returned URL is unguessable and is served by a separate
* listener on its own port, which is what makes the app's origin real
* instead of opaque.
*/
app.post("/api/app-document", async (c) => {
const publish = options.publishAppDocument;
if (!publish) {
return c.json({ error: "App-origin hosting is not available" }, 503);
}
let body: { html?: unknown; csp?: unknown };
try {
body = (await c.req.json()) as { html?: unknown; csp?: unknown };
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
const { html, csp } = body;
if (typeof html !== "string" || html === "") {
return c.json({ error: "Missing html" }, 400);
}
if (csp !== undefined && typeof csp !== "string") {
return c.json({ error: "csp must be a string" }, 400);
}
if (html.length > MAX_APP_DOCUMENT_CHARS) {
return c.json({ error: "Document too large" }, 413);
}
const published = publish({ html, csp });
if (!published) {
return c.json({ error: "App-origin hosting is not available" }, 503);
}
return c.json(published);
});
app.post("/api/mcp/connect", async (c) => {
let body: RemoteConnectRequest;
try {