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>
238 lines
9 KiB
TypeScript
238 lines
9 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
|
import type { JSONRPCMessage, Transport } from "@modelcontextprotocol/client";
|
|
import {
|
|
appAdvertisesElicitation,
|
|
observeAppCapabilities,
|
|
} from "./appCapabilities";
|
|
|
|
function makeBridge(parsed?: Record<string, unknown>): AppBridge {
|
|
return { getAppCapabilities: () => parsed } as unknown as AppBridge;
|
|
}
|
|
|
|
function makeTransport(): {
|
|
transport: Transport;
|
|
inner: ReturnType<typeof vi.fn>;
|
|
} {
|
|
const inner = vi.fn();
|
|
const transport = { onmessage: inner } as unknown as Transport;
|
|
return { transport, inner };
|
|
}
|
|
|
|
function initializeFrame(
|
|
appCapabilities: unknown,
|
|
overrides: Record<string, unknown> = {},
|
|
): JSONRPCMessage {
|
|
return {
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "ui/initialize",
|
|
params: {
|
|
protocolVersion: "2026-01-26",
|
|
appInfo: { name: "test-app", version: "1.0.0" },
|
|
appCapabilities,
|
|
...overrides,
|
|
},
|
|
} as unknown as JSONRPCMessage;
|
|
}
|
|
|
|
describe("appCapabilities (#1854)", () => {
|
|
it("records the raw ui/initialize capabilities the bridge's schema strips", () => {
|
|
// The whole reason this module exists: ext-apps 1.7.5 parses away the
|
|
// `elicitation` key, so an app that DID advertise it reads as one that did
|
|
// not — and every negotiated elicitation silently becomes a fallback.
|
|
const bridge = makeBridge({});
|
|
const { transport, inner } = makeTransport();
|
|
observeAppCapabilities(bridge, transport);
|
|
|
|
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
|
transport.onmessage?.(initializeFrame({ elicitation: {} }));
|
|
expect(appAdvertisesElicitation(bridge)).toBe(true);
|
|
// The bridge's own handler still runs — observing must not swallow.
|
|
expect(inner).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("prefers the bridge's own value once ext-apps carries it", () => {
|
|
const bridge = makeBridge({ elicitation: {} });
|
|
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();
|
|
observeAppCapabilities(bridge, transport);
|
|
transport.onmessage?.(initializeFrame({ availableDisplayModes: [] }));
|
|
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
|
});
|
|
|
|
it("ignores other methods and non-object capabilities", () => {
|
|
const bridge = makeBridge(undefined);
|
|
const { transport } = makeTransport();
|
|
observeAppCapabilities(bridge, transport);
|
|
transport.onmessage?.({
|
|
jsonrpc: "2.0",
|
|
method: "ui/notifications/initialized",
|
|
} as JSONRPCMessage);
|
|
transport.onmessage?.(initializeFrame("not-an-object"));
|
|
transport.onmessage?.(initializeFrame(null));
|
|
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
|
});
|
|
|
|
it("ignores frames that are not shaped like ui/initialize at all", () => {
|
|
// The frame comes from sandboxed view code, so nothing about its shape is
|
|
// guaranteed — a non-object, or an initialize with no params, must not
|
|
// throw on the way through to the bridge's own handler.
|
|
const bridge = makeBridge({});
|
|
const { transport, inner } = makeTransport();
|
|
observeAppCapabilities(bridge, transport);
|
|
transport.onmessage?.("not-a-frame" as unknown as JSONRPCMessage);
|
|
transport.onmessage?.(null as unknown as JSONRPCMessage);
|
|
transport.onmessage?.({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "ui/initialize",
|
|
} as unknown as JSONRPCMessage);
|
|
transport.onmessage?.({
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "ui/initialize",
|
|
params: null,
|
|
} as unknown as JSONRPCMessage);
|
|
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
|
expect(inner).toHaveBeenCalledTimes(4);
|
|
});
|
|
|
|
it("ignores a malformed initialize the bridge would reject", () => {
|
|
// Fail-closed: accepting one would let a view flip `elicitation` on with a
|
|
// frame that never negotiated anything, and the host would then forward a
|
|
// request the bridge does not consider negotiated.
|
|
const bridge = makeBridge({});
|
|
const { transport } = makeTransport();
|
|
observeAppCapabilities(bridge, transport);
|
|
|
|
// No protocolVersion.
|
|
transport.onmessage?.({
|
|
jsonrpc: "2.0",
|
|
id: 1,
|
|
method: "ui/initialize",
|
|
params: { appInfo: { name: "a" }, appCapabilities: { elicitation: {} } },
|
|
} as unknown as JSONRPCMessage);
|
|
// No appInfo.
|
|
transport.onmessage?.({
|
|
jsonrpc: "2.0",
|
|
id: 2,
|
|
method: "ui/initialize",
|
|
params: {
|
|
protocolVersion: "2026-01-26",
|
|
appCapabilities: { elicitation: {} },
|
|
},
|
|
} as unknown as JSONRPCMessage);
|
|
// A notification, not the handshake request.
|
|
transport.onmessage?.({
|
|
jsonrpc: "2.0",
|
|
method: "ui/initialize",
|
|
params: {
|
|
protocolVersion: "2026-01-26",
|
|
appInfo: { name: "a" },
|
|
appCapabilities: { elicitation: {} },
|
|
},
|
|
} as unknown as JSONRPCMessage);
|
|
|
|
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
|
});
|
|
|
|
it("takes the latest accepted handshake, in both directions", () => {
|
|
// Verified against ext-apps 1.7.5: a second `ui/initialize` is accepted —
|
|
// the bridge warns about the double-mount and the latest appInfo and
|
|
// capabilities REPLACE the previous ones. Freezing this at the first frame
|
|
// would leave the gate reporting capabilities the bridge no longer holds.
|
|
const bridge = makeBridge({});
|
|
const { transport } = makeTransport();
|
|
observeAppCapabilities(bridge, transport);
|
|
|
|
transport.onmessage?.(initializeFrame({ availableDisplayModes: [] }));
|
|
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
|
transport.onmessage?.(initializeFrame({ elicitation: {} }));
|
|
expect(appAdvertisesElicitation(bridge)).toBe(true);
|
|
// …and a re-handshake that drops the capability turns it back off.
|
|
transport.onmessage?.(initializeFrame({}));
|
|
expect(appAdvertisesElicitation(bridge)).toBe(false);
|
|
});
|
|
|
|
it("leaves a recorded capability alone when a later frame is malformed", () => {
|
|
// A rejected frame is not a route to changing the gate — in either
|
|
// direction: it cannot set `elicitation`, and it cannot clear one the
|
|
// bridge still holds.
|
|
const bridge = makeBridge({});
|
|
const { transport } = makeTransport();
|
|
observeAppCapabilities(bridge, transport);
|
|
transport.onmessage?.(initializeFrame({ elicitation: {} }));
|
|
transport.onmessage?.({
|
|
jsonrpc: "2.0",
|
|
id: 9,
|
|
method: "ui/initialize",
|
|
params: { appCapabilities: {} },
|
|
} as unknown as JSONRPCMessage);
|
|
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({});
|
|
const first = makeTransport();
|
|
const second = makeTransport();
|
|
observeAppCapabilities(a, first.transport);
|
|
observeAppCapabilities(b, second.transport);
|
|
first.transport.onmessage?.(initializeFrame({ elicitation: {} }));
|
|
expect(appAdvertisesElicitation(a)).toBe(true);
|
|
expect(appAdvertisesElicitation(b)).toBe(false);
|
|
});
|
|
|
|
it("tolerates a transport with no prior handler", () => {
|
|
const bridge = makeBridge({});
|
|
const transport = {} as unknown as Transport;
|
|
observeAppCapabilities(bridge, transport);
|
|
expect(() =>
|
|
transport.onmessage?.(initializeFrame({ elicitation: {} })),
|
|
).not.toThrow();
|
|
expect(appAdvertisesElicitation(bridge)).toBe(true);
|
|
});
|
|
});
|