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;
|
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(
|
function uiResource(
|
||||||
text: string | undefined,
|
text: string | undefined,
|
||||||
meta?: Record<string, unknown>,
|
meta?: Record<string, unknown>,
|
||||||
|
opts: { at?: "content" | "result"; flat?: boolean } = {},
|
||||||
): ReadResourceResult {
|
): ReadResourceResult {
|
||||||
|
const bag = meta ? { _meta: opts.flat ? meta : { ui: meta } } : {};
|
||||||
|
const onResult = opts.at === "result";
|
||||||
return {
|
return {
|
||||||
|
...(onResult ? bag : {}),
|
||||||
contents: [
|
contents: [
|
||||||
{
|
{
|
||||||
uri: "ui://weather/app.html",
|
uri: "ui://weather/app.html",
|
||||||
...(text === undefined ? {} : { text }),
|
...(text === undefined ? {} : { text }),
|
||||||
...(meta ? { _meta: meta } : {}),
|
...(onResult ? {} : bag),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as ReadResourceResult;
|
} 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 () => {
|
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
|
// The factory builds a per-app copy ({ ...HOST_CAPABILITIES }) so the
|
||||||
// sandbox echo never leaks across apps/renders. Lock that in: after a
|
// sandbox echo never leaks across apps/renders. Lock that in: after a
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,29 @@ export interface AppBridgeFactoryDeps {
|
||||||
advertiseElicitation?: boolean;
|
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): {
|
function extractHtmlAndMeta(result: ReadResourceResult): {
|
||||||
html: string;
|
html: string;
|
||||||
meta: McpUiResourceMeta | undefined;
|
meta: McpUiResourceMeta | undefined;
|
||||||
|
|
@ -110,7 +132,7 @@ function extractHtmlAndMeta(result: ReadResourceResult): {
|
||||||
if (typeof text === "string") {
|
if (typeof text === "string") {
|
||||||
return {
|
return {
|
||||||
html: text,
|
html: text,
|
||||||
meta: content._meta as McpUiResourceMeta | undefined,
|
meta: uiMeta(content._meta) ?? uiMeta(result._meta),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue