From 0c989a0c347e64c3be6dde80167fc70d61252277 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 23 Aug 2026 20:42:06 -0400 Subject: [PATCH] fix(web): use the resources/list entry as the UI metadata fallback The read RESULT's own `_meta` is not a documented carrier for the sandbox hints. ext-apps documents the `resources/list` entry's `_meta.ui` as the static default a host reviews at connection time, with a read content item's own `_meta.ui` taking precedence (McpUiAppResourceConfig). Swap the fallback accordingly and thread the listing through a new optional `getListedResourceMeta` dep, wired in App.tsx from the resources list. Signed-off-by: cliffhall --- clients/web/src/App.tsx | 21 +++- .../createAppBridgeFactory.test.ts | 100 ++++++++++++++---- .../AppRenderer/createAppBridgeFactory.ts | 29 ++++- 3 files changed, 121 insertions(+), 29 deletions(-) diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx index 729eb4d2..613ad758 100644 --- a/clients/web/src/App.tsx +++ b/clients/web/src/App.tsx @@ -25,6 +25,7 @@ import type { LoggingMessageNotification, Progress, ProgressToken, + Resource, Task, Tool, } from "@modelcontextprotocol/client"; @@ -784,10 +785,24 @@ function App() { }); }, [configBaseUrl]); + // The `resources/list` entries, read lazily by the App bridge factories below. + // ext-apps treats a listing entry's `_meta.ui` as the static default for its + // UI resource (a read content item's own `_meta.ui` wins), so the sandbox CSP + // has to be able to see it. A ref rather than a dependency: `resources` is + // derived further down this component, and the factories only read it inside + // an async sandboxready handler, long after any render that produced it. + const listedResourcesRef = useRef([]); + const getListedResourceMeta = useCallback( + (uri: string) => + listedResourcesRef.current.find((r) => r.uri === uri)?._meta, + [], + ); + const sandboxBridgeFactory = useMemo( () => createAppBridgeFactory({ getClient: () => inspectorClient?.getAppRendererClient() ?? null, + getListedResourceMeta, readResource: async (uri) => { if (!inspectorClient) throw new Error("No MCP client connected."); const invocation = await inspectorClient.readResource(uri); @@ -807,7 +822,7 @@ function App() { }); }, }), - [inspectorClient], + [inspectorClient, getListedResourceMeta], ); // App-rendered form elicitations (#1854). The controller is created once and @@ -861,6 +876,7 @@ function App() { createAppBridgeFactory({ advertiseElicitation: true, getClient: () => inspectorClient?.getAppRendererClient() ?? null, + getListedResourceMeta, readResource: async (uri) => { if (!inspectorClient) throw new Error("No MCP client connected."); const invocation = await inspectorClient.readResource(uri); @@ -877,7 +893,7 @@ function App() { }); }, }), - [inspectorClient], + [inspectorClient, getListedResourceMeta], ); /** * Close the previous client's session and open one for the client being @@ -1238,6 +1254,7 @@ function App() { const tools = toolsPagination.items; const prompts = promptsPagination.items; const resources = resourcesPagination.items; + listedResourcesRef.current = resources; const { tasks, refresh: refreshTasks, diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts index 60a5ba85..6b16d680 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.test.ts @@ -86,29 +86,30 @@ 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). + * test drives the real wire shape. `opts.flat` writes it to `_meta` unnested + * instead (the non-conforming shape the host must ignore). */ function uiResource( text: string | undefined, meta?: Record, - opts: { at?: "content" | "result"; flat?: boolean } = {}, + opts: { 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 }), - ...(onResult ? {} : bag), + ...(meta ? { _meta: opts.flat ? meta : { ui: meta } } : {}), }, ], } as ReadResourceResult; } +/** The `_meta` of a `resources/list` entry, as `getListedResourceMeta` returns it. */ +function listedMeta(meta: Record): { ui: unknown } { + return { ui: meta }; +} + async function flush(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -241,22 +242,54 @@ 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" }, - ), + it("falls back to the resources/list entry's _meta.ui when the content block carries none", async () => { + // ext-apps documents the listing-level `_meta.ui` as the static default for + // a UI resource, so an app declaring its CSP only there must still have it + // honored. + const readResource = vi.fn().mockResolvedValue(uiResource("

x

