feat(web): honor _meta.ui.domain with a dedicated app origin
An MCP App is rendered by handing its HTML to the sandbox proxy as `srcdoc`, in a frame sandboxed without `allow-same-origin` — the #1565 isolation model. That gives the app document an opaque origin, so every request it makes carries `Origin: null` and no CORS / OAuth-callback / API-key allowlist can admit it. `_meta.ui.domain` is the spec field a server uses to ask its host for a stable, dedicated origin, and it was being dropped on the floor. The field is explicitly host-dependent and the Inspector owns no domain infrastructure, so it treats any non-empty value as a request rather than an address, and answers with a real loopback origin on its own listener (MCP_APP_ORIGIN_PORT, default 6276). One shared origin, path-keyed per document: that delivers what the field is for without minting a port or DNS name per app, and is therefore not a per-app isolation boundary. The inner frame is granted `allow-same-origin` on this path only, which is what makes the origin real. It is not a #1565 regression: the listener is on its own port, so the app stays cross-origin to both the proxy and the Inspector. The proxy refuses the grant if the URL's origin equals its own, and the grant is never reachable from the server-supplied `sandbox` string, which is still stripped. The per-app CSP is delivered as a real response header there rather than only as a <meta>. Every failure path falls back to the default srcdoc render with a console warning naming the field, rather than blanking the app. Also fixes a pre-existing `tsc -b` break on v2/main (useServers.test.tsx passed `metadata: []` where `RequestMetadata` is expected), which has had CI red since #2087 and blocks the pre-push gate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
5de0b8a5ca
commit
ec40cf5531
2 changed files with 1554 additions and 18 deletions
|
|
@ -483,6 +483,141 @@ describe("createAppBridgeFactory", () => {
|
|||
expect(call.html).toContain("connect-src 'none'");
|
||||
});
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue