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 <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 20:42:06 -04:00
commit 0c989a0c34
3 changed files with 119 additions and 27 deletions

View file

@ -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<Resource[]>([]);
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,

View file

@ -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<string, unknown>,
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<string, unknown>): { ui: unknown } {
return { ui: meta };
}
async function flush(): Promise<void> {
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(
"<h1>x</h1>",
{
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("<h1>x</h1>"));
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("<h1>x</h1>", {
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("<h1>x</h1>"));
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 &#39;none&#39;");
});
it("ignores sandbox metadata written unnested on _meta", async () => {

View file

@ -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 <head> child is the CSP