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:
parent
fd1fa06cf5
commit
06c7985d5c
2 changed files with 96 additions and 3 deletions
|
|
@ -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 '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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue