From 06c7985d5c0a4fc6f9ebaa29726d83aecc1d5752 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 23 Aug 2026 20:27:58 -0400 Subject: [PATCH] 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 --- .../createAppBridgeFactory.test.ts | 73 ++++++++++++++++++- .../AppRenderer/createAppBridgeFactory.ts | 26 ++++++- 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts index 7f32c26b..60a5ba85 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts @@ -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, + 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( + "

x

", + { + 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( + "

x

", + { 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 'none'"); + }); + 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 diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts index 1447d359..3f3e7156 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts @@ -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), }; } }