diff --git a/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts b/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts index b251a57c..b8b2a6c8 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-app-elicitation.test.ts @@ -124,6 +124,15 @@ class ElicitTransport implements Transport { ]).finally(() => clearTimeout(timer)); } + /** Deliver a server→client notification (no reply expected). */ + notify(method: string, params?: Record): void { + this.deliver({ + jsonrpc: "2.0", + method, + ...(params && { params }), + } as unknown as JSONRPCMessage); + } + private deliver(message: JSONRPCMessage): void { this.onmessage?.(message); } @@ -591,6 +600,38 @@ describe("app-rendered elicitation routing (#1854)", () => { 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((_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 diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 9552d47c..fb0ad80e 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1439,7 +1439,13 @@ export class InspectorClient extends InspectorClientEventTarget { // an elicit option that enables no mode advertises nothing, and registering // regardless throws before the handshake. if (this.elicitationCapabilityAdvertised && this.client) { - const elicitHandler = (request: ElicitRequest): Promise => { + const elicitHandler = ( + request: ElicitRequest, + // Structural, and only the one field this needs: the SDK's + // `ClientContext` carries much more, and naming it here would tie the + // handler to a type the bypass helper below does not thread through. + ctx?: { mcpReq?: { signal?: AbortSignal } }, + ): Promise => { const paramsTask = (request.params as { task?: { ttl?: number } }) ?.task; if (this.tasksCapabilityAdvertised && paramsTask != null) { @@ -1528,7 +1534,19 @@ export class InspectorClient extends InspectorClientEventTarget { const taskResult: CreateTaskResult = { task: record.task }; return Promise.resolve(taskResult as unknown as ElicitResult); } - return this.enqueuePendingElicitation(request, "server-request"); + // `ctx.mcpReq.signal` aborts when the server cancels this request + // (`notifications/cancelled`). Threading it through means both answer + // surfaces — the native queue entry and an app-rendered elicitation's + // renderer — are torn down with the request, instead of a modal + // outliving work the server abandoned. The task-augmented branch above + // deliberately does NOT use it: that request is answered immediately + // with a `CreateTaskResult`, so its lifetime is the task's, which + // carries its own abort (see `ReceiverTaskRecord.abort`). + return this.enqueuePendingElicitation( + request, + "server-request", + ctx?.mcpReq?.signal, + ); }; this.client.setRequestHandler("elicitation/create", elicitHandler); // Registration, like the `setRequestHandler` above it — and the whole