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
2 changed files with 3718 additions and 93 deletions
119
clients/web/src/lib/appElicitationController.test.ts
Normal file
119
clients/web/src/lib/appElicitationController.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ElicitRequest } from "@modelcontextprotocol/client";
|
||||
import { AppElicitationController } from "./appElicitationController";
|
||||
|
||||
const params: ElicitRequest["params"] = {
|
||||
message: "Choose an option",
|
||||
requestedSchema: { type: "object", properties: {} },
|
||||
};
|
||||
|
||||
function request(
|
||||
controller: AppElicitationController,
|
||||
requestId: string,
|
||||
resourceUri = "ui://demo/pick.html",
|
||||
signal: AbortSignal = new AbortController().signal,
|
||||
) {
|
||||
return controller.render({ requestId, resourceUri, params, signal });
|
||||
}
|
||||
|
||||
describe("AppElicitationController (#1854)", () => {
|
||||
it("queues a request and notifies subscribers", () => {
|
||||
const controller = new AppElicitationController();
|
||||
const listener = vi.fn();
|
||||
controller.subscribe(listener);
|
||||
|
||||
void request(controller, "a").catch(() => {});
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(controller.getEntries()).toHaveLength(1);
|
||||
expect(controller.getEntries()[0]).toMatchObject({
|
||||
requestId: "a",
|
||||
resourceUri: "ui://demo/pick.html",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the entries array identity stable between changes", () => {
|
||||
// `useSyncExternalStore` re-renders on every getSnapshot identity change,
|
||||
// so a fresh array per read would loop forever.
|
||||
const controller = new AppElicitationController();
|
||||
expect(controller.getEntries()).toBe(controller.getEntries());
|
||||
});
|
||||
|
||||
it("settle resolves the render promise and drops the entry", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const pending = request(controller, "a");
|
||||
controller.settle("a", { action: "accept", content: { choice: "x" } });
|
||||
await expect(pending).resolves.toEqual({
|
||||
action: "accept",
|
||||
content: { choice: "x" },
|
||||
});
|
||||
expect(controller.getEntries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fail rejects the render promise so the client falls back", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const pending = request(controller, "a");
|
||||
controller.fail("a", new Error("sandbox unavailable"));
|
||||
await expect(pending).rejects.toThrow(/sandbox unavailable/);
|
||||
expect(controller.getEntries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("settling an unknown or already-settled id is a no-op", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const pending = request(controller, "a");
|
||||
controller.settle("a", { action: "cancel" });
|
||||
await expect(pending).resolves.toEqual({ action: "cancel" });
|
||||
// A second settle must not throw — the modal's unmount and the client's
|
||||
// abort can race, and both call in.
|
||||
expect(() => controller.settle("a", { action: "decline" })).not.toThrow();
|
||||
expect(() => controller.fail("nope", new Error("x"))).not.toThrow();
|
||||
});
|
||||
|
||||
it("keeps concurrent requests independent", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const first = request(controller, "a", "ui://demo/first.html");
|
||||
const second = request(controller, "b", "ui://demo/second.html");
|
||||
expect(controller.getEntries().map((e) => e.requestId)).toEqual(["a", "b"]);
|
||||
|
||||
controller.settle("b", { action: "accept", content: { choice: "b" } });
|
||||
expect(controller.getEntries().map((e) => e.requestId)).toEqual(["a"]);
|
||||
await expect(second).resolves.toMatchObject({ content: { choice: "b" } });
|
||||
|
||||
controller.settle("a", { action: "accept", content: { choice: "a" } });
|
||||
await expect(first).resolves.toMatchObject({ content: { choice: "a" } });
|
||||
});
|
||||
|
||||
it("drops and rejects an entry when the originating request aborts", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const aborter = new AbortController();
|
||||
const pending = request(
|
||||
controller,
|
||||
"a",
|
||||
"ui://demo/pick.html",
|
||||
aborter.signal,
|
||||
);
|
||||
expect(controller.getEntries()).toHaveLength(1);
|
||||
aborter.abort();
|
||||
await expect(pending).rejects.toThrow(/aborted/);
|
||||
expect(controller.getEntries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never queues a request whose signal already aborted", async () => {
|
||||
const controller = new AppElicitationController();
|
||||
const aborter = new AbortController();
|
||||
aborter.abort();
|
||||
await expect(
|
||||
request(controller, "a", "ui://demo/pick.html", aborter.signal),
|
||||
).rejects.toThrow(/aborted/);
|
||||
expect(controller.getEntries()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stops notifying an unsubscribed listener", () => {
|
||||
const controller = new AppElicitationController();
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = controller.subscribe(listener);
|
||||
unsubscribe();
|
||||
void request(controller, "a").catch(() => {});
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
99
clients/web/src/lib/appElicitationController.ts
Normal file
99
clients/web/src/lib/appElicitationController.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { ElicitResult } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
AppElicitationRenderer,
|
||||
AppElicitationRequest,
|
||||
} from "@inspector/core/mcp/appElicitation.js";
|
||||
|
||||
/**
|
||||
* One app-rendered elicitation awaiting an answer, plus the settle functions
|
||||
* for the `InspectorClient` promise it belongs to (#1854).
|
||||
*
|
||||
* The entry — not a "currently active app" — is what owns the renderer, so two
|
||||
* concurrent elicitations each drive their own iframe and bridge and cannot
|
||||
* resolve through each other's.
|
||||
*/
|
||||
export interface AppElicitationEntry extends AppElicitationRequest {
|
||||
/** Hands the app's standard `ElicitResult` back to the server. */
|
||||
resolve: (result: ElicitResult) => void;
|
||||
/** Asks `InspectorClient` to fall back to the native elicitation UI. */
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridges `InspectorClient`'s renderer callback — supplied at construction,
|
||||
* long before any React tree exists — to the React component that actually
|
||||
* mounts the app.
|
||||
*
|
||||
* The client is given {@link render} once and for all; the UI subscribes and
|
||||
* re-renders as entries come and go. Without this indirection the renderer
|
||||
* would have to be rebuilt (and the client reconstructed) whenever the host
|
||||
* component remounted.
|
||||
*/
|
||||
export class AppElicitationController {
|
||||
private entries: AppElicitationEntry[] = [];
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
/** Current queue. Stable identity between changes, for `useSyncExternalStore`. */
|
||||
getEntries = (): AppElicitationEntry[] => this.entries;
|
||||
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
private emit(): void {
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link AppElicitationRenderer} handed to `InspectorClient`. Queues the
|
||||
* request for the UI and resolves when {@link settle} or {@link fail} is
|
||||
* called for it.
|
||||
*
|
||||
* An abort of the originating request (cancelled tool call, disconnect) drops
|
||||
* the entry and rejects, so a modal can never outlive the request behind it.
|
||||
*/
|
||||
render: AppElicitationRenderer = (request: AppElicitationRequest) =>
|
||||
new Promise<ElicitResult>((resolve, reject) => {
|
||||
const entry: AppElicitationEntry = { ...request, resolve, reject };
|
||||
const onAbort = () => {
|
||||
this.remove(entry.requestId);
|
||||
reject(new Error("App-rendered elicitation aborted"));
|
||||
};
|
||||
if (request.signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
request.signal.addEventListener("abort", onAbort, { once: true });
|
||||
this.entries = [...this.entries, entry];
|
||||
this.emit();
|
||||
});
|
||||
|
||||
private take(requestId: string): AppElicitationEntry | undefined {
|
||||
const entry = this.entries.find((e) => e.requestId === requestId);
|
||||
if (entry) this.remove(requestId);
|
||||
return entry;
|
||||
}
|
||||
|
||||
private remove(requestId: string): void {
|
||||
const next = this.entries.filter((e) => e.requestId !== requestId);
|
||||
if (next.length === this.entries.length) return;
|
||||
this.entries = next;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete an elicitation with the app's result. `decline` and `cancel` are
|
||||
* completions too — they are returned to the server, not fallen back on.
|
||||
*/
|
||||
settle(requestId: string, result: ElicitResult): void {
|
||||
this.take(requestId)?.resolve(result);
|
||||
}
|
||||
|
||||
/** Give up on the app and let the native elicitation UI take the request. */
|
||||
fail(requestId: string, error: Error): void {
|
||||
this.take(requestId)?.reject(error);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue