Tasks: io.modelcontextprotocol/tasks extension on modern servers (era fork) (#1731)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* Tasks: modern io.modelcontextprotocol/tasks extension (era fork core) Core + test-server implementation of the SEP-2663 tasks extension for modern (2026-07-28) connections, forked from the legacy 2025-11-25 tasks path. SDK v2 removed all tasks support AND era-gates the tasks/* spec methods out of the modern era on both the client (outbound) and server (inbound) sides, and its codec rejects a resultType:"task" result outright. So the Inspector drives the extension itself: task creation rides tools/call (a modern method) with the resultType:"task" frame rewritten at the transport to a CallToolResult carrying the handle in _meta; tasks/get/update/cancel go over a raw-wire request channel that bypasses the era gate (full modern envelope, string ids, consumed by the transport). input_required tasks reuse the existing pending-request modal via fulfilInputRequests, answered with tasks/update. No tasks/list, no tasks/result. Tab + run-as-task gate on the negotiated extension; the managed task store's refresh re-polls known handles on modern (no server list). Test server serves the extension via an Express interceptor (SDK can't) + a shared poll-driven runtime. Full integration coverage of both eras. Refs #1631 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Tasks modern era: tests + coverage (raw-wire, schemas, era integration) Adds unit coverage for modernTaskSchemas and the raw-wire request channel (transport-null guard, send failure, timeout, error response, teardown), plus end-to-end era integration tests (create/poll/input-required/update/cancel, synchronous passthrough, unknown-id error, modern store re-poll). Fixes the App and constructor-capabilities tests for the always-on tasks extension. Refs #1631 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Tasks modern era: docs + share test servers per era to de-flake coverage Document the tasks era-fork test configs in README and the new core/test-server files in AGENTS.md. Share one modern + one legacy test server across the era integration block (started lazily, stopped in afterAll) — starting a fresh Express server per test made connection negotiation flaky under the heavy concurrent coverage run. Refs #1631 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Restore pr-screenshots deleted inadvertently The logging/subscriptions PR screenshots + README were staged-deleted in the working tree before this branch; restore them from v2/main so this PR doesn't remove other PRs' artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Tasks modern era: offer Run-as-task for any tool + PR screenshots On modern (SEP-2663) task creation is server-directed — the client declares the extension once and any tool may return a task — so the Tools screen now offers 'Run as task' for every tool on a modern connection (threaded via a modernTasks prop through InspectorView → ToolsScreen → ToolDetailPanel), rather than gating on the legacy per-tool taskSupport. Verified end-to-end in the web UI (remote proxy): create → poll tasks/get → input_required elicitation → tasks/update → inline result, plus the unchanged legacy tasks/list/result flow. Adds proof screenshots. Refs #1631 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Tasks modern era: address two follow-ups (task-input note + ordinary-path auto-poll) 1. The pending-request note now distinguishes a modern task round from an MRTR round: a new 'task-input-required' PendingRequestOrigin threads through fulfilInputRequests → the pending-request modal → MrtrOriginNote, which shows 'submitted via a tasks/update request (SEP-2663), not a retry' instead of the MRTR retry copy. 2. The ordinary (non-run-as-task) modern callTool path now auto-polls an unsolicited task handle: attemptToolCall detects the rewritten handle in the result _meta and drives it to termination (get → input_required/update → inline result), dispatching task events so the Tasks tab + toasts track it and the tool result resolves to the task's final result. The poll loop is shared with the streaming path via extracted helpers (submitModernTaskInput / modernTaskTerminalOutcome / modernTaskPollInterval). Refs #1631 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Tasks modern era: address review (negotiated envelope version, input-round cap, clarifying comment) + fresh smoke screenshots Review of #1731: 1. withModernTaskEnvelope now stamps the NEGOTIATED protocol version (getProtocolVersion() ?? MODERN_PROTOCOL_VERSION) so the envelope matches the MCP-Protocol-Version header instead of a hardcoded constant. 2. A never-completing input_required task is now bounded by MRTR_MAX_ROUNDS in the shared submitModernTaskInput (threaded through both poll loops), so a non-conformant server can't re-prompt the user forever. New modern_loop_task test-server tool + integration test cover the cap. 3. ToolDetailPanel: comment clarifying that per-tool taskSupport:"forbidden" is deliberately ignored on modern (task creation is server-directed under SEP-2663). (The abort-during-poll observation is addressed in the PR reply: the cancel path uses tasks/cancel and disconnect rejects the in-flight raw request, so the poll stops; matches legacy.) Fresh web-UI smoke screenshots (modern connected, task completed, input_required with the corrected tasks/update note, input completed). Refs #1631 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Tasks modern era: fix uncancelable input-required loop + sticky cancel Two bug fixes reported during manual testing of the tasks era-fork (#1631): 1. A modern task paused at `input_required` (e.g. `modern_loop_task`, which never advances) could not be cancelled: the poll loop blocked on the pending-request modal, so `cancelRequestorTask` sent `tasks/cancel` but the modal kept re-prompting. Now each modern poll loop registers a per-task AbortController (`registerTaskInputAbort`), `cancelRequestorTask` aborts it to reject the pending elicitation and unblock the poll, and the embedded input requests are tagged with their owning task id (`RELATED_TASK_META_KEY`) so the pending-request UI's Cancel action cancels the TASK rather than sending a "cancel" answer the server would re-prompt on. 2. On the legacy server, cancelling a task briefly showed CANCELLED then flipped to COMPLETED when the in-flight tool call resolved. Cancellation is a deliberate terminal decision, so `ManagedRequestorTasksState` now remembers cancelled task ids and ignores any later non-cancelled status update. Also: move the compact task id onto its own line in the embedded monitoring sidebar so the status badge / Cancel Task control don't wrap; DRY the duplicated task poll-interval logic into a shared `taskPollInterval` helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Refresh tasks PR screenshots + capture both bug-fix scenarios Recapture the five tasks flow screenshots against the current build and add two demonstrating the post-review fixes: modern_loop_task cancelled from the pending-request modal (uncancelable-loop fix) and a legacy task staying CANCELLED after the in-flight call resolves (sticky-cancel fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Address re-review: sticky cancel on legacy refresh + App cancel-routing test Re-review of the cancelable-loop / sticky-cancel fix raised two non-blocking hardening items: 1. The legacy tasks/list refresh() rebuilt the list from the server, honoring only dismissedTaskIds — so a cooperative-cancel server that re-lists a user-cancelled task as completed could un-stick the cancel on a manual Refresh. The refresh path now pins a cancelledTaskIds task to "cancelled" (keeping it visible-as-cancelled, matching the live-merge handlers) rather than dropping it. Covered by a new state test. 2. Added an App.test.tsx case locking in the user-facing half of the fix: Cancel on a task-input-required elicitation calls cancelRequestorTask (not respond). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
320daf2f67
commit
2259b1a3e1
7 changed files with 2483 additions and 132 deletions
|
|
@ -113,6 +113,24 @@ export class FakeInspectorClient
|
|||
listRequestorTasks = vi.fn(
|
||||
async () => this.taskPages.shift() ?? { tasks: [] },
|
||||
);
|
||||
// Modern task poll (#1631): defaults to echoing back a minimal task; tests
|
||||
// override the mock to drive status transitions. Dispatches nothing by
|
||||
// default — tests that exercise the merge path dispatch requestorTaskUpdated
|
||||
// themselves or override this to do so.
|
||||
getRequestorTask = vi.fn(async (taskId: string) => ({
|
||||
taskId,
|
||||
status: "working" as const,
|
||||
ttl: null,
|
||||
createdAt: "",
|
||||
lastUpdatedAt: "",
|
||||
}));
|
||||
// Whether this fake presents as a modern connection that negotiated the
|
||||
// tasks extension. Tests flip `tasksExtensionNegotiated` to true to exercise
|
||||
// the modern task path.
|
||||
tasksExtensionNegotiated = false;
|
||||
isTasksExtensionNegotiated(): boolean {
|
||||
return this.tasksExtensionNegotiated;
|
||||
}
|
||||
|
||||
// Aggregate variants used by the managed state stores on refresh: drain ALL
|
||||
// queued pages (mimicking the SDK's all-page walk) and return the flattened
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import type {
|
|||
JSONRPCNotification,
|
||||
JSONRPCResultResponse,
|
||||
JSONRPCErrorResponse,
|
||||
JSONRPCMessage,
|
||||
ServerCapabilities,
|
||||
ClientCapabilities,
|
||||
Implementation,
|
||||
|
|
@ -107,7 +108,23 @@ import {
|
|||
isInputRequiredResult,
|
||||
withInputRequired,
|
||||
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 {
|
||||
EmptyResultSchema,
|
||||
CallToolResultSchema,
|
||||
|
|
@ -330,6 +347,9 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// UI surfaces (Server Info modal) can display them without poking at the
|
||||
// SDK Client's private state.
|
||||
private clientCapabilities: ClientCapabilities = {};
|
||||
// The client identity ({name, version}) passed to the SDK Client. Reused to
|
||||
// build the modern per-request envelope for raw tasks/* requests.
|
||||
private clientInfo: Implementation;
|
||||
// Sampling requests
|
||||
private pendingSamples: SamplingCreateMessage[] = [];
|
||||
// Elicitation requests
|
||||
|
|
@ -376,6 +396,25 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// instead, so it lands in the right state immediately (#1455). Cleared on
|
||||
// disconnect.
|
||||
private cancelledTaskIds: Set<string> = 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<string, AbortController>();
|
||||
// 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<typeof setTimeout>;
|
||||
}
|
||||
>();
|
||||
private rawWireRequestCounter = 0;
|
||||
// Abort controller for the in-flight ordinary (non-task) tool call. Aborting
|
||||
// it makes the SDK send a `notifications/cancelled` for that request (the MCP
|
||||
// cancellation flow) and reject the pending call, which `callTool` surfaces as
|
||||
|
|
@ -551,20 +590,30 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
}
|
||||
if (options.oauth?.enterpriseManaged) {
|
||||
capabilities.extensions = {
|
||||
...capabilities.extensions,
|
||||
"io.modelcontextprotocol/enterprise-managed-authorization": {},
|
||||
};
|
||||
}
|
||||
if (Object.keys(capabilities).length > 0) {
|
||||
clientOptions.capabilities = capabilities;
|
||||
}
|
||||
// Advertise the modern Tasks extension (SEP-2663) so the SDK stamps it into
|
||||
// every modern request's `clientCapabilities` envelope — the per-request
|
||||
// declaration a server requires before it may return a `CreateTaskResult`.
|
||||
// Harmless on legacy (extensions are ignored there). This is what makes
|
||||
// server-directed ("unsolicited") task creation legal on modern, and it
|
||||
// makes `capabilities` always non-empty, so it's always attached.
|
||||
capabilities.extensions = {
|
||||
...capabilities.extensions,
|
||||
[TASKS_EXTENSION_KEY]: {},
|
||||
};
|
||||
clientOptions.capabilities = capabilities;
|
||||
this.clientCapabilities = capabilities;
|
||||
|
||||
this.appRendererClientProxy = null;
|
||||
this.clientInfo = options.clientIdentity ?? {
|
||||
name: corePackageJson.name.split("/")[1] ?? corePackageJson.name,
|
||||
version: corePackageJson.version,
|
||||
};
|
||||
this.client = new Client(
|
||||
options.clientIdentity ?? {
|
||||
name: corePackageJson.name.split("/")[1] ?? corePackageJson.name,
|
||||
version: corePackageJson.version,
|
||||
},
|
||||
this.clientInfo,
|
||||
Object.keys(clientOptions).length > 0 ? clientOptions : undefined,
|
||||
);
|
||||
}
|
||||
|
|
@ -1085,6 +1134,12 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
this.transport = new MessageTrackingTransport(
|
||||
baseTransport,
|
||||
messageTracking,
|
||||
{
|
||||
rewriteIncomingResult: (message) =>
|
||||
this.rewriteModernTaskResult(message),
|
||||
consumeIncomingResponse: (message) =>
|
||||
this.consumeRawWireResponse(message),
|
||||
},
|
||||
);
|
||||
this.attachTransportListeners(this.baseTransport);
|
||||
}
|
||||
|
|
@ -1580,6 +1635,14 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
INACTIVE_SUBSCRIPTION_STREAM_STATE,
|
||||
);
|
||||
this.cancelledTaskIds.clear();
|
||||
// Settle any pending raw-wire (modern tasks/*) requests so their callers
|
||||
// don't hang past teardown.
|
||||
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");
|
||||
|
|
@ -1704,6 +1767,174 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the connection is modern (2026-07-28) AND the server advertised
|
||||
* the `io.modelcontextprotocol/tasks` extension (SEP-2663) in its
|
||||
* `server/discover` capabilities. Modern task methods (`tasks/get`,
|
||||
* `tasks/update`, `tasks/cancel`) and the "unsolicited task handle" behavior
|
||||
* are gated on this — legacy servers use `capabilities.tasks` and the
|
||||
* `tasks/list`-backed flow instead. Exposed so the Tasks tab and the modern
|
||||
* task store gate on the extension rather than the legacy capability.
|
||||
*/
|
||||
isTasksExtensionNegotiated(): boolean {
|
||||
return (
|
||||
this.isModernEra() &&
|
||||
this.capabilities?.extensions?.[TASKS_EXTENSION_KEY] !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const existingMeta =
|
||||
(params._meta as Record<string, unknown> | undefined) ?? {};
|
||||
const clientCapabilities = {
|
||||
...this.clientCapabilities,
|
||||
// extensions always carries the tasks extension (advertised at
|
||||
// construction), so spreading it is never a no-op.
|
||||
extensions: {
|
||||
...this.clientCapabilities.extensions,
|
||||
[TASKS_EXTENSION_KEY]: {},
|
||||
},
|
||||
};
|
||||
// Use the NEGOTIATED protocol version so the envelope agrees with the
|
||||
// `MCP-Protocol-Version` header the transport stamps from the same source —
|
||||
// a future modern-family revision would negotiate a different string, and
|
||||
// the two must not disagree. The raw channel only runs on a connected modern
|
||||
// session, so this is always set; the constant is a defensive fallback.
|
||||
/* v8 ignore next -- fallback only if getProtocolVersion() is unset, which
|
||||
can't happen on the connected modern session this runs on. */
|
||||
const protocolVersion = this.getProtocolVersion() ?? MODERN_PROTOCOL_VERSION;
|
||||
return {
|
||||
...params,
|
||||
_meta: {
|
||||
...existingMeta,
|
||||
[PROTOCOL_VERSION_META_KEY]: protocolVersion,
|
||||
[CLIENT_INFO_META_KEY]: this.clientInfo,
|
||||
[CLIENT_CAPABILITIES_META_KEY]: clientCapabilities,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
resultSchema: { parse: (value: unknown) => T },
|
||||
): Promise<T> {
|
||||
const transport = this.transport;
|
||||
if (!transport) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
const id = `inspector-ext-${(this.rawWireRequestCounter += 1)}`;
|
||||
const message = {
|
||||
jsonrpc: "2.0" as const,
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
} as unknown as JSONRPCMessage;
|
||||
const timeoutMs = this.requestTimeout ?? 30_000;
|
||||
const raw = await new Promise<unknown>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRawWireRequests.delete(id);
|
||||
reject(new Error(`Raw request "${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)));
|
||||
});
|
||||
});
|
||||
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 consumeRawWireResponse(
|
||||
message: JSONRPCResultResponse | JSONRPCErrorResponse,
|
||||
): boolean {
|
||||
const id = String((message as { id?: unknown }).id);
|
||||
const pending = this.pendingRawWireRequests.get(id);
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
this.pendingRawWireRequests.delete(id);
|
||||
clearTimeout(pending.timer);
|
||||
if ("error" in message) {
|
||||
const err = (message as JSONRPCErrorResponse).error;
|
||||
pending.reject(new Error(err?.message ?? `Request ${id} failed`));
|
||||
} else {
|
||||
pending.resolve((message as JSONRPCResultResponse).result);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reject and clear all pending raw-wire requests (on disconnect/teardown). */
|
||||
private rejectPendingRawWireRequests(reason: string): void {
|
||||
for (const [, pending] of this.pendingRawWireRequests) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error(reason));
|
||||
}
|
||||
this.pendingRawWireRequests.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get requestor task status by taskId (tasks we created on the server)
|
||||
* @param taskId Task identifier
|
||||
|
|
@ -1713,9 +1944,26 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
// SDK v2 removed `client.experimental.tasks.*`; drive the 2025-11-25
|
||||
// `tasks/get` wire method directly with its explicit (deprecated-but-
|
||||
// importable) result schema. `GetTaskResult` is the flattened task object.
|
||||
// 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,
|
||||
|
|
@ -1762,16 +2010,60 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// 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);
|
||||
await this.client.request(
|
||||
{ method: "tasks/cancel", params: { taskId } },
|
||||
CancelTaskResultSchema,
|
||||
this.getRequestOptions(),
|
||||
);
|
||||
// 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
|
||||
*/
|
||||
async updateRequestorTask(
|
||||
taskId: string,
|
||||
inputResponses: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
await this.rawWireRequest(
|
||||
"tasks/update",
|
||||
this.withModernTaskEnvelope({ taskId, inputResponses }),
|
||||
ModernUpdateTaskResultSchema,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the in-flight ordinary (non-task) tool call started by
|
||||
* {@link callTool}. Aborting its request makes the SDK send a
|
||||
|
|
@ -1987,36 +2279,40 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
private async fulfilInputRequests(
|
||||
inputRequests: InputRequests | undefined,
|
||||
signal?: AbortSignal,
|
||||
origin: PendingRequestOrigin = "input-required",
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
if (!inputRequests) return undefined;
|
||||
const responses: Record<string, unknown> = {};
|
||||
for (const [key, embedded] of Object.entries(inputRequests)) {
|
||||
responses[key] = await this.fulfilEmbeddedInputRequest(embedded, signal);
|
||||
responses[key] = await this.fulfilEmbeddedInputRequest(
|
||||
embedded,
|
||||
signal,
|
||||
origin,
|
||||
);
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fulfil a single embedded MRTR request. `roots/list` is auto-answered from
|
||||
* Fulfil a single embedded input request. `roots/list` is auto-answered from
|
||||
* the configured roots (consistent with the legacy `roots/list` handler — no
|
||||
* pending UX); `elicitation/create` and `sampling/createMessage` surface
|
||||
* through the pending-request UI tagged `"input-required"`.
|
||||
* through the pending-request UI tagged with `origin`. `origin` distinguishes
|
||||
* an MRTR round (`"input-required"`, answer echoed as a retry) from a modern
|
||||
* task round (`"task-input-required"`, answer submitted via `tasks/update`).
|
||||
*/
|
||||
private async fulfilEmbeddedInputRequest(
|
||||
request: CreateMessageRequest | ElicitRequest | ListRootsRequest,
|
||||
signal?: AbortSignal,
|
||||
origin: PendingRequestOrigin = "input-required",
|
||||
): Promise<unknown> {
|
||||
switch (request.method) {
|
||||
case "roots/list":
|
||||
return { roots: this.roots ?? [] };
|
||||
case "elicitation/create":
|
||||
return this.enqueuePendingElicitation(
|
||||
request,
|
||||
"input-required",
|
||||
signal,
|
||||
);
|
||||
return this.enqueuePendingElicitation(request, origin, signal);
|
||||
case "sampling/createMessage":
|
||||
return this.enqueuePendingSample(request, "input-required", signal);
|
||||
return this.enqueuePendingSample(request, origin, signal);
|
||||
/* v8 ignore next 6 -- defensive: an SDK server rejects an unknown embedded
|
||||
method before it reaches the wire, so this only guards against a
|
||||
non-conformant hand-rolled server; not reproducible against the
|
||||
|
|
@ -2514,7 +2810,7 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// 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 result = await this.invokeMcpClient(
|
||||
const rawResult = await this.invokeMcpClient(
|
||||
() =>
|
||||
this.requestWithInputRequired(
|
||||
"tools/call",
|
||||
|
|
@ -2525,6 +2821,19 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
{ 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
|
||||
|
|
@ -2655,6 +2964,205 @@ 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<number> {
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
const inputResponses = await this.fulfilInputRequests(
|
||||
this.tagInputRequestsWithTask(readInputRequests(detailed), task.taskId),
|
||||
signal,
|
||||
"task-input-required",
|
||||
);
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown> = {};
|
||||
for (const [key, req] of Object.entries(inputRequests)) {
|
||||
const request = req as { params?: { _meta?: Record<string, unknown> } };
|
||||
tagged[key] = {
|
||||
...request,
|
||||
params: {
|
||||
...request.params,
|
||||
_meta: {
|
||||
...request.params?._meta,
|
||||
[RELATED_TASK_META_KEY]: { taskId },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return tagged as InputRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<CallToolResult> {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a task-augmented tool call to completion. Replaces the removed
|
||||
* `client.experimental.tasks.callToolStream` helper: it sends the
|
||||
|
|
@ -2700,12 +3208,27 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// may return an immediate `CallToolResult` instead — accept either with a
|
||||
// union schema and branch on the presence of `task`.
|
||||
//
|
||||
// NOTE: this task path does NOT opt into `allowInputRequired`, so a
|
||||
// task-augmented tool that returns `input_required` is not MRTR-driven here.
|
||||
// Driving MRTR over the tasks extension is out of scope for #1704.
|
||||
// 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(
|
||||
{ method: "tools/call", params },
|
||||
CreateTaskResultSchema.or(CallToolResultSchema),
|
||||
{
|
||||
// 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
|
||||
|
|
@ -2722,6 +3245,67 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
? [...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 };
|
||||
|
|
@ -2739,11 +3323,9 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
// 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)) {
|
||||
const pollInterval =
|
||||
typeof task.pollInterval === "number" && task.pollInterval > 0
|
||||
? task.pollInterval
|
||||
: DEFAULT_TASK_POLL_INTERVAL_MS;
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, this.taskPollInterval(task)),
|
||||
);
|
||||
task = (await client.request(
|
||||
{ method: "tasks/get", params: { taskId: task.taskId } },
|
||||
GetTaskResultSchema,
|
||||
|
|
|
|||
|
|
@ -91,6 +91,15 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
|
|||
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<Task>;
|
||||
/** 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. */
|
||||
isTasksExtensionNegotiated(): boolean;
|
||||
|
||||
// Aggregate (all-page) list methods used by the managed state stores on
|
||||
// refresh. Unlike the single-page methods above, these route through the
|
||||
|
|
|
|||
|
|
@ -26,15 +26,52 @@ 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;
|
||||
|
||||
export interface MessageTrackingHooks {
|
||||
rewriteIncomingResult?: IncomingResultRewriter;
|
||||
consumeIncomingResponse?: IncomingResponseConsumer;
|
||||
}
|
||||
|
||||
// Transport wrapper that intercepts all messages for tracking
|
||||
export class MessageTrackingTransport implements Transport {
|
||||
private baseTransport: Transport;
|
||||
private callbacks: MessageTrackingCallbacks;
|
||||
private negotiatedProtocolVersion?: string;
|
||||
private hooks: MessageTrackingHooks;
|
||||
|
||||
constructor(baseTransport: Transport, callbacks: MessageTrackingCallbacks) {
|
||||
constructor(
|
||||
baseTransport: Transport,
|
||||
callbacks: MessageTrackingCallbacks,
|
||||
hooks: MessageTrackingHooks = {},
|
||||
) {
|
||||
this.baseTransport = baseTransport;
|
||||
this.callbacks = callbacks;
|
||||
this.hooks = hooks;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
|
|
@ -121,6 +158,28 @@ export class MessageTrackingTransport implements Transport {
|
|||
message as JSONRPCResultResponse | JSONRPCErrorResponse,
|
||||
"server",
|
||||
);
|
||||
// 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?.(
|
||||
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");
|
||||
|
|
|
|||
132
core/mcp/modernTaskSchemas.ts
Normal file
132
core/mcp/modernTaskSchemas.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* 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<typeof ModernDetailedTaskSchema>;
|
||||
|
||||
/** `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<string, unknown> = { ...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;
|
||||
}
|
||||
|
|
@ -45,6 +45,12 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
// handlers so a late status update (or a server that still lists the task)
|
||||
// can't resurrect a cleared task. Reset on disconnect / destroy.
|
||||
private dismissedTaskIds = new Set<string>();
|
||||
// Task ids the user cancelled via the Cancel Task control. Cancellation is a
|
||||
// deliberate terminal decision, so it is sticky: a status update that arrives
|
||||
// afterwards — e.g. a server that completes the task anyway (cancellation is
|
||||
// cooperative) or the in-flight tool call resolving with a result — must not
|
||||
// flip a cancelled task back to "completed". Reset on disconnect / destroy.
|
||||
private cancelledTaskIds = new Set<string>();
|
||||
|
||||
constructor(client: InspectorClientProtocol) {
|
||||
super();
|
||||
|
|
@ -59,14 +65,21 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
if (isTerminalStatus(this.client?.getStatus())) {
|
||||
this.tasks = [];
|
||||
this.dismissedTaskIds.clear();
|
||||
this.cancelledTaskIds.clear();
|
||||
this.dispatchTypedEvent("tasksChange", []);
|
||||
}
|
||||
};
|
||||
// A status update is ignored when the task was dismissed (clearCompleted)
|
||||
// or when the user cancelled it and the update isn't itself "cancelled" —
|
||||
// the deliberate cancel wins over a late completed/working update.
|
||||
const shouldIgnoreUpdate = (taskId: string, status: Task["status"]): boolean =>
|
||||
this.dismissedTaskIds.has(taskId) ||
|
||||
(this.cancelledTaskIds.has(taskId) && status !== "cancelled");
|
||||
const onTaskStatusChange = (
|
||||
e: TypedEventGeneric<InspectorClientEventMap, "taskStatusChange">,
|
||||
): void => {
|
||||
const { taskId, task } = e.detail;
|
||||
if (this.dismissedTaskIds.has(taskId)) return;
|
||||
if (shouldIgnoreUpdate(taskId, task.status)) return;
|
||||
this.tasks = mergeTaskIntoList(this.tasks, taskId, task);
|
||||
this.dispatchTypedEvent("tasksChange", this.tasks);
|
||||
};
|
||||
|
|
@ -74,7 +87,7 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
e: TypedEventGeneric<InspectorClientEventMap, "requestorTaskUpdated">,
|
||||
): void => {
|
||||
const { taskId, task } = e.detail;
|
||||
if (this.dismissedTaskIds.has(taskId)) return;
|
||||
if (shouldIgnoreUpdate(taskId, task.status)) return;
|
||||
this.tasks = mergeTaskIntoList(this.tasks, taskId, task);
|
||||
this.dispatchTypedEvent("tasksChange", this.tasks);
|
||||
};
|
||||
|
|
@ -83,6 +96,8 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
): void => {
|
||||
const { taskId } = e.detail;
|
||||
if (this.dismissedTaskIds.has(taskId)) return;
|
||||
// Remember the cancel so a later status update can't un-cancel it.
|
||||
this.cancelledTaskIds.add(taskId);
|
||||
const idx = this.tasks.findIndex((t) => t.taskId === taskId);
|
||||
if (idx >= 0) {
|
||||
const next = [...this.tasks];
|
||||
|
|
@ -126,9 +141,18 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
if (!client || client.getStatus() !== "connected") {
|
||||
return this.getTasks();
|
||||
}
|
||||
// Gate on the server's `tasks` capability — calling tasks/list against a
|
||||
// server that doesn't advertise it returns -32601 "Method not found",
|
||||
// which then surfaces in the console for every connect against any
|
||||
// Modern era (SEP-2663): the `io.modelcontextprotocol/tasks` extension has
|
||||
// NO `tasks/list` — task handles are durable and client-held, arriving via
|
||||
// task-augmented tool calls (server-directed `CreateTaskResult`) or
|
||||
// unsolicited handles. "Refresh" therefore re-polls the tasks already known
|
||||
// to this store via `tasks/get`; a task the server has since dropped (TTL)
|
||||
// simply keeps its last-seen state. Gate on the extension being negotiated.
|
||||
if (client.isTasksExtensionNegotiated()) {
|
||||
return this.refreshModern(client);
|
||||
}
|
||||
// Legacy era: gate on the server's `tasks` capability — calling tasks/list
|
||||
// against a server that doesn't advertise it returns -32601 "Method not
|
||||
// found", which then surfaces in the console for every connect against any
|
||||
// server that doesn't implement task tracking. Empty list is the right
|
||||
// semantics for "this server doesn't support tasks."
|
||||
if (!client.getCapabilities()?.tasks) {
|
||||
|
|
@ -143,9 +167,17 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
const result = await client.listRequestorTasks(cursor);
|
||||
// Filter out tasks the user dismissed via clearCompleted() so a server
|
||||
// that still lists them (or an in-flight refresh) can't resurrect them.
|
||||
const page = result.tasks.filter(
|
||||
(t) => !this.dismissedTaskIds.has(t.taskId),
|
||||
);
|
||||
// For a user-cancelled task, keep it but pin its status to "cancelled":
|
||||
// cancellation is cooperative, so a server that completes and re-lists it
|
||||
// as "completed" must not un-stick the cancel on a manual Refresh — same
|
||||
// guarantee the live-merge handlers give via `cancelledTaskIds`.
|
||||
const page = result.tasks
|
||||
.filter((t) => !this.dismissedTaskIds.has(t.taskId))
|
||||
.map((t) =>
|
||||
this.cancelledTaskIds.has(t.taskId)
|
||||
? { ...t, status: "cancelled" as const }
|
||||
: t,
|
||||
);
|
||||
this.tasks = cursor ? [...this.tasks, ...page] : page;
|
||||
cursor = result.nextCursor;
|
||||
pageCount++;
|
||||
|
|
@ -159,6 +191,29 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
return this.getTasks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern refresh: re-poll each currently-known (non-dismissed) task via
|
||||
* `getRequestorTask` (`tasks/get`). Each poll dispatches `requestorTaskUpdated`,
|
||||
* which the live-merge handler folds back into `this.tasks` — so the list
|
||||
* updates in place. A poll that errors (e.g. the task expired and the server
|
||||
* returns "unknown taskId") is swallowed so one dead handle can't abort the
|
||||
* whole refresh. No known tasks ⇒ a no-op refresh (the correct "no server
|
||||
* list" semantics).
|
||||
*/
|
||||
private async refreshModern(client: InspectorClientProtocol): Promise<Task[]> {
|
||||
const ids = this.tasks
|
||||
.map((t) => t.taskId)
|
||||
.filter((id) => !this.dismissedTaskIds.has(id));
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await client.getRequestorTask(id);
|
||||
} catch {
|
||||
// Ignore a single failed poll (expired / unknown task); keep the rest.
|
||||
}
|
||||
}
|
||||
return this.getTasks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop terminal-state tasks (completed / failed / cancelled) from the list.
|
||||
* Their ids are remembered in `dismissedTaskIds` so they stay gone for the
|
||||
|
|
@ -186,5 +241,6 @@ export class ManagedRequestorTasksState extends TypedEventTarget<ManagedRequesto
|
|||
this.unsubscribe = null;
|
||||
this.tasks = [];
|
||||
this.dismissedTaskIds.clear();
|
||||
this.cancelledTaskIds.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -250,8 +250,15 @@ export type MessageOrigin = "client" | "server";
|
|||
* - `"input-required"` — a modern (2026-07-28) MRTR round: the request was
|
||||
* embedded in a tool-call/prompt/resource `input_required` result, and the
|
||||
* user's answer is echoed back to the server as a retry (SEP-2322).
|
||||
* - `"task-input-required"` — a modern task (SEP-2663) that reached
|
||||
* `input_required`: the request came from the task's `tasks/get` `inputRequests`
|
||||
* map, and the user's answer is submitted via a `tasks/update` request (NOT a
|
||||
* retry of the original call, unlike MRTR).
|
||||
*/
|
||||
export type PendingRequestOrigin = "server-request" | "input-required";
|
||||
export type PendingRequestOrigin =
|
||||
| "server-request"
|
||||
| "input-required"
|
||||
| "task-input-required";
|
||||
|
||||
export interface MessageEntry {
|
||||
id: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue