fix(web): restore the read result envelope as a metadata carrier
`McpUiReadResourceResult` — the return type of a registerAppResource read callback — types `_meta.ui` at the result level, so the envelope is a supported carrier after all and dropping it lost the hints for servers using that shape. Consult all three, most-specific first: content item, result envelope, listing entry; first found wins outright. Also make the App wiring test wait for BOTH factories by their advertiseElicitation flag. Waiting on a count passed as soon as either was built, so stripping the wiring from one call site left the other's deps in the array and the assertion loop still passed. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
286dd86f6b
commit
e2a88d49dd
3 changed files with 99 additions and 15 deletions
|
|
@ -3183,15 +3183,26 @@ describe("App MCP App listed-resource metadata wiring (#2055)", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
renderWithMantine(<App />);
|
renderWithMantine(<App />);
|
||||||
await waitFor(() => expect(appBridgeFactoryDeps.length).toBeGreaterThan(0));
|
|
||||||
|
|
||||||
// Every factory App builds gets the wiring, not just the Apps-tab one.
|
// App builds two factories, differing only in `advertiseElicitation`. Wait
|
||||||
for (const deps of appBridgeFactoryDeps) {
|
// for BOTH by that flag rather than for a count: waiting on "at least one"
|
||||||
expect(deps.getListedResourceMeta?.("ui://weather/app.html")).toEqual(
|
// would still pass with the wiring stripped from either call site, since
|
||||||
|
// the other's deps sit in the same array.
|
||||||
|
const appsFactory = () =>
|
||||||
|
appBridgeFactoryDeps.find((d) => !d.advertiseElicitation);
|
||||||
|
const elicitationFactory = () =>
|
||||||
|
appBridgeFactoryDeps.find((d) => d.advertiseElicitation === true);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(appsFactory()).toBeDefined();
|
||||||
|
expect(elicitationFactory()).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const deps of [appsFactory(), elicitationFactory()]) {
|
||||||
|
expect(deps?.getListedResourceMeta?.("ui://weather/app.html")).toEqual(
|
||||||
listedMeta,
|
listedMeta,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
deps.getListedResourceMeta?.("ui://other/app.html"),
|
deps?.getListedResourceMeta?.("ui://other/app.html"),
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -86,20 +86,24 @@ const fakeClient = { name: "sdk-client" } as unknown as Client;
|
||||||
/**
|
/**
|
||||||
* A UI resource read result. `meta` is the MCP Apps sandbox metadata, which the
|
* 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
|
* spec nests under the `_meta` bag's `ui` key — the helper wraps it so every
|
||||||
* test drives the real wire shape. `opts.flat` writes it to `_meta` unnested
|
* test drives the real wire shape. `opts.at` places the bag on the result
|
||||||
* instead (the non-conforming shape the host must ignore).
|
* envelope (`McpUiReadResourceResult`) instead of the content item, and
|
||||||
|
* `opts.flat` writes it unnested (the non-conforming shape the host ignores).
|
||||||
*/
|
*/
|
||||||
function uiResource(
|
function uiResource(
|
||||||
text: string | undefined,
|
text: string | undefined,
|
||||||
meta?: Record<string, unknown>,
|
meta?: Record<string, unknown>,
|
||||||
opts: { flat?: boolean } = {},
|
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: opts.flat ? meta : { ui: meta } } : {}),
|
...(onResult ? {} : bag),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as ReadResourceResult;
|
} as ReadResourceResult;
|
||||||
|
|
@ -242,6 +246,67 @@ describe("createAppBridgeFactory", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to the read result's _meta.ui when the content item carries none", async () => {
|
||||||
|
// `McpUiReadResourceResult` — what a registerAppResource callback returns —
|
||||||
|
// types `_meta.ui` at the envelope level, so a server stamping it there
|
||||||
|
// must have it honored.
|
||||||
|
const readResource = vi.fn().mockResolvedValue(
|
||||||
|
uiResource(
|
||||||
|
"<h1>x</h1>",
|
||||||
|
{
|
||||||
|
permissions: { microphone: {} },
|
||||||
|
csp: { connectDomains: ["https://envelope.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({ microphone: {} });
|
||||||
|
expect(call.html).toContain("connect-src https://envelope.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the read result's _meta.ui over the listing entry's", async () => {
|
||||||
|
// Precedence runs most-specific first: content item, then the result
|
||||||
|
// envelope, then the listing default.
|
||||||
|
const readResource = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
uiResource(
|
||||||
|
"<h1>x</h1>",
|
||||||
|
{ csp: { connectDomains: ["https://envelope.example.com"] } },
|
||||||
|
{ at: "result" },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const factory = createAppBridgeFactory({
|
||||||
|
getClient: () => fakeClient,
|
||||||
|
readResource,
|
||||||
|
getListedResourceMeta: () =>
|
||||||
|
listedMeta({ csp: { connectDomains: ["https://listed.example.com"] } }),
|
||||||
|
});
|
||||||
|
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).toContain("connect-src https://envelope.example.com");
|
||||||
|
expect(call.html).not.toContain("listed.example.com");
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to the resources/list entry's _meta.ui when the content block carries none", async () => {
|
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
|
// 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
|
// a UI resource, so an app declaring its CSP only there must still have it
|
||||||
|
|
|
||||||
|
|
@ -131,11 +131,18 @@ function uiMeta(meta: object | undefined): McpUiResourceMeta | undefined {
|
||||||
/**
|
/**
|
||||||
* First text content block of a UI resource, plus its `_meta.ui` sandbox hints.
|
* First text content block of a UI resource, plus its `_meta.ui` sandbox hints.
|
||||||
*
|
*
|
||||||
* `listedMeta` is the `_meta` of the matching `resources/list` entry, which
|
* ext-apps exposes three carriers, and they are consulted most-specific first:
|
||||||
* 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
|
* 1. the **read content item**'s own `_meta.ui`;
|
||||||
* precedence over it (`McpUiAppResourceConfig`). Nothing else is consulted —
|
* 2. the **read result envelope**'s — `McpUiReadResourceResult` (what a
|
||||||
* in particular the read RESULT's own `_meta` is not a documented carrier.
|
* `registerAppResource` callback returns) types `_meta.ui` at that level;
|
||||||
|
* 3. the matching **`resources/list` entry**'s, which `McpUiAppResourceConfig`
|
||||||
|
* documents as the static default a host reviews at connection time, and
|
||||||
|
* which a read content item explicitly takes precedence over.
|
||||||
|
*
|
||||||
|
* Whichever is found first wins outright — the levels are not merged, since a
|
||||||
|
* server that restates only `csp` at the more specific level means that to be
|
||||||
|
* the whole grant, not a patch over the broader one.
|
||||||
*/
|
*/
|
||||||
function extractHtmlAndMeta(
|
function extractHtmlAndMeta(
|
||||||
result: ReadResourceResult,
|
result: ReadResourceResult,
|
||||||
|
|
@ -149,7 +156,8 @@ function extractHtmlAndMeta(
|
||||||
if (typeof text === "string") {
|
if (typeof text === "string") {
|
||||||
return {
|
return {
|
||||||
html: text,
|
html: text,
|
||||||
meta: uiMeta(content._meta) ?? uiMeta(listedMeta),
|
meta:
|
||||||
|
uiMeta(content._meta) ?? uiMeta(result._meta) ?? uiMeta(listedMeta),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue