fix(apps): gate the capability observer on the bridge's own initialize schema

The hand-rolled accept check was shallower than the bridge's, so frames it
rejects could still set `elicitation` here — `appInfo: {}` passed, though
`McpUiInitializeRequestSchema` requires an implementation name and version.
Any approximation drifts; the schema itself does not, and ext-apps exports it.

Capabilities are still read from the ORIGINAL frame rather than the parse
output, since that schema is precisely what strips `elicitation` — validating
with it and reading through it would defeat the module. The one check the
schema cannot make is that the frame is a request at all (a notification
carries the same method and params), so the id check stays.

Also stops treating a truthy non-object as an advertisement: the draft declares
`elicitation` as an object, so `elicitation: true` is a value it does not
define and the bridge's schema will reject it the moment it carries the key.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 21:29:55 -04:00
commit 98d1fe752d

View file

@ -57,6 +57,10 @@ describe("appCapabilities (#1854)", () => {
expect(appAdvertisesElicitation(bridge)).toBe(true);
});
it("is false when the bridge advertises nothing at all", () => {
expect(appAdvertisesElicitation(makeBridge(undefined))).toBe(false);
});
it("is false for an app that advertised no elicitation", () => {
const bridge = makeBridge({ availableDisplayModes: ["inline"] });
const { transport } = makeTransport();
@ -176,6 +180,40 @@ describe("appCapabilities (#1854)", () => {
expect(appAdvertisesElicitation(bridge)).toBe(true);
});
it("rejects a frame the bridge's own schema rejects", () => {
// `appInfo: {}` is missing the implementation name/version that
// `McpUiInitializeRequestSchema` requires, so the bridge answers an error
// and stores nothing — this gate must not be the laxer of the two.
const bridge = makeBridge({});
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
transport.onmessage?.({
jsonrpc: "2.0",
id: 1,
method: "ui/initialize",
params: {
protocolVersion: "2026-01-26",
appInfo: {},
appCapabilities: { elicitation: {} },
},
} as unknown as JSONRPCMessage);
expect(appAdvertisesElicitation(bridge)).toBe(false);
});
it("does not treat a non-object elicitation value as an advertisement", () => {
// The draft declares `elicitation` as an object; `true` is a value it does
// not define, and truthiness would accept it.
const bridge = makeBridge({});
const { transport } = makeTransport();
observeAppCapabilities(bridge, transport);
transport.onmessage?.(initializeFrame({ elicitation: true }));
expect(appAdvertisesElicitation(bridge)).toBe(false);
transport.onmessage?.(initializeFrame({ elicitation: [] }));
expect(appAdvertisesElicitation(bridge)).toBe(false);
transport.onmessage?.(initializeFrame({ elicitation: {} }));
expect(appAdvertisesElicitation(bridge)).toBe(true);
});
it("keeps bridges independent", () => {
const a = makeBridge({});
const b = makeBridge({});