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

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