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:
cliffhall 2026-08-22 19:05:21 -04:00
commit 08fc20a22d
3 changed files with 3718 additions and 93 deletions

View 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();
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});