Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ vi.mock("@modelcontextprotocol/ext-apps/app-bridge", () => {
};
});

import { createAppBridgeFactory } from "./createAppBridgeFactory";
import {
createAppBridgeFactory,
HOST_CAPABILITIES,
} from "./createAppBridgeFactory";

const tool: Tool = {
name: "weather_app",
Expand Down Expand Up @@ -141,11 +144,11 @@ describe("createAppBridgeFactory", () => {
}
});

it("on sandboxready, reads the UI resource and pushes html + meta to the sandbox", async () => {
it("on sandboxready, reads the UI resource, wraps the html with the per-app CSP, and echoes the approved sandbox config", async () => {
const readResource = vi.fn().mockResolvedValue(
uiResource("<h1>weather</h1>", {
permissions: { geolocation: {} },
csp: { connectSrc: ["https://api.example.com"] },
csp: { connectDomains: ["https://api.example.com"] },
}),
);
const factory = createAppBridgeFactory({
Expand All @@ -159,11 +162,79 @@ describe("createAppBridgeFactory", () => {
await flush();

expect(readResource).toHaveBeenCalledWith("ui://weather/app.html");
expect(bridge.sendSandboxResourceReady).toHaveBeenCalledWith({
html: "<h1>weather</h1>",
// The html is wrapped in a host-authored shell whose first <head> child is
// the CSP <meta>; the untrusted markup lands inside <body>. The per-app
// connect-src the app requested is baked into the policy.
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
permissions: unknown;
csp?: unknown;
};
expect(call.permissions).toEqual({ geolocation: {} });
// csp is NOT sent inline — it is enforced via the wrapped <meta> and echoed
// through hostCapabilities.sandbox instead.
expect(call.csp).toBeUndefined();
expect(call.html).toContain('http-equiv="Content-Security-Policy"');
expect(call.html).toContain("connect-src https://api.example.com");
expect(call.html).toContain("<body><h1>weather</h1></body>");

// The approved (post-filter) csp + permissions are echoed on the bridge's
// hostCapabilities so the view sees what was granted.
const caps = bridge.ctorArgs[2] as {
sandbox?: { permissions?: unknown; csp?: unknown };
};
expect(caps.sandbox).toEqual({
permissions: { geolocation: {} },
csp: { connectSrc: ["https://api.example.com"] },
csp: { connectDomains: ["https://api.example.com"] },
});
});

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
// sandboxready run that sets hostCapabilities.sandbox, the shared constant
// must stay untouched.
const readResource = vi.fn().mockResolvedValue(
uiResource("<h1>x</h1>", {
csp: { connectDomains: ["https://api.example.com"] },
}),
);
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
});
await factory(makeIframe(), tool);
bridgeInstances[0].emit("sandboxready");
await flush();
expect(HOST_CAPABILITIES.sandbox).toBeUndefined();
});

it("drops an unsafe app-supplied CSP source before wrapping", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const readResource = vi.fn().mockResolvedValue(
uiResource("<h1>x</h1>", {
// The second source injects a directive terminator — it must be dropped.
csp: {
connectDomains: ["https://ok.example.com", "evil; script-src *"],
},
}),
);
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
});
await factory(makeIframe(), tool);
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();

const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
};
expect(call.html).toContain("connect-src https://ok.example.com;");
expect(call.html).not.toContain("evil");
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});

it("does not push when the tool has no UI resource uri", async () => {
Expand All @@ -182,21 +253,72 @@ describe("createAppBridgeFactory", () => {
expect(bridgeInstances[0].sendSandboxResourceReady).not.toHaveBeenCalled();
});

it("swallows a resources/read failure without rejecting", async () => {
it("reports a resources/read failure via onResourceError and console.error without rejecting", async () => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const onResourceError = vi.fn();
const readResource = vi.fn().mockRejectedValue(new Error("read boom"));
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
onResourceError,
});
await factory(makeIframe(), tool);
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled();
expect(onResourceError).toHaveBeenCalledWith(
expect.objectContaining({ message: "read boom" }),
);
expect(err).toHaveBeenCalled();
err.mockRestore();
});

it("swallows a UI resource that has no text content", async () => {
it("reports a UI resource that has no text content via onResourceError", async () => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const onResourceError = vi.fn();
const readResource = vi.fn().mockResolvedValue(uiResource(undefined));
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
onResourceError,
});
await factory(makeIframe(), tool);
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled();
expect(onResourceError).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining("no text"),
}),
);
err.mockRestore();
});

it("wraps a non-Error rejection into an Error before reporting", async () => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const onResourceError = vi.fn();
const readResource = vi.fn().mockRejectedValue("plain string boom");
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
onResourceError,
});
await factory(makeIframe(), tool);
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
expect(onResourceError).toHaveBeenCalledWith(
expect.objectContaining({ message: "plain string boom" }),
);
expect(onResourceError.mock.calls[0][0]).toBeInstanceOf(Error);
err.mockRestore();
});