")); + const getListedResourceMeta = vi.fn().mockReturnValue( + listedMeta({ + permissions: { camera: {} }, + csp: { connectDomains: ["https://api.example.com"] }, + }), ); const factory = createAppBridgeFactory({ getClient: () => fakeClient, readResource, + getListedResourceMeta, + }); + await factory(makeIframe(), { kind: "tool", tool }); + const bridge = bridgeInstances[0]; + bridge.emit("sandboxready"); + await flush(); + + expect(getListedResourceMeta).toHaveBeenCalledWith("ui://weather/app.html"); + 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("prefers the read content item's _meta.ui over the listing entry's", async () => { + // The listing value is only a default: a content item that carries its own + // `_meta.ui` wins outright, rather than being merged with it. + const readResource = vi.fn().mockResolvedValue( + uiResource("

x

", { + csp: { connectDomains: ["https://read.example.com"] }, + }), + ); + const getListedResourceMeta = vi.fn().mockReturnValue( + listedMeta({ + permissions: { camera: {} }, + csp: { connectDomains: ["https://listed.example.com"] }, + }), + ); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource, + getListedResourceMeta, }); await factory(makeIframe(), { kind: "tool", tool }); const bridge = bridgeInstances[0]; @@ -267,8 +300,31 @@ describe("createAppBridgeFactory", () => { html: string; permissions: unknown; }; - expect(call.permissions).toEqual({ camera: {} }); - expect(call.html).toContain("connect-src https://api.example.com"); + expect(call.html).toContain("connect-src https://read.example.com"); + expect(call.html).not.toContain("listed.example.com"); + expect(call.permissions).toBeUndefined(); + }); + + it("renders with no sandbox hints when neither the content item nor the listing has _meta.ui", async () => { + // A host with no resource listing (or one not covering this URI) is + // unaffected: the optional dep is simply absent. + const readResource = vi.fn().mockResolvedValue(uiResource("

x

")); + const factory = createAppBridgeFactory({ + getClient: () => fakeClient, + readResource, + getListedResourceMeta: () => undefined, + }); + 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).toBeUndefined(); + expect(call.html).toContain("connect-src 'none'"); }); it("ignores sandbox metadata written unnested on _meta", async () => { diff --git a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts index 3f3e7156..6363d14e 100644 --- a/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts +++ b/clients/web/src/components/elements/AppRenderer/createAppBridgeFactory.ts @@ -88,6 +88,15 @@ export interface AppBridgeFactoryDeps { * frame; the error is also always console.error'd. */ onResourceError?: (err: Error) => void; + /** + * The `_meta` of the `resources/list` entry for `uri`, when the host has that + * listing. ext-apps treats the listing-level `_meta.ui` as the static default + * for a UI resource, so an app that declares its CSP only there must still + * get it honored; a read content item's own `_meta.ui` wins over it. Omitted + * (or returning undefined) simply means no default — the host that has no + * resource listing, or whose listing does not include this URI, is unaffected. + */ + getListedResourceMeta?: (uri: string) => object | undefined; /** * Advertise `hostCapabilities.elicitation` — "this host can forward a * form-mode `elicitation/create` to the app and return its result to the @@ -120,10 +129,17 @@ function uiMeta(meta: object | undefined): 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. + * + * `listedMeta` is the `_meta` of the matching `resources/list` entry, which + * ext-apps documents as the static default a host can review at connection + * time; a `resources/read` content item carrying its own `_meta.ui` takes + * precedence over it (`McpUiAppResourceConfig`). Nothing else is consulted — + * in particular the read RESULT's own `_meta` is not a documented carrier. */ -function extractHtmlAndMeta(result: ReadResourceResult): { +function extractHtmlAndMeta( + result: ReadResourceResult, + listedMeta?: object, +): { html: string; meta: McpUiResourceMeta | undefined; } { @@ -132,7 +148,7 @@ function extractHtmlAndMeta(result: ReadResourceResult): { if (typeof text === "string") { return { html: text, - meta: uiMeta(content._meta) ?? uiMeta(result._meta), + meta: uiMeta(content._meta) ?? uiMeta(listedMeta), }; } } @@ -293,7 +309,10 @@ export function createAppBridgeFactory( const uri = resolveSourceUri(source); if (!uri) return; const result = await deps.readResource(uri); - const { html, meta } = extractHtmlAndMeta(result); + const { html, meta } = extractHtmlAndMeta( + result, + deps.getListedResourceMeta?.(uri), + ); // 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 child is the CSP