fix(web): read MCP App UI resource metadata from _meta.ui

McpUiResourceMeta describes the value of a `_meta` bag's `ui` key, not
`_meta` itself, so casting `content._meta` to it left `csp` and
`permissions` undefined for every spec-conforming app. The generated
sandbox CSP then fell back to `connect-src 'none'` and the app's declared
connectDomains never took effect.

Unwrap `_meta.ui` (with a result-level fallback, which the spec allows)
through a small type-guarded helper, mirroring the tool side of this file
where `getToolUiResourceUri` already reads `_meta.ui.resourceUri`. The
test fixture now builds the real wire shape, and cases cover the
result-level fallback plus the unnested bag being ignored.

Closes #2055

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 20:27:58 -04:00
commit 06c7985d5c
2 changed files with 96 additions and 3 deletions

View file

@ -83,16 +83,27 @@ function makeIframe(hasWindow = true): HTMLIFrameElement {
const fakeClient = { name: "sdk-client" } as unknown as Client;
/**
* A UI resource read result. `meta` is the MCP Apps sandbox metadata, which the
* spec nests under the `_meta` bag's `ui` key — the helper wraps it so every
* test drives the real wire shape. `opts.at` places it on the result instead of
* the content block, and `opts.flat` writes it to `_meta` unnested (the
* non-conforming shape the host must ignore).
*/
function uiResource(
text: string | undefined,
meta?: Record<string, unknown>,
opts: { at?: "content" | "result"; flat?: boolean } = {},
): ReadResourceResult {
const bag = meta ? { _meta: opts.flat ? meta : { ui: meta } } : {};
const onResult = opts.at === "result";
return {
...(onResult ? bag : {}),
contents: [
{
uri: "ui://weather/app.html",
...(text === undefined ? {} : { text }),
...(meta ? { _meta: meta } : {}),
...(onResult ? {} : bag),
},
],
} as ReadResourceResult;
@ -230,6 +241,66 @@ describe("createAppBridgeFactory", () => {
});
});
it("falls back to the result-level _meta.ui when the content block carries none", async () => {
// The spec allows the sandbox metadata at either level; a server that only
// stamps the result must still get its connectDomains honored.
const readResource = vi.fn().mockResolvedValue(
uiResource(
"<h1>x</h1>",
{
permissions: { camera: {} },
csp: { connectDomains: ["https://api.example.com"] },
},
{ at: "result" },
),
);
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
});
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
permissions: unknown;
};
expect(call.permissions).toEqual({ camera: {} });
expect(call.html).toContain("connect-src https://api.example.com");
});
it("ignores sandbox metadata written unnested on _meta", async () => {
// `McpUiResourceMeta` describes the value of `_meta.ui`, not `_meta`. A bag
// whose keys sit at the top level is not the spec shape, so it must not be
// read as one — reading it there is what produced #2055 in reverse.
const readResource = vi
.fn()
.mockResolvedValue(
uiResource(
"<h1>x</h1>",
{ csp: { connectDomains: ["https://api.example.com"] } },
{ flat: true },
),
);
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
});
await factory(makeIframe(), { kind: "tool", tool });
const bridge = bridgeInstances[0];
bridge.emit("sandboxready");
await flush();
const call = bridge.sendSandboxResourceReady.mock.calls[0][0] as {
html: string;
};
expect(call.html).not.toContain("api.example.com");
// The policy is rendered into an HTML attribute, so quotes arrive escaped.
expect(call.html).toContain("connect-src &#39;none&#39;");
});
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

@ -100,7 +100,29 @@ export interface AppBridgeFactoryDeps {
advertiseElicitation?: boolean;
}
/** First text content block of a UI resource, plus its `_meta` (sandbox hints). */
/**
* The MCP Apps sandbox hints (`csp`, `permissions`, …) nested under a `_meta`
* bag's `ui` key. `McpUiResourceMeta` describes the value of `_meta.ui`, NOT
* `_meta` itself — reading `_meta` directly leaves every field `undefined`, so
* a spec-conforming app's declared `connectDomains` never reach the generated
* CSP and it renders under `connect-src 'none'` (#2055). This mirrors the tool
* side, where `getToolUiResourceUri` already reads `_meta.ui.resourceUri`.
*/
function uiMeta(meta: object | undefined): McpUiResourceMeta | undefined {
// `_meta` is typed with an index signature, which has no properties in common
// with a `{ ui?: unknown }` parameter — hence the cast rather than a narrower
// parameter type. Reading one key off an untyped bag is safe.
const ui = (meta as { ui?: unknown } | undefined)?.ui;
return ui !== null && typeof ui === "object"
? (ui as McpUiResourceMeta)
: undefined;
}
/**
* First text content block of a UI resource, plus its `_meta.ui` sandbox hints.
* The spec allows the metadata at either the content-block or the result level,
* so fall back to the result's own `_meta.ui` when the block carries none.
*/
function extractHtmlAndMeta(result: ReadResourceResult): {
html: string;
meta: McpUiResourceMeta | undefined;
@ -110,7 +132,7 @@ function extractHtmlAndMeta(result: ReadResourceResult): {
if (typeof text === "string") {
return {
html: text,
meta: content._meta as McpUiResourceMeta | undefined,
meta: uiMeta(content._meta) ?? uiMeta(result._meta),
};
}
}