feat(apps): negotiated app-rendered form elicitations
Let a server hand a form `elicitation/create` to an MCP App and return the app's ordinary `ElicitResult`, with the native elicitation form as the fallback on every failure. No second extension, no custom method, and no custom result shape: the only new wire surface is a nested `elicitation` flag on the existing `io.modelcontextprotocol/ui` extension on each side, plus `_meta.ui.resourceUri` on the request. App rendering is selected only when all four gates hold — client `elicitation.form`, client MCP Apps MIME type, the nested `elicitation` setting on BOTH peers, and a valid absolute `ui://` URI on the request. Only the web client advertises the nested client-side setting, and only because it has a sandbox renderer: supplying `InspectorClientOptions.appElicitation` is what opts a client in, so CLI and TUI keep advertising the MIME type without ever claiming they can resolve an elicitation through an app. Routing lives in the one funnel both entry points already use (`enqueuePendingElicitation`), so the inbound handler and the MRTR driver cannot diverge. Ownership is request-scoped — a per-request id keys the renderer, iframe and bridge — so two concurrent elicitations can never resolve through each other's bridges. An explicit `decline`/`cancel` is a completed elicitation and goes back to the server; everything else (absent or malformed metadata, resource/sandbox/bridge failure, an app with no elicitation capability, a timeout, an invalid result or one that fails the requested schema) falls back. The Inspector speaks the ext-apps#733 / SEP-3118 wire protocol but cannot yet consume its helpers — the released `@modelcontextprotocol/ext-apps` (1.7.5) predates that PR. `core/mcp/appElicitation.ts` and `AppRenderer/requestAppElicitation.ts` mirror it exactly and are marked for deletion once a release containing it ships. One consequence needed its own seam: ext-apps 1.7.5 parses the view's `ui/initialize` through a schema that strips `elicitation`, so an app that correctly advertises it would look like one that did not, silently turning every negotiated elicitation into a fallback. `AppRenderer/appCapabilities.ts` records the raw frame instead, and prefers the bridge's own value once it carries the key. `AppRenderer` now takes an `AppRenderSource` union rather than a `Tool`, so the same renderer and bridge factory serve an App tool and an elicitation without either faking the other's shape. Adds the public fixture (`app_choose_option` + a self-contained `ui://demo/choose-option.html` app covering accept/decline/cancel), its two showcase configs, and `smoke:web:elicit`, which drives the negotiated path end to end in headless Chromium and then the same tool against a server that never advertised the capability, asserting the native form takes it. Closes #1854 Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
43525d9ea2
commit
08fc20a22d
4 changed files with 3718 additions and 93 deletions
274
clients/web/src/test/core/mcp/appElicitation.test.ts
Normal file
274
clients/web/src/test/core/mcp/appElicitation.test.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
ElicitRequest,
|
||||
ElicitResult,
|
||||
ServerCapabilities,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv";
|
||||
import {
|
||||
getElicitationUiResourceUri,
|
||||
getUiClientCapability,
|
||||
getUiServerCapability,
|
||||
isFormElicitation,
|
||||
supportsAppElicitation,
|
||||
validateAppElicitResult,
|
||||
} from "@inspector/core/mcp/appElicitation.js";
|
||||
import {
|
||||
MCP_APP_MIME_TYPE,
|
||||
UI_EXTENSION_KEY,
|
||||
} from "@inspector/core/mcp/extensions.js";
|
||||
|
||||
/** A client that satisfies all three client-side negotiation gates. */
|
||||
function eligibleClient(): ClientCapabilities {
|
||||
return {
|
||||
elicitation: { form: {} },
|
||||
extensions: {
|
||||
[UI_EXTENSION_KEY]: {
|
||||
mimeTypes: [MCP_APP_MIME_TYPE],
|
||||
elicitation: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A server that advertises the nested MCP Apps elicitation setting. */
|
||||
function eligibleServer(): ServerCapabilities {
|
||||
return { extensions: { [UI_EXTENSION_KEY]: { elicitation: {} } } };
|
||||
}
|
||||
|
||||
function formParams(
|
||||
extra: Partial<ElicitRequest["params"]> = {},
|
||||
): ElicitRequest["params"] {
|
||||
return {
|
||||
message: "Choose an option",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: { choice: { type: "string" } },
|
||||
required: ["choice"],
|
||||
},
|
||||
...extra,
|
||||
} as ElicitRequest["params"];
|
||||
}
|
||||
|
||||
describe("appElicitation negotiation (#1854)", () => {
|
||||
describe("capability readers", () => {
|
||||
it("reads the UI block from either side", () => {
|
||||
expect(getUiClientCapability(eligibleClient())).toEqual({
|
||||
mimeTypes: [MCP_APP_MIME_TYPE],
|
||||
elicitation: {},
|
||||
});
|
||||
expect(getUiServerCapability(eligibleServer())).toEqual({
|
||||
elicitation: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for absent, null and non-object capabilities", () => {
|
||||
expect(getUiClientCapability(undefined)).toBeUndefined();
|
||||
expect(getUiClientCapability(null)).toBeUndefined();
|
||||
expect(getUiClientCapability({})).toBeUndefined();
|
||||
expect(getUiServerCapability({ extensions: {} })).toBeUndefined();
|
||||
expect(
|
||||
getUiClientCapability({
|
||||
extensions: { [UI_EXTENSION_KEY]: null },
|
||||
} as unknown as ClientCapabilities),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsAppElicitation", () => {
|
||||
it("is true only when all four gates pass", () => {
|
||||
expect(supportsAppElicitation(eligibleClient(), eligibleServer())).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false without the core form elicitation capability", () => {
|
||||
const client = eligibleClient();
|
||||
delete client.elicitation;
|
||||
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the client advertised only url-mode elicitation", () => {
|
||||
const client = { ...eligibleClient(), elicitation: { url: {} } };
|
||||
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the client does not accept the MCP App MIME type", () => {
|
||||
const client: ClientCapabilities = {
|
||||
elicitation: { form: {} },
|
||||
extensions: {
|
||||
[UI_EXTENSION_KEY]: { mimeTypes: ["text/html"], elicitation: {} },
|
||||
},
|
||||
};
|
||||
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the client advertises the MIME type but not elicitation", () => {
|
||||
// The specific "MIME type alone is not sufficient" case: this is exactly
|
||||
// what a CLI/TUI client looks like, and it must not be offered an app.
|
||||
const client: ClientCapabilities = {
|
||||
elicitation: { form: {} },
|
||||
extensions: {
|
||||
[UI_EXTENSION_KEY]: { mimeTypes: [MCP_APP_MIME_TYPE] },
|
||||
},
|
||||
};
|
||||
expect(supportsAppElicitation(client, eligibleServer())).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the server did not advertise it", () => {
|
||||
expect(supportsAppElicitation(eligibleClient(), {})).toBe(false);
|
||||
expect(
|
||||
supportsAppElicitation(eligibleClient(), {
|
||||
extensions: { [UI_EXTENSION_KEY]: {} },
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(supportsAppElicitation(eligibleClient(), undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getElicitationUiResourceUri", () => {
|
||||
it("returns the URI from _meta.ui.resourceUri", () => {
|
||||
expect(
|
||||
getElicitationUiResourceUri(
|
||||
formParams({ _meta: { ui: { resourceUri: "ui://demo/pick.html" } } }),
|
||||
),
|
||||
).toBe("ui://demo/pick.html");
|
||||
});
|
||||
|
||||
it("returns undefined when no app is attached", () => {
|
||||
expect(getElicitationUiResourceUri(formParams())).toBeUndefined();
|
||||
expect(
|
||||
getElicitationUiResourceUri(formParams({ _meta: {} })),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getElicitationUiResourceUri(formParams({ _meta: { ui: "nope" } })),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getElicitationUiResourceUri(formParams({ _meta: { ui: null } })),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getElicitationUiResourceUri(formParams({ _meta: { ui: {} } })),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws on a non-string resourceUri", () => {
|
||||
expect(() =>
|
||||
getElicitationUiResourceUri(
|
||||
formParams({ _meta: { ui: { resourceUri: 42 } } }),
|
||||
),
|
||||
).toThrow(/must be a string/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["relative", "demo/pick.html"],
|
||||
["wrong scheme", "https://example.com/pick.html"],
|
||||
["scheme with no host", "ui:///pick.html"],
|
||||
["bare scheme", "ui:pick.html"],
|
||||
["not a URL at all", " "],
|
||||
])("throws on a %s URI", (_label, uri) => {
|
||||
expect(() =>
|
||||
getElicitationUiResourceUri(
|
||||
formParams({ _meta: { ui: { resourceUri: uri } } }),
|
||||
),
|
||||
).toThrow(/absolute ui:\/\/ URI/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFormElicitation", () => {
|
||||
it("treats an omitted mode as form", () => {
|
||||
expect(isFormElicitation(formParams())).toBe(true);
|
||||
expect(isFormElicitation(formParams({ mode: "form" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects url mode", () => {
|
||||
expect(isFormElicitation(formParams({ mode: "url" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateAppElicitResult", () => {
|
||||
const provider = new AjvJsonSchemaValidator();
|
||||
const params = formParams();
|
||||
|
||||
it("accepts a well-formed accept", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "accept",
|
||||
content: { choice: "option-a" },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts decline and cancel with no content", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, { action: "decline" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, { action: "cancel" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects a non-object result", () => {
|
||||
expect(
|
||||
validateAppElicitResult(
|
||||
provider,
|
||||
params,
|
||||
null as unknown as ElicitResult,
|
||||
),
|
||||
).toMatch(/non-object/);
|
||||
});
|
||||
|
||||
it("rejects an unknown action", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "maybe",
|
||||
} as unknown as ElicitResult),
|
||||
).toMatch(/unknown elicitation action/);
|
||||
});
|
||||
|
||||
it("rejects an accept with no usable content", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "accept",
|
||||
} as ElicitResult),
|
||||
).toMatch(/without a content object/);
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "accept",
|
||||
content: [] as unknown as Record<string, never>,
|
||||
} as ElicitResult),
|
||||
).toMatch(/without a content object/);
|
||||
});
|
||||
|
||||
it("rejects content that does not match the requested schema", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "accept",
|
||||
content: { choice: 7 } as unknown as Record<string, never>,
|
||||
} as ElicitResult),
|
||||
).toMatch(/does not match the requested schema/);
|
||||
});
|
||||
|
||||
it("skips validation when the request declared no usable schema", () => {
|
||||
const noSchema = { message: "hi" } as ElicitRequest["params"];
|
||||
expect(
|
||||
validateAppElicitResult(provider, noSchema, {
|
||||
action: "accept",
|
||||
content: { anything: true },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reject a result over a schema the validator cannot compile", () => {
|
||||
const badSchema = formParams({
|
||||
requestedSchema: { type: "object", properties: { a: { type: 9 } } },
|
||||
} as unknown as Partial<ElicitRequest["params"]>);
|
||||
expect(
|
||||
validateAppElicitResult(provider, badSchema, {
|
||||
action: "accept",
|
||||
content: { a: 1 },
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -145,4 +145,42 @@ describe("extensions (#1738, #1740)", () => {
|
|||
expect(map).toEqual({ [EMA_EXTENSION_KEY]: {} });
|
||||
});
|
||||
});
|
||||
|
||||
describe("app-rendered elicitation opt-in (#1854)", () => {
|
||||
it("does not advertise the nested elicitation setting by default", () => {
|
||||
const map = buildClientExtensions({ enterpriseManaged: false });
|
||||
expect(map[UI_EXTENSION_KEY]).toEqual(UI_ADVERTISEMENT);
|
||||
});
|
||||
|
||||
it("nests `elicitation` inside the UI extension when opted in", () => {
|
||||
const map = buildClientExtensions({
|
||||
enterpriseManaged: false,
|
||||
appElicitation: true,
|
||||
});
|
||||
expect(map[UI_EXTENSION_KEY]).toEqual({
|
||||
...UI_ADVERTISEMENT,
|
||||
elicitation: {},
|
||||
});
|
||||
// A nested setting, NOT a second extension — the contract is explicit
|
||||
// that no new extension id is introduced.
|
||||
expect(Object.keys(map)).toEqual([TASKS_EXTENSION_KEY, UI_EXTENSION_KEY]);
|
||||
});
|
||||
|
||||
it("advertises nothing when the UI extension itself is turned off", () => {
|
||||
const map = buildClientExtensions({
|
||||
enterpriseManaged: false,
|
||||
appElicitation: true,
|
||||
advertised: { [UI_EXTENSION_KEY]: false },
|
||||
});
|
||||
expect(map).not.toHaveProperty(UI_EXTENSION_KEY);
|
||||
});
|
||||
|
||||
it("does not mutate the shared registry advertisement", () => {
|
||||
buildClientExtensions({ enterpriseManaged: false, appElicitation: true });
|
||||
const ui = ADVERTISABLE_EXTENSIONS.find(
|
||||
(e) => e.key === UI_EXTENSION_KEY,
|
||||
);
|
||||
expect(ui?.advertisement).toEqual(UI_ADVERTISEMENT);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,431 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import type {
|
||||
ClientCapabilities,
|
||||
ElicitResult,
|
||||
JSONRPCMessage,
|
||||
ServerCapabilities,
|
||||
Transport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import type {
|
||||
AppElicitationRenderer,
|
||||
AppElicitationRequest,
|
||||
} from "@inspector/core/mcp/appElicitation.js";
|
||||
import {
|
||||
MCP_APP_MIME_TYPE,
|
||||
UI_EXTENSION_KEY,
|
||||
} from "@inspector/core/mcp/extensions.js";
|
||||
|
||||
/**
|
||||
* Routing coverage for app-rendered form elicitations (#1854).
|
||||
*
|
||||
* Everything here drives the REAL inbound `elicitation/create` handler over a
|
||||
* fake transport, because the whole feature is a decision made inside that
|
||||
* handler: which of two user interfaces answers a server's request. Asserting
|
||||
* on the reply frame — rather than on an internal — is what proves the server
|
||||
* gets the app's standard `ElicitResult` unchanged.
|
||||
*/
|
||||
|
||||
const APP_URI = "ui://demo/choose-option.html";
|
||||
|
||||
const REQUESTED_SCHEMA = {
|
||||
type: "object" as const,
|
||||
properties: { choice: { type: "string" as const } },
|
||||
required: ["choice"],
|
||||
};
|
||||
|
||||
/** Server capabilities advertising the nested MCP Apps elicitation setting. */
|
||||
const APP_SERVER_CAPABILITIES: ServerCapabilities = {
|
||||
extensions: { [UI_EXTENSION_KEY]: { elicitation: {} } },
|
||||
};
|
||||
|
||||
class ElicitTransport implements Transport {
|
||||
onmessage?: (message: JSONRPCMessage) => void;
|
||||
onclose?: () => void;
|
||||
onerror?: (error: Error) => void;
|
||||
|
||||
private readonly waiters = new Map<
|
||||
string | number,
|
||||
(m: JSONRPCMessage) => void
|
||||
>();
|
||||
|
||||
private readonly serverCapabilities: ServerCapabilities;
|
||||
|
||||
constructor(
|
||||
serverCapabilities: ServerCapabilities = APP_SERVER_CAPABILITIES,
|
||||
) {
|
||||
this.serverCapabilities = serverCapabilities;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {}
|
||||
async close(): Promise<void> {}
|
||||
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
if (
|
||||
"method" in message &&
|
||||
message.method === "initialize" &&
|
||||
"id" in message
|
||||
) {
|
||||
const params = message.params as { protocolVersion: string };
|
||||
this.deliver({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
protocolVersion: params.protocolVersion,
|
||||
capabilities: this.serverCapabilities,
|
||||
serverInfo: { name: "app-elicit-server", version: "1.0.0" },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
// A reply to something we injected.
|
||||
if (
|
||||
"id" in message &&
|
||||
message.id !== undefined &&
|
||||
("result" in message || "error" in message)
|
||||
) {
|
||||
this.waiters.get(message.id)?.(message);
|
||||
this.waiters.delete(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send an `elicitation/create` and resolve with the client's reply frame. */
|
||||
elicit(
|
||||
id: number,
|
||||
params: Record<string, unknown>,
|
||||
timeoutMs = 2000,
|
||||
): Promise<JSONRPCMessage> {
|
||||
const reply = new Promise<JSONRPCMessage>((resolve) => {
|
||||
this.waiters.set(id, resolve);
|
||||
});
|
||||
this.deliver({ jsonrpc: "2.0", id, method: "elicitation/create", params });
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
return Promise.race([
|
||||
reply,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error(`No reply to elicitation ${id}`)),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
private deliver(message: JSONRPCMessage): void {
|
||||
this.onmessage?.(message);
|
||||
}
|
||||
}
|
||||
|
||||
function appParams(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
message: "Choose an option",
|
||||
requestedSchema: REQUESTED_SCHEMA,
|
||||
_meta: { ui: { resourceUri: APP_URI } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function connectClient(options: {
|
||||
transport: ElicitTransport;
|
||||
appElicitation?: AppElicitationRenderer;
|
||||
elicit?: boolean | { form?: boolean; url?: boolean };
|
||||
}) {
|
||||
const client = new InspectorClient(
|
||||
{ type: "stdio", command: "noop", args: [] },
|
||||
{
|
||||
environment: { transport: () => ({ transport: options.transport }) },
|
||||
elicit: options.elicit ?? { form: true },
|
||||
...(options.appElicitation && { appElicitation: options.appElicitation }),
|
||||
},
|
||||
);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
/** The `io.modelcontextprotocol/ui` block the client advertised. */
|
||||
function advertisedUi(client: InspectorClient) {
|
||||
const capabilities = (
|
||||
client as unknown as { clientCapabilities: ClientCapabilities }
|
||||
).clientCapabilities;
|
||||
return capabilities.extensions?.[UI_EXTENSION_KEY] as
|
||||
| { mimeTypes?: string[]; elicitation?: object }
|
||||
| undefined;
|
||||
}
|
||||
|
||||
describe("app-rendered elicitation routing (#1854)", () => {
|
||||
describe("capability advertisement", () => {
|
||||
it("advertises the nested elicitation setting when a renderer is supplied", async () => {
|
||||
const client = await connectClient({
|
||||
transport: new ElicitTransport(),
|
||||
appElicitation: async () => ({ action: "cancel" }),
|
||||
});
|
||||
expect(advertisedUi(client)).toEqual({
|
||||
mimeTypes: [MCP_APP_MIME_TYPE],
|
||||
elicitation: {},
|
||||
});
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("does not advertise it on a client with no renderer (CLI/TUI)", async () => {
|
||||
// The MIME type alone is what CLI and TUI advertise, and it must stay
|
||||
// that way: they know the type but cannot host an app.
|
||||
const client = await connectClient({ transport: new ElicitTransport() });
|
||||
expect(advertisedUi(client)).toEqual({ mimeTypes: [MCP_APP_MIME_TYPE] });
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("omits both capabilities when form elicitation is disabled", async () => {
|
||||
const client = await connectClient({
|
||||
transport: new ElicitTransport(),
|
||||
appElicitation: async () => ({ action: "cancel" }),
|
||||
elicit: { url: true },
|
||||
});
|
||||
const capabilities = (
|
||||
client as unknown as { clientCapabilities: ClientCapabilities }
|
||||
).clientCapabilities;
|
||||
expect(capabilities.elicitation?.form).toBeUndefined();
|
||||
expect(advertisedUi(client)?.elicitation).toBeUndefined();
|
||||
await client.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe("negotiated happy path", () => {
|
||||
it("returns the app's accept result to the server, without queueing a native request", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
const seen: AppElicitationRequest[] = [];
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: async (request) => {
|
||||
seen.push(request);
|
||||
return { action: "accept", content: { choice: "option-a" } };
|
||||
},
|
||||
});
|
||||
|
||||
const reply = await transport.elicit(1, appParams());
|
||||
|
||||
expect(reply).toMatchObject({
|
||||
id: 1,
|
||||
result: { action: "accept", content: { choice: "option-a" } },
|
||||
});
|
||||
// Request-scoped: the renderer is told exactly which resource and which
|
||||
// params, and gets a distinct id per request.
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].resourceUri).toBe(APP_URI);
|
||||
expect(seen[0].params.message).toBe("Choose an option");
|
||||
// The native queue was never opened — the whole point of the routing.
|
||||
expect(client.getPendingElicitations()).toHaveLength(0);
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it.each([["decline"], ["cancel"]] as const)(
|
||||
"returns an explicit %s without opening the native UI",
|
||||
async (action) => {
|
||||
const transport = new ElicitTransport();
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: async () => ({ action }) as ElicitResult,
|
||||
});
|
||||
const reply = await transport.elicit(2, appParams());
|
||||
expect(reply).toMatchObject({ id: 2, result: { action } });
|
||||
expect(client.getPendingElicitations()).toHaveLength(0);
|
||||
await client.disconnect();
|
||||
},
|
||||
);
|
||||
|
||||
it("gives concurrent requests distinct ids and never crosses their results", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
const settle = new Map<string, (r: ElicitResult) => void>();
|
||||
const byUri = new Map<string, string>();
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: (request) =>
|
||||
new Promise<ElicitResult>((resolve) => {
|
||||
byUri.set(request.resourceUri, request.requestId);
|
||||
settle.set(request.requestId, resolve);
|
||||
}),
|
||||
});
|
||||
|
||||
const first = transport.elicit(
|
||||
10,
|
||||
appParams({ _meta: { ui: { resourceUri: "ui://demo/first.html" } } }),
|
||||
);
|
||||
const second = transport.elicit(
|
||||
11,
|
||||
appParams({ _meta: { ui: { resourceUri: "ui://demo/second.html" } } }),
|
||||
);
|
||||
await vi.waitFor(() => expect(settle.size).toBe(2));
|
||||
|
||||
const firstId = byUri.get("ui://demo/first.html")!;
|
||||
const secondId = byUri.get("ui://demo/second.html")!;
|
||||
expect(firstId).not.toBe(secondId);
|
||||
// Answer them out of order: an implementation keyed on "the active app"
|
||||
// rather than on the request would hand each answer to the wrong request.
|
||||
settle.get(secondId)!({ action: "accept", content: { choice: "b" } });
|
||||
settle.get(firstId)!({ action: "accept", content: { choice: "a" } });
|
||||
|
||||
expect(await first).toMatchObject({
|
||||
id: 10,
|
||||
result: { content: { choice: "a" } },
|
||||
});
|
||||
expect(await second).toMatchObject({
|
||||
id: 11,
|
||||
result: { content: { choice: "b" } },
|
||||
});
|
||||
await client.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe("native fallback", () => {
|
||||
/**
|
||||
* Every fallback case asserts the same shape: the renderer is not used (or
|
||||
* fails), and the request lands in the native pending queue instead, which
|
||||
* we then answer to keep the server from waiting.
|
||||
*/
|
||||
async function expectNativeFallback(
|
||||
transport: ElicitTransport,
|
||||
client: InspectorClient,
|
||||
id: number,
|
||||
params: Record<string, unknown>,
|
||||
) {
|
||||
const reply = transport.elicit(id, params);
|
||||
await vi.waitFor(() =>
|
||||
expect(client.getPendingElicitations()).toHaveLength(1),
|
||||
);
|
||||
// `respond` settles the queued request; its own send is fire-and-forget.
|
||||
void client
|
||||
.getPendingElicitations()[0]
|
||||
.respond({ action: "accept", content: { choice: "native" } });
|
||||
expect(await reply).toMatchObject({
|
||||
id,
|
||||
result: { content: { choice: "native" } },
|
||||
});
|
||||
}
|
||||
|
||||
it("falls back when the server did not advertise the capability", async () => {
|
||||
const transport = new ElicitTransport({});
|
||||
const renderer = vi.fn();
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: renderer as unknown as AppElicitationRenderer,
|
||||
});
|
||||
await expectNativeFallback(transport, client, 20, appParams());
|
||||
expect(renderer).not.toHaveBeenCalled();
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("falls back when the request names no app", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
const renderer = vi.fn();
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: renderer as unknown as AppElicitationRenderer,
|
||||
});
|
||||
await expectNativeFallback(
|
||||
transport,
|
||||
client,
|
||||
21,
|
||||
appParams({ _meta: undefined }),
|
||||
);
|
||||
expect(renderer).not.toHaveBeenCalled();
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("falls back on malformed metadata", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
const renderer = vi.fn();
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: renderer as unknown as AppElicitationRenderer,
|
||||
});
|
||||
await expectNativeFallback(
|
||||
transport,
|
||||
client,
|
||||
22,
|
||||
appParams({ _meta: { ui: { resourceUri: "not-a-ui-uri" } } }),
|
||||
);
|
||||
expect(renderer).not.toHaveBeenCalled();
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("falls back for a url-mode elicitation", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
const renderer = vi.fn();
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: renderer as unknown as AppElicitationRenderer,
|
||||
elicit: { form: true, url: true },
|
||||
});
|
||||
// A url-mode request carries no `requestedSchema` — it is a different
|
||||
// params shape, and only `form` is app-renderable.
|
||||
await expectNativeFallback(transport, client, 23, {
|
||||
mode: "url",
|
||||
message: "Sign in to continue",
|
||||
url: "https://example.com/form",
|
||||
elicitationId: "url-1",
|
||||
_meta: { ui: { resourceUri: APP_URI } },
|
||||
});
|
||||
expect(renderer).not.toHaveBeenCalled();
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("falls back when the renderer rejects (resource/sandbox/bridge failure, timeout)", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: async () => {
|
||||
throw new Error("App did not initialize in time");
|
||||
},
|
||||
});
|
||||
await expectNativeFallback(transport, client, 24, appParams());
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("falls back when the app returns an invalid result", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: async () =>
|
||||
({ action: "sure" }) as unknown as ElicitResult,
|
||||
});
|
||||
await expectNativeFallback(transport, client, 25, appParams());
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("falls back when accepted content fails the requested schema", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: async () => ({
|
||||
action: "accept",
|
||||
content: { choice: 7 } as unknown as Record<string, never>,
|
||||
}),
|
||||
});
|
||||
await expectNativeFallback(transport, client, 26, appParams());
|
||||
await client.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe("teardown", () => {
|
||||
it("aborts a pending app elicitation on disconnect", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
let aborted = false;
|
||||
const client = await connectClient({
|
||||
transport,
|
||||
appElicitation: (request) =>
|
||||
new Promise<ElicitResult>((_resolve, reject) => {
|
||||
request.signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
}),
|
||||
});
|
||||
void transport.elicit(30, appParams()).catch(() => {});
|
||||
await vi.waitFor(() => expect(aborted).toBe(false));
|
||||
await client.disconnect();
|
||||
await vi.waitFor(() => expect(aborted).toBe(true));
|
||||
// An aborted request must NOT resurface in the native queue: the user
|
||||
// abandoned it, and the connection it belonged to is gone.
|
||||
expect(client.getPendingElicitations()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
clients/web/src/test/integration/mcp/appElicitation.test.ts
Normal file
168
clients/web/src/test/integration/mcp/appElicitation.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import type { ElicitResult } from "@modelcontextprotocol/client";
|
||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||
import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
|
||||
import type { AppElicitationRequest } from "@inspector/core/mcp/appElicitation.js";
|
||||
import {
|
||||
APP_ELICITATION_URI,
|
||||
createAppElicitationResource,
|
||||
createAppElicitationTool,
|
||||
createTestServerHttp,
|
||||
createTestServerInfo,
|
||||
type TestServerHttp,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
|
||||
/**
|
||||
* Live coverage of app-rendered form elicitations (#1854) against the public
|
||||
* fixture, over a real transport.
|
||||
*
|
||||
* The unit tests drive the handler with a hand-written frame; this drives the
|
||||
* real `app_choose_option` tool on the real composable server, so the whole
|
||||
* path is exercised: the server's `_meta.ui.resourceUri`, its advertised
|
||||
* `io.modelcontextprotocol/ui.elicitation` capability, the SDK's own
|
||||
* `elicitInput` serialization (which is where a dropped `_meta` would hide),
|
||||
* and the Inspector's routing decision.
|
||||
*
|
||||
* The renderer stands in for the web client's sandbox — everything above it is
|
||||
* production code.
|
||||
*/
|
||||
describe("app-rendered elicitation, live server (#1854)", () => {
|
||||
let client: InspectorClient | null = null;
|
||||
const servers: TestServerHttp[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
if (client) {
|
||||
try {
|
||||
await client.disconnect();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
client = null;
|
||||
}
|
||||
while (servers.length) {
|
||||
try {
|
||||
await servers.pop()?.stop();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The fixture server. `appElicitation` is the server half of the
|
||||
* negotiation; omitting it is the "not negotiated" scenario.
|
||||
*/
|
||||
async function startServer(appElicitation: boolean): Promise<TestServerHttp> {
|
||||
const started = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("app-elicit-test", "1.0.0"),
|
||||
tools: [createAppElicitationTool()],
|
||||
resources: [createAppElicitationResource()],
|
||||
...(appElicitation && { appElicitation }),
|
||||
});
|
||||
await started.start();
|
||||
servers.push(started);
|
||||
return started;
|
||||
}
|
||||
|
||||
/** The fixture tool definition, as the server reports it in `tools/list`. */
|
||||
async function appTool(connected: InspectorClient) {
|
||||
const { tools } = await connected.listTools();
|
||||
const tool = tools.find((t) => t.name === "app_choose_option");
|
||||
if (!tool) throw new Error("app_choose_option missing from tools/list");
|
||||
return tool;
|
||||
}
|
||||
|
||||
async function connect(
|
||||
url: string,
|
||||
renderer?: (request: AppElicitationRequest) => Promise<ElicitResult>,
|
||||
): Promise<InspectorClient> {
|
||||
const connected = new InspectorClient(
|
||||
{ type: "streamable-http", url },
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
elicit: { form: true },
|
||||
...(renderer && { appElicitation: renderer }),
|
||||
},
|
||||
);
|
||||
await connected.connect();
|
||||
client = connected;
|
||||
return connected;
|
||||
}
|
||||
|
||||
it("routes the server's elicitation to the app and returns its result", async () => {
|
||||
const started = await startServer(true);
|
||||
const seen: AppElicitationRequest[] = [];
|
||||
const connected = await connect(started.url, async (request) => {
|
||||
seen.push(request);
|
||||
return { action: "accept", content: { choice: "option-a" } };
|
||||
});
|
||||
|
||||
const result = await connected.callTool(await appTool(connected), {
|
||||
prompt: "Choose option A or B.",
|
||||
});
|
||||
|
||||
// The tool echoes the ElicitResult it received, so this asserts the app's
|
||||
// standard result reached the SERVER — not merely the host.
|
||||
const text = JSON.stringify(result.result?.content);
|
||||
expect(text).toContain('\\"action\\":\\"accept\\"');
|
||||
expect(text).toContain("option-a");
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
// The URI the SERVER named, carried on the request's own `_meta`.
|
||||
expect(seen[0].resourceUri).toBe(APP_ELICITATION_URI);
|
||||
expect(seen[0].params.message).toBe("Choose option A or B.");
|
||||
// The native queue never opened.
|
||||
expect(connected.getPendingElicitations()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns an app decline to the server without opening the native UI", async () => {
|
||||
const started = await startServer(true);
|
||||
const connected = await connect(started.url, async () => ({
|
||||
action: "decline",
|
||||
}));
|
||||
const result = await connected.callTool(await appTool(connected), {});
|
||||
expect(JSON.stringify(result.result?.content)).toContain("decline");
|
||||
expect(connected.getPendingElicitations()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("falls back to the native UI when the server did not advertise the capability", async () => {
|
||||
// Same tool, same `_meta.ui.resourceUri` — only the server's advertisement
|
||||
// differs. This is the over-claiming failure mode: a client that renders an
|
||||
// app here would strand every user of a server that never opted in.
|
||||
const started = await startServer(false);
|
||||
let rendererCalls = 0;
|
||||
const connected = await connect(started.url, async () => {
|
||||
rendererCalls++;
|
||||
return { action: "cancel" };
|
||||
});
|
||||
|
||||
const tool = await appTool(connected);
|
||||
const call = connected.callTool(tool, {});
|
||||
await vi.waitFor(() =>
|
||||
expect(connected.getPendingElicitations()).toHaveLength(1),
|
||||
);
|
||||
await connected
|
||||
.getPendingElicitations()[0]
|
||||
.respond({ action: "accept", content: { choice: "option-b" } });
|
||||
|
||||
const result = await call;
|
||||
expect(JSON.stringify(result.result?.content)).toContain("option-b");
|
||||
expect(rendererCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back when the client cannot host an app (no renderer)", async () => {
|
||||
// The CLI/TUI shape: the server offers an app, the client never advertised
|
||||
// the nested capability, so the server's request is answered natively.
|
||||
const started = await startServer(true);
|
||||
const connected = await connect(started.url);
|
||||
const tool = await appTool(connected);
|
||||
const call = connected.callTool(tool, {});
|
||||
await vi.waitFor(() =>
|
||||
expect(connected.getPendingElicitations()).toHaveLength(1),
|
||||
);
|
||||
await connected
|
||||
.getPendingElicitations()[0]
|
||||
.respond({ action: "accept", content: { choice: "option-a" } });
|
||||
await expect(call).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue