From feced076c55ec698b935f119d224e0afd7d36cf5 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Tue, 8 Sep 2026 17:09:34 -0700 Subject: [PATCH 01/10] feat: draft MCP Tasks extension SDK migration --- clients/cli/tsup.config.ts | 1 + clients/tui/tsup.config.ts | 1 + ...nspectorClient-peer-handler-timing.test.ts | 50 +- .../core/mcp/inspectorClient-raw-wire.test.ts | 550 +++- .../test/core/mcp/modernTaskSchemas.test.ts | 136 - .../inspectorClient-coverage-backfill.test.ts | 264 +- .../mcp/inspectorClient-modern-era.test.ts | 2 +- .../mcp/inspectorClient-tasks-era.test.ts | 21 - .../integration/mcp/inspectorClient.test.ts | 144 - clients/web/tsup.runner.config.ts | 1 + core/mcp/inspectorClient.ts | 2351 ++++++----------- core/mcp/inspectorClientEventTarget.ts | 10 +- core/mcp/inspectorClientProtocol.ts | 18 +- core/mcp/messageTrackingTransport.ts | 47 +- core/mcp/modernTaskSchemas.ts | 136 +- core/mcp/types.ts | 26 + package-lock.json | 31 + package.json | 1 + 18 files changed, 1386 insertions(+), 2404 deletions(-) delete mode 100644 clients/web/src/test/core/mcp/modernTaskSchemas.test.ts diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts index 724317cc5d..2d2cf61fe5 100644 --- a/clients/cli/tsup.config.ts +++ b/clients/cli/tsup.config.ts @@ -44,6 +44,7 @@ export default defineConfig({ // zod-to-json-schema in with it — which the #2067 guard surfaced. ESM, so it // was not failing the way `undici` did; the rule is what it violated. "@modelcontextprotocol/ext-apps", + "@modelcontextprotocol/ext-tasks", "commander", "pino", // Consolidated to the ROOT manifest by #2195, along with every other diff --git a/clients/tui/tsup.config.ts b/clients/tui/tsup.config.ts index e28cf3bc19..4d2814ab6f 100644 --- a/clients/tui/tsup.config.ts +++ b/clients/tui/tsup.config.ts @@ -133,6 +133,7 @@ export default defineConfig({ // client's own code, so all three lists carry it (AGENTS.md). The CLI was // inlining it; the #2067 guard surfaced that. "@modelcontextprotocol/ext-apps", + "@modelcontextprotocol/ext-tasks", "@napi-rs/keyring", // Root-declared (see the repo's dependency-placement rule) and CJS, which // is the combination that bites: tsup externalizes what the *client's* diff --git a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts index cfc22003e4..fd92d2f3bb 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-peer-handler-timing.test.ts @@ -790,13 +790,8 @@ describe("InspectorClient peer-handler timing (#1797)", () => { ); await client.connect(); - // Seeded directly: subscribing for real needs a server that answers - // `resources/subscribe` and cancelling needs a live task, neither of which - // adds to what is under test — that a new session starts empty. The cast is - // the only route to `cancelledTaskIds`, which has no public reader. const internals = client as unknown as { subscribedResources: Set; - cancelledTaskIds: Set; modernStreamState: { active: boolean; status: string; @@ -804,7 +799,6 @@ describe("InspectorClient peer-handler timing (#1797)", () => { }; }; internals.subscribedResources.add("file:///watched"); - internals.cancelledTaskIds.add("task-1"); // The stream state a live modern subscription would have left behind. internals.modernStreamState = { active: true, @@ -829,7 +823,6 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.connect(); expect(client.getSubscribedResources()).toEqual([]); - expect(internals.cancelledTaskIds.size).toBe(0); // Cleared with the set it is derived from, not left reading `active` for // an empty one. expect(client.getResourceSubscriptionStreamState()).toMatchObject({ @@ -880,35 +873,6 @@ describe("InspectorClient peer-handler timing (#1797)", () => { await client.disconnect(); }); - it("aborts a paused task-input wait when the session ends", async () => { - // The bounded-window member: both registration sites release in a - // `finally`, so nothing leaks permanently — this closes the gap between a - // crash and the loop unwinding on its own. - const transport = new SampleAfterConnectTransport(); - const client = new InspectorClient( - { type: "stdio", command: "noop", args: [] }, - { environment: { transport: () => ({ transport }) } }, - ); - await client.connect(); - - // Seeded directly: reaching this map for real needs a modern task paused at - // `input_required`, which adds nothing to what is under test. No public - // reader, hence the cast. - const controller = new AbortController(); - ( - client as unknown as { - taskInputAbortControllers: Map; - } - ).taskInputAbortControllers.set("task-1", controller); - - transport.onclose?.(); - await client.connect(); - - expect(controller.signal.aborted).toBe(true); - - await client.disconnect(); - }); - it("closes a live listen stream the next connect drops", async () => { // An `onerror` without an `onclose` leaves the transport up, and `connect()` // reuses it — so the reference the reset drops can be the last one to a @@ -1001,25 +965,15 @@ describe("InspectorClient peer-handler timing (#1797)", () => { ); await client.connect(); - // Seeded directly, for the reasons `closes a live listen stream the - // next connect drops` and `aborts a paused task-input wait when the - // session ends` give. No public writer for either, hence the casts. + // Seed the live subscription directly; it has no public writer. ( client as unknown as { modernSubscription: { close: () => Promise } | null; } ).modernSubscription = { close }; - // A downstream teardown step, to witness that teardown continued. - const controller = new AbortController(); - ( - client as unknown as { - taskInputAbortControllers: Map; - } - ).taskInputAbortControllers.set("task-1", controller); - await expect(client.disconnect()).resolves.toBeUndefined(); - expect(controller.signal.aborted).toBe(true); + expect(client.getStatus()).toBe("disconnected"); // Node reports an unhandled rejection after the microtask checkpoint, // so yield to the macrotask queue before reading the listener — nothing diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts index 2d9af6b1c2..522f11e204 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, vi } from "vitest"; +import type { CallToolResult, Tool } from "@modelcontextprotocol/client"; +import { DispatchError } from "@modelcontextprotocol/ext-tasks/client"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; +import type { TaskWithOptionalCreatedAt } from "@inspector/core/mcp/inspectorClientEventTarget.js"; import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas.js"; /** @@ -20,8 +23,23 @@ describe("InspectorClient raw-wire channel (#1631)", () => { } interface RawWireInternals { - transport: { send: (m: unknown) => Promise } | null; + transport: { + send: ( + message: unknown, + options?: { + headers?: Readonly>; + requestSignal?: AbortSignal; + }, + ) => Promise; + } | null; requestTimeout?: number; + dispatchTaskRequest: ( + request: unknown, + options?: { + signal?: AbortSignal; + context?: { headers?: Readonly> }; + }, + ) => Promise; rawWireRequest: ( method: string, params: Record, @@ -31,10 +49,62 @@ describe("InspectorClient raw-wire channel (#1631)", () => { rejectPendingRawWireRequests: (reason: string) => void; } + interface TaskSessionCallOptions { + task: { preference: "allow" | "prefer"; retentionMs?: number }; + onEvent: (event: unknown) => void; + } + + interface TaskBoundaryInternals { + client: object | null; + protocolEra?: "legacy" | "modern"; + taskSession: { + callToolAndSettle: ( + name: string, + args: Readonly>, + options: TaskSessionCallOptions, + ) => Promise<{ outcome: unknown; lastTask?: unknown }>; + } | null; + taskInputOrigin: ( + delivery: "peer-request" | "request-retry" | "task-update", + ) => "server-request" | "input-required" | "task-input-required"; + emitTaskExecutionEvent: (event: unknown) => unknown; + emitTaskError: (lastTask: unknown, reason: unknown) => void; + } + function internals(client: InspectorClient): RawWireInternals { return client as unknown as RawWireInternals; } + function taskInternals(client: InspectorClient): TaskBoundaryInternals { + // Private session fields deliberately have no public mutation API; this narrow + // structurally matching cast injects only the ext-tasks boundary under test. + return client as unknown as TaskBoundaryInternals; + } + + const taskTool: Tool = { + name: "boundary_task", + description: "Exercises the Inspector/ext-tasks boundary", + inputSchema: { type: "object" }, + }; + + const successfulResult: CallToolResult = { + content: [{ type: "text", text: "done" }], + }; + + function attachTaskBoundary( + client: InspectorClient, + callToolAndSettle: TaskBoundaryInternals["taskSession"] extends infer Session + ? Session extends { callToolAndSettle: infer Call } + ? Call + : never + : never, + ): void { + const boundary = taskInternals(client); + boundary.client = {}; + boundary.protocolEra = "modern"; + boundary.taskSession = { callToolAndSettle }; + } + it("throws when there is no transport", async () => { const client = makeClient(); internals(client).transport = null; @@ -156,4 +226,482 @@ describe("InspectorClient raw-wire channel (#1631)", () => { internals(client).rejectPendingRawWireRequests("Disconnected"); await expect(promise).rejects.toThrow(/Disconnected/); }); + + it.each([ + [null, /JSON object/], + ["not-an-object", /JSON object/], + [7, /JSON object/], + [[], /JSON object/], + [{ method: 42 }, /method must be a string/], + [{ method: "tasks/get", params: null }, /params must be a JSON object/], + [{ method: "tasks/get", params: [] }, /params must be a JSON object/], + [{ method: "tasks/get", params: "bad" }, /params must be a JSON object/], + [{ method: "tasks/get", params: 7 }, /params must be a JSON object/], + ])("validates ext-tasks dispatch input %#", async (request, message) => { + const client = makeClient(); + internals(client).transport = { + send: vi.fn().mockResolvedValue(undefined), + }; + await expect( + internals(client).dispatchTaskRequest(request), + ).rejects.toThrow(message); + }); + + it("rejects a dispatch that is already aborted without sending", async () => { + const client = makeClient(); + const send = vi.fn().mockResolvedValue(undefined); + internals(client).transport = { send }; + const controller = new AbortController(); + controller.abort(new Error("stop before dispatch")); + + await expect( + internals(client).dispatchTaskRequest( + { method: "tasks/get", params: { taskId: "x" } }, + { signal: controller.signal }, + ), + ).rejects.toThrow(/stop before dispatch/); + expect(send).not.toHaveBeenCalled(); + }); + + it("uses the standard abort error for a non-Error reason", async () => { + const client = makeClient(); + const send = vi.fn().mockResolvedValue(undefined); + internals(client).transport = { send }; + const controller = new AbortController(); + controller.abort("stop"); + + await expect( + internals(client).dispatchTaskRequest( + { method: "tasks/get" }, + { signal: controller.signal }, + ), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(send).not.toHaveBeenCalled(); + }); + + it("frames an omitted-params dispatch and forwards headers and its signal", async () => { + const client = makeClient(); + let sent: { id: string; method: string; params?: unknown } | undefined; + let sendOptions: + | { + headers?: Readonly>; + requestSignal?: AbortSignal; + } + | undefined; + internals(client).transport = { + send: vi.fn(async (message, options) => { + sent = message as { id: string; method: string; params?: unknown }; + sendOptions = options; + }), + }; + const controller = new AbortController(); + const promise = internals(client).dispatchTaskRequest( + { method: "tasks/list" }, + { + signal: controller.signal, + context: { headers: { "x-route": "blue" } }, + }, + ); + await Promise.resolve(); + + expect(sent).toEqual({ + jsonrpc: "2.0", + id: expect.stringMatching(/^inspector-ext-/), + method: "tasks/list", + }); + expect(sendOptions).toEqual({ + headers: { "x-route": "blue" }, + requestSignal: controller.signal, + }); + internals(client).consumeRawWireResponse({ id: sent!.id, result: {} }); + await expect(promise).resolves.toEqual({ kind: "result", result: {} }); + }); + + it("aborts an in-flight dispatch and ignores its late response", async () => { + const client = makeClient(); + let sentId = ""; + internals(client).transport = { + send: vi.fn(async (message) => { + sentId = (message as { id: string }).id; + }), + }; + const controller = new AbortController(); + const promise = internals(client).dispatchTaskRequest( + { method: "tasks/get", params: { taskId: "x" } }, + { signal: controller.signal }, + ); + await Promise.resolve(); + controller.abort(new Error("stop in flight")); + + await expect(promise).rejects.toThrow(/stop in flight/); + expect( + internals(client).consumeRawWireResponse({ id: sentId, result: {} }), + ).toBe(false); + }); + + it("ignores a transport rejection after abort already settled the dispatch", async () => { + const client = makeClient(); + let rejectSend: ((reason: unknown) => void) | undefined; + internals(client).transport = { + send: vi.fn( + () => + new Promise((_resolve, reject) => { + rejectSend = reject; + }), + ), + }; + const controller = new AbortController(); + const promise = internals(client).dispatchTaskRequest( + { method: "tasks/get" }, + { signal: controller.signal }, + ); + await Promise.resolve(); + controller.abort(new Error("caller stopped")); + await expect(promise).rejects.toThrow("caller stopped"); + + rejectSend?.(new Error("late socket failure")); + await Promise.resolve(); + }); + + it("normalizes a non-Error transport rejection", async () => { + const client = makeClient(); + internals(client).transport = { + send: vi.fn().mockRejectedValue("socket vanished"), + }; + await expect( + internals(client).dispatchTaskRequest({ method: "tasks/get" }), + ).rejects.toThrow("socket vanished"); + }); + + it.each([ + [{ retryAfter: 5 }, { retryAfter: 5 }], + [10n, undefined], + ])("projects serializable error data %#", async (data, expectedData) => { + const client = makeClient(); + let sentId = ""; + internals(client).transport = { + send: vi.fn(async (message) => { + sentId = (message as { id: string }).id; + }), + }; + const promise = internals(client).dispatchTaskRequest({ + method: "tasks/get", + }); + await Promise.resolve(); + internals(client).consumeRawWireResponse({ + id: sentId, + error: { code: -32001, message: "task failed", data }, + }); + + await expect(promise).resolves.toEqual({ + kind: "error", + error: { + code: -32001, + message: "task failed", + ...(expectedData === undefined ? {} : { data: expectedData }), + }, + }); + }); + + it("rejects a non-JSON raw result", async () => { + const client = makeClient(); + let sentId = ""; + internals(client).transport = { + send: vi.fn(async (message) => { + sentId = (message as { id: string }).id; + }), + }; + const promise = internals(client).dispatchTaskRequest({ + method: "tasks/get", + }); + await Promise.resolve(); + internals(client).consumeRawWireResponse({ id: sentId, result: 10n }); + + await expect(promise).rejects.toThrow(/non-JSON result/); + }); + + it.each([ + [undefined, "allow", undefined], + [{ ttl: 2500 }, "prefer", 2500], + ] as const)( + "maps %s task options to the ext-tasks preference contract", + async (taskOptions, expectedPreference, expectedRetention) => { + const client = makeClient(); + const callToolAndSettle = vi.fn( + async ( + _name: string, + _args: Readonly>, + options: TaskSessionCallOptions, + ) => { + options.onEvent({ + type: "task", + task: { + taskId: "task-preference", + status: "working", + lastUpdatedAt: "2026-01-02T03:04:05.000Z", + }, + }); + options.onEvent({ + type: "outcome", + outcome: { + status: "completed", + result: successfulResult, + task: { + taskId: "task-preference", + status: "completed", + createdAt: "2026-01-02T03:04:05.000Z", + }, + }, + }); + return { + outcome: { status: "completed", result: successfulResult }, + }; + }, + ); + attachTaskBoundary(client, callToolAndSettle); + const updates: Array<{ + task: TaskWithOptionalCreatedAt; + result?: CallToolResult; + }> = []; + client.addEventListener("requestorTaskUpdated", (event) => { + updates.push(event.detail); + }); + + const invocation = await client.callTool( + taskTool, + {}, + undefined, + undefined, + taskOptions, + ); + + expect(invocation.result).toEqual(successfulResult); + expect(callToolAndSettle).toHaveBeenCalledWith( + taskTool.name, + {}, + expect.objectContaining({ + task: { + preference: expectedPreference, + retentionMs: expectedRetention, + }, + }), + ); + expect(updates).toEqual([ + { + taskId: "task-preference", + task: expect.objectContaining({ + createdAt: "2026-01-02T03:04:05.000Z", + lastUpdatedAt: "2026-01-02T03:04:05.000Z", + }), + }, + { + taskId: "task-preference", + task: expect.objectContaining({ + createdAt: "2026-01-02T03:04:05.000Z", + lastUpdatedAt: "2026-01-02T03:04:05.000Z", + }), + result: successfulResult, + }, + ]); + }, + ); + + it("projects a task-scoped failure onto the public task event", async () => { + const client = makeClient(); + attachTaskBoundary( + client, + vi.fn(async (_name, _args, options) => { + options.onEvent({ + type: "task", + task: { + taskId: "task-failed", + status: "working", + createdAt: "2026-01-02T03:04:05.000Z", + lastUpdatedAt: "2026-01-02T03:04:06.000Z", + }, + }); + throw new Error("worker exploded"); + }), + ); + const updates: Array<{ error?: Error }> = []; + client.addEventListener("requestorTaskUpdated", (event) => { + updates.push(event.detail); + }); + + await expect(client.callTool(taskTool, {})).rejects.toThrow( + "worker exploded", + ); + expect(updates.at(-1)?.error?.message).toBe("worker exploded"); + }); + + it("enforces and can explicitly bypass output validation on task results", async () => { + const client = makeClient(); + const invalidResult: CallToolResult = { + content: [], + structuredContent: { count: "not-a-number" }, + }; + attachTaskBoundary( + client, + vi.fn(async () => ({ + outcome: { status: "completed", result: invalidResult }, + })), + ); + const toolWithOutput: Tool = { + ...taskTool, + outputSchema: { + type: "object", + properties: { count: { type: "number" } }, + required: ["count"], + }, + }; + + await expect(client.callTool(toolWithOutput, {})).rejects.toThrow( + /output schema|must be number/i, + ); + const advisory = await client.callTool( + toolWithOutput, + {}, + undefined, + undefined, + undefined, + { skipOutputValidation: true }, + ); + expect(advisory.success).toBe(true); + expect(advisory.outputValidationError).toMatch( + /output schema|must be number/i, + ); + }); + + it("projects every ext-tasks event and input-origin boundary", () => { + const client = makeClient(); + const boundary = taskInternals(client); + expect( + ["peer-request", "request-retry", "task-update"].map((delivery) => + boundary.taskInputOrigin( + delivery as "peer-request" | "request-retry" | "task-update", + ), + ), + ).toEqual(["server-request", "input-required", "task-input-required"]); + + const updates: Array<{ + task: TaskWithOptionalCreatedAt; + result?: CallToolResult; + error?: Error; + }> = []; + client.addEventListener("requestorTaskUpdated", (event) => { + updates.push(event.detail); + }); + + expect( + boundary.emitTaskExecutionEvent({ + type: "outcome", + outcome: { status: "cancelled" }, + }), + ).toBeUndefined(); + boundary.emitTaskError(undefined, "ignored without a task"); + + const emptyTimestampTask = { + taskId: "task-projection", + status: "working", + }; + expect( + boundary.emitTaskExecutionEvent({ + type: "task", + task: emptyTimestampTask, + }), + ).toEqual({ + ...emptyTimestampTask, + createdAt: "", + lastUpdatedAt: "", + }); + boundary.emitTaskExecutionEvent({ + type: "outcome", + outcome: { + status: "failed", + error: "string failure", + task: { ...emptyTimestampTask, status: "failed" }, + }, + }); + boundary.emitTaskExecutionEvent({ + type: "outcome", + outcome: { + status: "cancelled", + task: { ...emptyTimestampTask, status: "cancelled" }, + }, + }); + + expect(updates).toHaveLength(3); + expect(updates[1]?.error?.message).toBe("string failure"); + expect(updates[2]?.result).toBeUndefined(); + expect(updates[2]?.error).toBeUndefined(); + }); + + it("restores DispatchError cause identity from ext-tasks", async () => { + const client = makeClient(); + const cause = new Error("host transport failed"); + attachTaskBoundary( + client, + vi.fn(async () => { + throw new DispatchError("dispatch policy wrapper", false, { cause }); + }), + ); + + await expect(client.callTool(taskTool, {})).rejects.toBe(cause); + }); + + it("validates output from the public streaming task path", async () => { + const client = makeClient(); + const invalidResult: CallToolResult = { + content: [], + structuredContent: { count: "not-a-number" }, + }; + attachTaskBoundary( + client, + vi.fn(async () => ({ + outcome: { status: "completed", result: invalidResult }, + })), + ); + const toolWithOutput: Tool = { + ...taskTool, + outputSchema: { + type: "object", + properties: { count: { type: "number" } }, + required: ["count"], + }, + }; + + await expect(client.callToolStream(toolWithOutput, {})).rejects.toThrow( + /output schema|must be number/i, + ); + const advisory = await client.callToolStream( + toolWithOutput, + {}, + undefined, + undefined, + undefined, + { skipOutputValidation: true }, + ); + expect(advisory.outputValidationError).toMatch( + /output schema|must be number/i, + ); + }); + + it("stringifies a non-Error streaming task failure", async () => { + const client = makeClient(); + attachTaskBoundary( + client, + vi.fn(async () => { + throw "worker vanished"; + }), + ); + const invocations: Array<{ error?: string }> = []; + client.addEventListener("toolCallResultChange", (event) => { + invocations.push(event.detail); + }); + + await expect(client.callToolStream(taskTool, {})).rejects.toBe( + "worker vanished", + ); + expect(invocations.at(-1)?.error).toBe("worker vanished"); + }); }); diff --git a/clients/web/src/test/core/mcp/modernTaskSchemas.test.ts b/clients/web/src/test/core/mcp/modernTaskSchemas.test.ts deleted file mode 100644 index 2bc9020a6b..0000000000 --- a/clients/web/src/test/core/mcp/modernTaskSchemas.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - TASKS_EXTENSION_KEY, - MODERN_PROTOCOL_VERSION, - MODERN_TASK_HANDLE_META, - TASKS_EXTENSION_CLIENT_CAPABILITY, - ModernDetailedTaskSchema, - ModernGetTaskResultSchema, - ModernUpdateTaskResultSchema, - ModernCancelTaskResultSchema, - normalizeModernTask, - readInputRequests, - isModernCreateTaskResult, -} from "@inspector/core/mcp/modernTaskSchemas.js"; - -describe("modernTaskSchemas (#1631)", () => { - const baseTask = { - taskId: "abc", - status: "working" as const, - createdAt: "2026-07-20T00:00:00Z", - lastUpdatedAt: "2026-07-20T00:00:01Z", - }; - - describe("constants", () => { - it("exposes the SEP-2663 identifiers", () => { - expect(TASKS_EXTENSION_KEY).toBe("io.modelcontextprotocol/tasks"); - expect(MODERN_PROTOCOL_VERSION).toBe("2026-07-28"); - expect(MODERN_TASK_HANDLE_META).toContain("modernTaskHandle"); - expect( - TASKS_EXTENSION_CLIENT_CAPABILITY.extensions[TASKS_EXTENSION_KEY], - ).toEqual({}); - }); - }); - - describe("ModernDetailedTaskSchema", () => { - it("parses a working task and passes unknown fields through (loose)", () => { - const parsed = ModernDetailedTaskSchema.parse({ - ...baseTask, - ttlMs: 60000, - pollIntervalMs: 500, - somethingNew: "kept", - }); - expect(parsed.taskId).toBe("abc"); - expect((parsed as Record).somethingNew).toBe("kept"); - }); - - it("accepts a null ttlMs and inline result/error/inputRequests", () => { - const completed = ModernGetTaskResultSchema.parse({ - ...baseTask, - status: "completed", - ttlMs: null, - result: { content: [{ type: "text", text: "done" }] }, - }); - expect(completed.result).toBeDefined(); - const failed = ModernDetailedTaskSchema.parse({ - ...baseTask, - status: "failed", - error: { code: -1, message: "boom" }, - }); - expect(failed.error).toBeDefined(); - }); - - it("rejects a task missing required identity fields", () => { - expect(() => - ModernDetailedTaskSchema.parse({ status: "working" }), - ).toThrow(); - }); - - it("accepts empty update/cancel acks", () => { - expect(ModernUpdateTaskResultSchema.parse({})).toEqual({}); - expect( - ModernCancelTaskResultSchema.parse({ resultType: "complete" }), - ).toBeDefined(); - }); - }); - - describe("normalizeModernTask", () => { - it("maps ttlMs → ttl and pollIntervalMs → pollInterval", () => { - const task = normalizeModernTask({ - ...baseTask, - ttlMs: 60000, - pollIntervalMs: 250, - }); - expect(task.ttl).toBe(60000); - expect((task as { pollInterval?: number }).pollInterval).toBe(250); - }); - - it("defaults ttl to null and omits pollInterval when absent", () => { - const task = normalizeModernTask({ ...baseTask }); - expect(task.ttl).toBeNull(); - expect((task as { pollInterval?: number }).pollInterval).toBeUndefined(); - }); - - it("carries the status-specific members structurally", () => { - const task = normalizeModernTask({ - ...baseTask, - status: "completed", - ttlMs: null, - result: { content: [] }, - }); - expect((task as { result?: unknown }).result).toEqual({ content: [] }); - }); - }); - - describe("readInputRequests", () => { - it("returns the inputRequests map when present", () => { - const requests = { confirm: { method: "elicitation/create" } }; - const out = readInputRequests({ - ...baseTask, - status: "input_required", - inputRequests: requests, - }); - expect(out).toBe(requests); - }); - - it("returns undefined when absent", () => { - expect(readInputRequests({ ...baseTask })).toBeUndefined(); - }); - }); - - describe("isModernCreateTaskResult", () => { - it("is true only for a resultType:task frame with a taskId", () => { - expect( - isModernCreateTaskResult({ resultType: "task", taskId: "x" }), - ).toBe(true); - }); - - it("is false for complete results, non-objects, and missing taskId", () => { - expect(isModernCreateTaskResult({ resultType: "complete" })).toBe(false); - expect(isModernCreateTaskResult({ resultType: "task" })).toBe(false); - expect(isModernCreateTaskResult({ content: [] })).toBe(false); - expect(isModernCreateTaskResult(null)).toBe(false); - expect(isModernCreateTaskResult("task")).toBe(false); - }); - }); -}); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts index d0067baf95..f3b5740897 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-coverage-backfill.test.ts @@ -574,34 +574,6 @@ describe("InspectorClient coverage backfill", () => { ).rejects.toThrow(/forced-call-failure/); }); - it("callToolStream error path covers metadata combinations", async () => { - client = stdioClient(); - await client.connect(); - // Force the streaming API to throw synchronously so the error-path - // metadata-merge branches run for each combination. `callToolStream` - // drives tasks via the private `pollTaskToolCall` generator (SDK v2 - // removed `client.experimental.tasks`), so patch that instead. - const c = client as unknown as { - pollTaskToolCall: () => never; - }; - c.pollTaskToolCall = () => { - throw new Error("forced-stream-failure"); - }; - - await expect( - client.callToolStream(badTool, {}, { g: "1" }, undefined), - ).rejects.toThrow(/forced-stream-failure/); - await expect( - client.callToolStream(badTool, {}, undefined, { t: "1" }), - ).rejects.toThrow(/forced-stream-failure/); - await expect( - client.callToolStream(badTool, {}, { g: "1" }, { t: "2" }), - ).rejects.toThrow(/forced-stream-failure/); - await expect( - client.callToolStream(badTool, {}, undefined, undefined), - ).rejects.toThrow(/forced-stream-failure/); - }); - it("callToolStream success path covers metadata combinations", async () => { client = stdioClient(); await client.connect(); @@ -731,21 +703,6 @@ describe("InspectorClient coverage backfill", () => { ); }); - it("callToolStream error dispatch handles a non-Error rejection", async () => { - client = stdioClient(); - await client.connect(); - const echo = await getTool(client, "echo"); - const c = client as unknown as { - pollTaskToolCall: () => never; - }; - c.pollTaskToolCall = () => { - throw "string-stream-failure"; - }; - await expect( - client.callToolStream(echo, { message: "x" }, { g: "1" }), - ).rejects.toBe("string-stream-failure"); - }); - it("list methods fall back to empty arrays when the response omits them", async () => { client = stdioClient(); await client.connect(); @@ -777,114 +734,7 @@ describe("InspectorClient coverage backfill", () => { }); }); - describe("callToolStream task-result fallback and terminal branches", () => { - function patchStream( - c: InspectorClient, - gen: () => AsyncGenerator, - getTaskResult?: () => Promise, - ): void { - // SDK v2 removed `client.experimental.tasks`; `callToolStream` now drives - // tasks via the private `pollTaskToolCall` generator, and the no-result - // fallback fetches the payload with a raw `client.request({method:"tasks/result"})`. - const internal = c as unknown as { - pollTaskToolCall: () => AsyncGenerator; - client: { - request: ( - req: { method: string }, - schema: unknown, - opts: unknown, - ) => Promise; - }; - }; - internal.pollTaskToolCall = gen; - if (getTaskResult) { - const origRequest = internal.client.request.bind(internal.client); - internal.client.request = (req, schema, opts) => - req.method === "tasks/result" - ? getTaskResult() - : origRequest(req, schema, opts); - } - } - - const fakeTask = (taskId: string) => ({ - taskId, - status: "working" as const, - ttl: null, - createdAt: new Date().toISOString(), - lastUpdatedAt: new Date().toISOString(), - }); - - it("falls back to getTaskResult when the stream yields no result message", async () => { - client = stdioClient(); - await client.connect(); - const echo = await getTool(client, "echo"); - patchStream( - client, - async function* () { - // taskCreated then taskStatus, but NO result → triggers the fallback. - yield { type: "taskCreated", task: fakeTask("T1") }; - yield { type: "taskStatus", task: fakeTask("T1") }; - }, - async () => ({ content: [{ type: "text", text: "from-fallback" }] }), - ); - const result = await client.callToolStream(echo, { message: "x" }); - expect(result.success).toBe(true); - expect(result.result?.content?.[0]).toMatchObject({ - text: "from-fallback", - }); - }); - - it("throws when the getTaskResult fallback itself fails", async () => { - client = stdioClient(); - await client.connect(); - const echo = await getTool(client, "echo"); - patchStream( - client, - async function* () { - yield { type: "taskCreated", task: fakeTask("T2") }; - }, - async () => { - throw new Error("fallback-failed"); - }, - ); - await expect( - client.callToolStream(echo, { message: "x" }), - ).rejects.toThrow(/Tool call did not return a result: fallback-failed/); - }); - - it("surfaces a stream error message and marks the task failed", async () => { - client = stdioClient(); - await client.connect(); - const echo = await getTool(client, "echo"); - patchStream(client, async function* () { - yield { type: "taskCreated", task: fakeTask("T3") }; - // Empty error message → `message.error.message || "Task execution failed"` - // falsy branch. - yield { type: "error", error: { message: "" } }; - }); - await expect( - client.callToolStream(echo, { message: "x" }), - ).rejects.toThrow(/Task execution failed/); - }); - - it("treats a stream taskStatus before taskCreated as the task id", async () => { - client = stdioClient(); - await client.connect(); - const echo = await getTool(client, "echo"); - patchStream(client, async function* () { - // taskStatus arrives first (no prior taskCreated) → `if (!taskId)` true. - yield { type: "taskStatus", task: fakeTask("T4") }; - yield { - type: "result", - result: { content: [{ type: "text", text: "ok" }] }, - }; - }); - const result = await client.callToolStream(echo, { message: "x" }); - expect(result.success).toBe(true); - }); - }); - - describe("constructor capability branches and createReceiverTask options", () => { + describe("constructor capability branches", () => { it("advertises only the tasks extension when no other capabilities are set", async () => { // sample:false + elicit:false + no roots + no receiverTasks → the only // advertised capability is the always-on Tasks extension (#1631), so @@ -909,117 +759,5 @@ describe("InspectorClient coverage backfill", () => { await client.connect(); expect(client.getStatus()).toBe("connected"); }); - - it("createReceiverTask honors pollInterval and statusMessage options", async () => { - client = stdioClient(); - await client.connect(); - const internal = client as unknown as { - createReceiverTask: (opts: { - initialStatus: string; - ttl?: number; - pollInterval?: number; - statusMessage?: string; - }) => { - task: { - taskId: string; - pollInterval?: number; - statusMessage?: string; - }; - }; - }; - const record = internal.createReceiverTask({ - initialStatus: "working", - ttl: 5000, - pollInterval: 250, - statusMessage: "in progress", - }); - expect(record.task.pollInterval).toBe(250); - expect(record.task.statusMessage).toBe("in progress"); - - // Omitting ttl falls back to the configured numeric receiverTaskTtlMs - // (default 60_000) → the non-function branch of the ttl resolution. - const recordNoTtl = internal.createReceiverTask({ - initialStatus: "working", - }) as unknown as { task: { ttl: number } }; - expect(recordNoTtl.task.ttl).toBe(60_000); - }); - - it("createReceiverTask falls back to the configured TTL when ttl is omitted (function form)", async () => { - // receiverTaskTtlMs as a function → exercises the typeof === 'function' - // branch of the ttl resolution. - client = new InspectorClient( - { - type: "stdio", - command: serverCommand.command, - args: serverCommand.args, - }, - { - environment: { transport: createTransportNode }, - receiverTasks: true, - receiverTaskTtlMs: () => 1234, - }, - ); - await client.connect(); - const internal = client as unknown as { - createReceiverTask: (opts: { initialStatus: string }) => { - task: { ttl: number }; - }; - }; - const record = internal.createReceiverTask({ initialStatus: "working" }); - expect(record.task.ttl).toBe(1234); - }); - }); - - describe("emitReceiverTaskStatus guards", () => { - it("is a no-op when there is no connected client", () => { - const c = stdioClient(); - (c as unknown as { client: unknown }).client = null; - // Should not throw even though client is null. - expect(() => - ( - c as unknown as { emitReceiverTaskStatus: (t: unknown) => void } - ).emitReceiverTaskStatus({ taskId: "t" }), - ).not.toThrow(); - }); - - it("swallows notification-build errors via the catch path", async () => { - client = stdioClient(); - await client.connect(); - const internal = client as unknown as { - emitReceiverTaskStatus: (t: unknown) => void; - }; - // Passing a malformed task makes TaskStatusNotificationSchema.parse throw, - // which is caught and logged (no throw). - expect(() => - internal.emitReceiverTaskStatus({ not: "a task" }), - ).not.toThrow(); - }); - - it("upsertReceiverTask emits status for an existing record", async () => { - client = stdioClient(); - await client.connect(); - const internal = client as unknown as { - createReceiverTask: (opts: { initialStatus: string; ttl?: number }) => { - task: { taskId: string; status: string }; - }; - upsertReceiverTask: (t: { taskId: string; status: string }) => void; - getReceiverTask: ( - id: string, - ) => { task: { status: string } } | undefined; - }; - const record = internal.createReceiverTask({ - initialStatus: "working", - ttl: 5000, - }); - const updated = { ...record.task, status: "completed" }; - internal.upsertReceiverTask(updated); - expect(internal.getReceiverTask(record.task.taskId)?.task.status).toBe( - "completed", - ); - // upsert on an unknown id is a no-op (record undefined branch). - expect(() => - internal.upsertReceiverTask({ taskId: "missing", status: "completed" }), - ).not.toThrow(); - }); }); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts index 83e15acbf5..7c0494a3ea 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts @@ -357,7 +357,7 @@ describe("modern-era negotiation (2026-07-28)", () => { const { tools } = await connected.listTools(); const tool = tools.find((t) => t.name === "mrtr_loop"); await expect(connected.callTool(tool!, {})).rejects.toThrow( - /exceeded .* input_required rounds/, + /exceeded .* input[-_]required rounds/, ); }); diff --git a/clients/web/src/test/integration/mcp/inspectorClient-tasks-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-tasks-era.test.ts index 6cb8413254..03fc5e80b5 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-tasks-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-tasks-era.test.ts @@ -260,27 +260,6 @@ describe("tasks era fork (#1631)", () => { expect(methodsSent(messages)).toContain("tasks/update"); }); - it("bounds a never-completing input_required task with the round cap (#1631 review)", async () => { - const started = await startModernTasksServer(); - const { connected } = await connect(started.url, "modern"); - const { tools } = await connected.listTools(); - const tool = tools.find((t) => t.name === "modern_loop_task")!; - - // The server never advances past input_required, so the client re-prompts - // each poll; auto-answer, and the round cap must eventually abort instead - // of looping forever. - connected.addEventListener("newPendingElicitation", (event) => { - void event.detail.respond({ - action: "accept", - content: { approved: true }, - }); - }); - - await expect(connected.callToolStream(tool, {})).rejects.toThrow( - /exceeded \d+ input_required rounds/, - ); - }); - it("cancels a task paused at input_required — aborts the pending elicitation and unblocks the poll (#1631)", async () => { const started = await startModernTasksServer(); const { connected, messages } = await connect(started.url, "modern"); diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index 55dd7dcad0..b72cf01df2 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -4417,68 +4417,6 @@ describe("InspectorClient", () => { expect(Array.isArray(result.tasks)).toBe(true); }); - it("should run tool as task (callTool with taskOptions returns task reference, poll getRequestorTask/getRequestorTaskResult yields result)", async () => { - // Same path as web App "Run as task": callTool with taskOptions -> task reference -> poll until completed - const optionalTaskTool = await getTool(client!, "optional_task"); - const invocation = await client!.callTool( - optionalTaskTool, - { message: "e2e-run-as-task" }, - undefined, - undefined, - { ttl: 5000 }, - ); - - expect(invocation.success).toBe(true); - expect(invocation.result).toBeDefined(); - expect(typeof invocation.result).toBe("object"); - const rawResult = invocation.result as Record; - expect(rawResult.task).toBeDefined(); - const taskRef = rawResult.task as { - taskId: string; - status: string; - pollInterval?: number; - }; - expect(taskRef.taskId).toBeDefined(); - expect(typeof taskRef.taskId).toBe("string"); - expect(taskRef.taskId.length).toBeGreaterThan(0); - expect(taskRef.status).toBeDefined(); - expect(typeof taskRef.status).toBe("string"); - - const taskId = taskRef.taskId; - const pollIntervalMs = taskRef.pollInterval ?? 1000; - const timeoutMs = 12000; - const start = Date.now(); - let task = await client!.getRequestorTask(taskId); - while ( - task.status !== "completed" && - task.status !== "failed" && - task.status !== "cancelled" - ) { - expect(Date.now() - start).toBeLessThan(timeoutMs); - await new Promise((r) => setTimeout(r, pollIntervalMs)); - task = await client!.getRequestorTask(taskId); - } - - expect(task.status).toBe("completed"); - - const result = await client!.getRequestorTaskResult(taskId); - expect(result).toBeDefined(); - expect(result).toHaveProperty("content"); - expect(Array.isArray(result.content)).toBe(true); - expect(result.content.length).toBe(1); - const firstContent = result.content[0]; - expect(firstContent).toBeDefined(); - expect(firstContent!.type).toBe("text"); - expect(firstContent!).toHaveProperty("text"); - const resultText = JSON.parse((firstContent as { text: string }).text); - expect(resultText.message).toBe("Task completed: e2e-run-as-task"); - expect(resultText.taskId).toBe(taskId); - - const listResult = await client!.listRequestorTasks(); - const found = listResult.tasks.some((t) => t.taskId === taskId); - expect(found).toBe(true); - }); - it("should call tool with task support using callToolStream", async () => { const toolCallTaskUpdatedEvents: Array<{ taskId: string; @@ -5892,88 +5830,6 @@ describe("InspectorClient", () => { ); expect(c.getStatus()).toBe("disconnected"); }); - - it("receiver-task internals: TTL cleanup and cancel terminate via private surface", async () => { - // Drive the private createReceiverTask + cancelReceiverTask + TTL-cleanup - // paths by reaching into the instance. These are server-driven in - // practice (tasks/cancel from server), but the existing receiver-task - // e2e tests don't exercise the cancel path; this is the focused unit - // pass the issue suggested. - const c = new InspectorClient( - { - type: "stdio", - command: serverCommand.command, - args: serverCommand.args, - }, - { environment: { transport: createTransportNode } }, - ); - const internal = c as unknown as { - createReceiverTask: (opts: { - ttl?: number; - initialStatus: "input_required" | "working"; - statusMessage?: string; - }) => { - task: { taskId: string; status: string }; - payloadPromise: Promise; - }; - cancelReceiverTask: (taskId: string) => { - taskId: string; - status: string; - }; - listReceiverTasks: () => Array<{ taskId: string; status: string }>; - getReceiverTask: (taskId: string) => unknown; - getReceiverTaskPayload: (taskId: string) => Promise; - receiverTaskRecords: Map; - }; - - // Short TTL so the cleanup setTimeout fires in-test - const record = internal.createReceiverTask({ - ttl: 50, - initialStatus: "working", - statusMessage: "running", - }); - // Capture the rejection's message for the assertion below. (Not for - // unhandled-rejection suppression — `createReceiverTask` marks the - // promise handled at the source.) - const payloadResult = record.payloadPromise.catch( - (e) => (e as Error).message, - ); - expect(record.task.taskId).toBeDefined(); - // listReceiverTasks contains the new task - const list = internal.listReceiverTasks(); - expect(list.some((t) => t.taskId === record.task.taskId)).toBe(true); - expect(internal.getReceiverTask(record.task.taskId)).toBeDefined(); - - // getReceiverTaskPayload on an unknown id throws InvalidParams - await expect( - internal.getReceiverTaskPayload("does-not-exist"), - ).rejects.toThrow(/Unknown taskId/); - - // Cancel before TTL fires - const cancelled = internal.cancelReceiverTask(record.task.taskId); - expect(cancelled.status).toBe("cancelled"); - await expect(payloadResult).resolves.toBe("Task cancelled"); - // Cancel again — record is in terminal state, returns existing task - const reCancel = internal.cancelReceiverTask(record.task.taskId); - expect(reCancel.status).toBe("cancelled"); - - // cancelReceiverTask on an unknown id throws InvalidParams - expect(() => internal.cancelReceiverTask("nope")).toThrow( - /Unknown taskId/, - ); - - // Drive the TTL-cleanup path: create a record with very short ttl and - // let setTimeout fire — receiverTaskRecords drops the entry. - const ttlRecord = internal.createReceiverTask({ - ttl: 20, - initialStatus: "working", - statusMessage: "running", - }); - await new Promise((r) => setTimeout(r, 80)); - expect(internal.receiverTaskRecords.has(ttlRecord.task.taskId)).toBe( - false, - ); - }); }); describe("defensive guards when client is uninitialized", () => { diff --git a/clients/web/tsup.runner.config.ts b/clients/web/tsup.runner.config.ts index 49a7a7c44c..a6e6afd773 100644 --- a/clients/web/tsup.runner.config.ts +++ b/clients/web/tsup.runner.config.ts @@ -50,6 +50,7 @@ export default defineConfig({ // client's own code, so all three lists carry it (AGENTS.md). The CLI was // inlining it; the #2067 guard surfaced that. "@modelcontextprotocol/ext-apps", + "@modelcontextprotocol/ext-tasks", // Consolidated to the ROOT manifest by #2195, along with every other // runtime dependency `core/` imports. tsup externalizes only what the // *nearest* package.json declares, so once a client stops declaring one it diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index cec72b4959..2819a05c87 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1,4 +1,35 @@ -import { Client } from "@modelcontextprotocol/client"; +import { + Client, + isInputRequiredResult, + withInputRequired, +} from "@modelcontextprotocol/client"; +import { + createApplicationInputHandler, + createTaskSessionEndpointId, + createTaskSessionFromClient, + DispatchError, + resultFromTaskOutcome, + taskViewFromExecutionEvent, + toolDeclarationFromMcpTool, + withRelatedTaskMetadata, +} from "@modelcontextprotocol/ext-tasks/client"; +import type { + DispatchOptions, + JsonRpcResponse, + RawClientDispatch, + TaskCapabilities, + TaskEnabledSession, + TaskExecutionEvent, + TaskView, +} from "@modelcontextprotocol/ext-tasks/client"; +import { bindTaskReceiver } from "@modelcontextprotocol/ext-tasks/receiver"; +import type { TaskReceiverBinding } from "@modelcontextprotocol/ext-tasks/receiver"; +import { + runtimeCodecFromStandardSchema, + taskId as extTaskId, + toJsonValue, +} from "@modelcontextprotocol/ext-tasks/core"; +import type { JsonValue as TasksJsonValue } from "@modelcontextprotocol/ext-tasks/core"; // The protocol's own schemas for the reserved `_meta` members, so the client // validates against the SDK rather than a restatement of it that can drift. import { @@ -24,6 +55,7 @@ import type { ResourceSubscriptionStreamState, ExcludedTool, RequestMetadata, + InspectorTask, } from "./types.js"; import { scanXMcpHeaderDeclarations, @@ -72,12 +104,11 @@ import { type MessageTrackingCallbacks, } from "./messageTrackingTransport.js"; import type { - CallToolRequest, JSONRPCRequest, JSONRPCNotification, JSONRPCResultResponse, JSONRPCErrorResponse, - JSONRPCMessage, + StandardSchemaV1, ServerCapabilities, ClientCapabilities, Implementation, @@ -89,7 +120,6 @@ import type { Root, CreateMessageRequest, CreateMessageResult, - CreateTaskResult, ElicitRequest, ElicitResult, ElicitRequestURLParams, @@ -117,31 +147,20 @@ import type { DiscoverResult, InputRequests, InputRequiredOptions, - StandardSchemaV1, McpSubscription, SubscriptionFilter, } from "@modelcontextprotocol/client"; -import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; import { - isInputRequiredResult, - withInputRequired, + ProtocolError, + ProtocolErrorCode, LOG_LEVEL_META_KEY, CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY, - RELATED_TASK_META_KEY, } from "@modelcontextprotocol/client"; import { TASKS_EXTENSION_KEY, - MODERN_TASK_HANDLE_META, MODERN_PROTOCOL_VERSION, - ModernGetTaskResultSchema, - ModernUpdateTaskResultSchema, - ModernCancelTaskResultSchema, - normalizeModernTask, - readInputRequests, - isModernCreateTaskResult, - type ModernDetailedTask, } from "./modernTaskSchemas.js"; import { buildClientExtensions } from "./extensions.js"; import { @@ -172,19 +191,7 @@ import { CallToolResultSchema, GetPromptResultSchema, ReadResourceResultSchema, - // Task request schemas — used for `.shape.params` in the 3-arg custom - // `setRequestHandler` form (tasks/* are excluded from v2's spec-method set). - ListTasksRequestSchema, - GetTaskRequestSchema, - GetTaskPayloadRequestSchema, - CancelTaskRequestSchema, TaskStatusNotificationSchema, - // Task result schemas — explicit result schemas for the raw requestor-task - // requests that replace the removed `client.experimental.tasks.*` helpers. - CreateTaskResultSchema, - GetTaskResultSchema, - CancelTaskResultSchema, - ListTasksResultSchema, // List result schemas — used by the single-page list methods below. SDK v2's // high-level `client.listTools()` etc. auto-aggregate ALL pages (returning // `nextCursor: undefined`), which defeats the Inspector's pagination-debugging @@ -202,7 +209,6 @@ import { ResourceTemplateSchema, PromptSchema, } from "@modelcontextprotocol/core"; -import type { ClientResult } from "@modelcontextprotocol/client"; import { AjvJsonSchemaValidator } from "@modelcontextprotocol/client/validators/ajv"; import { z } from "zod/v4"; import { validateToolOutput } from "./toolOutputValidation.js"; @@ -222,15 +228,13 @@ import { } from "./listSalvage.js"; import { TasksListChangedNotificationSchema } from "./taskNotificationSchemas.js"; import { + isSerializableJson, type JsonValue, convertToolParameters, convertPromptArguments, } from "../json/jsonUtils.js"; import { expandUriTemplateStrict } from "./uriTemplate.js"; -import { - InspectorClientEventTarget, - type TaskWithOptionalCreatedAt, -} from "./inspectorClientEventTarget.js"; +import { InspectorClientEventTarget } from "./inspectorClientEventTarget.js"; import { SamplingCreateMessage } from "./samplingCreateMessage.js"; import { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; import { @@ -263,23 +267,11 @@ import { createFetchTracker } from "./fetchTracking.js"; import { OAuthManager, type OAuthManagerConfig } from "./oauthManager.js"; import { RemoteClientTransport } from "./remote/remoteClientTransport.js"; -/** Internal record for a receiver task (server polls us for status/result). */ -interface ReceiverTaskRecord { - task: Task; - payloadPromise: Promise; - resolvePayload: (payload: ClientResult) => void; - rejectPayload: (reason?: unknown) => void; - cleanupTimeoutId?: ReturnType; - /** - * Aborted when the task reaches a terminal state some way other than the - * user answering — a `tasks/cancel`, or session teardown. Whatever is - * collecting the answer (the native pending-request entry, or an app-rendered - * elicitation and its bridge) is torn down from this, so a cancelled task - * cannot leave a modal on screen waiting for an answer nothing will read. - */ - abort: AbortController; +function abortError(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new DOMException("The operation was aborted", "AbortError"); } - /** * Cap on how many times a single `callTool` will surface URL elicitations and * retry after a `-32042` (UrlElicitationRequired) response. A spec-compliant @@ -299,6 +291,23 @@ function createPendingAbortError(): Error { return new Error("Pending request aborted"); } +function jsonObject(value: unknown): Readonly> { + const json = toJsonValue(value); + if (json === null || Array.isArray(json) || typeof json !== "object") { + throw new TypeError("Expected a JSON object"); + } + const object: Record = {}; + for (const [key, member] of Object.entries(json)) object[key] = member; + return object; +} + +/** Restore host/transport error identity after ext-tasks applies dispatch policy. */ +function unwrapTaskDispatchError(error: unknown): unknown { + return error instanceof DispatchError && error.cause instanceof Error + ? error.cause + : error; +} + /** * The abort reason used by `cancelToolCall()`. It rides along on the * `notifications/cancelled` sent to the server and lets `callToolWithRetries` @@ -308,13 +317,6 @@ function createPendingAbortError(): Error { */ const TOOL_CALL_CANCELLED_REASON = "Tool call cancelled by user"; -/** - * Fallback poll cadence (ms) for {@link InspectorClient.pollTaskToolCall} when a - * task does not advertise its own `pollInterval`. Replaces the cadence the - * removed SDK `experimental.tasks.callToolStream` helper managed internally. - */ -const DEFAULT_TASK_POLL_INTERVAL_MS = 500; - /** * Close a modern listen stream best-effort, absorbing both failure modes a * third-party `close()` can produce: a rejected promise and a synchronous @@ -462,33 +464,55 @@ function dropInvalidReservedMeta( return cleaned; } +const taskToolResultCodec = runtimeCodecFromStandardSchema({ + "~standard": { + version: 1, + vendor: "mcp-inspector", + validate(value) { + const result = CallToolResultSchema.safeParse(value); + return result.success + ? { value: result.data as CallToolResult } + : { issues: result.error.issues.map(({ message }) => ({ message })) }; + }, + }, +}); + const MODERN_RECONNECT_BASE_MS = 500; const MODERN_RECONNECT_MAX_MS = 15_000; const MODERN_RECONNECT_MAX_ATTEMPTS = 8; -/** - * InspectorClient wraps an MCP Client and provides: - * - Message tracking and storage - * - Stderr log tracking and storage (for stdio transports) - * - EventTarget interface for React hooks (cross-platform: works in browser and Node.js) - * - Access to client functionality (prompts, resources, tools) - */ export class InspectorClient extends InspectorClientEventTarget { /** - * Upper bound on MRTR (`input_required`) rounds for a single logical request - * before {@link requestWithInputRequired} gives up. We drive the loop - * ourselves (`inputRequired: { autoFulfill: false }`), so this is the manual - * counterpart to the SDK auto-driver's default `maxRounds` (10) and guards - * against a server that keeps returning `input_required` forever. + * We construct the v2 client with auto-fulfilment disabled and drive MRTR + * ourselves, so this mirrors the SDK auto-driver's default round bound. */ private static readonly MRTR_MAX_ROUNDS = 10; private client: Client | null = null; + /** Requester-side Tasks orchestration, attached once per connected SDK client. */ + private taskSession: TaskEnabledSession | null = null; + /** Receiver-side Tasks binding, replaced with each connected session. */ + private taskReceiverBinding: TaskReceiverBinding | null = null; private appRendererClientProxy: AppRendererClient | null = null; // Lazily-built validator used only on the skipOutputValidation path to detect // (non-fatally) when a delivered result violates the tool's outputSchema. private outputValidator: AjvJsonSchemaValidator | null = null; private transport: Transport | MessageTrackingTransport | null = null; private baseTransport: Transport | null = null; + // Pending below-SDK requests. String ids cannot collide with the SDK's numeric ids; + // MessageTrackingTransport consumes their responses before they reach the SDK. + private pendingRawWireRequests = new Map< + string, + { + resolve: (response: JsonRpcResponse) => void; + reject: (error: Error) => void; + cleanup: () => void; + } + >(); + private rawWireRequestCounter = 0; + private readonly dispatchTaskRequest: RawClientDispatch = ( + request, + options, + ) => this.dispatchRawWireRequest(request, options); // Correlation for `markResponseRejected` (#1953): the method of each // outbound request still awaiting a response, and — once one is answered — // the id of the most recently answered request per method. Entries are @@ -583,7 +607,7 @@ export class InspectorClient extends InspectorClientEventTarget { private readonly elicitationCapabilityAdvertised: boolean; /** As above, for `capabilities.sampling`. */ private readonly samplingCapabilityAdvertised: boolean; - /** As above, for `capabilities.tasks` (the receiver-side `tasks/*` polls). */ + /** Whether receiver-side Tasks were advertised for at least one input method. */ private readonly tasksCapabilityAdvertised: boolean; /** As above, for `capabilities.elicitation.url` (the URL-mode completion). */ private readonly urlElicitationCapabilityAdvertised: boolean; @@ -639,33 +663,8 @@ export class InspectorClient extends InspectorClientEventTarget { // site to the two failure handlers. Cleared by any user-initiated refresh (a // subscribe/unsubscribe is a fresh attempt, and the server may have changed). private modernNeverAcknowledged = false; - // Task ids the user explicitly cancelled. A cancel makes the in-flight - // `callToolStream` reject with a generic -32603 error, which the stream's - // error path would otherwise report as a *failed* task — flashing "failed" - // in the UI until a refresh fetches the server's true "cancelled" state. - // Recording the id lets that path label the terminal task "cancelled" - // instead, so it lands in the right state immediately (#1455). Cleared on - // disconnect. - private cancelledTaskIds: Set = new Set(); - // Per-task abort controllers for a modern task paused at `input_required`. - // While the poll loop blocks on the pending elicitation (the modal), the tool - // call's own abort path isn't in play — so `cancelRequestorTask` aborts this - // controller to reject the pending request, close the modal, and let the poll - // observe the cancellation. Keyed by taskId; created/removed by the poll loops. - private taskInputAbortControllers = new Map(); - // Pending raw-wire requests (modern tasks/* — see rawWireRequest). Keyed by a - // string JSON-RPC id we mint; the SDK Client only mints numeric ids, so ours - // never collide with (or reach) it. Resolved by the transport's - // consume-response hook and rejected on disconnect. - private pendingRawWireRequests = new Map< - string, - { - resolve: (result: unknown) => void; - reject: (err: Error) => void; - timer: ReturnType; - } - >(); - private rawWireRequestCounter = 0; + /** Correlates task-call progress tokens to task ids after the first snapshot. */ + private readonly taskProgressIds = new Map(); // Abort controller for the in-flight ordinary (non-task) tool call. Aborting // it hands the SDK the MCP cancellation flow for that request and rejects the // pending call, which `callTool` surfaces as a `ToolCallCancelledError`. Which @@ -676,7 +675,7 @@ export class InspectorClient extends InspectorClientEventTarget { // Task-augmented calls have a server-side task and are cancelled via // `cancelRequestorTask` instead, so they don't use this (#1458). private activeToolCallAbortController?: AbortController; - // Receiver tasks (server-initiated: server sends createMessage/elicit with params.task, server polls us) + /** Enable ext-tasks receiver ownership for advertised sampling/elicitation methods. */ private readonly receiverTasks: boolean; // Per-extension advertise overrides (#1738); undefined key falls back to the // registry default in ADVERTISABLE_EXTENSIONS. @@ -713,8 +712,7 @@ export class InspectorClient extends InspectorClientEventTarget { * cannot outlive the connection that asked for it. */ private activeAppElicitations = new Set(); - private receiverTaskTtlMs: number | (() => number); - private receiverTaskRecords: Map = new Map(); + private readonly receiverTaskTtlMs: number | (() => number); // OAuth support (config owned by oauthManager; client delegates and uses !!oauthManager for "is OAuth configured") private oauthManager: OAuthManager | null = null; private logger: InspectorLogger; @@ -915,26 +913,16 @@ export class InspectorClient extends InspectorClientEventTarget { if (this.roots !== undefined) { capabilities.roots = { listChanged: true }; } - // Receiver tasks: advertise so server can send task-augmented createMessage/elicit and poll us if (this.receiverTasks) { - // `requests` declares which server→client requests we accept as tasks, so - // it must name only capabilities we actually advertised — both are decided - // above. Advertising a channel we then answer `-32601` on is the shape - // #1797 is about, and `{ receiverTasks: true, elicit: false }` would do - // exactly that. - const taskRequests: NonNullable< + const requests: NonNullable< NonNullable["requests"] > = {}; - if (capabilities.sampling) { - taskRequests.sampling = { createMessage: {} }; - } - if (capabilities.elicitation) { - taskRequests.elicitation = { create: {} }; - } + if (capabilities.sampling) requests.sampling = { createMessage: {} }; + if (capabilities.elicitation) requests.elicitation = { create: {} }; capabilities.tasks = { list: {}, cancel: {}, - ...(Object.keys(taskRequests).length > 0 && { requests: taskRequests }), + ...(Object.keys(requests).length > 0 && { requests }), }; } // Assemble the advertised-extensions map from one builder (the single @@ -988,6 +976,9 @@ export class InspectorClient extends InspectorClientEventTarget { this.clientInfo, Object.keys(clientOptions).length > 0 ? clientOptions : undefined, ); + if (this.tasksCapabilityAdvertised) { + this.bindReceiverTasks(); + } } private buildEffectiveAuthFetch(): typeof fetch { @@ -1052,6 +1043,7 @@ export class InspectorClient extends InspectorClientEventTarget { message: JSONRPCNotification, origin: MessageOrigin, ) => { + if (origin === "server") this.dispatchTaskProgress(message); const entry: MessageEntry = { id: crypto.randomUUID(), timestamp: new Date(), @@ -1064,6 +1056,22 @@ export class InspectorClient extends InspectorClientEventTarget { }; } + private dispatchTaskProgress(message: JSONRPCNotification): void { + if (message.method !== "notifications/progress") return; + const params = message.params as Progress & { + progressToken?: ProgressToken; + }; + const progressToken = params.progressToken; + if (progressToken === undefined) return; + const taskId = this.taskProgressIds.get(progressToken); + if (!taskId) return; + if (this.progress) this.dispatchTypedEvent("progressNotification", params); + this.dispatchTypedEvent("requestorTaskProgress", { + taskId, + progress: params, + }); + } + private attachTransportListeners(baseTransport: Transport): void { baseTransport.onclose = () => { // An explicit disconnect() owns the teardown and will set the canonical @@ -1073,6 +1081,9 @@ export class InspectorClient extends InspectorClientEventTarget { // "error" would fire `disconnect`, then disconnect()'s own guard would // fire it again (#1490 re-review). if (this.disconnecting) return; + // A handshake-time close belongs to the awaited connect() failure path; + // changing status here races ahead of its catch and briefly reports disconnected. + if (this.status === "connecting") return; // Already fully torn down — nothing to do (avoids a duplicate // `disconnect` event after an explicit disconnect()). if (this.status === "disconnected") return; @@ -1104,6 +1115,8 @@ export class InspectorClient extends InspectorClientEventTarget { // would otherwise wait out its own 30s timeout and blame the timeout for // a crash. Rejecting a settled promise is a no-op and the helper clears // the map, so this can't double-settle with `disconnect()`. + // onclose is synchronous; this best-effort async cleanup owns and logs failures. + void this.closeTaskSessionBestEffort(); this.rejectPendingRawWireRequests("Connection closed"); this.dispatchTypedEvent("disconnect"); }; @@ -1263,198 +1276,68 @@ export class InspectorClient extends InspectorClientEventTarget { ); } - /** - * True when task status is completed, failed, or cancelled. - * We use this private helper instead of the SDK's experimental isTerminal() - * to avoid depending on experimental API and to get a type predicate so - * TypeScript narrows status to "completed" | "failed" | "cancelled" after the check. - */ - private static isTerminalTaskStatus( - status: Task["status"], - ): status is "completed" | "failed" | "cancelled" { - return ( - status === "completed" || status === "failed" || status === "cancelled" - ); - } - - /** - * Route a receiver (server-initiated) task-augmented `sampling/createMessage` - * or `elicitation/create` response around the v2 Client's result validation. - * - * SDK v2's `Client` wraps every spec request handler (`_wrapHandler`) to - * validate the result it returns — for sampling/elicitation it checks the - * value against `CreateMessageResult` / `ElicitResult` and rejects anything - * else with a `-32602`. The 2025-11-25 task flow answers a task-augmented - * request with a `CreateTaskResult` (`{ task }`), which that validation - * rejects — breaking server-initiated tasks that worked on the legacy client. - * - * There is no public seam to opt a handler out of result validation, so we - * swap the wrapped entry in the Protocol's private `_requestHandlers` map for - * one that dispatches the task-augmented branch straight through the raw - * handler (whose `{ task }` return then rides the legacy codec's pass-through - * `encodeResult` to the wire), while ordinary (non-task) requests keep the - * validating path. Mirrors the bypass a legacy server needs to emit `{ task }`. - * Delete once the SDK models task-augmented results natively (see #1624 stack). - */ - private installReceiverTaskResponseBypass( - method: "sampling/createMessage" | "elicitation/create", - rawHandler: ( - request: CreateMessageRequest & ElicitRequest, - ) => Promise | Promise, - ): void { - if (!this.client) return; - // SDK gap: `Client` exposes no public way to (a) read a registered request - // handler or (b) opt one out of the result validation its `_wrapHandler` - // installs, so we reach the private `_requestHandlers` map through a - // narrowed cast. A public "register a raw/unvalidated handler" API — or a - // handler-result type that includes `CreateTaskResult` — would remove both - // this cast and the ones on the sampling/elicit returns above. - const internal = this.client as unknown as { - _requestHandlers: Map< - string, - (request: unknown, ctx: unknown) => unknown - >; + private paramsWithRelatedTask( + params: Readonly>, + taskId: string, + ): Readonly> { + const rawMetadata = params._meta; + const metadata = + rawMetadata !== null && + !Array.isArray(rawMetadata) && + typeof rawMetadata === "object" + ? (rawMetadata as Readonly>) + : undefined; + return { + ...params, + _meta: withRelatedTaskMetadata(metadata, { taskId: extTaskId(taskId) }), }; - const validating = internal._requestHandlers.get(method); - if (!validating) return; - internal._requestHandlers.set(method, (request, ctx) => { - const task = (request as { params?: { task?: unknown } })?.params?.task; - // The advertisement check is redundant here — this wrapper only exists - // when tasks are advertised — but it mirrors the handler branch below - // deliberately: the two must agree, so they read one predicate. - if (this.tasksCapabilityAdvertised && task != null) { - return rawHandler(request as CreateMessageRequest & ElicitRequest); - } - return validating(request, ctx); - }); } - private createReceiverTask(opts: { - ttl?: number; - initialStatus: Task["status"]; - statusMessage?: string; - pollInterval?: number; - }): ReceiverTaskRecord { - const taskId = crypto.randomUUID(); - const ttlMs = - opts.ttl ?? - (typeof this.receiverTaskTtlMs === "function" - ? this.receiverTaskTtlMs() - : this.receiverTaskTtlMs); - const now = new Date().toISOString(); - const task: Task = { - taskId, - status: opts.initialStatus, - ttl: ttlMs, - createdAt: now, - lastUpdatedAt: now, - ...(opts.pollInterval != null && { pollInterval: opts.pollInterval }), - ...(opts.statusMessage != null && { statusMessage: opts.statusMessage }), - }; - let resolvePayload!: (payload: ClientResult) => void; - let rejectPayload!: (reason?: unknown) => void; - const payloadPromise = new Promise((resolve, reject) => { - resolvePayload = resolve; - rejectPayload = reject; - }); - // Mark it handled. The real consumer is the server polling `tasks/result` - // (`getReceiverTaskPayload` returns this same promise, so a real awaiter - // still sees the rejection), but nothing has attached a handler while the - // task sits in `input_required` — and it can be rejected from there, by an - // explicit `tasks/cancel` or by teardown settling a queued sample. Without - // this, that reject surfaces as an unhandled rejection. - void payloadPromise.catch(() => {}); - const record: ReceiverTaskRecord = { - task, - payloadPromise, - resolvePayload, - rejectPayload, - abort: new AbortController(), + /** Install package-owned receiver handlers for the current SDK client. */ + private bindReceiverTasks(): void { + this.taskReceiverBinding?.close(); + this.taskReceiverBinding = null; + const client = this.client; + if (!client || !this.tasksCapabilityAdvertised) return; + const methods = { + "sampling/createMessage": this.samplingCapabilityAdvertised, + "elicitation/create": this.elicitationCapabilityAdvertised, }; - record.cleanupTimeoutId = setTimeout(() => { - record.cleanupTimeoutId = undefined; - this.receiverTaskRecords.delete(taskId); - }, ttlMs); - this.receiverTaskRecords.set(taskId, record); - return record; - } - - private emitReceiverTaskStatus(task: Task): void { - if (!this.client) return; - try { - const notification = TaskStatusNotificationSchema.parse({ - method: "notifications/tasks/status" as const, - params: task, - }); - this.client.notification(notification).catch((err) => { - this.logger.warn( - { err, taskId: task.taskId }, - "receiver task status notification failed", + const ttlMs = this.receiverTaskTtlMs; + const binding = bindTaskReceiver(client, { + methods, + ttlMs, + sampling: async (request, context) => { + const result = await this.enqueuePendingSample( + { + method: "sampling/createMessage", + params: this.paramsWithRelatedTask(request.params, context.taskId), + } as CreateMessageRequest, + "server-request", + context.signal, ); - }); - } catch (err) { - this.logger.warn( - { err, taskId: task.taskId }, - "receiver task status notification failed", - ); - } - } - - private upsertReceiverTask(updatedTask: Task): void { - const record = this.receiverTaskRecords.get(updatedTask.taskId); - if (record) { - record.task = updatedTask; - this.emitReceiverTaskStatus(updatedTask); - } - } - - private getReceiverTask(taskId: string): ReceiverTaskRecord | undefined { - return this.receiverTaskRecords.get(taskId); - } - - private listReceiverTasks(): Task[] { - return Array.from(this.receiverTaskRecords.values()).map((r) => r.task); - } - - private async getReceiverTaskPayload(taskId: string): Promise { - const record = this.receiverTaskRecords.get(taskId); - if (!record) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Unknown taskId: ${taskId}`, - ); - } - return record.payloadPromise; + return toJsonValue(result) as Readonly>; + }, + elicitation: async (request, context) => { + const result = await this.enqueuePendingElicitation( + { + method: "elicitation/create", + params: this.paramsWithRelatedTask(request.params, context.taskId), + } as ElicitRequest, + "server-request", + context.signal, + ); + return toJsonValue(result) as Readonly>; + }, + onError: (error, context) => + this.logger.error({ error, ...context }, "ext-tasks receiver error"), + }); + this.taskReceiverBinding = binding; } - private cancelReceiverTask(taskId: string): Task { - const record = this.receiverTaskRecords.get(taskId); - if (!record) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Unknown taskId: ${taskId}`, - ); - } - if (InspectorClient.isTerminalTaskStatus(record.task.status)) { - return record.task; - } - const now = new Date().toISOString(); - const updatedTask: Task = { - ...record.task, - status: "cancelled", - lastUpdatedAt: now, - }; - record.task = updatedTask; - record.rejectPayload(new Error("Task cancelled")); - // Stop collecting an answer nobody will read: drops the native pending - // entry and tears down an app-rendered elicitation's renderer. - record.abort.abort(); - if (record.cleanupTimeoutId != null) { - clearTimeout(record.cleanupTimeoutId); - record.cleanupTimeoutId = undefined; - } - this.emitReceiverTaskStatus(updatedTask); - return updatedTask; + private closeTaskReceiver(): void { + this.taskReceiverBinding?.close(); + this.taskReceiverBinding = null; } /** @@ -1477,281 +1360,28 @@ export class InspectorClient extends InspectorClientEventTarget { this.transportHasAuthProvider = false; } - /** - * Register the handlers for requests the *server* makes of *us* — - * `roots/list`, `sampling/createMessage`, `elicitation/create`, and the - * receiver-side `tasks/*` polls. - * - * MUST be called before `client.connect()`. The matching capabilities are - * advertised on the `Client` at construction time, so from the moment - * `connect()` sends `notifications/initialized` the server is entitled to - * issue any of these requests. Registering afterwards leaves a window in - * which the SDK `Client` has no handler and answers `-32601 Method not - * found` — which is exactly what a server that asks for roots the instant it - * is initialized (e.g. `server-filesystem`, which learns its allowed - * directories that way) hits, while a server that asks later does not (#1797). - * - * Nothing here depends on the server's capabilities — only on constructor-set - * state — so there is nothing to wait for. Its sibling - * {@link registerPeerNotificationHandlers} does the same for the one - * notification handler in that position; the notification handlers that *do* - * gate on `this.capabilities` stay in `connect()`, after the handshake. - */ + /** Register ordinary server→client request handlers before the handshake. */ private registerPeerRequestHandlers(): void { - // Gated on what was advertised, like the others — see - // `rootsCapabilityAdvertised`. - if (this.samplingCapabilityAdvertised && this.client) { - const samplingHandler = ( - request: CreateMessageRequest, - ): Promise => { - const paramsTask = (request.params as { task?: { ttl?: number } }) - ?.task; - if (this.tasksCapabilityAdvertised && paramsTask != null) { - const record = this.createReceiverTask({ - ttl: paramsTask.ttl, - initialStatus: "input_required", - statusMessage: "Awaiting user input", - }); - void (async () => { - const samplingRequest = new SamplingCreateMessage( - request, - (result) => { - record.resolvePayload(result); - const now = new Date().toISOString(); - const updated: Task = { - ...record.task, - status: "completed", - lastUpdatedAt: now, - }; - record.task = updated; - this.upsertReceiverTask(updated); - }, - (error) => { - record.rejectPayload(error); - const now = new Date().toISOString(); - const updated: Task = { - ...record.task, - status: "failed", - lastUpdatedAt: now, - statusMessage: - error instanceof Error ? error.message : String(error), - }; - record.task = updated; - this.upsertReceiverTask(updated); - }, - (id) => this.removePendingSample(id), - ); - this.addPendingSample(samplingRequest); - })(); - // Task-augmented (2025-11-25) response: the server sent a - // task-augmented `sampling/createMessage`, so we reply with a - // `CreateTaskResult` (`{ task }`) rather than a `CreateMessageResult`. - // The v2 Client validates a spec handler's result and would reject - // `{ task }` with -32602; `installReceiverTaskResponseBypass` below - // routes this task-augmented branch around that validation so the - // legacy `{ task }` response reaches the wire. `taskResult` is typed - // as `CreateTaskResult` so its shape IS checked; the unavoidable - // `as unknown as CreateMessageResult` bridges the SDK gap — the 2-arg - // `setRequestHandler` overload types a sampling handler's return as - // `CreateMessageResult` only and doesn't model the (deprecated but - // wire-valid) task-augmented `CreateTaskResult`. A handler-result - // union `CreateMessageResult | CreateTaskResult` on the SDK side - // would remove this cast. - const taskResult: CreateTaskResult = { task: record.task }; - return Promise.resolve(taskResult as unknown as CreateMessageResult); - } - return this.enqueuePendingSample(request, "server-request"); - }; - this.client.setRequestHandler("sampling/createMessage", samplingHandler); - // Registration, like the `setRequestHandler` above it — and the whole - // bypass mechanism (install, wrapper branch, handler branch) reads this - // one predicate, so the install can't drift from the branch it controls. - if (this.tasksCapabilityAdvertised) { - this.installReceiverTaskResponseBypass( - "sampling/createMessage", - samplingHandler, - ); - } + if (!this.client) return; + if (this.samplingCapabilityAdvertised) { + this.client.setRequestHandler("sampling/createMessage", (request) => + this.enqueuePendingSample(request, "server-request"), + ); } - - // Gated on what was advertised, not on `this.elicit` — see the field's doc: - // 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, - // 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) { - const record = this.createReceiverTask({ - ttl: paramsTask.ttl, - initialStatus: "input_required", - statusMessage: "Awaiting user input", - }); - // Settling the receiver task, shared by both answer routes below so - // an app-rendered answer completes the task exactly as a native one - // does. - const completeTask = (result: ElicitResult) => { - // A cancelled (or otherwise terminal) task must not be re-settled: - // an answer that arrives after `tasks/cancel` would otherwise - // overwrite `cancelled` with `completed`. - if (InspectorClient.isTerminalTaskStatus(record.task.status)) - return; - record.resolvePayload(result); - const updated: Task = { - ...record.task, - status: "completed", - lastUpdatedAt: new Date().toISOString(), - }; - record.task = updated; - this.upsertReceiverTask(updated); - }; - const failTask = (error: Error) => { - if (InspectorClient.isTerminalTaskStatus(record.task.status)) - return; - record.rejectPayload(error); - const updated: Task = { - ...record.task, - status: "failed", - lastUpdatedAt: new Date().toISOString(), - statusMessage: error.message, - }; - record.task = updated; - this.upsertReceiverTask(updated); - }; - void (async () => { - // A task-augmented request is still an `elicitation/create`, so the - // app-rendering contract applies to it too (#1854). It cannot go - // through `enqueuePendingElicitation` — the response frame has - // already been sent as a `CreateTaskResult` and the answer settles - // the TASK rather than the request — so the same attempt is made - // here, falling back to the native queue exactly as that funnel - // does. An abort (disconnect) fails the task rather than reopening - // it natively. - let appResult: ElicitResult | null; - try { - appResult = await this.tryAppElicitation( - request, - record.abort.signal, - ); - } catch (error) { - failTask( - error instanceof Error ? error : new Error(String(error)), - ); - return; - } - if (appResult) { - completeTask(appResult); - return; - } - const elicitationRequest = new ElicitationCreateMessage( - request, - completeTask, - (id) => this.removePendingElicitation(id), - failTask, - ); - this.addPendingElicitation(elicitationRequest); - // A `tasks/cancel` (or teardown) drops the queued entry, so the - // modal does not outlive the task it belongs to. - this.wirePendingAbort(record.abort.signal, () => - this.removePendingElicitation(elicitationRequest.id), - ); - })(); - // Task-augmented (2025-11-25) response — see the sampling handler - // above. Reply with a `CreateTaskResult` (`{ task }`), routed around - // the v2 Client's result validation by - // `installReceiverTaskResponseBypass` below. `taskResult` is typed so - // its shape is checked; the `as unknown as ElicitResult` bridges the - // same SDK gap as the sampling handler — the 2-arg `setRequestHandler` - // overload types an elicitation handler's return as `ElicitResult` - // only and doesn't model the task-augmented `CreateTaskResult`. - const taskResult: CreateTaskResult = { task: record.task }; - return Promise.resolve(taskResult as unknown as ElicitResult); - } - // `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( + if (this.elicitationCapabilityAdvertised) { + this.client.setRequestHandler("elicitation/create", (request, context) => + this.enqueuePendingElicitation( request, "server-request", - ctx?.mcpReq?.signal, - ); - }; - this.client.setRequestHandler("elicitation/create", elicitHandler); - // Registration, like the `setRequestHandler` above it — and the whole - // bypass mechanism (install, wrapper branch, handler branch) reads this - // one predicate, so the install can't drift from the branch it controls. - if (this.tasksCapabilityAdvertised) { - this.installReceiverTaskResponseBypass( - "elicitation/create", - elicitHandler, - ); - } - } - - // Gated on what was advertised at construction, and it has to be: the SDK - // asserts the matching client capability inside `setRequestHandler`, so - // registering this on a client built without `roots` throws "Client does - // not support roots capability". Since `capabilities.roots` is negotiated at - // `initialize` (set in the constructor) and `registerCapabilities` refuses - // to run after connect, a client that omits the option can never serve - // `roots/list` — which is why every client that may call `setRoots()` later - // must pass `roots` up front (web does; the CLI and TUI now do too — #1797). - if (this.rootsCapabilityAdvertised && this.client) { - this.client.setRequestHandler("roots/list", async () => { - return { roots: this.roots ?? [] }; - }); - } - - // Set up receiver-task request handlers (server polls us for tasks/list, - // tasks/get, tasks/result, tasks/cancel). SDK v2 removed tasks from the - // spec-method set, so these register through the 3-arg custom form with an - // explicit params schema (from the deprecated-but-importable task request - // schemas). The `result` schema is intentionally omitted so the SDK does - // not validate our responder return — matching v1, where only the - // requester validated (our receiver `Task` may omit fields a strict result - // schema would require). - if (this.tasksCapabilityAdvertised && this.client) { - this.client.setRequestHandler( - "tasks/list", - { params: ListTasksRequestSchema.shape.params }, - async () => ({ tasks: this.listReceiverTasks() }), - ); - this.client.setRequestHandler( - "tasks/get", - { params: GetTaskRequestSchema.shape.params }, - async (params) => { - const record = this.getReceiverTask(params.taskId); - if (!record) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Unknown taskId: ${params.taskId}`, - ); - } - return record.task; - }, - ); - this.client.setRequestHandler( - "tasks/result", - { params: GetTaskPayloadRequestSchema.shape.params }, - async (params) => this.getReceiverTaskPayload(params.taskId), - ); - this.client.setRequestHandler( - "tasks/cancel", - { params: CancelTaskRequestSchema.shape.params }, - async (params) => this.cancelReceiverTask(params.taskId), + context.mcpReq?.signal, + ), ); } + if (this.rootsCapabilityAdvertised) { + this.client.setRequestHandler("roots/list", async () => ({ + roots: this.roots ?? [], + })); + } } /** @@ -1788,29 +1418,6 @@ export class InspectorClient extends InspectorClientEventTarget { ); } - /** - * Stop the receiver tasks' TTL timers and drop the records. - * - * These are tasks a *server* created with us, so they belong to the session - * that created them: `listReceiverTasks()` is what the `tasks/list` handler - * answers with, and a record surviving into the next session would report a - * task the new server never created. `disconnect()` clears them, and so does - * `connect()` — the auth-recovery retry reconnects the *same* client - * instance, so ending the session isn't the only way a new one begins - * (#1797). - */ - private clearReceiverTasks(): void { - for (const record of this.receiverTaskRecords.values()) { - if (record.cleanupTimeoutId != null) { - clearTimeout(record.cleanupTimeoutId); - } - // Same reason as `cancelReceiverTask`: the session that owns whatever is - // collecting the answer is ending. - record.abort.abort(); - } - this.receiverTaskRecords.clear(); - } - /** * Reset the modern listen-stream cluster: the subscribed set, the stream * state derived from it, and the reconnect machinery that reports on it. @@ -1871,22 +1478,12 @@ export class InspectorClient extends InspectorClientEventTarget { * this same instance (the auth-recovery path), both leave it behind. Called * start-clean from `connect()` so every route in is covered. * - * Each member has a symptom, not just untidiness: a stale `subscribedResources` - * entry makes the modern `subscribeToResource` early-return, so the user's - * Subscribe click silently sends nothing to the new server; a stale - * `cancelledTaskIds` entry mislabels a *new* task sharing the id as - * `cancelled` rather than `failed`; a stale subscription stream state reads - * `active` for a set that is now empty, which every reader of it treats as - * impossible; a receiver-task record is reported to the new server by - * `tasks/list`; and an un-aborted `taskInputAbortControllers` - * entry delays a paused poll loop unwinding — both registration sites release - * in a `finally`, so nothing leaks permanently; the abort just closes the - * window between the crash and the unwind (#1797). + * The task receiver binding is session-owned too: closing it aborts pending + * callbacks, drops package-owned records, and restores prior request handlers. */ private resetSessionState(): void { - this.clearReceiverTasks(); + this.closeTaskReceiver(); this.resetSubscriptionStream(); - this.cancelledTaskIds.clear(); // Correlation data is per-session: JSON-RPC ids don't survive it, and // MessageLogState drops its entries on disconnect, so anything left here // could only point at an entry that no longer exists. Clearing also @@ -1898,6 +1495,7 @@ export class InspectorClient extends InspectorClientEventTarget { // (#1953). this.outboundRequestMethods.clear(); this.lastAnsweredRequestByMethod.clear(); + this.taskProgressIds.clear(); // Per-session for the same reason: both name entries of the PREVIOUS // server's list. Cleared here as well as in `disconnect()` because the // route out that tears down nothing (`onerror` with no `onclose`) would @@ -1915,10 +1513,6 @@ export class InspectorClient extends InspectorClientEventTarget { this.excludedTools = []; this.dispatchTypedEvent("excludedToolsChange", []); } - for (const [, controller] of this.taskInputAbortControllers) { - controller.abort(new Error("Connection ended")); - } - this.taskInputAbortControllers.clear(); // Restore the configured opt-in rather than carrying a mid-session // `setModernLogLevel` override into the next connection — and rather than // leaving it `undefined` after a `disconnect()` cleared it, which silently @@ -2005,44 +1599,18 @@ export class InspectorClient extends InspectorClientEventTarget { if (this.status === "connected") { return; } + this.status = "connecting"; + this.dispatchTypedEvent("statusChange", this.status); // Start from a clean session — see `resetSessionState` for why this is // start-clean rather than relying on `disconnect()`. this.resetSessionState(); - // The two collections `resetSessionState` excludes as "settled on the way - // out", swept here as well — because one route out settles nothing. An - // `onerror` without an `onclose` only flips status to `"error"`: it runs - // neither teardown path, and it leaves `baseTransport` cached, so a - // `connect()` on this same instance reuses a *live* transport. That is the - // route the subscription-stream close exists for, and it strands these two - // the same way. The peer queue is the sharper of them — the web - // pending-request modal is derived from its length with no status gate, so - // it outlives the session, and a user answering it later would write - // *their* answer for the previous session's request id onto the new - // connection, arbitrarily far past the re-handshake. Note what the sweep - // does instead is emit a *cancel* for that same id, right here: still the - // settle-don't-discard rule, and this is the earliest moment available: - // the old connection is still the one on the wire here, and stays so at - // least until the conditional `dropCachedTransport()` below — which on a - // stdio server never runs at all, so the same transport carries straight - // through the re-handshake. - // - // Both helpers are idempotent (one guards on a non-empty queue, the other - // clears its map and re-rejecting a settled promise is a no-op), so these - // are no-ops on the routes that already ran them; and anything still - // pending here belongs to a session that is, by definition, no longer - // connected. - // - // Must stay *after* `resetSessionState()`, which reads as independent of it - // but is not: cancelling a task-augmented peer request settles it - // synchronously into the record callback, which ends in - // `upsertReceiverTask`. That is a no-op only because `clearReceiverTasks()` - // just emptied the map — hoisted above the reset, it would instead emit a - // `notifications/tasks/status` for the outgoing session's task, onto the - // transport this connect is about to reuse, moments before the reset drops - // the record anyway. + // Settle UI requests from any previous session before installing fresh + // receiver handlers. Binding close in resetSessionState aborts receiver + // callbacks; this sweep also covers ordinary peer requests. this.clearAndAnnouncePendingPeerRequests(); this.rejectPendingRawWireRequests("Connection ended"); + await this.closeTaskSessionBestEffort(); const oauthManager = this.oauthManager; if ( @@ -2165,10 +1733,9 @@ export class InspectorClient extends InspectorClientEventTarget { baseTransport, messageTracking, { - rewriteIncomingResult: (message) => - this.rewriteModernTaskResult(message), - consumeIncomingResponse: (message) => - this.consumeRawWireResponse(message), + rawRequestChannel: { + consume: (message) => this.consumeRawWireResponse(message), + }, }, ); this.attachTransportListeners(this.baseTransport); @@ -2179,13 +1746,11 @@ export class InspectorClient extends InspectorClientEventTarget { } try { - this.status = "connecting"; - this.dispatchTypedEvent("statusChange", this.status); - // Register the handlers for server→client requests and the // capability-independent notifications before the handshake — see // `registerPeerRequestHandlers` for why the ordering is load-bearing. this.registerPeerRequestHandlers(); + this.bindReceiverTasks(); this.registerPeerNotificationHandlers(); // Optional connect-time timeout from per-server settings. The MCP SDK @@ -2277,6 +1842,14 @@ export class InspectorClient extends InspectorClientEventTarget { // #1395). If "connect" fired first, that gate would read undefined // capabilities and wipe tools/prompts/resources to empty on every connect. await this.fetchServerInfo(); + try { + await this.attachTaskSession(); + } catch (error) { + this.logger.warn( + { error }, + "Failed to attach ext-tasks session; continuing without task support", + ); + } // Set initial logging level if configured and server supports it. // @@ -2446,6 +2019,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.status = "error"; this.dispatchTypedEvent("statusChange", this.status); } + await this.closeTaskSessionBestEffort(); if (this.baseTransport && !this.transportHasAuthProvider) { await this.dropCachedTransport(); } @@ -2507,6 +2081,7 @@ export class InspectorClient extends InspectorClientEventTarget { await new Promise((r) => setTimeout(r, 10)); } } + await this.closeTaskSessionBestEffort(); try { await this.client.close(); } catch { @@ -2542,7 +2117,6 @@ export class InspectorClient extends InspectorClientEventTarget { // stream (best-effort — the transport is already going away) and bump the // generation so any in-flight re-listen/reconnect bails (#1630). this.resetSubscriptionStream(); - this.cancelledTaskIds.clear(); // Settle any pending raw-wire (modern tasks/*) requests so their callers // don't hang past teardown. Rejected outright on every disconnect: the // drain above polls the SDK's own response-handler map, which never holds @@ -2550,16 +2124,11 @@ export class InspectorClient extends InspectorClientEventTarget { // opt-in anyway — every production caller leaves `safeDisconnectTimeout` at // 0, so nothing is drained for anyone. this.rejectPendingRawWireRequests("Disconnected"); - // Abort any task paused at input_required so its poll loop unwinds. - for (const [, controller] of this.taskInputAbortControllers) { - controller.abort(new Error("Disconnected")); - } - this.taskInputAbortControllers.clear(); // Abort any in-flight ordinary tool call so its promise settles instead of // hanging past teardown; drop the controller reference either way. this.activeToolCallAbortController?.abort("Disconnected"); this.activeToolCallAbortController = undefined; - this.clearReceiverTasks(); + this.closeTaskReceiver(); this.appRendererClientProxy = null; this.capabilities = undefined; this.serverInfo = undefined; @@ -2681,6 +2250,10 @@ export class InspectorClient extends InspectorClientEventTarget { }; } + /** Authoritative generation-neutral capabilities for requester task behavior. */ + getTaskSessionCapabilities(): TaskCapabilities | undefined { + return this.taskSession?.capabilities; + } /** * True when the connection is modern (2026-07-28) AND the server advertised * the `io.modelcontextprotocol/tasks` extension (SEP-2663) in its @@ -2697,31 +2270,6 @@ export class InspectorClient extends InspectorClientEventTarget { ); } - /** - * Build the full modern (2026-07-28) per-request envelope for a RAW tasks/* - * request. The SDK's codec normally stamps this envelope, but raw requests - * bypass the codec, and the modern server rejects a request whose - * `MCP-Protocol-Version` header names 2026-07-28 but omits the required - * envelope `_meta` keys (`protocolVersion`, `clientInfo`, plus - * `clientCapabilities` carrying the tasks extension). We reproduce it here. - */ - private withModernTaskEnvelope( - params: Record, - ): Record { - const clientCapabilities = { - ...this.clientCapabilities, - // Force-stamp the tasks extension regardless of what the client - // advertised at construction: the raw `tasks/*` channel requires it, and - // a user may disable general tasks advertisement via `advertisedExtensions` - // (#1738). So this stamp is load-bearing, not a redundant re-add. - extensions: { - ...this.clientCapabilities.extensions, - [TASKS_EXTENSION_KEY]: {}, - }, - }; - return this.withModernEnvelope(params, clientCapabilities); - } - /** * Stamp the `_meta` envelope every raw-wire request needs on the modern leg: * the negotiated protocol version, the client identity, and the client @@ -2761,251 +2309,196 @@ export class InspectorClient extends InspectorClientEventTarget { }; } - /** - * Transport-level rewrite of a modern (SEP-2663) `CreateTaskResult` - * (`resultType: "task"`) — the one task frame the SDK v2 codec rejects (tasks - * were removed, so the codec knows only `complete`/`input_required`). The true - * frame is already logged by `trackResponse`; here we hand the SDK a benign - * `CallToolResult` that carries the real `DetailedTask` under - * {@link MODERN_TASK_HANDLE_META}, where {@link pollTaskToolCall} reads it to - * drive the poll. Any other message passes through untouched. - */ - private rewriteModernTaskResult( - message: JSONRPCResultResponse, - ): JSONRPCMessage { - if (!isModernCreateTaskResult(message.result)) { - return message; - } - const task = message.result as ModernDetailedTask; - return { - ...message, - result: { - resultType: "complete", - content: [{ type: "text", text: `Modern task ${task.taskId} created` }], - _meta: { [MODERN_TASK_HANDLE_META]: task }, - }, - }; - } - - /** - * Send an extension method the SDK v2 era gate refuses to route — the modern - * `tasks/get` / `tasks/update` / `tasks/cancel`, which are spec-method names - * absent from the 2026-07-28 era, so `client.request` throws - * `MethodNotSupportedByProtocolVersion` before anything reaches the wire. - * - * We mint a string JSON-RPC id (the SDK only mints numeric ids, so ours never - * collide), send the raw frame straight through the transport (which still - * logs it via `trackRequest`, so the Protocol/Network tabs see it), and await - * the matching response — captured and consumed by the transport's - * consume-response hook so it never confuses the SDK Client. The response is - * validated with the caller's explicit schema. - */ - private async rawWireRequest( - method: string, - params: Record, - resultSchema: { parse: (value: unknown) => T }, - ): Promise { + private async dispatchRawWireRequest( + request: TasksJsonValue, + options: DispatchOptions = {}, + timeoutOverride?: number, + ): Promise { const transport = this.transport; - if (!transport) { - throw new Error("Client is not connected"); + if (!transport) + throw new DispatchError("MCP client is not connected", true); + if ( + request === null || + Array.isArray(request) || + typeof request !== "object" + ) { + throw new DispatchError("Raw MCP request must be a JSON object"); + } + const record = request as Readonly>; + if (typeof record.method !== "string") { + throw new DispatchError("Raw MCP request method must be a string"); + } + const params = record.params; + if ( + params !== undefined && + (params === null || Array.isArray(params) || typeof params !== "object") + ) { + throw new DispatchError("Raw MCP request params must be a JSON object"); } + const signal = options.signal; + if (signal?.aborted) throw abortError(signal); + const id = `inspector-ext-${(this.rawWireRequestCounter += 1)}`; - // `params` is an arbitrary caller-supplied record; the SDK types request - // params with a specific optional `_meta` shape it can't satisfy, so widen - // it with a single structural cast. Typing `message` as `JSONRPCRequest` - // (a `JSONRPCMessage` member) then needs no further cast. const message: JSONRPCRequest = { jsonrpc: "2.0", id, - method, - params: params as JSONRPCRequest["params"], + method: record.method, + ...(params === undefined ? {} : { params }), }; - const timeoutMs = this.requestTimeout ?? 30_000; - const raw = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { + const timeoutMs = timeoutOverride ?? this.requestTimeout ?? 30_000; + + return await new Promise((resolve, reject) => { + let onAbort: (() => void) | undefined; + const cleanup = () => { + clearTimeout(timer); + if (signal && onAbort) signal.removeEventListener("abort", onAbort); this.pendingRawWireRequests.delete(id); + }; + const timer = setTimeout(() => { + cleanup(); reject( - new Error(`Raw request "${method}" timed out after ${timeoutMs} ms`), + new DispatchError( + `Raw MCP request "${message.method}" timed out after ${timeoutMs} ms`, + ), ); }, timeoutMs); - this.pendingRawWireRequests.set(id, { resolve, reject, timer }); - transport.send(message).catch((err: unknown) => { - const pending = this.pendingRawWireRequests.get(id); - if (pending) { - clearTimeout(pending.timer); - this.pendingRawWireRequests.delete(id); - } - reject(err instanceof Error ? err : new Error(String(err))); - }); + + this.pendingRawWireRequests.set(id, { resolve, reject, cleanup }); + if (signal) { + onAbort = () => { + const pending = this.pendingRawWireRequests.get(id); + if (!pending) return; + pending.cleanup(); + reject(abortError(signal)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + } + transport + .send(message, { + ...(options.context?.headers === undefined + ? {} + : { headers: options.context.headers }), + ...(signal === undefined ? {} : { requestSignal: signal }), + }) + .catch((error: unknown) => { + const pending = this.pendingRawWireRequests.get(id); + if (!pending) return; + pending.cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + }); }); - return resultSchema.parse(raw); } - /** - * Transport consume-response hook: resolve/reject a pending - * {@link rawWireRequest} when its response arrives, and report it as consumed - * (so the transport does not forward it to the SDK Client, which never sent - * it). Returns false for any id we don't own, leaving normal SDK traffic - * untouched. - */ + private async rawWireRequest( + method: string, + params: Record, + resultSchema: { parse: (value: unknown) => T }, + options: { + readonly signal?: AbortSignal; + readonly timeoutMs?: number; + } = {}, + ): Promise { + const response = await this.dispatchRawWireRequest( + toJsonValue({ method, params }), + { signal: options.signal }, + options.timeoutMs, + ); + if (response.kind === "error") { + throw new ProtocolError( + response.error.code, + response.error.message, + response.error.data, + ); + } + return resultSchema.parse(response.result); + } + private consumeRawWireResponse( message: JSONRPCResultResponse | JSONRPCErrorResponse, ): boolean { - const id = String((message as { id?: unknown }).id); - const pending = this.pendingRawWireRequests.get(id); - if (!pending) { + const { id } = message; + if (typeof id !== "string" || !id.startsWith("inspector-ext-")) { return false; } - this.pendingRawWireRequests.delete(id); - clearTimeout(pending.timer); + const pending = this.pendingRawWireRequests.get(id); + if (!pending) return false; + + pending.cleanup(); if ("error" in message) { - const err = (message as JSONRPCErrorResponse).error; - pending.reject(new Error(err?.message ?? `Request ${id} failed`)); + const { error } = message; + pending.resolve({ + kind: "error", + error: { + code: error.code, + message: error.message, + ...(isSerializableJson(error.data) ? { data: error.data } : {}), + }, + }); + } else if (!isSerializableJson(message.result)) { + pending.reject( + new DispatchError(`Raw MCP request ${id} returned a non-JSON result`), + ); } else { - pending.resolve((message as JSONRPCResultResponse).result); + pending.resolve({ kind: "result", result: message.result }); } return true; } - /** - * Reject and clear all pending raw-wire requests — on every route out that - * can hold one, and at the top of `connect()` for the route in that settles - * nothing (see the comment there). - */ private rejectPendingRawWireRequests(reason: string): void { - for (const [, pending] of this.pendingRawWireRequests) { - clearTimeout(pending.timer); - pending.reject(new Error(reason)); - } + const pendingRequests = [...this.pendingRawWireRequests.values()]; this.pendingRawWireRequests.clear(); - } - - /** - * Get requestor task status by taskId (tasks we created on the server) - * @param taskId Task identifier - * @returns Task status - */ - async getRequestorTask(taskId: string): Promise { - if (!this.client) { - throw new Error("Client is not connected"); + for (const pending of pendingRequests) { + pending.cleanup(); + pending.reject(new DispatchError(reason)); } - // Modern (SEP-2663): `tasks/get` returns a `DetailedTask` (ttlMs/pollIntervalMs, - // inlined result/error/inputRequests) — a different wire shape than the - // deprecated SDK schema. Parse with the explicit modern schema and normalize - // onto the internal Task shape, stamping the extension client capability. - if (this.isTasksExtensionNegotiated()) { - const modern = await this.rawWireRequest( - "tasks/get", - this.withModernTaskEnvelope({ taskId }), - ModernGetTaskResultSchema, - ); - const task = normalizeModernTask(modern); - this.dispatchTypedEvent("requestorTaskUpdated", { - taskId: task.taskId, - task, - }); - return task; - } - // Legacy (2025-11-25): SDK v2 removed `client.experimental.tasks.*`; drive - // the `tasks/get` wire method directly with its deprecated-but-importable - // result schema. `GetTaskResult` is the flattened task object. - const task = (await this.client.request( - { method: "tasks/get", params: { taskId } }, - GetTaskResultSchema, - this.getRequestOptions(), - )) as Task; + } - // Dispatch client-origin event (taskStatusChange is server-only) + /** Fetch a task created by this client and publish its latest state. */ + async getRequestorTask(taskId: string): Promise { + const view = await this.runTaskSessionOperation((session) => + session.task(extTaskId(taskId)).snapshot(), + ); + const task = this.toInspectorTask(view); this.dispatchTypedEvent("requestorTaskUpdated", { taskId: task.taskId, - task: task, + task, }); return task; } - /** - * Get requestor task result by taskId (tasks we created on the server) - * @param taskId Task identifier - * @returns Task result - */ + /** Fetch the terminal result of a task created by this client. */ async getRequestorTaskResult(taskId: string): Promise { - if (!this.client) { - throw new Error("Client is not connected"); - } - // `tasks/result` returns the task's stored payload; for a task-augmented - // tool call that payload is a CallToolResult, so validate with - // CallToolResultSchema (replacing the removed experimental helper). - return await this.client.request( - { method: "tasks/result", params: { taskId } }, - CallToolResultSchema, - this.getRequestOptions(), + const outcome = await this.runTaskSessionOperation((session) => + session.task(extTaskId(taskId)).result({ + resultCodec: taskToolResultCodec, + }), + ); + return this.unwrapTaskOutcome(outcome); + } + + /** Cancel a running task created by this client. */ + async cancelRequestorTask(taskId: string): Promise { + await this.runTaskSessionOperation((session) => + session.cancelTask(extTaskId(taskId)), ); + this.cancelPendingTaskInput(taskId); + this.dispatchTypedEvent("taskCancelled", { taskId }); } /** - * Cancel a running requestor task (task we created on the server) - * @param taskId Task identifier - * @returns Cancel result - */ - async cancelRequestorTask(taskId: string): Promise { - if (!this.client) { - throw new Error("Client is not connected"); - } - // Mark before awaiting: cancelling unblocks the in-flight callToolStream, - // whose error message may arrive before this resolves — the stream's error - // path reads this set to label the task "cancelled" rather than "failed". - this.cancelledTaskIds.add(taskId); - // If the task is paused at `input_required` (its poll loop blocked on the - // pending-request modal), abort it so the modal closes and the poll observes - // the cancellation — otherwise the user is stuck answering a modal that a - // non-advancing server would keep re-showing. - const inputAbort = this.taskInputAbortControllers.get(taskId); - if (inputAbort) { - inputAbort.abort(new Error(`Task ${taskId} cancelled by user`)); - } - // Modern `tasks/cancel` is a raw-wire request (the SDK era gate blocks the - // spec-method name on 2026-07-28); legacy uses the SDK path + deprecated - // schema. - if (this.isTasksExtensionNegotiated()) { - await this.rawWireRequest( - "tasks/cancel", - this.withModernTaskEnvelope({ taskId }), - ModernCancelTaskResultSchema, - ); - } else { - await this.client.request( - { method: "tasks/cancel", params: { taskId } }, - CancelTaskResultSchema, - this.getRequestOptions(), - ); - } - - // Dispatch event - this.dispatchTypedEvent("taskCancelled", { taskId }); - } - - /** - * Fulfil the outstanding `inputRequests` of a modern (SEP-2663) - * `input_required` task by sending `tasks/update` with the collected - * `inputResponses`. The server acks with an empty result; the task's - * observable status advances on a subsequent `tasks/get` poll (the update is - * eventually consistent). Modern-only — legacy tasks surface input through the - * server→client request channel, not `tasks/update`. - * - * @param taskId Task identifier - * @param inputResponses Responses keyed by the server's `inputRequests` ids + * Fulfil the outstanding `inputRequests` of a modern (SEP-2663) + * `input_required` task by sending `tasks/update` with the collected + * `inputResponses`. The server acks with an empty result; the task's + * observable status advances on a subsequent `tasks/get` poll (the update is + * eventually consistent). Modern-only — legacy tasks surface input through the + * server→client request channel, not `tasks/update`. */ async updateRequestorTask( taskId: string, inputResponses: Record, ): Promise { - if (!this.client) { - throw new Error("Client is not connected"); - } - await this.rawWireRequest( - "tasks/update", - this.withModernTaskEnvelope({ taskId, inputResponses }), - ModernUpdateTaskResultSchema, + await this.runTaskSessionOperation((session) => + session.task(extTaskId(taskId)).updateJson(inputResponses), ); } @@ -3040,29 +2533,32 @@ export class InspectorClient extends InspectorClientEventTarget { return true; } - /** - * List all requestor tasks with optional pagination (tasks we created on the server) - * @param cursor Optional pagination cursor - * @returns List of tasks with optional next cursor - */ + /** List server-held tasks created by this client. */ async listRequestorTasks( cursor?: string, - ): Promise<{ tasks: Task[]; nextCursor?: string }> { - if (!this.client) { - throw new Error("Client is not connected"); - } - const result = await this.client.request( - { - method: "tasks/list", - // `!== undefined`, not truthiness: a cursor is opaque and `""` is a - // legal value a server may hand back. Dropping it asks for page one - // again, so a caller walking pages would loop on the first page. - params: cursor !== undefined ? { cursor } : {}, - }, - ListTasksResultSchema, - this.getRequestOptions(), + ): Promise<{ tasks: InspectorTask[]; nextCursor?: string }> { + const result = await this.runTaskSessionOperation((session) => + session.listTasks(cursor), ); - return { tasks: result.tasks as Task[], nextCursor: result.nextCursor }; + return { + tasks: result.tasks.map((task) => this.toInspectorTask(task)), + nextCursor: result.nextCursor, + }; + } + + /** Run a task operation through auth recovery against the current session. */ + private async runTaskSessionOperation( + operation: (session: TaskEnabledSession) => Promise, + ): Promise { + try { + return await this.withDirectAuthRecovery(() => { + const session = this.taskSession; + if (!session) throw new Error("Client is not connected"); + return operation(session); + }); + } catch (error) { + throw unwrapTaskDispatchError(error); + } } /** @@ -3264,6 +2760,7 @@ export class InspectorClient extends InspectorClientEventTarget { * On legacy connections a server never returns `input_required`, so the first * response is always complete and this is a single `client.request` call. */ + private async requestWithInputRequired( method: "tools/call" | "prompts/get" | "resources/read", params: Record, @@ -3873,6 +3370,9 @@ export class InspectorClient extends InspectorClientEventTarget { // goes straight through the transport (still logged for the Protocol / // Network tabs) and only the caller's schema is applied. Legacy keeps the // ordinary SDK path, which honors request options and `_meta` for us. + const requestOptions = this.getRequestOptions( + this.progressTokenOf(metadata), + ); const page = await this.invokeMcpClient( () => this.isModernEra() @@ -3880,11 +3380,15 @@ export class InspectorClient extends InspectorClientEventTarget { method, this.withModernEnvelope(params), pageSchema, + { + signal: requestOptions.signal, + timeoutMs: requestOptions.timeout, + }, ) : this.client!.request( { method, params }, pageSchema, - this.getRequestOptions(this.progressTokenOf(metadata)), + requestOptions, ), { method }, ); @@ -4067,11 +3571,11 @@ export class InspectorClient extends InspectorClientEventTarget { ListToolsResultSchema, "tools", ); - // Through `invokeMcpClient`, like the strict `listTools` above and like - // `salvageList`'s walk: this re-fetch can meet an auth challenge of its - // own, and outside that wrapper the recovery never runs. Its failure is - // then swallowed by `listAllTools`'s best-effort scan catch, leaving a - // stale excluded-tools set and no sign of why. + // Apply direct auth recovery and route modern pages below the SDK codec + // while legacy pages retain ordinary SDK request semantics. + const requestOptions = this.getRequestOptions( + this.progressTokenOf(metadata), + ); const page = await this.invokeMcpClient( () => this.isModernEra() @@ -4079,11 +3583,15 @@ export class InspectorClient extends InspectorClientEventTarget { "tools/list", this.withModernEnvelope(params), pageSchema, + { + signal: requestOptions.signal, + timeoutMs: requestOptions.timeout, + }, ) : this.client!.request( { method: "tools/list", params }, pageSchema, - this.getRequestOptions(this.progressTokenOf(metadata)), + requestOptions, ), { method: "tools/list" }, ); @@ -4244,6 +3752,9 @@ export class InspectorClient extends InspectorClientEventTarget { try { return await this.attemptToolCall(request, abortController.signal); } catch (error) { + const operationError = unwrapTaskDispatchError(error); + if (operationError instanceof ToolCallCancelledError) + throw operationError; // The controller was aborted. A deliberate `cancelToolCall()` (matched // by reason) means the SDK already sent `notifications/cancelled` if the // abort landed during a `client.request` leg — so surface a clean @@ -4261,7 +3772,7 @@ export class InspectorClient extends InspectorClientEventTarget { ) { throw new ToolCallCancelledError(tool.name); } - const urlElicitations = getUrlElicitationsFromError(error); + const urlElicitations = getUrlElicitationsFromError(operationError); if ( urlElicitations && urlElicitations.length > 0 && @@ -4326,9 +3837,11 @@ export class InspectorClient extends InspectorClientEventTarget { args, generalMetadata, toolSpecificMetadata, - error instanceof Error ? error.message : String(error), + operationError instanceof Error + ? operationError.message + : String(operationError), ); - throw error; + throw operationError; } } } @@ -4353,30 +3866,82 @@ export class InspectorClient extends InspectorClientEventTarget { return { ...args, ...convertToolParameters(tool, stringArgs) }; } - /** - * SEP-2243: mirror `x-mcp-header`-annotated arguments into `Mcp-Param-*` - * headers on a modern connection. The SDK only does this inside - * `client.callTool()` (and skips it in the browser), but we route - * `tools/call` through `client.request()` for manual MRTR driving (#1704), so - * we mirror ourselves. `Protocol.request` forwards `headers` (preserved - * across MRTR retry legs) to the transport, and the remote transport relays - * them to the backend's upstream send — issued server-side, where the browser - * skip doesn't apply. No-op on legacy/stdio (no annotations). - * - * Applied by BOTH `tools/call` entry points: a plain call - * ({@link attemptToolCall}) and a task-augmented one - * ({@link callToolStream}) — a strict modern server rejects either with - * `-32020` when the mirrored header is missing. - */ + /** Add modern x-mcp-* parameter mirrors without dropping caller headers. */ private applyMirroredParamHeaders( - tool: Tool, - convertedArgs: Record, requestOptions: RequestOptions, + tool: Tool, + args: Record, ): void { - if (this.protocolEra !== "modern") return; - const paramHeaders = mcpParamHeadersForTool(tool, convertedArgs); - if (Object.keys(paramHeaders).length === 0) return; - requestOptions.headers = { ...requestOptions.headers, ...paramHeaders }; + if (!this.isModernEra()) return; + const mirroredHeaders = mcpParamHeadersForTool(tool, args); + if (Object.keys(mirroredHeaders).length === 0) return; + requestOptions.headers = { + ...requestOptions.headers, + ...mirroredHeaders, + }; + } + + /** + * Return only the headers the ext-tasks raw call supports. The package/raw + * channel owns its fixed request timeout, and task progress is observed from + * transport events; ordinary SDK calls continue to use getRequestOptions(). + */ + private mirroredTaskParamHeaders( + tool: Tool, + args: Record, + ): Readonly> | undefined { + if (!this.isModernEra()) return undefined; + const headers = mcpParamHeadersForTool(tool, args); + return Object.keys(headers).length === 0 ? undefined : headers; + } + + private async callTaskToolAndSettle( + tool: Tool, + args: Record, + metadata: RequestMetadata | undefined, + preference: "allow" | "prefer", + retentionMs: number | undefined, + signal?: AbortSignal, + progressToken?: ProgressToken, + ): Promise { + const session = this.taskSession; + if (!session) throw new Error("Client is not connected"); + const headers = this.mirroredTaskParamHeaders(tool, args); + let lastTask: InspectorTask | undefined; + try { + const settlement = await session.callToolAndSettle( + tool.name, + toJsonValue(args) as Readonly>, + { + resultCodec: taskToolResultCodec, + declaration: toolDeclarationFromMcpTool(tool), + signal, + task: { preference, retentionMs }, + ...(metadata === undefined + ? {} + : { + metadata: toJsonValue(metadata) as Readonly< + Record + >, + }), + ...(headers === undefined ? {} : { headers }), + onEvent: (event) => { + lastTask = + this.emitTaskExecutionEvent(event, progressToken) ?? lastTask; + }, + }, + ); + if (settlement.outcome.status === "cancelled") { + throw new ToolCallCancelledError(tool.name); + } + return this.unwrapTaskOutcome(settlement.outcome); + } catch (error) { + const operationError = unwrapTaskDispatchError(error); + if (!(operationError instanceof ToolCallCancelledError)) { + this.emitTaskError(lastTask, operationError); + } + throw operationError; + } } /** @@ -4397,96 +3962,59 @@ export class InspectorClient extends InspectorClientEventTarget { taskOptions, options, } = request; - const client = this.client; - if (!client) { - throw new Error("Client is not connected"); - } + if (!this.client) throw new Error("Client is not connected"); const convertedArgs = this.convertStringToolArgs(tool, args); - - // Merge general metadata with tool-specific metadata; tool-specific wins. - const callMetadata: RequestMetadata | undefined = + const callMetadata = generalMetadata || toolSpecificMetadata ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } : undefined; - - const timestamp = new Date(); - // Fold in this client's defaultMetadata so server-wide _meta reaches - // the wire even when the caller passed nothing. const metadata = this.mergeMeta(callMetadata); + const timestamp = new Date(); - const callParams: { - name: string; - arguments: Record; - _meta?: RequestMetadata; - task?: { ttl: number }; - } = { - name: tool.name, - arguments: convertedArgs, - _meta: metadata, - }; - if (taskOptions?.ttl != null) { - callParams.task = { ttl: taskOptions.ttl }; + let result: CallToolResult; + if (taskOptions === undefined && !this.isModernEra()) { + const params = { + name: tool.name, + arguments: convertedArgs, + ...(metadata ? { _meta: metadata } : {}), + }; + const requestOptions = this.getRequestOptions( + this.progressTokenOf(metadata), + signal, + ); + this.applyMirroredParamHeaders(requestOptions, tool, convertedArgs); + result = await this.invokeMcpClient( + () => + this.requestWithInputRequired( + "tools/call", + params, + CallToolResultSchema, + requestOptions, + ), + { method: "tools/call", toolName: tool.name }, + ); + } else { + result = await this.withDirectAuthRecovery( + () => + this.callTaskToolAndSettle( + tool, + convertedArgs, + metadata, + taskOptions === undefined ? "allow" : "prefer", + taskOptions?.ttl, + signal, + ), + { method: "tools/call", toolName: tool.name }, + ); } - const requestOptions = this.getRequestOptions( - this.progressTokenOf(metadata), - signal, - ); - this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions); - // Route through the MRTR driver (`requestWithInputRequired`) so a modern - // `input_required` result pauses at the pending-request UI and retries with - // the user's answer (#1704). Both eras use `client.request` with - // `CallToolResultSchema`; on legacy this is a single round. We deliberately - // do NOT use `client.callTool` (which would auto-fulfil / reject on an - // `input_required` result) — its only extra behavior over `request` is - // structuredContent output validation, which we already re-implement below - // via `validateToolOutput`. MCP Apps passthrough (skipOutputValidation) - // simply skips that check; both paths yield a CallToolResult once the - // driver returns a complete (non-`input_required`) result. - const rawResult = await this.invokeMcpClient( - () => - this.requestWithInputRequired( - "tools/call", - callParams, - CallToolResultSchema, - requestOptions, - ), - { method: "tools/call", toolName: tool.name }, - ); - - // Unsolicited modern task handle (SEP-2663): on a modern connection the - // server may answer ANY `tools/call` with a task rather than a result. The - // transport rewrote that frame into a `CallToolResult` carrying the real - // `DetailedTask` in `_meta`; poll it to completion here (the run-as-task - // path does the same via `callToolStream`) so the ordinary call resolves to - // the task's final result and the Tasks tab tracks it. - const taskHandle = (rawResult as CallToolResult)._meta?.[ - MODERN_TASK_HANDLE_META - ] as ModernDetailedTask | undefined; - const result = taskHandle - ? await this.pollModernTaskToTermination(taskHandle) - : rawResult; - - // Output-schema validation. SDK v2's `callTool` relaxed some checks (e.g. it - // no longer rejects a structuredContent with undeclared properties against a - // strict `additionalProperties: false` schema), so we run our own Ajv check - // to preserve the Inspector's v1 behavior: - // - default path: strict — a schema violation rejects the call (matching - // what a strict host would do), so the caller sees the error. - // - skipOutputValidation (MCP Apps passthrough): non-fatal — surface it as - // an advisory so a schema-violating-but-real result still reaches the app. const outputValidationError = this.validateToolOutput(tool, result); if (outputValidationError && !options?.skipOutputValidation) { - // Match the prior contract: on v1 a strict output-schema violation - // surfaced as the SDK's typed `McpError`/`ProtocolError` (code - // InvalidParams), not a bare Error — so downstream code that branches on - // `instanceof ProtocolError` / `error.code` keeps working. throw new ProtocolError( ProtocolErrorCode.InvalidParams, outputValidationError, ); } - const invocation: ToolCallInvocation = { toolName: tool.name, params: args, @@ -4496,17 +4024,7 @@ export class InspectorClient extends InspectorClientEventTarget { metadata, outputValidationError, }; - - this.dispatchTypedEvent("toolCallResultChange", { - toolName: tool.name, - params: args, - result: invocation.result, - timestamp, - success: true, - metadata, - outputValidationError, - }); - + this.dispatchTypedEvent("toolCallResultChange", invocation); return invocation; } @@ -4597,401 +4115,191 @@ export class InspectorClient extends InspectorClientEventTarget { return validateToolOutput(this.outputValidator, tool, result); } - /** - * When a modern (SEP-2663) task is `input_required`, fulfil its embedded - * `inputRequests` through the pending-request UI and submit them via - * `tasks/update`. No-op for any other status. Shared by the streaming - * ({@link pollTaskToolCall}) and ordinary ({@link pollModernTaskToTermination}) - * poll loops so the input handling lives in one place. - * - * `priorRounds` is the count of `input_required` rounds already handled for - * this task; the return value is the updated count. A non-conformant server - * that keeps returning `input_required` without ever completing would - * otherwise re-prompt the user on every poll forever, so we bound it with the - * same {@link MRTR_MAX_ROUNDS} cap the MRTR driver uses. - */ - private async submitModernTaskInput( - detailed: ModernDetailedTask, - task: Task, - priorRounds: number, - signal?: AbortSignal, - ): Promise { - if (task.status !== "input_required") { - return priorRounds; - } - const rounds = priorRounds + 1; - if (rounds > InspectorClient.MRTR_MAX_ROUNDS) { - throw new Error( - `Modern task "${task.taskId}" exceeded ${InspectorClient.MRTR_MAX_ROUNDS} input_required rounds without completing.`, - ); + private cancelPendingTaskInput(taskId: string): void { + for (const request of [ + ...this.pendingElicitations, + ...this.pendingSamples, + ]) { + if (request.taskId === taskId) request.cancel(); } - const inputResponses = await this.fulfilInputRequests( - this.tagInputRequestsWithTask(readInputRequests(detailed), task.taskId), - signal, - "task-input-required", + this.pendingElicitations = this.pendingElicitations.filter( + (request) => request.taskId !== taskId, ); - /* v8 ignore next 3 -- a conformant `input_required` task always carries - `inputRequests`, so `fulfilInputRequests` returns a (possibly empty) - object here, never undefined; the guard is defensive. */ - if (inputResponses) { - await this.updateRequestorTask(task.taskId, inputResponses); - } - return rounds; + this.pendingSamples = this.pendingSamples.filter( + (request) => request.taskId !== taskId, + ); + this.dispatchTypedEvent( + "pendingElicitationsChange", + this.pendingElicitations, + ); + this.dispatchTypedEvent("pendingSamplesChange", this.pendingSamples); } - /** - * Stamp `_meta[RELATED_TASK_META_KEY]` with the owning task id on each embedded - * request of a modern task's `inputRequests`. The pending-request UI reads that - * id (via `ElicitationCreateMessage.taskId`) so its Cancel control can cancel - * the TASK — not just answer the request — when a task is paused at - * `input_required`. - */ - private tagInputRequestsWithTask( - inputRequests: InputRequests | undefined, - taskId: string, - ): InputRequests | undefined { - /* v8 ignore next -- only called for an input_required task, which always - carries inputRequests; the undefined passthrough is defensive. */ - if (!inputRequests) return inputRequests; - const tagged: Record = {}; - for (const [key, req] of Object.entries(inputRequests)) { - const request = req as { params?: { _meta?: Record } }; - tagged[key] = { - ...request, - params: { - ...request.params, - _meta: { - ...request.params?._meta, - [RELATED_TASK_META_KEY]: { taskId }, + /** Attach ext-tasks to the negotiated SDK session without taking over tool discovery. */ + private async attachTaskSession(): Promise { + const client = this.client; + if (!client) return; + const endpointId = await createTaskSessionEndpointId( + "inspector", + this.transportConfig.type === "sse" || + this.transportConfig.type === "streamable-http" + ? { + host: this.clientInfo, + transport: { + type: this.transportConfig.type, + url: new URL(this.transportConfig.url).toString(), + }, + } + : { + host: this.clientInfo, + transport: { + type: "stdio", + command: this.transportConfig.command, + args: this.transportConfig.args, + cwd: this.transportConfig.cwd ?? null, + }, }, + ); + await this.closeTaskSession(); + this.taskSession = createTaskSessionFromClient(client, { + endpointId, + rawDispatch: this.dispatchTaskRequest, + v2RequestFraming: { + protocolVersion: this.protocolVersion!, + clientInfo: toJsonValue(this.clientInfo) as Readonly< + Record + >, + clientCapabilities: toJsonValue(this.clientCapabilities) as Readonly< + Record + >, + }, + // declaration. A no-op recovery fallback prevents duplicate tools/list traffic. + tools: { currentTool: () => undefined }, + onInputRequest: createApplicationInputHandler({ + elicitation: async (request, context) => { + const result = await this.enqueuePendingElicitation( + { + method: "elicitation/create", + params: request.params, + } as ElicitRequest, + this.taskInputOrigin(context.delivery), + context.signal, + ); + return { + ...jsonObject(result), + action: result.action, + ...(result.content === undefined + ? {} + : { content: jsonObject(result.content) }), + }; }, - }; - } - return tagged as InputRequests; + sampling: async (request, context) => { + const result = await this.enqueuePendingSample( + { + method: "sampling/createMessage", + params: request.params, + } as CreateMessageRequest, + this.taskInputOrigin(context.delivery), + context.signal, + ); + return { + ...jsonObject(result), + model: result.model, + role: result.role, + content: toJsonValue(result.content), + }; + }, + roots: async () => ({ + roots: this.roots?.map((root) => jsonObject(root)) ?? [], + }), + }), + onError: (error) => + this.logger.error({ error }, "ext-tasks background error"), + }); } - /** - * Terminal outcome for a modern task: the inlined `CallToolResult` for a - * `completed` task (SEP-2663 removed the blocking `tasks/result`), or a - * `ProtocolError` for `failed` / `cancelled`. Shared so both poll loops agree - * on the result/error shape. - */ - private modernTaskTerminalOutcome( - task: Task, - detailed: ModernDetailedTask, - ): - | { type: "result"; result: CallToolResult } - | { type: "error"; error: ProtocolError } { - if (task.status === "completed") { - /* v8 ignore next -- a conformant `completed` task always inlines its - `result`; the `{ content: [] }` fallback is defensive. */ - return { - type: "result", - result: (detailed.result ?? { content: [] }) as CallToolResult, - }; + /** Release extension-owned state without ever leaving the SDK adapter installed. */ + private async closeTaskSession(): Promise { + const session = this.taskSession; + this.taskSession = null; + await session?.close(); + } + + private async closeTaskSessionBestEffort(): Promise { + try { + await this.closeTaskSession(); + } catch (error) { + this.logger.warn({ error }, "Failed to close ext-tasks session"); } - return { - type: "error", - error: new ProtocolError( - ProtocolErrorCode.InternalError, - task.statusMessage ?? `Task ${task.status}`, - ), - }; } - /** - * Poll cadence for a task: the server-advertised `pollInterval` when - * positive, else the default. Shared by every task poll loop (both eras). - */ - private taskPollInterval(task: Task): number { - const advertised = task.pollInterval; - if (typeof advertised !== "number") return DEFAULT_TASK_POLL_INTERVAL_MS; - // A spec-conformant server never advertises a non-positive interval; the - // `> 0` guard is defensive against a malformed value. - /* v8 ignore next -- non-positive pollInterval is unreachable from a conformant server. */ - return advertised > 0 ? advertised : DEFAULT_TASK_POLL_INTERVAL_MS; + private taskInputOrigin( + delivery: "peer-request" | "request-retry" | "task-update", + ): PendingRequestOrigin { + if (delivery === "task-update") return "task-input-required"; + if (delivery === "request-retry") return "input-required"; + return "server-request"; } - /** - * Register a per-task abort controller (keyed by taskId) whose signal gates - * the task's `input_required` pending request, and return the signal plus a - * `release` cleanup. {@link cancelRequestorTask} aborts it to unblock a task - * paused at the pending-request modal. - */ - private registerTaskInputAbort(taskId: string): { - signal: AbortSignal; - release: () => void; - } { - const controller = new AbortController(); - this.taskInputAbortControllers.set(taskId, controller); + private toInspectorTask(view: TaskView): InspectorTask { + const timestamp = view.createdAt ?? view.lastUpdatedAt ?? ""; return { - signal: controller.signal, - release: () => { - // Only delete our own entry — tool calls are serial, so a second task - // never replaces this id's controller mid-poll; the guard is defensive. - /* v8 ignore next */ - if (this.taskInputAbortControllers.get(taskId) === controller) { - this.taskInputAbortControllers.delete(taskId); - } - }, + ...view, + createdAt: timestamp, + lastUpdatedAt: view.lastUpdatedAt ?? timestamp, }; } - /** - * Drive a modern (SEP-2663) task to a terminal state from a seed - * `DetailedTask`, dispatching task events so the Tasks tab and toasts track - * it, and return the completed task's inlined `CallToolResult` (or throw on - * `failed` / `cancelled`). Used by the ORDINARY `callTool` path when a server - * returns an unsolicited task handle (the run-as-task streaming path drives - * the equivalent loop inline in {@link pollTaskToolCall}). `input_required` - * rounds are answered through the pending-request UI and submitted via - * `tasks/update`. - */ - private async pollModernTaskToTermination( - seed: ModernDetailedTask, - ): Promise { - let detailed = seed; - let task = normalizeModernTask(detailed); - const emit = (t: Task): void => { - this.dispatchTypedEvent("toolCallTaskUpdated", { - taskId: t.taskId, - task: t, - }); - this.dispatchTypedEvent("requestorTaskUpdated", { - taskId: t.taskId, - task: t, - }); - }; - emit(task); - const { signal: inputSignal, release } = this.registerTaskInputAbort( - task.taskId, - ); - try { - let inputRounds = 0; - while (!InspectorClient.isTerminalTaskStatus(task.status)) { - inputRounds = await this.submitModernTaskInput( - detailed, - task, - inputRounds, - inputSignal, - ); - await new Promise((resolve) => - setTimeout(resolve, this.taskPollInterval(task)), - ); - detailed = await this.rawWireRequest( - "tasks/get", - this.withModernTaskEnvelope({ taskId: task.taskId }), - ModernGetTaskResultSchema, - ); - task = normalizeModernTask(detailed); - emit(task); - } - } finally { - release(); - } - const outcome = this.modernTaskTerminalOutcome(task, detailed); - if (outcome.type === "error") { - throw outcome.error; - } - return outcome.result; + private emitTaskExecutionEvent( + event: TaskExecutionEvent, + progressToken?: ProgressToken, + ): InspectorTask | undefined { + const view = taskViewFromExecutionEvent(event); + if (view === undefined) return undefined; + const task = this.toInspectorTask(view); + if (progressToken !== undefined) { + this.taskProgressIds.set(progressToken, task.taskId); + } + const outcomeDetail = + event.type !== "outcome" || event.outcome.status === "cancelled" + ? {} + : event.outcome.status === "completed" + ? { result: event.outcome.result } + : { error: this.toProtocolError(event.outcome.error) }; + const detail = { taskId: task.taskId, task, ...outcomeDetail }; + this.dispatchTypedEvent("toolCallTaskUpdated", detail); + this.dispatchTypedEvent("requestorTaskUpdated", detail); + return task; } - /** - * Poll a task-augmented tool call to completion. Replaces the removed - * `client.experimental.tasks.callToolStream` helper: it sends the - * task-augmented `tools/call` (the server responds with a task handle, i.e. a - * `CreateTaskResult`), then polls `tasks/get` until the task reaches a - * terminal status, yielding the same `taskCreated | taskStatus | result | - * error` message shapes the caller's `for await` loop consumes — so all the - * downstream event dispatch and terminal-state handling stays unchanged. - */ - private async *pollTaskToolCall( - params: CallToolRequest["params"], - requestOptions: RequestOptions, - ): AsyncGenerator< - | { type: "taskCreated"; task: Task } - | { type: "taskStatus"; task: Task } - | { type: "result"; result: CallToolResult } - | { type: "error"; error: ProtocolError } - > { - if (!this.client) { - throw new Error("Client is not connected"); - } - const client = this.client; - // The server streams `notifications/progress` for a task AFTER the - // task-augmented `tools/call` has already returned its `{ task }` handle. But - // SDK v2 deletes a request's progress subscription the moment that request - // resolves, so those later ticks would be dropped. Capture the subscription - // id the SDK registers for this request (the only new key in the private - // `_progressHandlers` map) so we can keep the caller's `onprogress` alive - // through the poll and clean it up when the task terminates. - // SDK gap: `Client` exposes no public API to keep a progress subscription - // alive across a resolved request (or to subscribe to progress by token), so - // we reach the private `_progressHandlers` map through a narrowed cast. A - // public "durable progress subscription" hook would remove this cast. - const progressHandlers = ( - client as unknown as { - _progressHandlers: Map; - } - )._progressHandlers; - const keysBeforeRequest = new Set(progressHandlers.keys()); - // Create the task-augmented tool call. A task-capable server returns a task - // handle (`CreateTaskResult` = `{ task }`), but a server that completes - // synchronously (or for which the tool forbids/ignores task augmentation) - // may return an immediate `CallToolResult` instead — accept either with a - // union schema and branch on the presence of `task`. - // - // NOTE: the LEGACY task path does NOT opt into `allowInputRequired` (MRTR - // over legacy tasks is out of scope for #1704). The MODERN path (SEP-2663) - // instead surfaces a task's `input_required` through `tasks/get`'s - // `inputRequests` and answers via `tasks/update` (handled in the poll loop - // below), reusing the same pending-request UI. - const modernTasks = this.isTasksExtensionNegotiated(); - const requestPromise = client.request( - { - // On modern the SDK codec stamps the tasks-extension client capability - // into the request envelope (advertised at construction), so a server - // may answer with a `CreateTaskResult` — no per-call `_meta` needed. - method: "tools/call", - params, - }, - // Modern: the SDK codec can't decode a `resultType: "task"` result, so the - // transport rewrote it to a `CallToolResult` carrying the task handle in - // `_meta` — parse as a CallToolResult and read the handle below. Legacy: - // accept a `{ task }` handle or an immediate result. - modernTasks - ? CallToolResultSchema - : CreateTaskResultSchema.or(CallToolResultSchema), - requestOptions, - ); - // The SDK registers the progress handler synchronously while constructing - // the request promise (before this await), so the new key is present now. - // ASSUMES SERIAL CONSTRUCTION: `find` takes the first key not present in the - // pre-request snapshot, which is unambiguous only because no OTHER request - // registers a progress handler between the snapshot and this request's - // synchronous registration. Tool calls are user-driven and serial, so that - // holds today; if concurrent task-augmented calls are ever constructed in - // the same microtask window, two subscription ids could cross-wire and this - // must move to an SDK-supported correlation (see the delete-when-native note - // on `installReceiverTaskResponseBypass`). - const progressSubscriptionId = requestOptions.onprogress - ? [...progressHandlers.keys()].find((k) => !keysBeforeRequest.has(k)) - : undefined; - const created = await requestPromise; - - if (modernTasks) { - // Modern (SEP-2663): a task-creating `tools/call` came back as a - // `resultType: "task"` frame the SDK can't decode, so the transport - // rewrote it to a `CallToolResult` carrying the real `DetailedTask` under - // MODERN_TASK_HANDLE_META. A synchronous completion has no such handle — - // yield that `CallToolResult` directly. - const handle = (created as CallToolResult)._meta?.[ - MODERN_TASK_HANDLE_META - ] as ModernDetailedTask | undefined; - if (!handle) { - yield { type: "result", result: created as CallToolResult }; - return; - } - let detailed = handle; - let task = normalizeModernTask(detailed); - yield { type: "taskCreated", task }; - if (progressSubscriptionId != null && requestOptions.onprogress) { - progressHandlers.set(progressSubscriptionId, requestOptions.onprogress); - } - const { signal: inputSignal, release } = this.registerTaskInputAbort( - task.taskId, - ); - let inputRounds = 0; - try { - while (!InspectorClient.isTerminalTaskStatus(task.status)) { - // `input_required`: fulfil the embedded server→client requests through - // the same pending-request UI the MRTR path uses, then submit them via - // `tasks/update`. The update is eventually consistent — the task's - // status advances on a following `tasks/get`, so keep polling - // (bounded by MRTR_MAX_ROUNDS against a server that never advances). - // `inputSignal` fires if the task is cancelled while paused here. - inputRounds = await this.submitModernTaskInput( - detailed, - task, - inputRounds, - inputSignal, - ); - await new Promise((resolve) => - setTimeout(resolve, this.taskPollInterval(task)), - ); - detailed = await this.rawWireRequest( - "tasks/get", - this.withModernTaskEnvelope({ taskId: task.taskId }), - ModernGetTaskResultSchema, - ); - task = normalizeModernTask(detailed); - yield { type: "taskStatus", task }; - } - } finally { - release(); - if (progressSubscriptionId != null) { - progressHandlers.delete(progressSubscriptionId); - } - } - // Modern removes the blocking `tasks/result`: a completed task inlines its - // CallToolResult; failed/cancelled surface as an error. - yield this.modernTaskTerminalOutcome(task, detailed); - return; - } - - if (!("task" in created) || created.task == null) { - // Immediate result — no task was created; yield it directly. - yield { type: "result", result: created as CallToolResult }; - return; - } - let task = created.task as Task; - yield { type: "taskCreated", task }; - - // Revive the (now-deleted) progress subscription for the poll so task- - // execution progress ticks reach the caller's `onprogress`. - if (progressSubscriptionId != null && requestOptions.onprogress) { - progressHandlers.set(progressSubscriptionId, requestOptions.onprogress); - } + private unwrapTaskOutcome( + outcome: Parameters>[0], + ): CallToolResult { try { - // Poll `tasks/get` until the task reaches a terminal status. Honour the - // server-advertised `pollInterval` when present, else the default cadence. - while (!InspectorClient.isTerminalTaskStatus(task.status)) { - await new Promise((resolve) => - setTimeout(resolve, this.taskPollInterval(task)), - ); - task = (await client.request( - { method: "tasks/get", params: { taskId: task.taskId } }, - GetTaskResultSchema, - this.getRequestOptions(), - )) as Task; - yield { type: "taskStatus", task }; - } - } finally { - if (progressSubscriptionId != null) { - progressHandlers.delete(progressSubscriptionId); - } + return resultFromTaskOutcome(outcome); + } catch (error) { + throw this.toProtocolError(error); } + } - if (task.status === "completed") { - const result = await client.request( - { method: "tasks/result", params: { taskId: task.taskId } }, - CallToolResultSchema, - this.getRequestOptions(), - ); - yield { type: "result", result }; - } else { - // failed | cancelled — surface as an error the caller's loop labels as - // "cancelled" (via cancelledTaskIds) or "failed". Carry a ProtocolError so - // the `error` payload matches the event map's type (the SDK helper this - // replaces also yielded a protocol-error-shaped value). - yield { - type: "error", - error: new ProtocolError( + private toProtocolError(reason: unknown): ProtocolError { + return reason instanceof ProtocolError + ? reason + : new ProtocolError( ProtocolErrorCode.InternalError, - task.statusMessage ?? `Task ${task.status}`, - ), - }; - } + reason instanceof Error ? reason.message : String(reason), + ); + } + + private emitTaskError( + lastTask: InspectorTask | undefined, + reason: unknown, + ): void { + if (!lastTask) return; + const error = this.toProtocolError(reason); + const detail = { taskId: lastTask.taskId, task: lastTask, error }; + this.dispatchTypedEvent("toolCallTaskUpdated", detail); + this.dispatchTypedEvent("requestorTaskUpdated", detail); } /** @@ -5010,232 +4318,71 @@ export class InspectorClient extends InspectorClientEventTarget { generalMetadata?: RequestMetadata, toolSpecificMetadata?: RequestMetadata, taskOptions?: { ttl?: number }, + options?: { skipOutputValidation?: boolean }, ): Promise { - if (!this.client) { - throw new Error("Client is not connected"); - } + const convertedArgs = this.convertStringToolArgs(tool, args); + const callMetadata = + generalMetadata || toolSpecificMetadata + ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } + : undefined; + const metadata = this.mergeMeta(callMetadata); + const progressToken = this.progress + ? (this.progressTokenOf(metadata) ?? crypto.randomUUID()) + : undefined; + const taskCallMetadata = + progressToken === undefined + ? metadata + : { ...(metadata ?? {}), progressToken }; + const timestamp = new Date(); try { - const convertedArgs = this.convertStringToolArgs(tool, args); - - // Merge general metadata with tool-specific metadata; tool-specific wins. - const callMetadata: RequestMetadata | undefined = - generalMetadata || toolSpecificMetadata - ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } - : undefined; - - const timestamp = new Date(); - const metadata = this.mergeMeta(callMetadata); - - // Call the streaming API - const streamParams: Record = { - name: tool.name, - arguments: convertedArgs, - }; - if (metadata) { - streamParams._meta = metadata; - } - if (taskOptions?.ttl != null) { - streamParams.task = { ttl: taskOptions.ttl }; - } - - let finalResult: CallToolResult | undefined; - let taskId: string | undefined; - let error: Error | undefined; - - // Correlate progress → task. getRequestOptions already wires onprogress to - // dispatch the generic progressNotification (keyed by the caller's - // progressToken). Wrap it so each tick that arrives after the task is - // created also dispatches requestorTaskProgress tagged with the taskId - // this stream owns — the only place that mapping is known. Ticks before - // taskCreated (rare) just fall through to the generic event. - // - // Gate on `this.progress`, mirroring getRequestOptions: when progress is - // globally disabled there's no inner handler to wrap, and we must not - // attach one here either — doing so would request a progress token (and - // emit requestorTaskProgress) for task calls only, bypassing the toggle - // that governs every other call path. - const requestOptions = this.getRequestOptions( - this.progressTokenOf(metadata), - ); - // The task-augmented `tools/call` needs the same SEP-2243 mirroring as the - // plain one — a strict modern server rejects it with -32020 otherwise. - this.applyMirroredParamHeaders(tool, convertedArgs, requestOptions); - if (this.progress) { - const innerOnProgress = requestOptions.onprogress; - requestOptions.onprogress = (progress: Progress) => { - innerOnProgress?.(progress); - if (taskId) { - this.dispatchTypedEvent("requestorTaskProgress", { - taskId, - progress, - }); - } - }; - } - - const stream = this.pollTaskToolCall( - streamParams as CallToolRequest["params"], - requestOptions, + const result = await this.withDirectAuthRecovery( + () => + this.callTaskToolAndSettle( + tool, + convertedArgs, + taskCallMetadata, + "prefer", + taskOptions?.ttl, + undefined, + progressToken, + ), + { method: "tools/call", toolName: tool.name }, ); - - // Iterate through the async generator - for await (const message of stream) { - switch (message.type) { - case "taskCreated": - taskId = message.task.taskId; - this.dispatchTypedEvent("toolCallTaskUpdated", { - taskId: message.task.taskId, - task: message.task, - }); - this.dispatchTypedEvent("requestorTaskUpdated", { - taskId: message.task.taskId, - task: message.task, - }); - break; - - case "taskStatus": - if (!taskId) { - taskId = message.task.taskId; - } - this.dispatchTypedEvent("toolCallTaskUpdated", { - taskId: message.task.taskId, - task: message.task, - }); - this.dispatchTypedEvent("requestorTaskUpdated", { - taskId: message.task.taskId, - task: message.task, - }); - break; - - case "result": - finalResult = message.result as CallToolResult; - if (taskId) { - const completedTask: TaskWithOptionalCreatedAt = { - taskId, - ttl: null, - status: "completed", - statusMessage: "Task completed" as string, - lastUpdatedAt: new Date().toISOString(), - }; - this.dispatchTypedEvent("toolCallTaskUpdated", { - taskId, - task: completedTask, - result: finalResult, - }); - this.dispatchTypedEvent("requestorTaskUpdated", { - taskId, - task: completedTask, - result: finalResult, - }); - } - break; - - case "error": { - const errorMessage = - message.error.message || "Task execution failed"; - error = new Error(errorMessage); - if (taskId) { - // A user-cancelled task surfaces here as a generic error; report - // it as "cancelled" (not "failed") so the UI lands on the true - // terminal state immediately, matching what a refresh would show - // (#1455). - const cancelled = this.cancelledTaskIds.has(taskId); - // Consume the marker — task ids are single-use, so this keeps the - // set from growing across a long session of cancellations (the - // disconnect-clear stays the backstop for cancels whose task - // completed before the cancel landed and never hit this path). - this.cancelledTaskIds.delete(taskId); - const terminalTask: TaskWithOptionalCreatedAt = { - taskId, - ttl: null, - status: cancelled ? "cancelled" : "failed", - statusMessage: cancelled - ? "Client cancelled task execution." - : errorMessage, - lastUpdatedAt: new Date().toISOString(), - }; - this.dispatchTypedEvent("toolCallTaskUpdated", { - taskId, - task: terminalTask, - error: message.error, - }); - this.dispatchTypedEvent("requestorTaskUpdated", { - taskId, - task: terminalTask, - error: message.error, - }); - } - break; - } - } - } - - // If we got an error, throw it - if (error) { - throw error; - } - - // If we didn't get a result, something went wrong - // This can happen if the task completed but result wasn't in the stream - // Try to get it from the task result endpoint - if (!finalResult && taskId) { - try { - finalResult = await this.client.request( - { method: "tasks/result", params: { taskId } }, - CallToolResultSchema, - this.getRequestOptions(), // no metadata for fallback - ); - } catch (resultError) { - throw new Error( - `Tool call did not return a result: ${resultError instanceof Error ? resultError.message : String(resultError)}`, - { cause: resultError }, - ); - } - } - if (!finalResult) { - throw new Error("Tool call did not return a result"); + const outputValidationError = this.validateToolOutput(tool, result); + if (outputValidationError && !options?.skipOutputValidation) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + outputValidationError, + ); } - const invocation: ToolCallInvocation = { toolName: tool.name, params: args, - result: finalResult, + result, timestamp, success: true, - metadata, + metadata: taskCallMetadata, + outputValidationError, }; - - this.dispatchTypedEvent("toolCallResultChange", { - toolName: tool.name, - params: args, - result: invocation.result, - timestamp, - success: true, - metadata, - }); - + this.dispatchTypedEvent("toolCallResultChange", invocation); return invocation; } catch (error) { - // Merge general metadata with tool-specific metadata for error case - const callMetadata: RequestMetadata | undefined = - generalMetadata || toolSpecificMetadata - ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } - : undefined; - - const timestamp = new Date(); - const metadata = this.mergeMeta(callMetadata); - - this.dispatchTypedEvent("toolCallResultChange", { - toolName: tool.name, - params: args, - result: null, - timestamp, - success: false, - error: error instanceof Error ? error.message : String(error), - metadata, - }); - - throw error; + const operationError = unwrapTaskDispatchError(error); + if (!(operationError instanceof ToolCallCancelledError)) { + this.dispatchFailedToolCall( + tool, + args, + generalMetadata, + toolSpecificMetadata, + operationError instanceof Error + ? operationError.message + : String(operationError), + ); + } + throw operationError; + } finally { + if (progressToken !== undefined) + this.taskProgressIds.delete(progressToken); } } diff --git a/core/mcp/inspectorClientEventTarget.ts b/core/mcp/inspectorClientEventTarget.ts index 58aa41f66b..259c255f8d 100644 --- a/core/mcp/inspectorClientEventTarget.ts +++ b/core/mcp/inspectorClientEventTarget.ts @@ -26,6 +26,7 @@ import type { ResourceSubscriptionStreamState, ExcludedTool, RequestMetadata, + InspectorTask, } from "./types.js"; import type { MalformedListItem } from "./listSalvage.js"; import type { @@ -35,7 +36,6 @@ import type { Root, Progress, ProgressToken, - Task, CallToolResult, ProtocolError, ProtocolEra, @@ -47,8 +47,8 @@ import type { JsonValue } from "../json/jsonUtils.js"; import type { OAuthTokens } from "@modelcontextprotocol/client"; import type { AuthChallenge } from "../auth/challenge.js"; -/** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */ -export type TaskWithOptionalCreatedAt = Omit & { +/** Task update shape retained for state-store compatibility. */ +export type TaskWithOptionalCreatedAt = Omit & { createdAt?: string; }; @@ -148,7 +148,7 @@ export interface InspectorClientEventMap { resourceSubscriptionStreamChange: ResourceSubscriptionStreamState; // Task events /** Fired only from server notification notifications/tasks/status. */ - taskStatusChange: { taskId: string; task: Task }; + taskStatusChange: { taskId: string; task: InspectorTask }; /** Fired from callToolStream for each task update. */ toolCallTaskUpdated: { taskId: string; @@ -171,7 +171,7 @@ export interface InspectorClientEventMap { * event carries only the caller's progressToken, not the taskId). */ requestorTaskProgress: { taskId: string; progress: Progress }; - tasksChange: Task[]; + tasksChange: InspectorTask[]; // Signal events (no payload) connect: void; disconnect: void; diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts index 2da4c4e617..e6bd1324c1 100644 --- a/core/mcp/inspectorClientProtocol.ts +++ b/core/mcp/inspectorClientProtocol.ts @@ -22,6 +22,7 @@ import type { ResourceSubscriptionStreamState, ExcludedTool, RequestMetadata, + InspectorTask, } from "./types.js"; import type { CacheMode, @@ -34,7 +35,6 @@ import type { Resource, ResourceTemplateType as ResourceTemplate, ServerCapabilities, - Task, Tool, } from "@modelcontextprotocol/client"; import type { JsonValue } from "../json/jsonUtils.js"; @@ -43,6 +43,7 @@ import type { InspectorClientEventTarget } from "./inspectorClientEventTarget.js import type { SkillEntry } from "./skillsSchemas.js"; import type { SkillsExtensionSupport } from "./skills.js"; import type { DirectoryReadResult } from "./skillsSchemas.js"; +import type { TaskCapabilities } from "@modelcontextprotocol/ext-tasks/client"; import type { SamplingCreateMessage } from "./samplingCreateMessage.js"; import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js"; @@ -102,15 +103,12 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget { ): Promise<{ resourceTemplates: ResourceTemplate[]; nextCursor?: string }>; listRequestorTasks( cursor?: string, - ): Promise<{ tasks: Task[]; nextCursor?: string }>; - /** Poll one requestor task's current status (era-aware: modern `DetailedTask` - * via `tasks/get`, or the legacy flattened task). Dispatches - * `requestorTaskUpdated`. Used by the modern task store's refresh (no - * `tasks/list`). */ - getRequestorTask(taskId: string): Promise; - /** True when a modern (2026-07-28) connection negotiated the - * `io.modelcontextprotocol/tasks` extension (SEP-2663). Gates the Tasks tab - * and the modern task store's poll-based refresh. */ + ): Promise<{ tasks: InspectorTask[]; nextCursor?: string }>; + /** Poll one requestor task's current status through the neutral task façade. */ + getRequestorTask(taskId: string): Promise; + /** Authoritative generation-neutral capabilities for requester task behavior. */ + getTaskSessionCapabilities?(): TaskCapabilities | undefined; + /** Compatibility predicate for consumers that distinguish known-handle tasks. */ isTasksExtensionNegotiated(): boolean; /** The Skills extension (SEP-2640) the server declared, or `undefined`. diff --git a/core/mcp/messageTrackingTransport.ts b/core/mcp/messageTrackingTransport.ts index 3402e244b6..851fe1e477 100644 --- a/core/mcp/messageTrackingTransport.ts +++ b/core/mcp/messageTrackingTransport.ts @@ -26,35 +26,13 @@ export interface MessageTrackingCallbacks { ) => void; } -/** - * Optional rewrite of an incoming response BEFORE it reaches the SDK's codec. - * Used for extension result shapes the SDK v2 codec would reject outright — e.g. - * a modern (SEP-2663) `resultType: "task"` result, which the codec has no - * knowledge of (tasks were removed from the SDK). The ORIGINAL message is still - * what `trackResponse` logs (so the Protocol/Network tabs show the true wire); - * only the copy handed to the SDK is rewritten. Return the message unchanged to - * pass it through untouched. - */ -export type IncomingResultRewriter = ( - message: JSONRPCResultResponse, -) => JSONRPCMessage; - -/** - * Optional consumer for an incoming response the SDK Client did not originate — - * used for the raw-wire channel that drives extension methods the SDK v2 era - * gate refuses to send (e.g. modern `tasks/get`/`tasks/update`/`tasks/cancel`, - * which are spec-method names absent from the 2026-07-28 era). When this returns - * `true` the response is treated as fully handled and is NOT forwarded to the - * SDK Client (which has no pending request for it). The response is still logged - * by `trackResponse` first, so the Protocol/Network tabs see the true frame. - */ -export type IncomingResponseConsumer = ( - message: JSONRPCResultResponse | JSONRPCErrorResponse, -) => boolean; +/** Narrow raw-channel surface used to consume below-SDK responses. */ +export interface IncomingRawRequestChannel { + consume(message: JSONRPCResultResponse | JSONRPCErrorResponse): boolean; +} export interface MessageTrackingHooks { - rewriteIncomingResult?: IncomingResultRewriter; - consumeIncomingResponse?: IncomingResponseConsumer; + rawRequestChannel?: IncomingRawRequestChannel; } // Transport wrapper that intercepts all messages for tracking @@ -161,25 +139,12 @@ export class MessageTrackingTransport implements Transport { // Consume a response to a raw-wire request the SDK never sent (e.g. // a modern `tasks/get`); handled entirely by the caller, not the SDK. if ( - this.hooks.consumeIncomingResponse?.( + this.hooks.rawRequestChannel?.consume( message as JSONRPCResultResponse | JSONRPCErrorResponse, ) ) { return; } - // Rewrite a result the SDK codec can't decode (e.g. a modern - // `resultType: "task"` handle) AFTER logging the true wire, so the - // SDK receives a shape it accepts while the Protocol/Network tabs - // still show the real frame. - if (this.hooks.rewriteIncomingResult && "result" in message) { - const rewritten = this.hooks.rewriteIncomingResult( - message as JSONRPCResultResponse, - ); - if (rewritten !== message) { - handler(rewritten as T, extra); - return; - } - } } else if ("method" in message) { // This is a request coming from the server this.callbacks.trackRequest?.(message as JSONRPCRequest, "server"); diff --git a/core/mcp/modernTaskSchemas.ts b/core/mcp/modernTaskSchemas.ts index 8857175083..e57784e46c 100644 --- a/core/mcp/modernTaskSchemas.ts +++ b/core/mcp/modernTaskSchemas.ts @@ -1,132 +1,4 @@ -/** - * Modern (2026-07-28) task extension wire schemas — SEP-2663 - * (`io.modelcontextprotocol/tasks`). - * - * SDK v2 removed all built-in tasks support: the `Task` / `GetTaskResultSchema` - * / `CreateTaskResultSchema` it still exports are the **deprecated 2025-11-25** - * vocabulary (`ttl` / `pollInterval`, blocking `tasks/result`, `tasks/list`). - * The redesigned extension is a different wire shape — `ttlMs` / `pollIntervalMs`, - * a polymorphic `DetailedTask` that inlines `result` / `error` / `inputRequests` - * by status, `tasks/get` polling, a new `tasks/update`, no `tasks/list`, and no - * blocking `tasks/result`. There is no SDK schema for it, so the Inspector drives - * modern `tasks/*` as raw requests with these explicit schemas (the "explicit- - * schema raw-request form" the SDK docs prescribe). - * - * Schemas are intentionally permissive (`looseObject`) so an unknown wire field - * (e.g. a future status-specific member) passes through rather than failing the - * parse — the Inspector is a debugging tool and should surface, not reject. - */ - -import { z } from "zod/v4"; -import type { InputRequests, Task } from "@modelcontextprotocol/client"; - -/** SEP-2133 extension identifier for the redesigned Tasks extension (SEP-2663). */ -export const TASKS_EXTENSION_KEY = "io.modelcontextprotocol/tasks"; - -/** The modern protocol revision, used as the raw-request envelope's - * `protocolVersion` when the negotiated version isn't otherwise available. */ -export const MODERN_PROTOCOL_VERSION = "2026-07-28"; - -/** The `_meta` value stamped on modern task-eligible requests to declare the - * client supports the tasks extension (per-request capability, SEP-2663). */ -export const TASKS_EXTENSION_CLIENT_CAPABILITY = { - extensions: { [TASKS_EXTENSION_KEY]: {} }, -} as const; - -/** - * `_meta` key under which the transport-level rewriter stashes a modern task - * handle. SDK v2's codec rejects a `resultType: "task"` result outright (tasks - * were removed), so a task-creating `tools/call` response is rewritten to a - * benign `CallToolResult` carrying the real `DetailedTask` here, where the task - * poll driver reads it. See `MessageTrackingTransport`'s rewrite hook. - */ -export const MODERN_TASK_HANDLE_META = - "io.modelcontextprotocol/inspector/modernTaskHandle"; - -/** True when a decoded wire result is a modern `CreateTaskResult` - * (`resultType: "task"`) — the frame the SDK codec cannot handle. */ -export function isModernCreateTaskResult(result: unknown): boolean { - return ( - typeof result === "object" && - result !== null && - (result as { resultType?: unknown }).resultType === "task" && - typeof (result as { taskId?: unknown }).taskId === "string" - ); -} - -const ModernTaskStatusSchema = z.enum([ - "working", - "input_required", - "completed", - "failed", - "cancelled", -]); - -/** - * `DetailedTask` (SEP-2663): the modern task shape returned by `tasks/get` and - * carried by a `CreateTaskResult`. Status-specific members (`result`, `error`, - * `inputRequests`) are optional here because a single loose schema stands in for - * the wire union `Working | InputRequired | Completed | Failed | Cancelled`. - */ -export const ModernDetailedTaskSchema = z.looseObject({ - taskId: z.string(), - status: ModernTaskStatusSchema, - statusMessage: z.string().optional(), - createdAt: z.string(), - lastUpdatedAt: z.string(), - ttlMs: z.number().nullable().optional(), - pollIntervalMs: z.number().optional(), - /** Present on `completed`: the original request's result (e.g. CallToolResult). */ - result: z.record(z.string(), z.unknown()).optional(), - /** Present on `failed`: the JSON-RPC error that ended the task. */ - error: z.record(z.string(), z.unknown()).optional(), - /** Present on `input_required`: embedded server→client requests, keyed by id. */ - inputRequests: z.record(z.string(), z.unknown()).optional(), -}); - -export type ModernDetailedTask = z.infer; - -/** `GetTaskResult = Result & DetailedTask`. Same fields we need as the task itself. */ -export const ModernGetTaskResultSchema = ModernDetailedTaskSchema; - -/** `CreateTaskResult = Result & Task` (`resultType: "task"`). The seed task state. */ -export const ModernCreateTaskResultSchema = ModernDetailedTaskSchema; - -/** `UpdateTaskResult` — an empty acknowledgement (`resultType: "complete"`). */ -export const ModernUpdateTaskResultSchema = z.looseObject({}); - -/** `CancelTaskResult` — modern cancel acks with an empty/loose result. */ -export const ModernCancelTaskResultSchema = z.looseObject({}); - -/** - * Normalize a modern `DetailedTask` onto the internal (SDK 2025-11-25) `Task` - * shape the state store, events, and `TaskCard` consume: `ttlMs` → `ttl`, - * `pollIntervalMs` → `pollInterval`. The status-specific members - * (`result` / `error` / `inputRequests`) ride along structurally so the poll - * driver can read them; they are not part of the `Task` type but are harmless - * extra properties on the object (the card renders the full task JSON). - */ -export function normalizeModernTask(modern: ModernDetailedTask): Task { - const { ttlMs, pollIntervalMs, ...rest } = modern; - const normalized: Record = { ...rest }; - // The internal Task requires `ttl: number | null`; map the modern `ttlMs` - // (which is itself `number | null`) straight across, defaulting to null. - normalized.ttl = ttlMs ?? null; - if (pollIntervalMs != null) normalized.pollInterval = pollIntervalMs; - // The loose modern schema is a structural superset of the internal Task - // (taskId/status/statusMessage/createdAt/lastUpdatedAt present; ttl/pollInterval - // mapped above). No SDK schema relates the two nominal types, so a single - // narrowing cast bridges the structurally-identical shape. - return normalized as unknown as Task; -} - -/** Read the embedded `inputRequests` map off a modern task, typed for - * {@link fulfilInputRequests}. The loose parse yields `unknown` values; the - * per-request `fulfilEmbeddedInputRequest` switch validates each by method. */ -export function readInputRequests( - modern: ModernDetailedTask, -): InputRequests | undefined { - // Structural bridge: the loose record parse cannot express the InputRequests - // union, but fulfilEmbeddedInputRequest validates each entry by its `method`. - return modern.inputRequests as InputRequests | undefined; -} +/** Compatibility exports retained after task wire schemas moved to ext-tasks. */ +export { TASKS_EXTENSION_ID_V2 as TASKS_EXTENSION_KEY } from "@modelcontextprotocol/ext-tasks/core/v2"; +export { GetTaskResultV2Schema as ModernGetTaskResultSchema } from "@modelcontextprotocol/ext-tasks/core/v2"; +export { MODERN_PROTOCOL_VERSION } from "./types.js"; diff --git a/core/mcp/types.ts b/core/mcp/types.ts index 5755018cc9..18e30fffdb 100644 --- a/core/mcp/types.ts +++ b/core/mcp/types.ts @@ -40,6 +40,32 @@ import type { import type { OAuthStorage } from "../auth/storage.js"; import type { AuthChallenge } from "../auth/challenge.js"; +/** Generation-neutral task status rendered by Inspector requester surfaces. */ +export type InspectorTaskStatus = + | "working" + | "input_required" + | "completed" + | "failed" + | "cancelled"; + +/** + * Project-owned requester task shape. Stable fields are normalized across MCP + * task generations; generation-specific wire detail remains available as raw data. + */ +export interface InspectorTask { + taskId: string; + status: InspectorTaskStatus; + statusMessage?: string; + createdAt: string; + lastUpdatedAt: string; + /** Requested/advertised retention in milliseconds; null means unspecified. */ + ttl: number | null; + /** Server-suggested delay before the next poll, in milliseconds. */ + pollInterval?: number; + /** Original generation-specific task payload, preserved without type claims. */ + raw?: Readonly>; +} + // Stdio transport config export interface StdioServerConfig { // Optional: stdio is the implicit default when `type` is absent. A diff --git a/package-lock.json b/package-lock.json index 337f87b07c..5cce4d35dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/ext-tasks": "file:../mcp-ext-tasks/packages/ext-tasks", "@modelcontextprotocol/server": "2.0.0", "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", @@ -55,6 +56,32 @@ "node": ">=22.19.0" } }, + "../mcp-ext-tasks/packages/ext-tasks": { + "name": "@modelcontextprotocol/ext-tasks", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.5.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@modelcontextprotocol/client": "^2.0.0", + "eslint": "^10.10.0", + "eslint-plugin-jsdoc": "^64.3.5", + "fast-check": "^4.9.0", + "globals": "^17.12.0", + "prettier": "^3.9.6", + "typescript-eslint": "^8.69.0" + }, + "peerDependencies": { + "@modelcontextprotocol/client": "^2.0.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/client": { + "optional": true + } + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", @@ -684,6 +711,10 @@ } } }, + "node_modules/@modelcontextprotocol/ext-tasks": { + "resolved": "../mcp-ext-tasks/packages/ext-tasks", + "link": true + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", diff --git a/package.json b/package.json index 6a18ee076e..70ed60acf0 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/ext-tasks": "file:../mcp-ext-tasks/packages/ext-tasks", "@modelcontextprotocol/server": "2.0.0", "@modelcontextprotocol/server-legacy": "2.0.0", "@napi-rs/keyring": "^1.3.0", From 88cea2b2545f9bfe2a072d83a383895cfda5e831 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Wed, 9 Sep 2026 13:28:37 -0700 Subject: [PATCH 02/10] chore: update task tool calls --- .../core/mcp/inspectorClient-raw-wire.test.ts | 120 ++++++++++-------- core/mcp/inspectorClient.ts | 13 +- 2 files changed, 74 insertions(+), 59 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts index 522f11e204..b7825947a5 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -51,6 +51,10 @@ describe("InspectorClient raw-wire channel (#1631)", () => { interface TaskSessionCallOptions { task: { preference: "allow" | "prefer"; retentionMs?: number }; + } + + interface TaskExecutionSettleOptions { + signal?: AbortSignal; onEvent: (event: unknown) => void; } @@ -58,11 +62,15 @@ describe("InspectorClient raw-wire channel (#1631)", () => { client: object | null; protocolEra?: "legacy" | "modern"; taskSession: { - callToolAndSettle: ( + callTool: ( name: string, args: Readonly>, options: TaskSessionCallOptions, - ) => Promise<{ outcome: unknown; lastTask?: unknown }>; + ) => Promise<{ + settle: ( + options: TaskExecutionSettleOptions, + ) => Promise<{ outcome: unknown; lastTask?: unknown }>; + }>; } | null; taskInputOrigin: ( delivery: "peer-request" | "request-retry" | "task-update", @@ -93,16 +101,12 @@ describe("InspectorClient raw-wire channel (#1631)", () => { function attachTaskBoundary( client: InspectorClient, - callToolAndSettle: TaskBoundaryInternals["taskSession"] extends infer Session - ? Session extends { callToolAndSettle: infer Call } - ? Call - : never - : never, + callTool: NonNullable["callTool"], ): void { const boundary = taskInternals(client); boundary.client = {}; boundary.protocolEra = "modern"; - boundary.taskSession = { callToolAndSettle }; + boundary.taskSession = { callTool }; } it("throws when there is no transport", async () => { @@ -427,38 +431,33 @@ describe("InspectorClient raw-wire channel (#1631)", () => { "maps %s task options to the ext-tasks preference contract", async (taskOptions, expectedPreference, expectedRetention) => { const client = makeClient(); - const callToolAndSettle = vi.fn( - async ( - _name: string, - _args: Readonly>, - options: TaskSessionCallOptions, - ) => { - options.onEvent({ - type: "task", + const settle = vi.fn(async (options: TaskExecutionSettleOptions) => { + options.onEvent({ + type: "task", + task: { + taskId: "task-preference", + status: "working", + lastUpdatedAt: "2026-01-02T03:04:05.000Z", + }, + }); + options.onEvent({ + type: "outcome", + outcome: { + status: "completed", + result: successfulResult, task: { taskId: "task-preference", - status: "working", - lastUpdatedAt: "2026-01-02T03:04:05.000Z", - }, - }); - options.onEvent({ - type: "outcome", - outcome: { status: "completed", - result: successfulResult, - task: { - taskId: "task-preference", - status: "completed", - createdAt: "2026-01-02T03:04:05.000Z", - }, + createdAt: "2026-01-02T03:04:05.000Z", }, - }); - return { - outcome: { status: "completed", result: successfulResult }, - }; - }, - ); - attachTaskBoundary(client, callToolAndSettle); + }, + }); + return { + outcome: { status: "completed", result: successfulResult }, + }; + }); + const callTool = vi.fn(async () => ({ settle })); + attachTaskBoundary(client, callTool); const updates: Array<{ task: TaskWithOptionalCreatedAt; result?: CallToolResult; @@ -476,7 +475,7 @@ describe("InspectorClient raw-wire channel (#1631)", () => { ); expect(invocation.result).toEqual(successfulResult); - expect(callToolAndSettle).toHaveBeenCalledWith( + expect(callTool).toHaveBeenCalledWith( taskTool.name, {}, expect.objectContaining({ @@ -486,6 +485,11 @@ describe("InspectorClient raw-wire channel (#1631)", () => { }, }), ); + expect(settle).toHaveBeenCalledWith( + expect.objectContaining({ + onEvent: expect.any(Function), + }), + ); expect(updates).toEqual([ { taskId: "task-preference", @@ -510,18 +514,20 @@ describe("InspectorClient raw-wire channel (#1631)", () => { const client = makeClient(); attachTaskBoundary( client, - vi.fn(async (_name, _args, options) => { - options.onEvent({ - type: "task", - task: { - taskId: "task-failed", - status: "working", - createdAt: "2026-01-02T03:04:05.000Z", - lastUpdatedAt: "2026-01-02T03:04:06.000Z", - }, - }); - throw new Error("worker exploded"); - }), + vi.fn(async () => ({ + settle: vi.fn(async (options: TaskExecutionSettleOptions) => { + options.onEvent({ + type: "task", + task: { + taskId: "task-failed", + status: "working", + createdAt: "2026-01-02T03:04:05.000Z", + lastUpdatedAt: "2026-01-02T03:04:06.000Z", + }, + }); + throw new Error("worker exploded"); + }), + })), ); const updates: Array<{ error?: Error }> = []; client.addEventListener("requestorTaskUpdated", (event) => { @@ -543,7 +549,9 @@ describe("InspectorClient raw-wire channel (#1631)", () => { attachTaskBoundary( client, vi.fn(async () => ({ - outcome: { status: "completed", result: invalidResult }, + settle: vi.fn(async () => ({ + outcome: { status: "completed", result: invalidResult }, + })), })), ); const toolWithOutput: Tool = { @@ -641,9 +649,11 @@ describe("InspectorClient raw-wire channel (#1631)", () => { const cause = new Error("host transport failed"); attachTaskBoundary( client, - vi.fn(async () => { - throw new DispatchError("dispatch policy wrapper", false, { cause }); - }), + vi.fn(async () => ({ + settle: vi.fn(async () => { + throw new DispatchError("dispatch policy wrapper", false, { cause }); + }), + })), ); await expect(client.callTool(taskTool, {})).rejects.toBe(cause); @@ -658,7 +668,9 @@ describe("InspectorClient raw-wire channel (#1631)", () => { attachTaskBoundary( client, vi.fn(async () => ({ - outcome: { status: "completed", result: invalidResult }, + settle: vi.fn(async () => ({ + outcome: { status: "completed", result: invalidResult }, + })), })), ); const toolWithOutput: Tool = { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 2819a05c87..a5e8dfa9dc 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -3909,7 +3909,7 @@ export class InspectorClient extends InspectorClientEventTarget { const headers = this.mirroredTaskParamHeaders(tool, args); let lastTask: InspectorTask | undefined; try { - const settlement = await session.callToolAndSettle( + const execution = await session.callTool( tool.name, toJsonValue(args) as Readonly>, { @@ -3925,12 +3925,15 @@ export class InspectorClient extends InspectorClientEventTarget { >, }), ...(headers === undefined ? {} : { headers }), - onEvent: (event) => { - lastTask = - this.emitTaskExecutionEvent(event, progressToken) ?? lastTask; - }, }, ); + const settlement = await execution.settle({ + signal, + onEvent: (event) => { + lastTask = + this.emitTaskExecutionEvent(event, progressToken) ?? lastTask; + }, + }); if (settlement.outcome.status === "cancelled") { throw new ToolCallCancelledError(tool.name); } From cd8dea14ce86dc8b369a2ce7ae4adcdaac7adc10 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Wed, 9 Sep 2026 18:07:40 -0700 Subject: [PATCH 03/10] fix: address Copilot comments --- .../core/mcp/inspectorClient-raw-wire.test.ts | 3 + .../mcp/inspectorClient-modern-era.test.ts | 22 ++ .../integration/mcp/inspectorClient.test.ts | 18 ++ core/mcp/inspectorClient.ts | 280 +++++++++--------- 4 files changed, 180 insertions(+), 143 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts index b7825947a5..02de9e31ab 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -50,6 +50,7 @@ describe("InspectorClient raw-wire channel (#1631)", () => { } interface TaskSessionCallOptions { + requestTimeoutMs: number; task: { preference: "allow" | "prefer"; retentionMs?: number }; } @@ -431,6 +432,7 @@ describe("InspectorClient raw-wire channel (#1631)", () => { "maps %s task options to the ext-tasks preference contract", async (taskOptions, expectedPreference, expectedRetention) => { const client = makeClient(); + internals(client).requestTimeout = 12_345; const settle = vi.fn(async (options: TaskExecutionSettleOptions) => { options.onEvent({ type: "task", @@ -479,6 +481,7 @@ describe("InspectorClient raw-wire channel (#1631)", () => { taskTool.name, {}, expect.objectContaining({ + requestTimeoutMs: 12_345, task: { preference: expectedPreference, retentionMs: expectedRetention, diff --git a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts index 7c0494a3ea..4efa0ab0f7 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient-modern-era.test.ts @@ -97,6 +97,28 @@ describe("modern-era negotiation (2026-07-28)", () => { return connected; } + it("fails connect when the required Tasks session cannot attach", async () => { + const started = await startServer(); + const connected = new InspectorClient( + { type: "streamable-http", url: started.url }, + { + environment: { transport: createTransportNode }, + versionNegotiation: eraToVersionNegotiation("modern"), + }, + ); + const boundary = connected as unknown as { + attachTaskSession: () => Promise; + }; + boundary.attachTaskSession = () => + Promise.reject(new Error("task session attach failed")); + client = connected; + + await expect(connected.connect()).rejects.toThrow( + "task session attach failed", + ); + expect(connected.getStatus()).toBe("error"); + }); + it("negotiates the modern era under 'auto' with a populated discover result", async () => { const started = await startServer(); const connected = await connectWithEra(started.url, "auto"); diff --git a/clients/web/src/test/integration/mcp/inspectorClient.test.ts b/clients/web/src/test/integration/mcp/inspectorClient.test.ts index b72cf01df2..08e8dbda74 100644 --- a/clients/web/src/test/integration/mcp/inspectorClient.test.ts +++ b/clients/web/src/test/integration/mcp/inspectorClient.test.ts @@ -362,6 +362,24 @@ describe("InspectorClient", () => { messageLogState.destroy(); }); + it("sets error status when transport creation fails", async () => { + client = new InspectorClient( + { type: "stdio", command: "missing", args: [] }, + { + environment: { + transport: () => { + throw new Error("transport factory failed"); + }, + }, + }, + ); + + await expect(client.connect()).rejects.toThrow( + "transport factory failed", + ); + expect(client.getStatus()).toBe("error"); + }); + it("rejects connect() with a timeout error when serverSettings.connectionTimeout fires", async () => { // Stub transport whose start() never resolves — simulates a slow / // unreachable upstream. InspectorClient.connect() should race against diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index a5e8dfa9dc..1948e456c8 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1601,151 +1601,151 @@ export class InspectorClient extends InspectorClientEventTarget { } this.status = "connecting"; this.dispatchTypedEvent("statusChange", this.status); + try { + // Start from a clean session — see `resetSessionState` for why this is + // start-clean rather than relying on `disconnect()`. + this.resetSessionState(); + // Settle UI requests from any previous session before installing fresh + // receiver handlers. Binding close in resetSessionState aborts receiver + // callbacks; this sweep also covers ordinary peer requests. + this.clearAndAnnouncePendingPeerRequests(); + this.rejectPendingRawWireRequests("Connection ended"); + await this.closeTaskSessionBestEffort(); - // Start from a clean session — see `resetSessionState` for why this is - // start-clean rather than relying on `disconnect()`. - this.resetSessionState(); - // Settle UI requests from any previous session before installing fresh - // receiver handlers. Binding close in resetSessionState aborts receiver - // callbacks; this sweep also covers ordinary peer requests. - this.clearAndAnnouncePendingPeerRequests(); - this.rejectPendingRawWireRequests("Connection ended"); - await this.closeTaskSessionBestEffort(); - - const oauthManager = this.oauthManager; - if ( - this.baseTransport && - this.isHttpOAuthConfig() && - oauthManager && - !this.transportHasAuthProvider && - !oauthManager.isEnterpriseManaged() && - (await oauthManager.isOAuthAuthorized()) - ) { - await this.dropCachedTransport(); - } - - // Create transport (single place for create / wrap / attach). - if (!this.baseTransport) { - const transportOptions: CreateTransportOptions = { - fetchFn: this.fetchFn, - pipeStderr: this.pipeStderr, - onStderr: (entry: StderrLogEntry) => { - this.dispatchStderrLog(entry); - }, - onFetchRequest: (entry: FetchRequestEntryBase) => { - this.dispatchFetchRequest({ ...entry, category: "transport" }); - }, - onFetchResponseBody: (id: string, body: string) => { - this.dispatchFetchRequestBodyUpdate(id, body); - }, - ...(this.serverSettings && { settings: this.serverSettings }), - }; - if (this.isHttpOAuthConfig() && oauthManager) { - // Record every 401/403 the transport sees, whatever else happens to - // it. The legacy first-authorization path deliberately runs with no - // authProvider and no challenge interception (see below), so the SDK - // raises a headerless `UnauthorizedError` and the client calls - // `authenticate()` with nothing in hand — this is the only place the - // challenge's RFC 9728 `resource_metadata` still exists (#2071). - const manager = oauthManager; - transportOptions.onAuthChallengeObserved = (challenge) => { - manager.noteObservedAuthChallenge(challenge); - }; - if (oauthManager.isEnterpriseManaged()) { - await oauthManager.trySilentEnterpriseManagedAuth(); - const provider = await oauthManager.createOAuthProviderForTransport(); - const tokens = await provider.tokens(); - if (!tokens?.access_token) { - const err = new Error( - "Unauthorized: EMA resource access token unavailable", - ) as Error & { status?: number; code?: number }; - err.status = 401; - err.code = 401; - throw err; - } - transportOptions.authProvider = provider; - } else if (await oauthManager.isOAuthAuthorized()) { - // Without stored tokens, omit authProvider so connect() surfaces a plain - // 401 instead of the SDK opening a browser before the app callback - // server is listening (TUI/CLI run authenticate() explicitly). - transportOptions.authProvider = - await oauthManager.createOAuthProviderForTransport(); - } - } + const oauthManager = this.oauthManager; if ( - this.directAuthRecovery && - this.directAuthRecoveryActive !== false && + this.baseTransport && this.isHttpOAuthConfig() && oauthManager && - // No stored tokens means no authProvider (see above), and then a 401 on - // the era-negotiation probe reaches the SDK as a raw `SdkHttpError`. - // The probe's classifier ignores the HTTP status — it only looks for a - // JSON-RPC error body — so it verdicts "not a modern server", and pin - // ("modern") mode rethrows that as ERA_NEGOTIATION_FAILED with the 401 - // discarded entirely: no status, not even a cause. Intercepting makes - // the 401 a typed AuthChallengeError, which survives the probe as - // `data.cause` for `findNestedAuthError` to recover (#1805). - // - // WORKAROUND (#1807, upstream modelcontextprotocol/typescript-sdk#2561): - // remove this clause once the SDK classifies a probe 401/403 as - // auth-required. `findNestedAuthError` is the permanent fix; the - // `|| this.probesProtocolEra()` clause below exists only to compensate - // for that upstream gap and should be deleted with it. - // - // Known, accepted side effect of turning intercept on with no stored - // tokens: `parseAuthChallengeFromResponse` treats 403 as a challenge - // too, so a probe answered 403 for a *non-auth* reason (a gateway - // rejecting the unknown `server/discover` method, say) now starts OAuth - // discovery instead of letting "auto" fall back to the legacy - // `initialize`. The outcome is a surfaced `oauthError`, not a hang, and - // it goes away with this clause. - (transportOptions.authProvider || this.probesProtocolEra()) + !this.transportHasAuthProvider && + !oauthManager.isEnterpriseManaged() && + (await oauthManager.isOAuthAuthorized()) ) { - transportOptions.interceptAuthChallenges = true; + await this.dropCachedTransport(); } - this.transportHasAuthProvider = !!transportOptions.authProvider; - const { transport: baseTransport } = this.transportClientFactory( - this.transportConfig, - transportOptions, - ); - this.baseTransport = baseTransport; - if (this.directAuthRecovery) { - this.directAuthRecoveryActive = !( - baseTransport instanceof RemoteClientTransport + + // Create transport (single place for create / wrap / attach). + if (!this.baseTransport) { + const transportOptions: CreateTransportOptions = { + fetchFn: this.fetchFn, + pipeStderr: this.pipeStderr, + onStderr: (entry: StderrLogEntry) => { + this.dispatchStderrLog(entry); + }, + onFetchRequest: (entry: FetchRequestEntryBase) => { + this.dispatchFetchRequest({ ...entry, category: "transport" }); + }, + onFetchResponseBody: (id: string, body: string) => { + this.dispatchFetchRequestBodyUpdate(id, body); + }, + ...(this.serverSettings && { settings: this.serverSettings }), + }; + if (this.isHttpOAuthConfig() && oauthManager) { + // Record every 401/403 the transport sees, whatever else happens to + // it. The legacy first-authorization path deliberately runs with no + // authProvider and no challenge interception (see below), so the SDK + // raises a headerless `UnauthorizedError` and the client calls + // `authenticate()` with nothing in hand — this is the only place the + // challenge's RFC 9728 `resource_metadata` still exists (#2071). + const manager = oauthManager; + transportOptions.onAuthChallengeObserved = (challenge) => { + manager.noteObservedAuthChallenge(challenge); + }; + if (oauthManager.isEnterpriseManaged()) { + await oauthManager.trySilentEnterpriseManagedAuth(); + const provider = + await oauthManager.createOAuthProviderForTransport(); + const tokens = await provider.tokens(); + if (!tokens?.access_token) { + const err = new Error( + "Unauthorized: EMA resource access token unavailable", + ) as Error & { status?: number; code?: number }; + err.status = 401; + err.code = 401; + throw err; + } + transportOptions.authProvider = provider; + } else if (await oauthManager.isOAuthAuthorized()) { + // Without stored tokens, omit authProvider so connect() surfaces a plain + // 401 instead of the SDK opening a browser before the app callback + // server is listening (TUI/CLI run authenticate() explicitly). + transportOptions.authProvider = + await oauthManager.createOAuthProviderForTransport(); + } + } + if ( + this.directAuthRecovery && + this.directAuthRecoveryActive !== false && + this.isHttpOAuthConfig() && + oauthManager && + // No stored tokens means no authProvider (see above), and then a 401 on + // the era-negotiation probe reaches the SDK as a raw `SdkHttpError`. + // The probe's classifier ignores the HTTP status — it only looks for a + // JSON-RPC error body — so it verdicts "not a modern server", and pin + // ("modern") mode rethrows that as ERA_NEGOTIATION_FAILED with the 401 + // discarded entirely: no status, not even a cause. Intercepting makes + // the 401 a typed AuthChallengeError, which survives the probe as + // `data.cause` for `findNestedAuthError` to recover (#1805). + // + // WORKAROUND (#1807, upstream modelcontextprotocol/typescript-sdk#2561): + // remove this clause once the SDK classifies a probe 401/403 as + // auth-required. `findNestedAuthError` is the permanent fix; the + // `|| this.probesProtocolEra()` clause below exists only to compensate + // for that upstream gap and should be deleted with it. + // + // Known, accepted side effect of turning intercept on with no stored + // tokens: `parseAuthChallengeFromResponse` treats 403 as a challenge + // too, so a probe answered 403 for a *non-auth* reason (a gateway + // rejecting the unknown `server/discover` method, say) now starts OAuth + // discovery instead of letting "auto" fall back to the legacy + // `initialize`. The outcome is a surfaced `oauthError`, not a hang, and + // it goes away with this clause. + (transportOptions.authProvider || this.probesProtocolEra()) + ) { + transportOptions.interceptAuthChallenges = true; + } + this.transportHasAuthProvider = !!transportOptions.authProvider; + const { transport: baseTransport } = this.transportClientFactory( + this.transportConfig, + transportOptions, ); - } - if ( - baseTransport instanceof RemoteClientTransport && - oauthManager && - this.isHttpOAuthConfig() - ) { - baseTransport.setAuthRecovery({ - handleAuthChallenge: (challenge, options) => - oauthManager.handleAuthChallenge(challenge, options), - pushAuthState: () => this.pushRemoteAuthState(), - }); - baseTransport.setOnAuthChallenge((challenge) => { - void this.handleAmbientAuthChallenge(challenge); - }); - } - const messageTracking = this.createMessageTrackingCallbacks(); - this.transport = new MessageTrackingTransport( - baseTransport, - messageTracking, - { - rawRequestChannel: { - consume: (message) => this.consumeRawWireResponse(message), + this.baseTransport = baseTransport; + if (this.directAuthRecovery) { + this.directAuthRecoveryActive = !( + baseTransport instanceof RemoteClientTransport + ); + } + if ( + baseTransport instanceof RemoteClientTransport && + oauthManager && + this.isHttpOAuthConfig() + ) { + baseTransport.setAuthRecovery({ + handleAuthChallenge: (challenge, options) => + oauthManager.handleAuthChallenge(challenge, options), + pushAuthState: () => this.pushRemoteAuthState(), + }); + baseTransport.setOnAuthChallenge((challenge) => { + void this.handleAmbientAuthChallenge(challenge); + }); + } + const messageTracking = this.createMessageTrackingCallbacks(); + this.transport = new MessageTrackingTransport( + baseTransport, + messageTracking, + { + rawRequestChannel: { + consume: (message) => this.consumeRawWireResponse(message), + }, }, - }, - ); - this.attachTransportListeners(this.baseTransport); - } + ); + this.attachTransportListeners(this.baseTransport); + } - if (!this.transport) { - throw new Error("Transport not initialized"); - } + if (!this.transport) { + throw new Error("Transport not initialized"); + } - try { // Register the handlers for server→client requests and the // capability-independent notifications before the handshake — see // `registerPeerRequestHandlers` for why the ordering is load-bearing. @@ -1842,14 +1842,7 @@ export class InspectorClient extends InspectorClientEventTarget { // #1395). If "connect" fired first, that gate would read undefined // capabilities and wipe tools/prompts/resources to empty on every connect. await this.fetchServerInfo(); - try { - await this.attachTaskSession(); - } catch (error) { - this.logger.warn( - { error }, - "Failed to attach ext-tasks session; continuing without task support", - ); - } + await this.attachTaskSession(); // Set initial logging level if configured and server supports it. // @@ -3916,6 +3909,7 @@ export class InspectorClient extends InspectorClientEventTarget { resultCodec: taskToolResultCodec, declaration: toolDeclarationFromMcpTool(tool), signal, + requestTimeoutMs: this.requestTimeout, task: { preference, retentionMs }, ...(metadata === undefined ? {} From 8d1fdc76cb1f0f3d1f4e660372c152f794b64f47 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 10 Sep 2026 11:39:40 -0700 Subject: [PATCH 04/10] fix: address Copilot comments --- .../core/mcp/inspectorClient-raw-wire.test.ts | 238 +++++++++++++++++- .../mcp/inspectorClientUrlElicitation.test.ts | 16 +- core/mcp/inspectorClient.ts | 75 ++++-- 3 files changed, 305 insertions(+), 24 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts index 02de9e31ab..420dec04ed 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi } from "vitest"; -import type { CallToolResult, Tool } from "@modelcontextprotocol/client"; -import { DispatchError } from "@modelcontextprotocol/ext-tasks/client"; +import { + ProtocolError, + type CallToolResult, + type Tool, +} from "@modelcontextprotocol/client"; +import { + DispatchError, + JsonRpcResponseError, +} from "@modelcontextprotocol/ext-tasks/client"; import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; import type { TaskWithOptionalCreatedAt } from "@inspector/core/mcp/inspectorClientEventTarget.js"; import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas.js"; @@ -37,7 +44,10 @@ describe("InspectorClient raw-wire channel (#1631)", () => { request: unknown, options?: { signal?: AbortSignal; - context?: { headers?: Readonly> }; + context?: { + headers?: Readonly>; + requestTimeoutMs?: number; + }; }, ) => Promise; rawWireRequest: ( @@ -50,7 +60,8 @@ describe("InspectorClient raw-wire channel (#1631)", () => { } interface TaskSessionCallOptions { - requestTimeoutMs: number; + requestTimeoutMs?: number; + metadata?: Readonly>; task: { preference: "allow" | "prefer"; retentionMs?: number }; } @@ -78,6 +89,7 @@ describe("InspectorClient raw-wire channel (#1631)", () => { ) => "server-request" | "input-required" | "task-input-required"; emitTaskExecutionEvent: (event: unknown) => unknown; emitTaskError: (lastTask: unknown, reason: unknown) => void; + dispatchTaskProgress: (notification: unknown) => void; } function internals(client: InspectorClient): RawWireInternals { @@ -141,12 +153,13 @@ describe("InspectorClient raw-wire channel (#1631)", () => { const consumed = internals(client).consumeRawWireResponse({ id: sent!.id, result: { + resultType: "complete", taskId: "x", status: "completed", createdAt: "a", lastUpdatedAt: "b", ttlMs: null, - result: { content: [] }, + result: { resultType: "complete", content: [] }, }, }); expect(consumed).toBe(true); @@ -217,6 +230,50 @@ describe("InspectorClient raw-wire channel (#1631)", () => { } }); + it("honors the ext-tasks operation timeout context", async () => { + vi.useFakeTimers(); + try { + const client = makeClient(); + internals(client).requestTimeout = 10_000; + internals(client).transport = { + send: vi.fn().mockResolvedValue(undefined), + }; + const promise = internals(client).dispatchTaskRequest( + { method: "tasks/get" }, + { context: { requestTimeoutMs: 25 } }, + ); + const assertion = expect(promise).rejects.toThrow(/25 ms/); + await vi.advanceTimersByTimeAsync(25); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + it("uses the SDK 60-second default when no timeout is configured", async () => { + vi.useFakeTimers(); + try { + const client = makeClient(); + internals(client).transport = { + send: vi.fn().mockResolvedValue(undefined), + }; + const promise = internals(client).dispatchTaskRequest({ + method: "tasks/get", + }); + let settled = false; + void promise.catch(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(30_000); + expect(settled).toBe(false); + const assertion = expect(promise).rejects.toThrow(/60000 ms/); + await vi.advanceTimersByTimeAsync(30_000); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + it("rejects all pending requests on teardown", async () => { const client = makeClient(); internals(client).transport = { @@ -513,6 +570,137 @@ describe("InspectorClient raw-wire channel (#1631)", () => { }, ); + it("delivers raw-call progress before a task snapshot and releases the token", async () => { + const client = makeClient(); + const progress: unknown[] = []; + client.addEventListener("progressNotification", (event) => { + progress.push(event.detail); + }); + let progressToken: string | number | undefined; + attachTaskBoundary( + client, + vi.fn(async (_name, _args, options) => { + progressToken = options.metadata?.progressToken as + | string + | number + | undefined; + taskInternals(client).dispatchTaskProgress({ + method: "notifications/progress", + params: { progressToken, progress: 1, total: 2 }, + }); + return { + settle: vi.fn(async () => ({ + outcome: { status: "completed", result: successfulResult }, + })), + }; + }), + ); + + const invocation = await client.callTool(taskTool, {}); + + expect(invocation.metadata?.progressToken).toBe(progressToken); + expect(progress).toEqual([{ progressToken, progress: 1, total: 2 }]); + taskInternals(client).dispatchTaskProgress({ + method: "notifications/progress", + params: { progressToken, progress: 2, total: 2 }, + }); + expect(progress).toHaveLength(1); + }); + + const makeBoundaryClient = ( + options?: ConstructorParameters[1], + ): InspectorClient => + new InspectorClient( + { type: "stdio", command: "noop", args: [] }, + options ?? { environment: { transport: () => ({}) as never } }, + ); + + it("omits the progress token when progress is disabled", async () => { + const client = makeBoundaryClient({ + environment: { transport: () => ({}) as never }, + progress: false, + }); + let sentMetadata: Readonly> | undefined; + attachTaskBoundary( + client, + vi.fn(async (_name, _args, options) => { + sentMetadata = options.metadata; + return { + settle: vi.fn(async () => ({ + outcome: { status: "completed", result: successfulResult }, + })), + }; + }), + ); + + const invocation = await client.callTool(taskTool, {}); + + expect(sentMetadata?.progressToken).toBeUndefined(); + expect(invocation.metadata?.progressToken).toBeUndefined(); + }); + + it("reuses a caller progress token across concurrent raw calls", async () => { + const client = makeBoundaryClient(); + let releaseSettle: (() => void) | undefined; + const gate = new Promise((resolve) => { + releaseSettle = resolve; + }); + const callTool = vi.fn(async () => ({ + settle: vi.fn(async (options: TaskExecutionSettleOptions) => { + options.onEvent({ + type: "task", + task: { + taskId: "shared-token", + status: "working", + lastUpdatedAt: "2026-01-02T03:04:05.000Z", + }, + }); + await gate; + return { + outcome: { status: "completed", result: successfulResult }, + }; + }), + })); + attachTaskBoundary(client, callTool); + const progresses: unknown[] = []; + client.addEventListener("progressNotification", (event) => { + progresses.push(event.detail); + }); + + const sharedToken = "caller-token"; + const first = client.callTool( + taskTool, + {}, + { + progressToken: sharedToken, + }, + ); + const second = client.callTool( + taskTool, + {}, + { + progressToken: sharedToken, + }, + ); + await vi.waitFor(() => expect(callTool).toHaveBeenCalledTimes(2)); + taskInternals(client).dispatchTaskProgress({ + method: "notifications/progress", + params: { progressToken: sharedToken, progress: 1 }, + }); + expect(progresses).toHaveLength(1); + + releaseSettle?.(); + const [firstDone, secondDone] = await Promise.all([first, second]); + expect(firstDone.metadata?.progressToken).toBe(sharedToken); + expect(secondDone.metadata?.progressToken).toBe(sharedToken); + + taskInternals(client).dispatchTaskProgress({ + method: "notifications/progress", + params: { progressToken: sharedToken, progress: 2 }, + }); + expect(progresses).toHaveLength(1); + }); + it("projects a task-scoped failure onto the public task event", async () => { const client = makeClient(); attachTaskBoundary( @@ -662,6 +850,46 @@ describe("InspectorClient raw-wire channel (#1631)", () => { await expect(client.callTool(taskTool, {})).rejects.toBe(cause); }); + it("preserves ext-tasks protocol error code and data", async () => { + const client = makeClient(); + const errorData = { reason: "task rejected" }; + attachTaskBoundary( + client, + vi.fn(async () => ({ + settle: vi.fn(async (options: TaskExecutionSettleOptions) => { + options.onEvent({ + type: "task", + task: { + taskId: "task-protocol-error", + status: "working", + createdAt: "2026-01-02T03:04:05.000Z", + lastUpdatedAt: "2026-01-02T03:04:05.000Z", + }, + }); + throw new JsonRpcResponseError({ + code: -32099, + message: "Task protocol failure", + data: errorData, + }); + }), + })), + ); + const taskErrors: ProtocolError[] = []; + client.addEventListener("requestorTaskUpdated", (event) => { + if (event.detail.error) taskErrors.push(event.detail.error); + }); + + const rejection = client.callTool(taskTool, {}); + + await expect(rejection).rejects.toMatchObject({ + code: -32099, + data: errorData, + }); + expect(taskErrors.at(-1)).toMatchObject({ + code: -32099, + data: errorData, + }); + }); it("validates output from the public streaming task path", async () => { const client = makeClient(); const invalidResult: CallToolResult = { diff --git a/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts b/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts index 764c6baf2f..fe2e0bb466 100644 --- a/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClientUrlElicitation.test.ts @@ -4,6 +4,7 @@ import { ProtocolError, UrlElicitationRequiredError, } from "@modelcontextprotocol/client"; +import { JsonRpcResponseError } from "@modelcontextprotocol/ext-tasks/client"; import type { ElicitRequestURLParams, Tool, @@ -80,7 +81,20 @@ describe("InspectorClient URL-elicitation error path", () => { request: vi.fn(async () => { attempt += 1; if (attempt === 1) { - throw new UrlElicitationRequiredError([elicitation]); + throw new JsonRpcResponseError({ + code: ProtocolErrorCode.UrlElicitationRequired, + message: "Authorization required", + data: { + elicitations: [ + { + mode: elicitation.mode, + elicitationId: elicitation.elicitationId, + url: elicitation.url, + message: elicitation.message, + }, + ], + }, + }); } return okResult; }), diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 1948e456c8..b6e6683c8f 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -1,5 +1,6 @@ import { Client, + DEFAULT_REQUEST_TIMEOUT_MSEC, isInputRequiredResult, withInputRequired, } from "@modelcontextprotocol/client"; @@ -8,8 +9,10 @@ import { createTaskSessionEndpointId, createTaskSessionFromClient, DispatchError, + JsonRpcResponseError, resultFromTaskOutcome, taskViewFromExecutionEvent, + TaskFailedError, toolDeclarationFromMcpTool, withRelatedTaskMetadata, } from "@modelcontextprotocol/ext-tasks/client"; @@ -301,11 +304,17 @@ function jsonObject(value: unknown): Readonly> { return object; } -/** Restore host/transport error identity after ext-tasks applies dispatch policy. */ +/** Restore host/transport and protocol error identity after ext-tasks policy. */ function unwrapTaskDispatchError(error: unknown): unknown { - return error instanceof DispatchError && error.cause instanceof Error - ? error.cause - : error; + const unwrapped = + error instanceof DispatchError && error.cause instanceof Error + ? error.cause + : error; + if (unwrapped instanceof JsonRpcResponseError) + return new ProtocolError(unwrapped.code, unwrapped.message, unwrapped.data); + if (unwrapped instanceof TaskFailedError && unwrapped.code !== undefined) + return new ProtocolError(unwrapped.code, unwrapped.message, unwrapped.data); + return unwrapped; } /** @@ -665,6 +674,8 @@ export class InspectorClient extends InspectorClientEventTarget { private modernNeverAcknowledged = false; /** Correlates task-call progress tokens to task ids after the first snapshot. */ private readonly taskProgressIds = new Map(); + /** Active raw tools/call owners per progress token, before task correlation. */ + private readonly rawCallProgressTokens = new Map(); // Abort controller for the in-flight ordinary (non-task) tool call. Aborting // it hands the SDK the MCP cancellation flow for that request and rejects the // pending call, which `callTool` surfaces as a `ToolCallCancelledError`. Which @@ -1064,12 +1075,13 @@ export class InspectorClient extends InspectorClientEventTarget { const progressToken = params.progressToken; if (progressToken === undefined) return; const taskId = this.taskProgressIds.get(progressToken); - if (!taskId) return; + if (!taskId && !this.rawCallProgressTokens.has(progressToken)) return; if (this.progress) this.dispatchTypedEvent("progressNotification", params); - this.dispatchTypedEvent("requestorTaskProgress", { - taskId, - progress: params, - }); + if (taskId) + this.dispatchTypedEvent("requestorTaskProgress", { + taskId, + progress: params, + }); } private attachTransportListeners(baseTransport: Transport): void { @@ -1496,6 +1508,7 @@ export class InspectorClient extends InspectorClientEventTarget { this.outboundRequestMethods.clear(); this.lastAnsweredRequestByMethod.clear(); this.taskProgressIds.clear(); + this.rawCallProgressTokens.clear(); // Per-session for the same reason: both name entries of the PREVIOUS // server's list. Cleared here as well as in `disconnect()` because the // route out that tears down nothing (`onerror` with no `onclose`) would @@ -2338,7 +2351,11 @@ export class InspectorClient extends InspectorClientEventTarget { method: record.method, ...(params === undefined ? {} : { params }), }; - const timeoutMs = timeoutOverride ?? this.requestTimeout ?? 30_000; + const timeoutMs = + timeoutOverride ?? + options.context?.requestTimeoutMs ?? + this.requestTimeout ?? + DEFAULT_REQUEST_TIMEOUT_MSEC; return await new Promise((resolve, reject) => { let onAbort: (() => void) | undefined; @@ -3901,6 +3918,12 @@ export class InspectorClient extends InspectorClientEventTarget { if (!session) throw new Error("Client is not connected"); const headers = this.mirroredTaskParamHeaders(tool, args); let lastTask: InspectorTask | undefined; + if (progressToken !== undefined) { + this.rawCallProgressTokens.set( + progressToken, + (this.rawCallProgressTokens.get(progressToken) ?? 0) + 1, + ); + } try { const execution = await session.callTool( tool.name, @@ -3938,6 +3961,15 @@ export class InspectorClient extends InspectorClientEventTarget { this.emitTaskError(lastTask, operationError); } throw operationError; + } finally { + if (progressToken !== undefined) { + const owners = this.rawCallProgressTokens.get(progressToken) ?? 0; + if (owners <= 1) this.rawCallProgressTokens.delete(progressToken); + else this.rawCallProgressTokens.set(progressToken, owners - 1); + if (this.taskProgressIds.get(progressToken) === lastTask?.taskId) { + this.taskProgressIds.delete(progressToken); + } + } } } @@ -3966,6 +3998,7 @@ export class InspectorClient extends InspectorClientEventTarget { ? { ...(generalMetadata || {}), ...(toolSpecificMetadata || {}) } : undefined; const metadata = this.mergeMeta(callMetadata); + let invocationMetadata = metadata; const timestamp = new Date(); let result: CallToolResult; @@ -3991,15 +4024,23 @@ export class InspectorClient extends InspectorClientEventTarget { { method: "tools/call", toolName: tool.name }, ); } else { + const progressToken = this.progress + ? (this.progressTokenOf(metadata) ?? crypto.randomUUID()) + : undefined; + invocationMetadata = + progressToken === undefined + ? metadata + : { ...(metadata ?? {}), progressToken }; result = await this.withDirectAuthRecovery( () => this.callTaskToolAndSettle( tool, convertedArgs, - metadata, + invocationMetadata, taskOptions === undefined ? "allow" : "prefer", taskOptions?.ttl, signal, + progressToken, ), { method: "tools/call", toolName: tool.name }, ); @@ -4018,7 +4059,7 @@ export class InspectorClient extends InspectorClientEventTarget { result, timestamp, success: true, - metadata, + metadata: invocationMetadata, outputValidationError, }; this.dispatchTypedEvent("toolCallResultChange", invocation); @@ -4280,11 +4321,12 @@ export class InspectorClient extends InspectorClientEventTarget { } private toProtocolError(reason: unknown): ProtocolError { - return reason instanceof ProtocolError - ? reason + const normalized = unwrapTaskDispatchError(reason); + return normalized instanceof ProtocolError + ? normalized : new ProtocolError( ProtocolErrorCode.InternalError, - reason instanceof Error ? reason.message : String(reason), + normalized instanceof Error ? normalized.message : String(normalized), ); } @@ -4377,9 +4419,6 @@ export class InspectorClient extends InspectorClientEventTarget { ); } throw operationError; - } finally { - if (progressToken !== undefined) - this.taskProgressIds.delete(progressToken); } } From 3a9cbba4c99753875b5156bbb5ed5b00d3b39650 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 10 Sep 2026 14:24:57 -0700 Subject: [PATCH 05/10] fix: address Copilot comments --- .../core/mcp/inspectorClient-raw-wire.test.ts | 63 +++++++++++++++++++ core/mcp/inspectorClient.ts | 41 +++++++++++- 2 files changed, 101 insertions(+), 3 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts index 420dec04ed..d80dc22a5a 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -40,6 +40,7 @@ describe("InspectorClient raw-wire channel (#1631)", () => { ) => Promise; } | null; requestTimeout?: number; + resetTimeoutOnProgress?: boolean; dispatchTaskRequest: ( request: unknown, options?: { @@ -250,6 +251,68 @@ describe("InspectorClient raw-wire channel (#1631)", () => { } }); + it("re-arms the raw request timeout on progress when resetTimeoutOnProgress is enabled", async () => { + vi.useFakeTimers(); + try { + const client = makeClient(); + internals(client).requestTimeout = 10; + internals(client).transport = { + send: vi.fn().mockResolvedValue(undefined), + }; + const promise = internals(client).dispatchTaskRequest({ + method: "tools/call", + params: { name: "x", _meta: { progressToken: "raw-progress" } }, + }); + let settled = false; + void promise.catch(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(8); + taskInternals(client).dispatchTaskProgress({ + method: "notifications/progress", + params: { progressToken: "raw-progress", progress: 1 }, + }); + // 16ms elapsed exceeds the 10ms budget, but progress at 8ms re-armed it. + await vi.advanceTimersByTimeAsync(8); + expect(settled).toBe(false); + const assertion = expect(promise).rejects.toThrow( + /timed out after 10 ms/, + ); + await vi.advanceTimersByTimeAsync(10); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + it("does not re-arm the raw request timeout when resetTimeoutOnProgress is disabled", async () => { + vi.useFakeTimers(); + try { + const client = makeClient(); + internals(client).requestTimeout = 10; + internals(client).resetTimeoutOnProgress = false; + internals(client).transport = { + send: vi.fn().mockResolvedValue(undefined), + }; + const promise = internals(client).dispatchTaskRequest({ + method: "tools/call", + params: { name: "x", _meta: { progressToken: "raw-progress" } }, + }); + const assertion = expect(promise).rejects.toThrow( + /timed out after 10 ms/, + ); + await vi.advanceTimersByTimeAsync(8); + taskInternals(client).dispatchTaskProgress({ + method: "notifications/progress", + params: { progressToken: "raw-progress", progress: 1 }, + }); + await vi.advanceTimersByTimeAsync(4); + await assertion; + } finally { + vi.useRealTimers(); + } + }); + it("uses the SDK 60-second default when no timeout is configured", async () => { vi.useFakeTimers(); try { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index b6e6683c8f..9c7e9b482e 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -515,6 +515,10 @@ export class InspectorClient extends InspectorClientEventTarget { resolve: (response: JsonRpcResponse) => void; reject: (error: Error) => void; cleanup: () => void; + // Present when the request carries a progress token and + // resetTimeoutOnProgress is enabled: re-arms the request's timeout. + progressToken?: ProgressToken; + resetTimeout?: () => void; } >(); private rawWireRequestCounter = 0; @@ -1075,6 +1079,12 @@ export class InspectorClient extends InspectorClientEventTarget { const progressToken = params.progressToken; if (progressToken === undefined) return; const taskId = this.taskProgressIds.get(progressToken); + // Re-arm any pending raw request's timeout on progress because a + // long-running immediate modern call that keeps reporting progress must + // not time out (same contract as the SDK path's resetTimeoutOnProgress). + for (const pending of this.pendingRawWireRequests.values()) { + if (pending.progressToken === progressToken) pending.resetTimeout?.(); + } if (!taskId && !this.rawCallProgressTokens.has(progressToken)) return; if (this.progress) this.dispatchTypedEvent("progressNotification", params); if (taskId) @@ -2357,6 +2367,17 @@ export class InspectorClient extends InspectorClientEventTarget { this.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + // Extract the request's progress token (if any) because + // notifications/progress uses it to re-arm this request's timeout, + // matching the SDK path's resetTimeoutOnProgress. + const meta = (params as Readonly> | undefined)?.[ + "_meta" + ]; + const progressToken = + meta !== null && typeof meta === "object" && !Array.isArray(meta) + ? (meta as { progressToken?: ProgressToken }).progressToken + : undefined; + return await new Promise((resolve, reject) => { let onAbort: (() => void) | undefined; const cleanup = () => { @@ -2364,16 +2385,30 @@ export class InspectorClient extends InspectorClientEventTarget { if (signal && onAbort) signal.removeEventListener("abort", onAbort); this.pendingRawWireRequests.delete(id); }; - const timer = setTimeout(() => { + const onTimeout = () => { cleanup(); reject( new DispatchError( `Raw MCP request "${message.method}" timed out after ${timeoutMs} ms`, ), ); - }, timeoutMs); + }; + let timer = setTimeout(onTimeout, timeoutMs); + const resetTimeout = + progressToken !== undefined && this.resetTimeoutOnProgress + ? () => { + clearTimeout(timer); + timer = setTimeout(onTimeout, timeoutMs); + } + : undefined; - this.pendingRawWireRequests.set(id, { resolve, reject, cleanup }); + this.pendingRawWireRequests.set(id, { + resolve, + reject, + cleanup, + progressToken, + resetTimeout, + }); if (signal) { onAbort = () => { const pending = this.pendingRawWireRequests.get(id); From 790b42108afaad0ad76c87177f230aed133f0df2 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 10 Sep 2026 16:46:18 -0700 Subject: [PATCH 06/10] fix: address Copilot comments --- .../mcp/inspectorClient-list-cursor.test.ts | 35 ++++++++++--- .../core/mcp/inspectorClient-raw-wire.test.ts | 50 +++++++++++++++++++ core/mcp/inspectorClient.ts | 12 +++++ 3 files changed, 91 insertions(+), 6 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-list-cursor.test.ts b/clients/web/src/test/core/mcp/inspectorClient-list-cursor.test.ts index 345f60b7c3..17f4540564 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-list-cursor.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-list-cursor.test.ts @@ -18,6 +18,12 @@ import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js"; * pins is that `""` survives and that a genuinely absent cursor still sends no * `cursor` key at all. The SDK client is stubbed rather than connected — the * decision under test is made entirely in `InspectorClient`. + * + * `listRequestorTasks` is asserted against the ext-tasks session instead of + * the SDK client because this branch routes it through + * `runTaskSessionOperation`; the session owns the wire params, and its own + * suite pins the verbatim-cursor behavior there. The Inspector-side property + * is that the cursor reaches `session.listTasks` unchanged. */ describe("InspectorClient list cursor handling (#2220)", () => { /** @@ -106,12 +112,6 @@ describe("InspectorClient list cursor handling (#2220)", () => { result: { resourceTemplates: [] }, call: (client, cursor) => client.listResourceTemplates(cursor), }, - { - name: "listRequestorTasks", - method: "tasks/list", - result: { tasks: [] }, - call: (client, cursor) => client.listRequestorTasks(cursor), - }, ]; it.each(ADAPTERS)( @@ -154,4 +154,27 @@ describe("InspectorClient list cursor handling (#2220)", () => { expect(sent.params.cursor).toBe("page-2"); }, ); + + it.each([[""], [undefined], ["page-2"]])( + "listRequestorTasks forwards cursor %j to the ext-tasks session unchanged", + async (cursor) => { + const client = makeClient(); + const listTasks = vi.fn(async (received?: string) => { + // The session owns the wire params; the Inspector-side property is + // that the cursor arrives here verbatim. + void received; + return { tasks: [] }; + }); + ( + client as unknown as { + taskSession: { listTasks: typeof listTasks } | null; + } + ).taskSession = { listTasks }; + + await client.listRequestorTasks(cursor); + + expect(listTasks).toHaveBeenCalledTimes(1); + expect(listTasks.mock.calls[0][0]).toBe(cursor); + }, + ); }); diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts index d80dc22a5a..f090bcb3b4 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -73,6 +73,12 @@ describe("InspectorClient raw-wire channel (#1631)", () => { interface TaskBoundaryInternals { client: object | null; + status: string; + disconnecting: boolean; + protocolVersion?: string; + transportConfig: { type: string; command?: string; args?: string[] }; + attachTaskSession: () => Promise; + closeTaskSession: () => Promise; protocolEra?: "legacy" | "modern"; taskSession: { callTool: ( @@ -313,6 +319,50 @@ describe("InspectorClient raw-wire channel (#1631)", () => { } }); + it("does not install a task session when a disconnect overtakes attachTaskSession", async () => { + const client = makeClient(); + const boundary = taskInternals(client); + // Minimal SDK-client surface createTaskSessionFromClient reads on install. + const sdkClient = { + getServerCapabilities: () => ({}), + getProtocolEra: () => "modern" as const, + }; + boundary.client = sdkClient; + boundary.status = "connected"; + boundary.protocolVersion = "2026-07-28"; + + // No SDK client at all: attach is a no-op. + boundary.client = null; + await boundary.attachTaskSession(); + expect(boundary.taskSession).toBeNull(); + boundary.client = sdkClient; + + // A disconnect that claimed teardown ownership while attach was suspended. + boundary.disconnecting = true; + await boundary.attachTaskSession(); + expect(boundary.taskSession).toBeNull(); + + // A disconnect that already settled the status. + boundary.disconnecting = false; + boundary.status = "disconnected"; + await boundary.attachTaskSession(); + expect(boundary.taskSession).toBeNull(); + + // A reconnect that replaced the SDK client while attach was suspended on + // its first await (the endpoint-id derivation). + boundary.status = "connected"; + const staleAttach = boundary.attachTaskSession(); + boundary.client = {}; + await staleAttach; + expect(boundary.taskSession).toBeNull(); + + // The same attach with no overtaking teardown installs the session. + boundary.client = sdkClient; + await boundary.attachTaskSession(); + expect(boundary.taskSession).not.toBeNull(); + await boundary.closeTaskSession(); + }); + it("uses the SDK 60-second default when no timeout is configured", async () => { vi.useFakeTimers(); try { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 9c7e9b482e..0a9b6e805e 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4234,6 +4234,18 @@ export class InspectorClient extends InspectorClientEventTarget { }, ); await this.closeTaskSession(); + // Re-check session ownership because disconnect() (or a transport crash) + // can overtake either await above: it closes the current task session + // while this method is suspended, and installing a new session on the + // torn-down client would leave extension-owned callbacks and state alive + // until a later reconnect/disconnect. `disconnecting` covers a teardown + // that claimed ownership but has not yet settled the status. + if ( + this.client !== client || + this.disconnecting || + this.status !== "connected" + ) + return; this.taskSession = createTaskSessionFromClient(client, { endpointId, rawDispatch: this.dispatchTaskRequest, From 7ae45f6394f7803e50edd26efbf1b892572d8c21 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Thu, 10 Sep 2026 17:40:23 -0700 Subject: [PATCH 07/10] fix: address Copilot comments --- .../core/mcp/inspectorClient-raw-wire.test.ts | 20 +++++++++++++ core/mcp/inspectorClient.ts | 30 ++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts index f090bcb3b4..e1e7287a4b 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -363,6 +363,26 @@ describe("InspectorClient raw-wire channel (#1631)", () => { await boundary.closeTaskSession(); }); + it("projects configured roots to the typed ext-tasks handler shape", () => { + const client = makeClient(); + const boundary = client as unknown as { + roots?: readonly Record[]; + applicationRoots: () => readonly Record[]; + }; + // No roots configured: an empty list, not undefined. + expect(boundary.applicationRoots()).toEqual([]); + boundary.roots = [ + { uri: "file:///bare" }, + { uri: "file:///named", name: "Named" }, + { uri: "file:///meta", _meta: { vendor: true } }, + ]; + expect(boundary.applicationRoots()).toEqual([ + { uri: "file:///bare" }, + { uri: "file:///named", name: "Named" }, + { uri: "file:///meta", _meta: { vendor: true } }, + ]); + }); + it("uses the SDK 60-second default when no timeout is configured", async () => { vi.useFakeTimers(); try { diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 0a9b6e805e..6fc2d8b9ca 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -17,6 +17,8 @@ import { withRelatedTaskMetadata, } from "@modelcontextprotocol/ext-tasks/client"; import type { + ApplicationElicitContentValue, + ApplicationRoot, DispatchOptions, JsonRpcResponse, RawClientDispatch, @@ -4275,7 +4277,14 @@ export class InspectorClient extends InspectorClientEventTarget { action: result.action, ...(result.content === undefined ? {} - : { content: jsonObject(result.content) }), + : { + // Narrowing cast (subset of JsonValue): the elicitation UI + // produces schema-constrained scalars/string arrays, and + // the ext-tasks wire schema re-validates on send. + content: jsonObject(result.content) as Readonly< + Record + >, + }), }; }, sampling: async (request, context) => { @@ -4294,15 +4303,28 @@ export class InspectorClient extends InspectorClientEventTarget { content: toJsonValue(result.content), }; }, - roots: async () => ({ - roots: this.roots?.map((root) => jsonObject(root)) ?? [], - }), + roots: async () => ({ roots: this.applicationRoots() }), }), onError: (error) => this.logger.error({ error }, "ext-tasks background error"), }); } + /** + * Project configured roots to the ext-tasks handler shape. Mapped + * field-by-field because ApplicationRoot requires a typed string uri, + * which a JSON-record projection would erase. + */ + private applicationRoots(): readonly ApplicationRoot[] { + return ( + this.roots?.map((root) => ({ + uri: root.uri, + ...(root.name === undefined ? {} : { name: root.name }), + ...(root._meta === undefined ? {} : { _meta: jsonObject(root._meta) }), + })) ?? [] + ); + } + /** Release extension-owned state without ever leaving the SDK adapter installed. */ private async closeTaskSession(): Promise { const session = this.taskSession; From bad03fca1fdc4ed5db1ed840743a6fc5e0683d2a Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 11 Sep 2026 11:39:50 -0700 Subject: [PATCH 08/10] fix: address Copilot comments --- .../core/mcp/inspectorClient-raw-wire.test.ts | 151 ++++++++++++++++++ core/mcp/inspectorClient.ts | 90 +++++++---- scripts/sdk-watch.mjs | 6 + scripts/sdk-watch.test.mjs | 25 +++ 4 files changed, 245 insertions(+), 27 deletions(-) diff --git a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts index e1e7287a4b..1426b8f7df 100644 --- a/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts +++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts @@ -86,6 +86,17 @@ describe("InspectorClient raw-wire channel (#1631)", () => { args: Readonly>, options: TaskSessionCallOptions, ) => Promise<{ + kind?: string; + serializeReference?: () => Record; + settle: ( + options: TaskExecutionSettleOptions, + ) => Promise<{ outcome: unknown; lastTask?: unknown }>; + }>; + resumeTask?: ( + reference: Record, + options: Record, + ) => Promise<{ + kind?: string; settle: ( options: TaskExecutionSettleOptions, ) => Promise<{ outcome: unknown; lastTask?: unknown }>; @@ -834,6 +845,146 @@ describe("InspectorClient raw-wire channel (#1631)", () => { expect(progresses).toHaveLength(1); }); + it("routes shared-token progress to every correlated task, not only the latest", async () => { + const client = makeBoundaryClient(); + let releaseSettle: (() => void) | undefined; + const gate = new Promise((resolve) => { + releaseSettle = resolve; + }); + // Each concurrent call correlates the shared token to its OWN task id. + let taskCounter = 0; + const callTool = vi.fn(async () => { + taskCounter += 1; + const taskId = `shared-token-task-${taskCounter}`; + return { + settle: vi.fn(async (options: TaskExecutionSettleOptions) => { + options.onEvent({ + type: "task", + task: { + taskId, + status: "working", + lastUpdatedAt: "2026-01-02T03:04:05.000Z", + }, + }); + await gate; + return { + outcome: { status: "completed", result: successfulResult }, + }; + }), + }; + }); + attachTaskBoundary(client, callTool); + const taskProgress: { taskId: string }[] = []; + client.addEventListener("requestorTaskProgress", (event) => { + taskProgress.push({ taskId: event.detail.taskId }); + }); + + const sharedToken = "caller-token"; + const first = client.callTool(taskTool, {}, { progressToken: sharedToken }); + const second = client.callTool( + taskTool, + {}, + { progressToken: sharedToken }, + ); + await vi.waitFor(() => expect(callTool).toHaveBeenCalledTimes(2)); + taskInternals(client).dispatchTaskProgress({ + method: "notifications/progress", + params: { progressToken: sharedToken, progress: 1 }, + }); + // The wire cannot say which owner the progress belongs to, so both + // correlated tasks receive it — the earlier one is not overwritten. + expect(taskProgress.map((p) => p.taskId).sort()).toEqual([ + "shared-token-task-1", + "shared-token-task-2", + ]); + + releaseSettle?.(); + await Promise.all([first, second]); + // Both calls settled, so the correlation set is fully released. + taskProgress.length = 0; + taskInternals(client).dispatchTaskProgress({ + method: "notifications/progress", + params: { progressToken: sharedToken, progress: 2 }, + }); + expect(taskProgress).toHaveLength(0); + }); + + it("resumes the created task instead of re-calling the tool on a recovery rerun", async () => { + const client = makeBoundaryClient(); + const reference = { + endpointId: "e", + generation: "v2", + taskId: "recovered-task", + originalOperation: "tools/call", + }; + const callTool = vi.fn(async () => ({ + kind: "task", + serializeReference: () => reference, + settle: vi.fn(async () => ({ + outcome: { status: "completed", result: successfulResult }, + })), + })); + const resumeTask = vi.fn(async (reference: Record) => ({ + kind: "task", + // A resumed execution re-serializes to the same task identity. + serializeReference: () => reference, + settle: vi.fn(async () => ({ + outcome: { status: "completed", result: successfulResult }, + })), + })); + const boundary = taskInternals(client); + boundary.client = {}; + boundary.protocolEra = "modern"; + boundary.taskSession = { callTool, resumeTask }; + const invoke = ( + client as unknown as { + callTaskToolAndSettle: ( + tool: Tool, + args: Record, + metadata: undefined, + preference: "prefer", + retentionMs: undefined, + signal: undefined, + progressToken: undefined, + recovery?: { reference?: Record }, + ) => Promise; + } + ).callTaskToolAndSettle.bind(client); + + // First attempt: no reference yet, so the tool is called and the box is + // populated the moment the task exists. + const recovery: { reference?: Record } = {}; + await invoke( + taskTool, + {}, + undefined, + "prefer", + undefined, + undefined, + undefined, + recovery, + ); + expect(callTool).toHaveBeenCalledTimes(1); + expect(resumeTask).not.toHaveBeenCalled(); + expect(recovery.reference).toBe(reference); + + // Recovery rerun: the populated box routes through resumeTask, so no + // second remote task is created. + await invoke( + taskTool, + {}, + undefined, + "prefer", + undefined, + undefined, + undefined, + recovery, + ); + expect(callTool).toHaveBeenCalledTimes(1); + expect(resumeTask).toHaveBeenCalledTimes(1); + expect(resumeTask.mock.calls[0]?.[0]).toBe(reference); + }); + it("projects a task-scoped failure onto the public task event", async () => { const client = makeClient(); attachTaskBoundary( diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 6fc2d8b9ca..d38fd2c26a 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -22,6 +22,7 @@ import type { DispatchOptions, JsonRpcResponse, RawClientDispatch, + SerializedTaskReference, TaskCapabilities, TaskEnabledSession, TaskExecutionEvent, @@ -678,8 +679,13 @@ export class InspectorClient extends InspectorClientEventTarget { // site to the two failure handlers. Cleared by any user-initiated refresh (a // subscribe/unsubscribe is a fresh attempt, and the server may have changed). private modernNeverAcknowledged = false; - /** Correlates task-call progress tokens to task ids after the first snapshot. */ - private readonly taskProgressIds = new Map(); + /** + * Correlates task-call progress tokens to task ids after the first snapshot. + * A Set per token because concurrent calls may reuse a caller-supplied + * token; collapsing them to one task id would cross-wire + * `requestorTaskProgress` between the calls. + */ + private readonly taskProgressIds = new Map>(); /** Active raw tools/call owners per progress token, before task correlation. */ private readonly rawCallProgressTokens = new Map(); // Abort controller for the in-flight ordinary (non-task) tool call. Aborting @@ -1080,16 +1086,19 @@ export class InspectorClient extends InspectorClientEventTarget { }; const progressToken = params.progressToken; if (progressToken === undefined) return; - const taskId = this.taskProgressIds.get(progressToken); + const taskIds = this.taskProgressIds.get(progressToken); // Re-arm any pending raw request's timeout on progress because a // long-running immediate modern call that keeps reporting progress must // not time out (same contract as the SDK path's resetTimeoutOnProgress). for (const pending of this.pendingRawWireRequests.values()) { if (pending.progressToken === progressToken) pending.resetTimeout?.(); } - if (!taskId && !this.rawCallProgressTokens.has(progressToken)) return; + if (!taskIds?.size && !this.rawCallProgressTokens.has(progressToken)) + return; if (this.progress) this.dispatchTypedEvent("progressNotification", params); - if (taskId) + // The wire cannot say which owner a shared token's progress belongs to, so + // every correlated task receives it rather than only the most recent one. + for (const taskId of taskIds ?? []) this.dispatchTypedEvent("requestorTaskProgress", { taskId, progress: params, @@ -3950,6 +3959,7 @@ export class InspectorClient extends InspectorClientEventTarget { retentionMs: number | undefined, signal?: AbortSignal, progressToken?: ProgressToken, + recovery?: { reference?: SerializedTaskReference }, ): Promise { const session = this.taskSession; if (!session) throw new Error("Client is not connected"); @@ -3962,25 +3972,37 @@ export class InspectorClient extends InspectorClientEventTarget { ); } try { - const execution = await session.callTool( - tool.name, - toJsonValue(args) as Readonly>, - { - resultCodec: taskToolResultCodec, - declaration: toolDeclarationFromMcpTool(tool), - signal, - requestTimeoutMs: this.requestTimeout, - task: { preference, retentionMs }, - ...(metadata === undefined - ? {} - : { - metadata: toJsonValue(metadata) as Readonly< - Record - >, - }), - ...(headers === undefined ? {} : { headers }), - }, - ); + // On an auth-recovery rerun, resume the task the first attempt created + // (its reference is captured below), because repeating tools/call would + // start a duplicate task on the server. + const execution = recovery?.reference + ? await session.resumeTask(recovery.reference, { + resultCodec: taskToolResultCodec, + declaration: toolDeclarationFromMcpTool(tool), + signal, + }) + : await session.callTool( + tool.name, + toJsonValue(args) as Readonly>, + { + resultCodec: taskToolResultCodec, + declaration: toolDeclarationFromMcpTool(tool), + signal, + requestTimeoutMs: this.requestTimeout, + task: { preference, retentionMs }, + ...(metadata === undefined + ? {} + : { + metadata: toJsonValue(metadata) as Readonly< + Record + >, + }), + ...(headers === undefined ? {} : { headers }), + }, + ); + if (recovery !== undefined && execution.kind === "task") { + recovery.reference = execution.serializeReference(); + } const settlement = await execution.settle({ signal, onEvent: (event) => { @@ -4003,8 +4025,12 @@ export class InspectorClient extends InspectorClientEventTarget { const owners = this.rawCallProgressTokens.get(progressToken) ?? 0; if (owners <= 1) this.rawCallProgressTokens.delete(progressToken); else this.rawCallProgressTokens.set(progressToken, owners - 1); - if (this.taskProgressIds.get(progressToken) === lastTask?.taskId) { - this.taskProgressIds.delete(progressToken); + // Release only this call's own correlation; a concurrent call sharing + // the token keeps its entry in the set. + if (lastTask !== undefined) { + const taskIds = this.taskProgressIds.get(progressToken); + taskIds?.delete(lastTask.taskId); + if (taskIds?.size === 0) this.taskProgressIds.delete(progressToken); } } } @@ -4068,6 +4094,9 @@ export class InspectorClient extends InspectorClientEventTarget { progressToken === undefined ? metadata : { ...(metadata ?? {}), progressToken }; + // Shared across recovery reruns because a rerun must resume the task the + // first attempt already created rather than start a duplicate. + const recovery: { reference?: SerializedTaskReference } = {}; result = await this.withDirectAuthRecovery( () => this.callTaskToolAndSettle( @@ -4078,6 +4107,7 @@ export class InspectorClient extends InspectorClientEventTarget { taskOptions?.ttl, signal, progressToken, + recovery, ), { method: "tools/call", toolName: tool.name }, ); @@ -4365,7 +4395,9 @@ export class InspectorClient extends InspectorClientEventTarget { if (view === undefined) return undefined; const task = this.toInspectorTask(view); if (progressToken !== undefined) { - this.taskProgressIds.set(progressToken, task.taskId); + const taskIds = this.taskProgressIds.get(progressToken) ?? new Set(); + taskIds.add(task.taskId); + this.taskProgressIds.set(progressToken, taskIds); } const outcomeDetail = event.type !== "outcome" || event.outcome.status === "cancelled" @@ -4443,6 +4475,9 @@ export class InspectorClient extends InspectorClientEventTarget { : { ...(metadata ?? {}), progressToken }; const timestamp = new Date(); try { + // Shared across recovery reruns because a rerun must resume the task the + // first attempt already created rather than start a duplicate. + const recovery: { reference?: SerializedTaskReference } = {}; const result = await this.withDirectAuthRecovery( () => this.callTaskToolAndSettle( @@ -4453,6 +4488,7 @@ export class InspectorClient extends InspectorClientEventTarget { taskOptions?.ttl, undefined, progressToken, + recovery, ), { method: "tools/call", toolName: tool.name }, ); diff --git a/scripts/sdk-watch.mjs b/scripts/sdk-watch.mjs index 5dcd0a368c..c750eda3d1 100644 --- a/scripts/sdk-watch.mjs +++ b/scripts/sdk-watch.mjs @@ -96,6 +96,12 @@ export const SDK_GROUPS = [ repo: "modelcontextprotocol/ext-apps", packages: ["@modelcontextprotocol/ext-apps"], }, + { + key: "ext-tasks", + label: "MCP Tasks extension SDK", + repo: "modelcontextprotocol/ext-tasks", + packages: ["@modelcontextprotocol/ext-tasks"], + }, ]; /** Every package under this prefix is in scope for the watch. */ diff --git a/scripts/sdk-watch.test.mjs b/scripts/sdk-watch.test.mjs index 3fcdb6c9ec..91cc74a707 100644 --- a/scripts/sdk-watch.test.mjs +++ b/scripts/sdk-watch.test.mjs @@ -41,6 +41,7 @@ import { const SDK = SDK_GROUPS[0]; const EXT = SDK_GROUPS[1]; +const TASKS = SDK_GROUPS[2]; /** Every version triple current, so a group is behind only where a test says so. */ function currentVersions(overrides = {}) { @@ -900,6 +901,30 @@ test("main files one issue per upstream when both groups are behind", () => { assert.equal(filed[1].from, "1.7.5", "from is the installed version"); }); +test("main watches ext-tasks as its own upstream group", () => { + // ext-tasks ships from its own repo, so it needs its own group entry + // rather than being folded into a sibling's. + const spawn = fakeSpawn({ latest: latestAt(TASKS, "0.2.0") }); + const output = outputFile(); + writeFileSync(output, ""); + + main("o/r", spawn, { + readFile: fakeReadFile({ + declared: { "@modelcontextprotocol/ext-tasks": "0.1.0" }, + installed: { "@modelcontextprotocol/ext-tasks": "0.1.0" }, + }), + output, + }); + + const filed = readFiled(output); + assert.deepEqual( + filed.map((f) => f.repo), + ["modelcontextprotocol/ext-tasks"], + ); + assert.equal(filed[0].from, "0.1.0"); + assert.equal(filed[0].to, "0.2.0"); +}); + /** An open issue this sweep already filed for `target`. */ function existingIssue(number, target, group = SDK) { return { From 837da6643ade5959ffbdaf179f4fdb387a65fbbe Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Fri, 11 Sep 2026 14:46:29 -0700 Subject: [PATCH 09/10] fix: address Copilot comments --- AGENTS.md | 4 ++-- core/mcp/inspectorClient.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a42c9669d1..40b3ace5e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,7 +129,7 @@ The board write needs `organization projects: write`, which `GITHUB_TOKEN` canno **`.github/workflows/sdk-watch.yml` → `scripts/sdk-watch.mjs` runs nightly (#1063) and files one issue per MCP SDK release we are behind**, labeled `v2` + `chore` + `dependencies`. It is not a Dependabot replacement — it exists because SDK churn, OAuth especially, was being tracked by habit rather than by mechanism — but it obeys the same rule as the two above: **it files an issue, never a PR.** -- **Two upstreams, two issues.** `client`/`core`/`server`/`server-legacy` ship from `modelcontextprotocol/typescript-sdk` in lockstep and share one issue; `ext-apps` ships from its own repo and gets its own. A fifth `@modelcontextprotocol/*` package added to the root manifest and not added to `SDK_GROUPS` **fails the sweep loudly** rather than going unwatched — that guard is the point, since a hardcoded group table is otherwise a silent blind spot. +- **One issue per upstream.** `client`/`core`/`server`/`server-legacy` ship from `modelcontextprotocol/typescript-sdk` in lockstep and share one issue; `ext-apps` and `ext-tasks` each ship from their own repo and get their own. A `@modelcontextprotocol/*` package added to the root manifest and not added to `SDK_GROUPS` **fails the sweep loudly** rather than going unwatched — that guard is the point, since a hardcoded group table is otherwise a silent blind spot. - **It compares the INSTALLED version, not the declared range.** The four SDK packages are pinned exactly, so the two agree for them; `ext-apps` is a caret range whose lockfile already resolves higher, and comparing the declared string would file an issue for a bump `npm install` has already taken. - **The target is the LOWEST `latest` across a group — the version the whole group has reached — not the highest.** npm publishes a lockstep release one package at a time, so a sweep landing mid-publish sees one package ahead of its three siblings. Targeting the highest would name a version three of them do not have _and_ write a marker that suppresses the real filing once the publication completes, so the release would never be tracked at all. Taking the minimum keeps the issue actionable and lets the completed release file its own. - **It never boards, like the monthly sweep** — no `PROJECT_TOKEN` exists in this org — so the issue arrives labeled and milestoned and `/issue-triage` places it. @@ -386,7 +386,7 @@ diagnose a failing gate — is the `testing` skill. These are the rules. - **`clients/web`**: `.test.tsx` **next to the source** — components, hooks, `lib/`, `utils/`. A web-owned test living under `src/test/` instead is a bug. `src/test/` is for the three things that cannot be co-located: tests of the repo-root **`core/`** package (`src/test/core/…`, mirroring the `core/` layout — it lives outside `clients/web/` and has no harness of its own); the **`integration`** project (`src/test/integration/…` — _placement is the manifest_, picked up by a folder glob, with no enumeration to keep in sync); and **shared test infrastructure** (`renderWithMantine.tsx`, `setup.ts`, `fixtures/`). - **`clients/cli`, `clients/tui`, `clients/launcher`**: **all** tests in a top-level **`__tests__/`**, not beside their source. Their `tsconfig.json` excludes `**/*.test.*`, so a co-located test lands in **no** tsconfig project and fails `npm run verify:typecheck-coverage`. - **Root tooling**: a `scripts/*.mjs` helper with pure logic gets a sibling `*.test.mjs`. Keep that exact filename — `node --test` silently _skips_ a file its glob misses and still exits 0. -- **Render Ink components through the TUI's own `render`** (`clients/tui/__tests__/helpers/renderTui.tsx`), never `ink-testing-library`'s directly. It is the same function with every frame ANSI-stripped, which is what keeps an assertion on styled text from depending on the ambient environment: Ink writes styling *inside* the styled run, so `Info` reaches the frame buffer with escapes between `I` and `nfo` and `toContain("Info")` fails. It only bites where chalk emits color — a developer whose shell exports `FORCE_COLOR` — so CI is green on a suite that is broken for them (#2207). A test that genuinely needs the raw bytes reads `stdout.lastFrame()` off the returned instance. +- **Render Ink components through the TUI's own `render`** (`clients/tui/__tests__/helpers/renderTui.tsx`), never `ink-testing-library`'s directly. It is the same function with every frame ANSI-stripped, which is what keeps an assertion on styled text from depending on the ambient environment: Ink writes styling _inside_ the styled run, so `Info` reaches the frame buffer with escapes between `I` and `nfo` and `toContain("Info")` fails. It only bites where chalk emits color — a developer whose shell exports `FORCE_COLOR` — so CI is green on a suite that is broken for them (#2207). A test that genuinely needs the raw bytes reads `stdout.lastFrame()` off the returned instance. - **Render React components through `renderWithMantine`** (`src/test/renderWithMantine.tsx`); do not hand-roll a bare `MantineProvider`, which skips the project theme and the helper's options and drifts from every other test. Pass the `colorScheme` option to exercise a forced scheme rather than hand-rolling `defaultColorScheme`. Use `renderWithMantineTransitions` **only** when a test must assert mid-flight transition state, and read the long comment on the helper before changing anything about it. - **The web coverage `include` is a whitelist.** It names `components`/`hooks`/`theme`/`lib`/`utils`/`server` plus the browser-consumed `core/*` runtime, so a module placed **outside** those directories falls out of the gate entirely, silently. Place new modules inside a gated directory. The documented exceptions — `src/App.tsx` and the `src/main.tsx` / `src/index.ts` bootstraps — are called out in a comment on the `include` array itself. diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index d38fd2c26a..1c8d103be5 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -4011,6 +4011,16 @@ export class InspectorClient extends InspectorClientEventTarget { }, }); if (settlement.outcome.status === "cancelled") { + // Synthesize the terminal "cancelled" update when none was observed, + // because the cancel ack ends the local task lifetime immediately — + // often before the server publishes a cancelled snapshot — and the + // UI must land on the true state without a refresh (#1455). + if (settlement.outcome.task === undefined && lastTask !== undefined) { + const task: InspectorTask = { ...lastTask, status: "cancelled" }; + const detail = { taskId: task.taskId, task }; + this.dispatchTypedEvent("toolCallTaskUpdated", detail); + this.dispatchTypedEvent("requestorTaskUpdated", detail); + } throw new ToolCallCancelledError(tool.name); } return this.unwrapTaskOutcome(settlement.outcome); From c5b40061ab82dbb7ee27820ec3e50336614612d4 Mon Sep 17 00:00:00 2001 From: Luca Chang Date: Sat, 12 Sep 2026 14:03:36 -0700 Subject: [PATCH 10/10] fix: address Copilot concerns --- core/mcp/inspectorClient.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts index 1c8d103be5..aa70613c05 100644 --- a/core/mcp/inspectorClient.ts +++ b/core/mcp/inspectorClient.ts @@ -3965,6 +3965,7 @@ export class InspectorClient extends InspectorClientEventTarget { if (!session) throw new Error("Client is not connected"); const headers = this.mirroredTaskParamHeaders(tool, args); let lastTask: InspectorTask | undefined; + let outcomeEmitted = false; if (progressToken !== undefined) { this.rawCallProgressTokens.set( progressToken, @@ -3989,6 +3990,10 @@ export class InspectorClient extends InspectorClientEventTarget { declaration: toolDeclarationFromMcpTool(tool), signal, requestTimeoutMs: this.requestTimeout, + // Forwarded so legacy calls routed through the SDK adapter keep + // the inactivity-timeout semantics of the old direct + // client.request path (modern raw dispatch re-arms on its own). + resetTimeoutOnProgress: this.resetTimeoutOnProgress, task: { preference, retentionMs }, ...(metadata === undefined ? {} @@ -4006,8 +4011,12 @@ export class InspectorClient extends InspectorClientEventTarget { const settlement = await execution.settle({ signal, onEvent: (event) => { - lastTask = - this.emitTaskExecutionEvent(event, progressToken) ?? lastTask; + const emitted = this.emitTaskExecutionEvent(event, progressToken); + // A dispatched outcome event already carried the terminal error, + // so the catch below must not re-emit the same failure. + if (event.type === "outcome" && emitted !== undefined) + outcomeEmitted = true; + lastTask = emitted ?? lastTask; }, }); if (settlement.outcome.status === "cancelled") { @@ -4026,7 +4035,10 @@ export class InspectorClient extends InspectorClientEventTarget { return this.unwrapTaskOutcome(settlement.outcome); } catch (error) { const operationError = unwrapTaskDispatchError(error); - if (!(operationError instanceof ToolCallCancelledError)) { + if ( + !(operationError instanceof ToolCallCancelledError) && + !outcomeEmitted + ) { this.emitTaskError(lastTask, operationError); } throw operationError;