it("does not throw on read failure when onResourceError is omitted", async () => {
const err = vi.spyOn(console, "error").mockImplementation(() => {});
const readResource = vi.fn().mockRejectedValue(new Error("read boom"));
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
Expand All @@ -206,6 +328,8 @@ describe("createAppBridgeFactory", () => {
bridge.emit("sandboxready");
await flush();
expect(bridge.sendSandboxResourceReady).not.toHaveBeenCalled();
expect(err).toHaveBeenCalled();
err.mockRestore();
});

it("opens http(s) links in a new tab and reports non-http as error", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import type {
Implementation,
ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
import {
approveCspSources,
buildSandboxCspPolicy,
wrapSandboxedHtml,
} from "../../../lib/sandbox-csp";
import type { BridgeFactory } from "./AppRenderer";

/**
Expand Down Expand Up @@ -42,6 +47,12 @@ export interface AppBridgeFactoryDeps {
getClient: () => Client | null;
/** Reads a UI resource (resources/read) and returns the SDK result. */
readResource: (uri: string) => Promise<ReadResourceResult>;
/**
* Called when reading or posting the UI resource fails after the sandbox
* proxy is ready. Without this the user is left staring at a blank-but-live
* frame; the error is also always console.error'd.
*/
onResourceError?: (err: Error) => void;
}

/**
Expand Down Expand Up @@ -99,7 +110,8 @@ function extractHtmlAndMeta(result: ReadResourceResult): {
* 1. constructs a host-side {@link AppBridge} over the SDK client (so the view
* can call tools/resources/prompts directly),
* 2. on the sandbox proxy's `sandboxready` signal, reads the tool's UI
* resource and pushes its HTML + sandbox/permissions into the inner iframe,
* resource and pushes its HTML + sandbox/permissions/CSP into the inner
* iframe, echoing the applied sandbox config back via hostCapabilities,
* 3. handles `openLinks` by opening http(s) URLs in a new tab,
* 4. connects a {@link PostMessageTransport} to the iframe and returns the
* live bridge.
Expand All @@ -123,29 +135,64 @@ export function createAppBridgeFactory(
throw new Error("Cannot render MCP App: sandbox iframe has no window.");
}

const bridge = new AppBridge(client, HOST_INFO, HOST_CAPABILITIES, {
// Per-app copy so the approved-sandbox echo (set on sandboxready below)
// never mutates the shared HOST_CAPABILITIES constant — each app may
// declare its own csp/permissions.
const hostCapabilities: McpUiHostCapabilities = { ...HOST_CAPABILITIES };
const bridge = new AppBridge(client, HOST_INFO, hostCapabilities, {
hostContext: { theme: currentTheme() },
});

// The double-iframe proxy posts `sandboxready` once it can receive content.
// Read the tool's UI resource and hand its HTML (plus any sandbox/permission
// hints from the resource _meta) to the inner sandboxed iframe. Swallow
// failures: a read error leaves an empty (but live) app frame rather than
// tearing down the bridge mid-handshake.
// hints from the resource _meta) to the inner sandboxed iframe. A failure
// here is the case a developer most needs surfaced (their app's resource is
// erroring or malformed) — log it and report it via deps.onResourceError so
// the host can show something better than a blank frame. The bridge stays
// live so a retry path remains possible.
bridge.addEventListener("sandboxready", () => {
void (async () => {
try {
const uri = getToolUiResourceUri(tool);
if (!uri) return;
const result = await deps.readResource(uri);
const { html, meta } = extractHtmlAndMeta(result);
// Build the per-app CSP host-side: filter the requested sources to
// ones the host accepts, render the policy string, and wrap the
// app's HTML in a fixed shell whose first <head> child is the CSP
// <meta>. The proxy assigns that document to srcdoc verbatim — it
// never parses the untrusted bytes — so the policy is guaranteed to
// apply before any app content loads. The approved (post-filter) csp
// is what we echo back via hostCapabilities.sandbox so the view sees
// what was granted, not what it asked for. Set before
// sendSandboxResourceReady: the view only sends ui/initialize once it
// has the HTML, so the bridge reflects this in the initialize result.
const approvedCsp = approveCspSources(meta?.csp);
// NOTE on the CSP-vs-permissions asymmetry: `csp` is injection-filtered
// by approveCspSources because its sources are interpolated into the
// CSP <meta> content string. `permissions` is NOT filtered here — it is
// a structured object (camera/microphone/geolocation/clipboardWrite
// booleans), and its only consumer is the sandbox proxy's
// buildAllowAttribute(), which maps each known key to a fixed
// Permissions-Policy token and ignores anything else. Untrusted values
// therefore can't reach the iframe `sandbox`/`allow` attribute as raw
// text (that layer, and the allow-same-origin strip, is owned by the
// sandbox-hardening work in #1565), so no source-style allowlist applies.
hostCapabilities.sandbox = {
permissions: meta?.permissions,
csp: approvedCsp,
};
await bridge.sendSandboxResourceReady({
html,
html: wrapSandboxedHtml(html, buildSandboxCspPolicy(approvedCsp)),
permissions: meta?.permissions,
csp: meta?.csp,
});
} catch {
/* read/post failed (or bridge closed) — leave the frame empty */
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
console.error(
"[mcp-app] failed to load UI resource into sandbox:",
error,
);
deps.onResourceError?.(error);
}
})();
});
Expand Down
Loading