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

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