Merge remote-tracking branch 'origin/v2/main' into v2/feat/1950-file-secret-store
Signed-off-by: cliffhall <cliff@futurescale.com> # Conflicts: # README.md # clients/web/src/App.tsx
This commit is contained in:
commit
a859093bd3
4 changed files with 4824 additions and 139 deletions
286
clients/web/src/test/core/mcp/appElicitation.test.ts
Normal file
286
clients/web/src/test/core/mcp/appElicitation.test.ts
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
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(/invalid elicitation result/);
|
||||
});
|
||||
|
||||
it("rejects an unknown action", () => {
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "maybe",
|
||||
} as unknown as ElicitResult),
|
||||
).toMatch(/invalid elicitation result/);
|
||||
});
|
||||
|
||||
it("rejects a decline whose content the standard result forbids", () => {
|
||||
// Checking `action` alone would wave this through: `ElicitResult` permits
|
||||
// only primitives and string arrays in `content`, so a nested object
|
||||
// would reach the server as a result it can legitimately reject.
|
||||
expect(
|
||||
validateAppElicitResult(provider, params, {
|
||||
action: "decline",
|
||||
content: { x: {} },
|
||||
} as unknown as ElicitResult),
|
||||
).toMatch(/invalid elicitation result/);
|
||||
});
|
||||
|
||||
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(/invalid elicitation result|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,661 @@
|
|||
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> {
|
||||
return this.sendRequest(id, "elicitation/create", params, timeoutMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver any server→client request and resolve with the client's reply.
|
||||
* NOT named `send` — that is the Transport method the client calls.
|
||||
*/
|
||||
sendRequest(
|
||||
id: number,
|
||||
method: string,
|
||||
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, ...(params && { 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));
|
||||
}
|
||||
|
||||
/** Deliver a server→client notification (no reply expected). */
|
||||
notify(method: string, params?: Record<string, unknown>): void {
|
||||
this.deliver({
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
...(params && { params }),
|
||||
} as unknown as JSONRPCMessage);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** The receiver tasks the client reports over `tasks/list`, by status. */
|
||||
async function taskStatuses(
|
||||
transport: ElicitTransport,
|
||||
id: number,
|
||||
): Promise<string[]> {
|
||||
const reply = (await transport.sendRequest(id, "tasks/list")) as {
|
||||
result?: { tasks?: { status?: string }[] };
|
||||
};
|
||||
return (reply.result?.tasks ?? []).map((t) => t.status ?? "");
|
||||
}
|
||||
|
||||
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("task-augmented inbound requests", () => {
|
||||
/**
|
||||
* A `params.task` elicitation answers the server immediately with a
|
||||
* `CreateTaskResult` and settles the TASK when the user answers, so it
|
||||
* cannot ride `enqueuePendingElicitation`. It is still an
|
||||
* `elicitation/create`, so the app-rendering contract applies to it too.
|
||||
*/
|
||||
async function connectWithTasks(
|
||||
transport: ElicitTransport,
|
||||
renderer?: AppElicitationRenderer,
|
||||
) {
|
||||
const client = new InspectorClient(
|
||||
{ type: "stdio", command: "noop", args: [] },
|
||||
{
|
||||
environment: { transport: () => ({ transport }) },
|
||||
elicit: { form: true },
|
||||
receiverTasks: true,
|
||||
...(renderer && { appElicitation: renderer }),
|
||||
},
|
||||
);
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
const taskParams = () => appParams({ task: { ttl: 60_000 } });
|
||||
|
||||
it("renders the app and completes the task with its result", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
const seen: AppElicitationRequest[] = [];
|
||||
const client = await connectWithTasks(transport, async (request) => {
|
||||
seen.push(request);
|
||||
return { action: "accept", content: { choice: "option-a" } };
|
||||
});
|
||||
|
||||
// The immediate response is the task handle, not the answer.
|
||||
const reply = await transport.elicit(50, taskParams());
|
||||
expect(reply).toMatchObject({
|
||||
id: 50,
|
||||
result: { task: { taskId: expect.any(String) } },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(seen).toHaveLength(1));
|
||||
expect(seen[0].resourceUri).toBe(APP_URI);
|
||||
// The app answered, so the native queue never opened and the task
|
||||
// completed on its own — read back the way a server reads it, over
|
||||
// `tasks/list`.
|
||||
expect(client.getPendingElicitations()).toHaveLength(0);
|
||||
await vi.waitFor(async () =>
|
||||
expect(await taskStatuses(transport, 60)).toContain("completed"),
|
||||
);
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("tears the app down on tasks/cancel and keeps the task cancelled", async () => {
|
||||
// `tasks/cancel` is the server changing its mind. Without an abort the
|
||||
// modal stayed live, and a later answer overwrote `cancelled` with
|
||||
// `completed` — a task the server was told it had cancelled.
|
||||
const transport = new ElicitTransport();
|
||||
let aborted = false;
|
||||
let settle: ((r: ElicitResult) => void) | undefined;
|
||||
const client = await connectWithTasks(
|
||||
transport,
|
||||
(request) =>
|
||||
new Promise<ElicitResult>((resolve) => {
|
||||
settle = resolve;
|
||||
request.signal.addEventListener("abort", () => (aborted = true));
|
||||
}),
|
||||
);
|
||||
|
||||
const created = (await transport.elicit(52, taskParams())) as {
|
||||
result?: { task?: { taskId?: string } };
|
||||
};
|
||||
const taskId = created.result?.task?.taskId;
|
||||
expect(taskId).toBeDefined();
|
||||
await vi.waitFor(() => expect(settle).toBeDefined());
|
||||
|
||||
await transport.sendRequest(53, "tasks/cancel", { taskId });
|
||||
expect(aborted).toBe(true);
|
||||
expect(await taskStatuses(transport, 54)).toContain("cancelled");
|
||||
|
||||
// An answer that lands after the cancel must not resurrect the task.
|
||||
settle?.({ action: "accept", content: { choice: "too-late" } });
|
||||
await vi.waitFor(async () =>
|
||||
expect(await taskStatuses(transport, 55)).toEqual(["cancelled"]),
|
||||
);
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("falls back to the native queue when the app cannot answer", async () => {
|
||||
const transport = new ElicitTransport();
|
||||
const client = await connectWithTasks(transport, async () => {
|
||||
throw new Error("sandbox unavailable");
|
||||
});
|
||||
await transport.elicit(51, taskParams());
|
||||
await vi.waitFor(() =>
|
||||
expect(client.getPendingElicitations()).toHaveLength(1),
|
||||
);
|
||||
await client
|
||||
.getPendingElicitations()[0]
|
||||
.respond({ action: "accept", content: { choice: "native" } });
|
||||
await vi.waitFor(async () =>
|
||||
expect(await taskStatuses(transport, 61)).toContain("completed"),
|
||||
);
|
||||
await client.disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
describe("request-scoped ids", () => {
|
||||
it("mints ids that are unique across client instances, not just within one", async () => {
|
||||
// A host may hold ONE renderer across replacement clients (the web client
|
||||
// rebuilds its InspectorClient on a settings change). A per-client
|
||||
// counter alone would give each new client's first request the same id,
|
||||
// and settling it would resolve the wrong server's request.
|
||||
const ids: string[] = [];
|
||||
const renderer: AppElicitationRenderer = async (request) => {
|
||||
ids.push(request.requestId);
|
||||
return { action: "cancel" };
|
||||
};
|
||||
const first = new ElicitTransport();
|
||||
const clientA = await connectClient({
|
||||
transport: first,
|
||||
appElicitation: renderer,
|
||||
});
|
||||
await first.elicit(40, appParams());
|
||||
await clientA.disconnect();
|
||||
|
||||
const second = new ElicitTransport();
|
||||
const clientB = await connectClient({
|
||||
transport: second,
|
||||
appElicitation: renderer,
|
||||
});
|
||||
await second.elicit(41, appParams());
|
||||
await clientB.disconnect();
|
||||
|
||||
expect(ids).toHaveLength(2);
|
||||
expect(ids[0]).not.toBe(ids[1]);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("tears the app down when the server cancels the request", async () => {
|
||||
// A direct server→client `elicitation/create` can be cancelled with
|
||||
// `notifications/cancelled`; the SDK aborts `ctx.mcpReq.signal` for it.
|
||||
// Without that signal threaded through, the modal and its bridge outlive
|
||||
// the request and could answer work the server abandoned.
|
||||
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(32, appParams()).catch(() => {});
|
||||
await vi.waitFor(() => expect(aborted).toBe(false));
|
||||
|
||||
transport.notify("notifications/cancelled", {
|
||||
requestId: 32,
|
||||
reason: "server changed its mind",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(aborted).toBe(true));
|
||||
// Not reopened natively: the request the user was answering is gone.
|
||||
expect(client.getPendingElicitations()).toHaveLength(0);
|
||||
await client.disconnect();
|
||||
});
|
||||
|
||||
it("aborts a pending app elicitation on a mid-session transport close", async () => {
|
||||
// The `onclose` route reaches teardown only through
|
||||
// `clearAndAnnouncePendingPeerRequests`, whose emptiness check used to
|
||||
// look at the native queues alone — so a lone app elicitation survived a
|
||||
// dropped connection and left its modal on screen.
|
||||
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(31, appParams()).catch(() => {});
|
||||
await vi.waitFor(() => expect(aborted).toBe(false));
|
||||
|
||||
transport.onclose?.();
|
||||
|
||||
await vi.waitFor(() => expect(aborted).toBe(true));
|
||||
expect(client.getPendingElicitations()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
296
clients/web/src/test/integration/mcp/appElicitation.test.ts
Normal file
296
clients/web/src/test/integration/mcp/appElicitation.test.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
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 { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
|
||||
import type { AppElicitationRequest } from "@inspector/core/mcp/appElicitation.js";
|
||||
import {
|
||||
APP_ELICITATION_URI,
|
||||
createAppElicitationResource,
|
||||
createAppElicitationTool,
|
||||
createMrtrAppElicitationTool,
|
||||
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;
|
||||
}
|
||||
|
||||
/** The modern MRTR fixture tool, as the server reports it. */
|
||||
async function mrtrTool(connected: InspectorClient) {
|
||||
const { tools } = await connected.listTools();
|
||||
const tool = tools.find((t) => t.name === "mrtr_app_choose_option");
|
||||
if (!tool)
|
||||
throw new Error("mrtr_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);
|
||||
});
|
||||
|
||||
/**
|
||||
* The modern (2026-07-28) leg, where an elicitation reaches the client as an
|
||||
* `input_required` result the MRTR driver unpacks — not as a server→client
|
||||
* request. Stateless per request, hence a separate server.
|
||||
*/
|
||||
async function startModernServer(): Promise<TestServerHttp> {
|
||||
const started = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("app-elicit-mrtr", "1.0.0"),
|
||||
tools: [createMrtrAppElicitationTool()],
|
||||
resources: [createAppElicitationResource()],
|
||||
appElicitation: true,
|
||||
modern: {},
|
||||
});
|
||||
await started.start();
|
||||
servers.push(started);
|
||||
return started;
|
||||
}
|
||||
|
||||
async function connectModern(
|
||||
url: string,
|
||||
renderer?: (request: AppElicitationRequest) => Promise<ElicitResult>,
|
||||
): Promise<InspectorClient> {
|
||||
const connected = new InspectorClient(
|
||||
{ type: "streamable-http", url },
|
||||
{
|
||||
environment: { transport: createTransportNode },
|
||||
elicit: { form: true },
|
||||
versionNegotiation: eraToVersionNegotiation("modern"),
|
||||
...(renderer && { appElicitation: renderer }),
|
||||
},
|
||||
);
|
||||
await connected.connect();
|
||||
client = connected;
|
||||
return connected;
|
||||
}
|
||||
|
||||
describe("modern MRTR input_required", () => {
|
||||
it("routes an embedded elicitation to the app and retries with its result", async () => {
|
||||
// The other half of the contract: the same routing decision has to hold
|
||||
// on a leg where the elicitation never arrives as a request at all.
|
||||
const started = await startModernServer();
|
||||
const seen: AppElicitationRequest[] = [];
|
||||
const connected = await connectModern(started.url, async (request) => {
|
||||
seen.push(request);
|
||||
return { action: "accept", content: { choice: "option-b" } };
|
||||
});
|
||||
|
||||
const tool = await mrtrTool(connected);
|
||||
const result = await connected.callTool(tool, {
|
||||
prompt: "Choose option A or B.",
|
||||
});
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].resourceUri).toBe(APP_ELICITATION_URI);
|
||||
// The retry carried the app's answer as `inputResponses`, and the server
|
||||
// echoed it — so the app's result completed the ORIGINAL tool call.
|
||||
expect(JSON.stringify(result.result?.content)).toContain("option-b");
|
||||
expect(connected.getPendingElicitations()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("aborts the app when the tool call it belongs to is cancelled", async () => {
|
||||
// MRTR passes the call's signal down; without it a cancelled tool call
|
||||
// would leave the app's modal open with nothing left to answer.
|
||||
const started = await startModernServer();
|
||||
let rendering = false;
|
||||
let aborted = false;
|
||||
const connected = await connectModern(
|
||||
started.url,
|
||||
(request) =>
|
||||
new Promise<ElicitResult>((_resolve, reject) => {
|
||||
rendering = true;
|
||||
request.signal.addEventListener("abort", () => {
|
||||
aborted = true;
|
||||
reject(new Error("aborted"));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const tool = await mrtrTool(connected);
|
||||
const call = connected.callTool(tool, {}).catch((err: unknown) => err);
|
||||
// Cancel only once the app is actually up: cancelling earlier is a
|
||||
// different case (the signal is already aborted and no app is mounted).
|
||||
await vi.waitFor(() => expect(rendering).toBe(true));
|
||||
expect(connected.cancelToolCall()).toBe(true);
|
||||
await vi.waitFor(() => expect(aborted).toBe(true));
|
||||
await call;
|
||||
});
|
||||
|
||||
it("falls back to the native UI when the modern server did not advertise it", async () => {
|
||||
const started = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo("app-elicit-mrtr-native", "1.0.0"),
|
||||
tools: [createMrtrAppElicitationTool()],
|
||||
resources: [createAppElicitationResource()],
|
||||
modern: {},
|
||||
});
|
||||
await started.start();
|
||||
servers.push(started);
|
||||
|
||||
let rendererCalls = 0;
|
||||
const connected = await connectModern(started.url, async () => {
|
||||
rendererCalls++;
|
||||
return { action: "cancel" };
|
||||
});
|
||||
const tool = await mrtrTool(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" } });
|
||||
const result = await call;
|
||||
expect(JSON.stringify(result.result?.content)).toContain("option-a");
|
||||
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