fix(apps): tear the app down when the server cancels the elicitation
The direct server→client `elicitation/create` handler ignored its request context, so `notifications/cancelled` aborted `ctx.mcpReq.signal` and nothing was listening: the modal and its bridge outlived the cancelled request and could still answer work the server had abandoned. The handler now threads that signal into `enqueuePendingElicitation`, which already wires it to both answer surfaces — the native queue entry and the app renderer. The task-augmented branch 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. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
0f3ee265fc
commit
ce263366b6
2 changed files with 61 additions and 2 deletions
|
|
@ -124,6 +124,15 @@ class ElicitTransport implements Transport {
|
||||||
]).finally(() => clearTimeout(timer));
|
]).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 {
|
private deliver(message: JSONRPCMessage): void {
|
||||||
this.onmessage?.(message);
|
this.onmessage?.(message);
|
||||||
}
|
}
|
||||||
|
|
@ -591,6 +600,38 @@ describe("app-rendered elicitation routing (#1854)", () => {
|
||||||
expect(client.getPendingElicitations()).toHaveLength(0);
|
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 () => {
|
it("aborts a pending app elicitation on a mid-session transport close", async () => {
|
||||||
// The `onclose` route reaches teardown only through
|
// The `onclose` route reaches teardown only through
|
||||||
// `clearAndAnnouncePendingPeerRequests`, whose emptiness check used to
|
// `clearAndAnnouncePendingPeerRequests`, whose emptiness check used to
|
||||||
|
|
|
||||||
|
|
@ -1439,7 +1439,13 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
// an elicit option that enables no mode advertises nothing, and registering
|
// an elicit option that enables no mode advertises nothing, and registering
|
||||||
// regardless throws before the handshake.
|
// regardless throws before the handshake.
|
||||||
if (this.elicitationCapabilityAdvertised && this.client) {
|
if (this.elicitationCapabilityAdvertised && this.client) {
|
||||||
const elicitHandler = (request: ElicitRequest): Promise<ElicitResult> => {
|
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<ElicitResult> => {
|
||||||
const paramsTask = (request.params as { task?: { ttl?: number } })
|
const paramsTask = (request.params as { task?: { ttl?: number } })
|
||||||
?.task;
|
?.task;
|
||||||
if (this.tasksCapabilityAdvertised && paramsTask != null) {
|
if (this.tasksCapabilityAdvertised && paramsTask != null) {
|
||||||
|
|
@ -1528,7 +1534,19 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
const taskResult: CreateTaskResult = { task: record.task };
|
const taskResult: CreateTaskResult = { task: record.task };
|
||||||
return Promise.resolve(taskResult as unknown as ElicitResult);
|
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);
|
this.client.setRequestHandler("elicitation/create", elicitHandler);
|
||||||
// Registration, like the `setRequestHandler` above it — and the whole
|
// Registration, like the `setRequestHandler` above it — and the whole
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue