test(apps): cover the modern MRTR leg of app-rendered elicitations

Both review findings from round two.

The routing change sits in the funnel BOTH entry points share, but only the
legacy inbound handler was driven by a test — the modern leg, where an
elicitation arrives as an `input_required` result the MRTR driver unpacks and
retries, was covered by construction alone. Adds
`mrtr_app_choose_option` (a modern MRTR tool whose *embedded* elicitation
carries `_meta.ui.resourceUri`) and drives it live over a real transport: the
app answers, the retry carries that answer through `inputResponses`, and the
server echoes it back as the tool's result. A second case cancels the tool
call while the app is up and asserts the request-scoped signal aborts; a third
runs the same tool against a modern server that never advertised the
capability and asserts the native queue takes it.

The cancellation case turned up a real gap: `tryAppElicitation` mounted an app
for an already-aborted signal (the caller cancelled during an earlier MRTR
round), leaving a modal nobody was waiting on and a promise that could never
settle. It now throws the abort straight through.

Also fixes the smoke count in the root README, which still said "two"
headless-Chromium smokes and then introduced a third.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-22 20:17:37 -04:00
commit 44010c313b

View file

@ -2,11 +2,13 @@ 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,
@ -72,6 +74,15 @@ describe("app-rendered elicitation, live server (#1854)", () => {
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>,
@ -150,6 +161,123 @@ describe("app-rendered elicitation, live server (#1854)", () => {
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.