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

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