diff --git a/AGENTS.md b/AGENTS.md
index 8cf77bb4c..4f5a91393 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,6 +33,9 @@ inspector/
│ ├── json/ # JSON utilities and parameter/argument conversion
│ ├── logging/ # Silent pino logger singleton
│ ├── mcp/ # InspectorClient runtime + state stores
+│ │ # (modernTaskSchemas.ts: SEP-2663 modern Tasks
+│ │ # extension wire schemas + normalize/handle helpers,
+│ │ # used by the raw-wire tasks/* channel — #1631)
│ │ ├── import/ # Config import strategies (#1348): client-config parsers
│ │ │ # (Claude Desktop/Cursor/Cline/VS Code), registry
│ │ │ # server.json parser, strategy registry + well-known
@@ -45,7 +48,9 @@ inspector/
│ ├── react/ # React hooks over the state stores
│ └── storage/ # File I/O helpers (store-io.ts) used by OAuth persist backends
├── test-servers/ # Composable MCP test servers + fixtures used by integration tests.
-│ ├── src/ # TypeScript sources.
+│ ├── src/ # TypeScript sources. (modern-tasks.ts: SEP-2663 modern
+│ │ # Tasks extension runtime + tasks/* Express interceptor
+│ │ # + modern_task/modern_input_task tools — #1631)
│ ├── build/ # Built JS (gitignored). Produced by `npm run test-servers:build`
│ │ # so integration tests can spawn the stdio server as a real
│ │ # subprocess via `node test-servers/build/test-server-stdio.js`.
diff --git a/README.md b/README.md
index 12c41b953..148202b31 100644
--- a/README.md
+++ b/README.md
@@ -120,6 +120,8 @@ A streamable-HTTP server can also serve the **modern (2026-07-28) protocol era**
`test-servers/configs/subscriptions-legacy-http.json` and `test-servers/configs/subscriptions-modern-http.json` are the **resource-subscription era-fork showcase** (#1630). Both serve three `numbered_resources` with `subscriptions: true`; the legacy one also serves an `update_resource` tool, and the modern one sets `transport.modern: true`. Connect to the **legacy** server, open a resource in the **Resources** tab and click **Subscribe** — the client sends `resources/subscribe` (Network/Protocol view) and the Subscriptions section lists the URI with no stream chrome; call `update_resource` with that URI and the server updates the content and emits `notifications/resources/updated`, stamping the subscribed tile's last-updated time. Connect to the **modern** server with **Protocol Era = Modern** and the same Subscribe instead sends **`subscriptions/listen`** (its filter carries `resourceSubscriptions` + the `resourcesListChanged` opt-in) and resolves on `notifications/subscriptions/acknowledged`; the Subscriptions section then shows the stream-status badge (`Connecting…` → `Listening`) in its header, and if the long-lived stream drops it reconnects by re-listing. The modern config deliberately **omits** `update_resource`: the SDK's modern leg is stateless/per-request (`createMcpHandler(() => createMcpServer(config))`), so the tool would run against a throwaway server instance — the content change wouldn't persist for the next `resources/read`, and its `resources/updated` wouldn't reach the (separate) listen stream — which is more confusing than useful. The live update-notification round-trip is therefore demonstrated on the legacy (stateful-session) server; the modern server is for the subscribe/listen/badge behavior. (The Inspector's *receive* path is era-transparent, so a real stateful modern server that routes `resources/updated` onto the listen stream drives the subscribed tile the same way.)
+`test-servers/configs/tasks-legacy-http.json` and `test-servers/configs/tasks-modern-http.json` are the **Tasks era-fork showcase** (#1631). The legacy server advertises `capabilities.tasks` (`tasks: { list, cancel }`) with the `simple_task` / `progress_task` / `elicitation_task` presets — connect to it, run one of those tools with **Run as task** on, and the **Tasks** tab lists it (populated via `tasks/list`), polls `tasks/get`, fetches the payload with the blocking `tasks/result`, and cancels with `tasks/cancel`. The modern server sets `transport.modern: true` and `tasksExtension: true`, advertising the `io.modelcontextprotocol/tasks` extension (SEP-2663) and serving the `modern_task` / `modern_input_task` tools. Connect with **Protocol Era = Modern**: the **Tasks** tab is now gated on the negotiated extension (not `capabilities.tasks`). Run `modern_task` as a task — the `tools/call` returns a `CreateTaskResult` (`resultType: "task"`, visible in the Protocol/Network tabs), the client polls **`tasks/get`** (no `tasks/list`), and the completed task inlines its result (no blocking `tasks/result`). Run `modern_input_task` and the task moves to `input_required`, surfacing an embedded elicitation through the pending-request modal; answering it sends **`tasks/update`** with the `inputResponses`, and the next poll completes. SDK v2 removed all tasks support **and** era-gates the `tasks/*` spec methods out of the modern era on both sides — so the Inspector drives the extension itself (the `resultType: "task"` frame is rewritten at the transport into a `CallToolResult` carrying the handle; `tasks/get`/`update`/`cancel` ride a raw-wire request channel with the full modern envelope), and the test server serves `tasks/*` from an Express interceptor ahead of the SDK handler (the SDK's modern leg would answer them `-32601`). The Tasks tab's **Refresh** re-polls the handles already known to the client (modern has no server-side task list).
+
## Building
```bash
diff --git a/clients/web/src/App.test.tsx b/clients/web/src/App.test.tsx
index fc7fc9b11..1ffd72eea 100644
--- a/clients/web/src/App.test.tsx
+++ b/clients/web/src/App.test.tsx
@@ -64,6 +64,14 @@ vi.mock("@inspector/core/mcp/index.js", async (importOriginal) => {
.fn()
.mockResolvedValue({ success: true, result: { acts: [] } });
cancelRequestorTask = vi.fn().mockResolvedValue(undefined);
+ isTasksExtensionNegotiated = vi.fn().mockReturnValue(false);
+ getRequestorTask = vi.fn().mockResolvedValue({
+ taskId: "t",
+ status: "working",
+ ttl: null,
+ createdAt: "",
+ lastUpdatedAt: "",
+ });
cancelToolCall = vi.fn().mockReturnValue(true);
getPrompt = vi.fn().mockResolvedValue({ result: { messages: [] } });
readResource = vi
@@ -1107,6 +1115,55 @@ describe("App task wiring", () => {
expect(client.cancelRequestorTask).toHaveBeenCalledWith("task-42");
});
+ it("Cancel on a task-input-required elicitation cancels the task, not answers it (#1631)", async () => {
+ // The user-facing half of the cancelable-loop fix: clicking Cancel on a
+ // MODERN task's input_required modal must cancel the underlying task (so the
+ // poll unblocks) rather than send an { action: "cancel" } answer that a
+ // non-advancing server would just re-prompt on.
+ const user = userEvent.setup();
+ renderWithMantine();
+ await user.click(screen.getByText("connect"));
+ await waitFor(() => expect(clientInstances).toHaveLength(1));
+
+ const client = clientInstances[0] as unknown as {
+ cancelRequestorTask: ReturnType;
+ };
+
+ const respond = vi.fn().mockResolvedValue(undefined);
+ const elicitation = {
+ id: "elicitation-1",
+ origin: "task-input-required",
+ taskId: "task-77",
+ request: {
+ params: {
+ message: "Approve this task before it continues?",
+ requestedSchema: {
+ type: "object",
+ properties: { approved: { type: "boolean", title: "Approved" } },
+ },
+ },
+ },
+ respond,
+ };
+ act(() => {
+ clientInstances[0].dispatchEvent(
+ new CustomEvent("pendingElicitationsChange", { detail: [elicitation] }),
+ );
+ });
+
+ await waitFor(() =>
+ expect(
+ screen.getByText(/Approve this task before it continues/),
+ ).toBeInTheDocument(),
+ );
+
+ await user.click(screen.getByRole("button", { name: "Cancel" }));
+
+ expect(client.cancelRequestorTask).toHaveBeenCalledWith("task-77");
+ // The task is cancelled instead of answering the request.
+ expect(respond).not.toHaveBeenCalled();
+ });
+
it("does not re-cancel on a rapid second Cancel click", async () => {
const user = userEvent.setup();
renderWithMantine();
diff --git a/clients/web/src/App.tsx b/clients/web/src/App.tsx
index e9aaa44e7..ce2fe159a 100644
--- a/clients/web/src/App.tsx
+++ b/clients/web/src/App.tsx
@@ -2823,8 +2823,16 @@ function App() {
// error). The created task shows up on the Tasks screen via the
// `requestorTaskUpdated` events callToolStream dispatches, and its live
// status/progress surface as toasts + progress bar.
+ // Legacy servers advertise task tool calls via
+ // `tasks.requests.tools.call`. Modern servers (SEP-2663) instead negotiate
+ // the `io.modelcontextprotocol/tasks` extension and are server-directed:
+ // task creation is decided per-request by the server, so declaring the
+ // extension on the call (which the task path does) is what makes a returned
+ // task handle legal ("unsolicited handles"). Either era routes the flagged
+ // call through the streaming task pipeline.
const serverSupportsTaskToolCalls =
- !!capabilities?.tasks?.requests?.tools?.call;
+ !!capabilities?.tasks?.requests?.tools?.call ||
+ inspectorClient.isTasksExtensionNegotiated();
const asTask =
serverSupportsTaskToolCalls &&
(runAsTask || tool.execution?.taskSupport === "required");
@@ -3999,9 +4007,25 @@ function App() {
const onElicitationRespond = useCallback(
(result: ElicitResult) => {
- void pendingElicitations[0]?.respond(result);
+ const pending = pendingElicitations[0];
+ if (!pending) return;
+ // "Cancel" on a MODERN task's input_required request means "give up on the
+ // task" — not "send a cancel answer" (which a non-advancing server would
+ // just re-prompt on). Cancel the underlying task instead; that aborts the
+ // pending request, closes this modal, and lets the poll settle as
+ // cancelled (#1631). Submit/Decline still answer the task normally.
+ if (
+ result.action === "cancel" &&
+ pending.origin === "task-input-required" &&
+ pending.taskId &&
+ inspectorClient
+ ) {
+ void inspectorClient.cancelRequestorTask(pending.taskId);
+ return;
+ }
+ void pending.respond(result);
},
- [pendingElicitations],
+ [pendingElicitations, inspectorClient],
);
const handleStepUpAuthorize = async () => {
@@ -4256,7 +4280,8 @@ function App() {
highlightedServerIds={highlightedServerIds}
onClearHighlight={clearHighlight}
serverSupportsTaskToolCalls={
- !!capabilities?.tasks?.requests?.tools?.call
+ !!capabilities?.tasks?.requests?.tools?.call ||
+ (inspectorClient?.isTasksExtensionNegotiated() ?? false)
}
onToolsUiChange={onToolsUiChange}
onCallTool={(name, args, runAsTask) => {
diff --git a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx
index e9e4345fd..790e2bc54 100644
--- a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx
+++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.stories.tsx
@@ -13,6 +13,10 @@ export const InputRequired: Story = {
args: { origin: "input-required" },
};
+export const TaskInputRequired: Story = {
+ args: { origin: "task-input-required" },
+};
+
export const ServerRequest: Story = {
args: { origin: "server-request" },
};
diff --git a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.test.tsx b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.test.tsx
index 558ab6207..5d664d369 100644
--- a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.test.tsx
+++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.test.tsx
@@ -9,6 +9,16 @@ describe("MrtrOriginNote", () => {
expect(screen.getByText(/sent back as a retry/i)).toBeInTheDocument();
});
+ it("renders the tasks/update note for a modern task input-required round (#1631)", () => {
+ renderWithMantine();
+ expect(screen.getByText("input_required")).toBeInTheDocument();
+ expect(
+ screen.getByText(/submitted via a tasks\/update request/i),
+ ).toBeInTheDocument();
+ // The task note must NOT claim the answer is a retry (that's the MRTR case).
+ expect(screen.queryByText(/sent back as a retry/i)).not.toBeInTheDocument();
+ });
+
it("renders nothing for a legacy server request", () => {
renderWithMantine();
expect(screen.queryByText("input_required")).not.toBeInTheDocument();
diff --git a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx
index 8219f4356..c4aa7c58d 100644
--- a/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx
+++ b/clients/web/src/components/elements/MrtrOriginNote/MrtrOriginNote.tsx
@@ -6,7 +6,8 @@ export interface MrtrOriginNoteProps {
* How the pending request reached the Inspector. `"server-request"` (the
* default) is a legacy server→client request and renders nothing, keeping the
* panel visually identical to its pre-MRTR form. `"input-required"` is a
- * modern (2026-07-28) MRTR round and renders the era-accurate note.
+ * modern (2026-07-28) MRTR round and `"task-input-required"` is a modern task
+ * (SEP-2663) round — each renders its own era-accurate note.
*/
origin?: PendingRequestOrigin;
}
@@ -22,27 +23,37 @@ const NoteText = Text.withProps({
c: "dimmed",
});
+// The two modern origins differ in HOW the answer is delivered: an MRTR round
+// retries the original call with the answer (SEP-2322); a task round submits it
+// via a separate `tasks/update` request (SEP-2663). The note is accurate to each
+// so a user debugging the wire isn't told to expect the wrong follow-up frame.
+const NOTE_BY_ORIGIN: Partial> = {
+ "input-required":
+ "The server returned input_required; your answer is sent back as a retry of the original request (MRTR).",
+ "task-input-required":
+ "This task reached input_required; your answer is submitted via a tasks/update request (SEP-2663), not a retry.",
+};
+
/**
- * Era-accurate note for a pending sampling/elicitation request. On a modern
- * MRTR round (`origin: "input-required"`, SEP-2322) the request was embedded in
- * a tool-call/prompt/resource `input_required` result rather than sent as a
- * server→client JSON-RPC request, and the user's answer is echoed back to the
- * server as a retry of the original call. Legacy requests
- * (`origin: "server-request"`) render nothing so their panels are unchanged.
+ * Era-accurate note for a pending sampling/elicitation request. A modern MRTR
+ * round (`origin: "input-required"`, SEP-2322) embeds the request in a
+ * tool-call/prompt/resource `input_required` result and retries the original
+ * call with the answer; a modern task round (`origin: "task-input-required"`,
+ * SEP-2663) surfaces it from the task's `inputRequests` and submits the answer
+ * via `tasks/update`. Legacy requests (`origin: "server-request"`) render
+ * nothing so their panels are unchanged.
*/
export function MrtrOriginNote({
origin = "server-request",
}: MrtrOriginNoteProps) {
- if (origin !== "input-required") return null;
+ const note = NOTE_BY_ORIGIN[origin];
+ if (!note) return null;
return (
input_required
-
- The server returned input_required; your answer is sent back as a retry
- of the original request (MRTR).
-
+ {note}
);
}
diff --git a/clients/web/src/components/groups/TaskCard/TaskCard.stories.tsx b/clients/web/src/components/groups/TaskCard/TaskCard.stories.tsx
index 29c334ee5..e56522f2e 100644
--- a/clients/web/src/components/groups/TaskCard/TaskCard.stories.tsx
+++ b/clients/web/src/components/groups/TaskCard/TaskCard.stories.tsx
@@ -90,3 +90,10 @@ export const Cancelled: Story = {
export const Collapsed: Story = {
args: { task: workingTask, isListExpanded: false },
};
+
+// Compact monitoring-sidebar variant: the task id moves to its own line below
+// the header so the status badge and Cancel Task control stay on the first row
+// in the narrow column (#1631).
+export const Embedded: Story = {
+ args: { task: workingTask, embedded: true },
+};
diff --git a/clients/web/src/components/groups/TaskCard/TaskCard.test.tsx b/clients/web/src/components/groups/TaskCard/TaskCard.test.tsx
index 8f8d5a6c6..715c75d24 100644
--- a/clients/web/src/components/groups/TaskCard/TaskCard.test.tsx
+++ b/clients/web/src/components/groups/TaskCard/TaskCard.test.tsx
@@ -51,6 +51,25 @@ describe("TaskCard", () => {
expect(screen.getByText("300000ms")).toBeInTheDocument();
});
+ it("still renders the task id (on its own line) in the embedded variant (#1631)", () => {
+ // The embedded sidebar moves the id out of the header so the badge/Cancel
+ // don't wrap, but the id must remain visible (not removed entirely).
+ renderWithMantine(
+ ,
+ );
+ expect(screen.getByText("ID: task-working-1")).toBeInTheDocument();
+ // The status badge and Cancel Task control are still present on the header.
+ expect(screen.getByText("working")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Cancel Task" }),
+ ).toBeInTheDocument();
+ });
+
it("renders the Cancel Task button when status is working", () => {
renderWithMantine(
,
diff --git a/clients/web/src/components/groups/TaskCard/TaskCard.tsx b/clients/web/src/components/groups/TaskCard/TaskCard.tsx
index 5edb57fa9..8946e9048 100644
--- a/clients/web/src/components/groups/TaskCard/TaskCard.tsx
+++ b/clients/web/src/components/groups/TaskCard/TaskCard.tsx
@@ -24,6 +24,13 @@ export interface TaskCardProps {
progress?: TaskProgress;
isListExpanded: boolean;
onCancel: () => void;
+ /**
+ * Compact variant for the narrow monitoring sidebar. Moves the long task ID
+ * out of the header onto its own line below it (truncated) so the header
+ * doesn't wrap the status badge / Cancel Task control onto a second row. The
+ * full-width Tasks screen keeps the ID inline in the header.
+ */
+ embedded?: boolean;
}
const TaskContainer = Card.withProps({
@@ -80,6 +87,9 @@ const HeaderLeftGroup = Group.withProps({
const HeaderRightGroup = Group.withProps({
gap: "xs",
+ // Keep the Cancel Task control and the status badge together on the header's
+ // first row rather than letting the badge wrap beneath the button.
+ wrap: "nowrap",
});
const SectionTitle = Text.withProps({
@@ -100,6 +110,7 @@ export function TaskCard({
progress,
isListExpanded,
onCancel,
+ embedded = false,
}: TaskCardProps) {
const { taskId, status, ttl, createdAt, lastUpdatedAt, statusMessage } = task;
const [isExpanded, setIsExpanded] = useState(isListExpanded);
@@ -115,7 +126,11 @@ export function TaskCard({
Task Details
- {formatTaskId(taskId)}
+ {/* Full-width screen: the id sits inline after the title. In the
+ narrow embedded sidebar it moves to its own line below (see
+ below) so it can't push the status badge / Cancel Task onto a
+ second row. */}
+ {!embedded && {formatTaskId(taskId)}}
{isActive && (
@@ -125,6 +140,10 @@ export function TaskCard({
+ {embedded && (
+ {formatTaskId(taskId)}
+ )}
+
diff --git a/clients/web/src/components/groups/TaskListPanel/TaskListPanel.tsx b/clients/web/src/components/groups/TaskListPanel/TaskListPanel.tsx
index 278f8e433..702da4d92 100644
--- a/clients/web/src/components/groups/TaskListPanel/TaskListPanel.tsx
+++ b/clients/web/src/components/groups/TaskListPanel/TaskListPanel.tsx
@@ -135,6 +135,7 @@ export function TaskListPanel({
progress={progressByTaskId?.[task.taskId]}
isListExpanded={!compact}
onCancel={() => onCancel(task.taskId)}
+ embedded={embedded}
/>
))}
>
@@ -157,6 +158,7 @@ export function TaskListPanel({
progress={progressByTaskId?.[task.taskId]}
isListExpanded={!compact}
onCancel={() => onCancel(task.taskId)}
+ embedded={embedded}
/>
))}
>
diff --git a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx
index ac9f94fbe..e1a4591e7 100644
--- a/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx
+++ b/clients/web/src/components/groups/ToolDetailPanel/ToolDetailPanel.test.tsx
@@ -325,6 +325,42 @@ describe("ToolDetailPanel", () => {
expect(screen.queryByLabelText("Run as task")).not.toBeInTheDocument();
});
+ it("shows the toggle for a task-forbidden tool on a modern connection (#1631)", () => {
+ // On modern (SEP-2663) task creation is server-directed, so any tool can
+ // become a task — the toggle is offered regardless of per-tool taskSupport.
+ renderWithMantine(
+ ,
+ );
+ const toggle = screen.getByLabelText("Run as task");
+ expect(toggle).not.toBeChecked();
+ expect(toggle).not.toBeDisabled();
+ });
+
+ it("routes the user's runAsTask choice on a modern connection (#1631)", async () => {
+ const user = userEvent.setup();
+ const onExecute = vi.fn();
+ renderWithMantine(
+ ,
+ );
+ // The toggle is checked (user's choice), and executing forwards true.
+ expect(screen.getByLabelText("Run as task")).toBeChecked();
+ await user.click(screen.getByRole("button", { name: "Execute Tool" }));
+ expect(onExecute).toHaveBeenCalledWith(true);
+ });
+
it("shows an unchecked, enabled toggle for an optional tool (off by default)", () => {
renderWithMantine(
void;
onFormChange: (values: Record) => void;
@@ -154,6 +162,7 @@ export function ToolDetailPanel({
isExecuting,
progress,
serverSupportsTaskToolCalls,
+ modernTasks = false,
runAsTask,
onRunAsTaskChange,
onFormChange,
@@ -180,20 +189,28 @@ export function ToolDetailPanel({
// single expandable control (aria-expanded + aria-controls).
const descriptionRegionId = useId();
- // Show the toggle only when the server supports task tool calls and the tool
- // doesn't forbid them. `required` tools are forced on (checked + disabled);
- // `optional` tools follow the user's `runAsTask` choice.
+ // Show the toggle when the server supports task tool calls and either the
+ // connection is modern (task creation is server-directed there, so any tool
+ // may become a task) or the tool doesn't forbid per-tool task support
+ // (legacy). `required` tools are forced on (checked + disabled); `optional`
+ // and (on modern) any tool follow the user's `runAsTask` choice.
+ //
+ // NOTE: on modern, a per-tool `taskSupport: "forbidden"` is DELIBERATELY
+ // ignored. Under SEP-2663 task creation is decided by the server per request,
+ // not declared per tool, so `taskSupport` (a legacy 2025-11-25 concept) does
+ // not gate the affordance — the server may return a task for any call. The
+ // toggle just declares intent to poll a returned handle.
const taskSupport = getTaskSupport(tool);
const showRunAsTask =
- serverSupportsTaskToolCalls && taskSupport !== "forbidden";
- // Gate the effective decision on `showRunAsTask` (which includes
- // `serverSupportsTaskToolCalls`): a stale `runAsTask`/`required` value must
- // not route through callToolStream when the toggle is hidden because the
- // server doesn't advertise task tool calls. Per spec, a tool's taskSupport is
- // only considered when the server advertises `tasks.requests.tools.call`.
+ serverSupportsTaskToolCalls && (modernTasks || taskSupport !== "forbidden");
+ // Gate the effective decision on `showRunAsTask`: a stale `runAsTask`/`required`
+ // value must not route through callToolStream when the toggle is hidden. On
+ // legacy, a tool's taskSupport is only considered when the server advertises
+ // `tasks.requests.tools.call`; on modern, the user's choice governs any tool.
const effectiveRunAsTask =
showRunAsTask &&
- (taskSupport === "required" || (taskSupport === "optional" && runAsTask));
+ (taskSupport === "required" ||
+ ((taskSupport === "optional" || modernTasks) && runAsTask));
return (
diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
index ae50d6e90..d4570d365 100644
--- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
+++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.tsx
@@ -43,6 +43,9 @@ export interface ToolsScreenProps {
listChanged: boolean;
/** Whether the connected server advertises task-augmented tool calls. */
serverSupportsTaskToolCalls: boolean;
+ /** Modern (SEP-2663) tasks extension negotiated — "Run as task" is offered
+ * for any tool (server-directed task creation). */
+ modernTasks?: boolean;
onUiChange: (next: ToolsUiState) => void;
onRefreshList: () => void;
/** Pagination controls rendered in the sidebar (#1721). */
@@ -133,6 +136,7 @@ export function ToolsScreen({
ui,
listChanged,
serverSupportsTaskToolCalls,
+ modernTasks = false,
onUiChange,
onRefreshList,
pagination,
@@ -216,6 +220,7 @@ export function ToolsScreen({
isExecuting={isExecuting}
progress={callState?.progress}
serverSupportsTaskToolCalls={serverSupportsTaskToolCalls}
+ modernTasks={modernTasks}
runAsTask={ui.runAsTask}
onRunAsTaskChange={(value) =>
onUiChange({ ...ui, runAsTask: value })
diff --git a/clients/web/src/components/views/InspectorView/InspectorView.tsx b/clients/web/src/components/views/InspectorView/InspectorView.tsx
index 8a4b3a77c..46672930c 100644
--- a/clients/web/src/components/views/InspectorView/InspectorView.tsx
+++ b/clients/web/src/components/views/InspectorView/InspectorView.tsx
@@ -38,6 +38,7 @@ import type {
} from "@inspector/core/mcp/types.js";
import { isTerminalStatus } from "@inspector/core/mcp/types.js";
import { isAppTool } from "@inspector/core/mcp/apps.js";
+import { TASKS_EXTENSION_KEY } from "@inspector/core/mcp/modernTaskSchemas.js";
import { ViewHeader } from "../../groups/ViewHeader/ViewHeader";
import { VersionBadge } from "../../elements/VersionBadge/VersionBadge";
import { CopyrightBadge } from "../../elements/CopyrightBadge/CopyrightBadge";
@@ -807,8 +808,11 @@ export function InspectorView({
// Logs → capabilities.logging
// Prompts → capabilities.prompts
// Resources → capabilities.resources
- // Tasks → capabilities.tasks (the "run as task" affordance on the
- // Tools screen separately keys off tasks.requests.tools.call)
+ // Tasks → capabilities.tasks (legacy) OR the
+ // io.modelcontextprotocol/tasks extension (modern, SEP-2663).
+ // The "run as task" affordance on the Tools screen separately
+ // keys off tasks.requests.tools.call (legacy) / the negotiated
+ // extension (modern).
// Apps → capabilities.tools (MCP Apps build on tools) AND at least one
// app tool — Apps is a filtered view of tools, not its own
// capability, so it keeps the content check; when app tools
@@ -830,7 +834,12 @@ export function InspectorView({
const hasApps = hasTools && appTools.length > 0;
const hasPrompts = capabilities?.prompts !== undefined;
const hasResources = capabilities?.resources !== undefined;
- const hasTasks = capabilities?.tasks !== undefined;
+ // Legacy servers gate Tasks on `capabilities.tasks`; modern servers
+ // (SEP-2663) advertise the `io.modelcontextprotocol/tasks` extension in
+ // `server/discover` instead, so gate on either being present.
+ const hasTasks =
+ capabilities?.tasks !== undefined ||
+ capabilities?.extensions?.[TASKS_EXTENSION_KEY] !== undefined;
const hasLogging = capabilities?.logging !== undefined;
return ALL_TABS.filter((t) => {
if (t === NETWORK_TAB && isStdio) return false;
@@ -1314,6 +1323,12 @@ export function InspectorView({
ui={toolsUi}
listChanged={toolsListChanged}
serverSupportsTaskToolCalls={serverSupportsTaskToolCalls}
+ modernTasks={
+ protocolEra === "modern" &&
+ initializeResult?.capabilities?.extensions?.[
+ TASKS_EXTENSION_KEY
+ ] !== undefined
+ }
onUiChange={onToolsUiChange}
onRefreshList={onRefreshTools}
pagination={toolsPagination}
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
new file mode 100644
index 000000000..2d9af6b1c
--- /dev/null
+++ b/clients/web/src/test/core/mcp/inspectorClient-raw-wire.test.ts
@@ -0,0 +1,159 @@
+import { describe, it, expect, vi } from "vitest";
+import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+import { ModernGetTaskResultSchema } from "@inspector/core/mcp/modernTaskSchemas.js";
+
+/**
+ * Unit coverage for the raw-wire request channel (#1631) that drives the modern
+ * tasks/* methods the SDK v2 era gate refuses to send. Exercised directly (with
+ * a fake transport) so the defensive branches — transport-null guard, send
+ * rejection, timeout, error response, and disconnect cleanup — are deterministic
+ * rather than dependent on server timing.
+ */
+describe("InspectorClient raw-wire channel (#1631)", () => {
+ function makeClient(): InspectorClient {
+ return new InspectorClient(
+ { type: "stdio", command: "noop", args: [] },
+ // environment.transport is only used on connect(); these tests never
+ // connect, they poke the private raw-wire methods directly.
+ { environment: { transport: () => ({}) as never } },
+ );
+ }
+
+ interface RawWireInternals {
+ transport: { send: (m: unknown) => Promise } | null;
+ requestTimeout?: number;
+ rawWireRequest: (
+ method: string,
+ params: Record,
+ schema: { parse: (v: unknown) => unknown },
+ ) => Promise;
+ consumeRawWireResponse: (message: unknown) => boolean;
+ rejectPendingRawWireRequests: (reason: string) => void;
+ }
+
+ function internals(client: InspectorClient): RawWireInternals {
+ return client as unknown as RawWireInternals;
+ }
+
+ it("throws when there is no transport", async () => {
+ const client = makeClient();
+ internals(client).transport = null;
+ await expect(
+ internals(client).rawWireRequest(
+ "tasks/get",
+ {},
+ ModernGetTaskResultSchema,
+ ),
+ ).rejects.toThrow(/not connected/i);
+ });
+
+ it("resolves when a matching response is consumed", async () => {
+ const client = makeClient();
+ let sent: { id: string } | undefined;
+ internals(client).transport = {
+ send: vi.fn(async (m: unknown) => {
+ sent = m as { id: string };
+ }),
+ };
+ const promise = internals(client).rawWireRequest(
+ "tasks/get",
+ { taskId: "x" },
+ ModernGetTaskResultSchema,
+ );
+ // Let the send microtask register the pending entry.
+ await Promise.resolve();
+ expect(sent?.id).toMatch(/^inspector-ext-/);
+ const consumed = internals(client).consumeRawWireResponse({
+ id: sent!.id,
+ result: {
+ taskId: "x",
+ status: "completed",
+ createdAt: "a",
+ lastUpdatedAt: "b",
+ ttlMs: null,
+ result: { content: [] },
+ },
+ });
+ expect(consumed).toBe(true);
+ const result = (await promise) as { taskId: string };
+ expect(result.taskId).toBe("x");
+ });
+
+ it("ignores a response id it does not own", () => {
+ const client = makeClient();
+ expect(
+ internals(client).consumeRawWireResponse({ id: 42, result: {} }),
+ ).toBe(false);
+ });
+
+ it("rejects when the response is an error", async () => {
+ const client = makeClient();
+ let sent: { id: string } | undefined;
+ internals(client).transport = {
+ send: vi.fn(async (m: unknown) => {
+ sent = m as { id: string };
+ }),
+ };
+ const promise = internals(client).rawWireRequest(
+ "tasks/cancel",
+ { taskId: "x" },
+ ModernGetTaskResultSchema,
+ );
+ await Promise.resolve();
+ internals(client).consumeRawWireResponse({
+ id: sent!.id,
+ error: { code: -32602, message: "Unknown taskId" },
+ });
+ await expect(promise).rejects.toThrow(/Unknown taskId/);
+ });
+
+ it("rejects when the transport send fails", async () => {
+ const client = makeClient();
+ internals(client).transport = {
+ send: vi.fn().mockRejectedValue(new Error("socket closed")),
+ };
+ await expect(
+ internals(client).rawWireRequest(
+ "tasks/get",
+ {},
+ ModernGetTaskResultSchema,
+ ),
+ ).rejects.toThrow(/socket closed/);
+ });
+
+ it("rejects on timeout when no response arrives", async () => {
+ vi.useFakeTimers();
+ try {
+ const client = makeClient();
+ internals(client).requestTimeout = 10;
+ internals(client).transport = {
+ send: vi.fn().mockResolvedValue(undefined),
+ };
+ const promise = internals(client).rawWireRequest(
+ "tasks/get",
+ {},
+ ModernGetTaskResultSchema,
+ );
+ const assertion = expect(promise).rejects.toThrow(/timed out/);
+ await vi.advanceTimersByTimeAsync(20);
+ await assertion;
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("rejects all pending requests on teardown", async () => {
+ const client = makeClient();
+ internals(client).transport = {
+ send: vi.fn().mockResolvedValue(undefined),
+ };
+ const promise = internals(client).rawWireRequest(
+ "tasks/get",
+ {},
+ ModernGetTaskResultSchema,
+ );
+ await Promise.resolve();
+ internals(client).rejectPendingRawWireRequests("Disconnected");
+ await expect(promise).rejects.toThrow(/Disconnected/);
+ });
+});
diff --git a/clients/web/src/test/core/mcp/modernTaskSchemas.test.ts b/clients/web/src/test/core/mcp/modernTaskSchemas.test.ts
new file mode 100644
index 000000000..2bc9020a6
--- /dev/null
+++ b/clients/web/src/test/core/mcp/modernTaskSchemas.test.ts
@@ -0,0 +1,136 @@
+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/core/mcp/state/managedRequestorTasksState.test.ts b/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts
index 7e001c11a..2a7d3c8e1 100644
--- a/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts
+++ b/clients/web/src/test/core/mcp/state/managedRequestorTasksState.test.ts
@@ -210,6 +210,68 @@ describe("ManagedRequestorTasksState", () => {
expect(next[0]!.status).toBe("cancelled");
});
+ it("keeps a cancelled task cancelled when a later completed update arrives (#1631)", async () => {
+ // Cancellation is a deliberate terminal decision: a server that completes the
+ // task anyway (cooperative cancel) or the in-flight call resolving must not
+ // flip it back to completed.
+ client.setStatus("connected");
+ client.queueTaskPages({ tasks: [task("t1", "working")] });
+ await state.refresh();
+
+ client.dispatchTypedEvent("taskCancelled", { taskId: "t1" });
+ expect(state.getTasks()[0]!.status).toBe("cancelled");
+
+ // A late server notification and a client-origin update, both "completed":
+ let changed = false;
+ state.addEventListener("tasksChange", () => {
+ changed = true;
+ });
+ client.dispatchTypedEvent("taskStatusChange", {
+ taskId: "t1",
+ task: task("t1", "completed"),
+ });
+ client.dispatchTypedEvent("requestorTaskUpdated", {
+ taskId: "t1",
+ task: task("t1", "completed"),
+ });
+ expect(changed).toBe(false);
+ expect(state.getTasks()[0]!.status).toBe("cancelled");
+ });
+
+ it("still applies a subsequent cancelled update for a cancelled task", async () => {
+ client.setStatus("connected");
+ client.queueTaskPages({ tasks: [task("t1", "working")] });
+ await state.refresh();
+ client.dispatchTypedEvent("taskCancelled", { taskId: "t1" });
+
+ // A cancelled-status update is not blocked (it agrees with the sticky state).
+ const changePromise = waitForChange(state);
+ client.dispatchTypedEvent("taskStatusChange", {
+ taskId: "t1",
+ task: { ...task("t1", "cancelled"), statusMessage: "stopped" },
+ });
+ const next = await changePromise;
+ expect(next[0]!.status).toBe("cancelled");
+ expect(next[0]!.statusMessage).toBe("stopped");
+ });
+
+ it("keeps a cancelled task cancelled across a legacy refresh that re-lists it completed (#1631)", async () => {
+ // A cooperative-cancel server may complete the task anyway and still return
+ // it as "completed" from tasks/list. A manual Refresh must not un-stick the
+ // user's cancel — the legacy refresh path pins it to "cancelled" too.
+ client.setStatus("connected");
+ client.queueTaskPages({ tasks: [task("t1", "working")] });
+ await state.refresh();
+
+ client.dispatchTypedEvent("taskCancelled", { taskId: "t1" });
+ expect(state.getTasks()[0]!.status).toBe("cancelled");
+
+ // Server now lists the same task as completed; refresh must keep it cancelled.
+ client.queueTaskPages({ tasks: [task("t1", "completed")] });
+ await state.refresh();
+ expect(state.getTasks()[0]!.status).toBe("cancelled");
+ });
+
it("taskCancelled is a no-op when the task is unknown", async () => {
client.setStatus("connected");
client.queueTaskPages({ tasks: [task("t1")] });
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 8c6de5325..291a1d89f 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
@@ -903,9 +903,10 @@ describe("InspectorClient coverage backfill", () => {
});
describe("constructor capability branches and createReceiverTask options", () => {
- it("constructs with no advertised capabilities (clientOptions undefined branch)", async () => {
- // sample:false + elicit:false + no roots + no receiverTasks → the
- // capabilities object is empty, so clientOptions is passed as undefined.
+ 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
+ // `capabilities` is non-empty and is always attached.
client = new InspectorClient(
{
type: "stdio",
@@ -919,7 +920,10 @@ describe("InspectorClient coverage backfill", () => {
},
);
const caps = client.getClientCapabilities();
- expect(Object.keys(caps)).toHaveLength(0);
+ expect(caps.sampling).toBeUndefined();
+ expect(caps.elicitation).toBeUndefined();
+ expect(caps.roots).toBeUndefined();
+ expect(caps.extensions?.["io.modelcontextprotocol/tasks"]).toBeDefined();
await client.connect();
expect(client.getStatus()).toBe("connected");
});
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
new file mode 100644
index 000000000..6cb841325
--- /dev/null
+++ b/clients/web/src/test/integration/mcp/inspectorClient-tasks-era.test.ts
@@ -0,0 +1,380 @@
+import { describe, it, expect, afterEach, afterAll } from "vitest";
+import * as z from "zod/v4";
+import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
+import { createTransportNode } from "@inspector/core/mcp/node/transport.js";
+import { eraToVersionNegotiation } from "@inspector/core/mcp/types.js";
+import { ManagedRequestorTasksState } from "@inspector/core/mcp/state/managedRequestorTasksState.js";
+import {
+ createTestServerHttp,
+ type TestServerHttp,
+ createTestServerInfo,
+} from "@modelcontextprotocol/inspector-test-server";
+import type { ServerConfig } from "@modelcontextprotocol/inspector-test-server";
+import { getTaskServerConfig } from "@modelcontextprotocol/inspector-test-server";
+import type { ContentBlock } from "@modelcontextprotocol/client";
+import type { MessageEntry } from "@inspector/core/mcp/types.js";
+
+/**
+ * Live coverage of the Tasks era fork (#1631). Modern (2026-07-28) servers serve
+ * the `io.modelcontextprotocol/tasks` extension (SEP-2663): a task-augmented
+ * `tools/call` returns a `CreateTaskResult`, the client polls `tasks/get`
+ * (no `tasks/list`, no blocking `tasks/result`), an `input_required` task
+ * surfaces an embedded elicitation answered via `tasks/update`, and a completed
+ * task inlines its result. Legacy servers keep the `capabilities.tasks` /
+ * `tasks/list` flow. Both are driven against a real server over a real transport.
+ */
+describe("tasks era fork (#1631)", () => {
+ let client: InspectorClient | null = null;
+ // One server per era, shared across the block's tests (started lazily, stopped
+ // in afterAll). Starting a fresh Express server per test made connection
+ // negotiation flaky under the heavy concurrent coverage run; task ids are
+ // unique per create so sharing state is harmless.
+ let modernServer: TestServerHttp | null = null;
+ let legacyServer: TestServerHttp | null = null;
+
+ afterEach(async () => {
+ if (client) {
+ try {
+ await client.disconnect();
+ } catch {
+ // ignore
+ }
+ client = null;
+ }
+ });
+
+ afterAll(async () => {
+ for (const s of [modernServer, legacyServer]) {
+ if (s) {
+ try {
+ await s.stop();
+ } catch {
+ // ignore
+ }
+ }
+ }
+ modernServer = null;
+ legacyServer = null;
+ });
+
+ async function startModernTasksServer(): Promise {
+ if (modernServer) return modernServer;
+ const started = createTestServerHttp({
+ serverInfo: createTestServerInfo("tasks-modern-test", "1.0.0"),
+ tasksExtension: true,
+ // A plain tool alongside the task tools, so a modern tools/call that does
+ // NOT create a task exercises the synchronous-result path.
+ tools: [
+ {
+ name: "plain_echo",
+ description: "Echo the message without creating a task",
+ inputSchema: { message: z.string().optional() },
+ handler: async (args: Record) => ({
+ content: [
+ { type: "text" as const, text: `echo:${args.message ?? ""}` },
+ ],
+ }),
+ },
+ ],
+ modern: {},
+ });
+ await started.start();
+ modernServer = started;
+ return started;
+ }
+
+ async function startLegacyTasksServer(): Promise {
+ if (legacyServer) return legacyServer;
+ const config = getTaskServerConfig() as ServerConfig;
+ const started = createTestServerHttp({
+ ...config,
+ serverInfo: createTestServerInfo("tasks-legacy-test", "1.0.0"),
+ });
+ await started.start();
+ legacyServer = started;
+ return started;
+ }
+
+ async function connect(
+ url: string,
+ era: "legacy" | "modern",
+ ): Promise<{ connected: InspectorClient; messages: MessageEntry[] }> {
+ const connected = new InspectorClient(
+ { type: "streamable-http", url },
+ {
+ environment: { transport: createTransportNode },
+ versionNegotiation: eraToVersionNegotiation(era),
+ },
+ );
+ const messages: MessageEntry[] = [];
+ connected.addEventListener("message", (event) => {
+ messages.push(event.detail);
+ });
+ await connected.connect();
+ client = connected;
+ return { connected, messages };
+ }
+
+ function methodsSent(messages: MessageEntry[]): string[] {
+ return messages
+ .filter((m) => m.direction === "request")
+ .map((m) => ("method" in m.message ? m.message.method : ""))
+ .filter(Boolean);
+ }
+
+ function firstText(content: ContentBlock[] | undefined): string {
+ const block = content?.[0];
+ return block && "text" in block ? block.text : "";
+ }
+
+ describe("modern era", () => {
+ it("negotiates the tasks extension and gates on it", async () => {
+ const started = await startModernTasksServer();
+ const { connected } = await connect(started.url, "modern");
+ expect(connected.getProtocolEra()).toBe("modern");
+ expect(connected.isTasksExtensionNegotiated()).toBe(true);
+ expect(
+ connected.getCapabilities()?.extensions?.[
+ "io.modelcontextprotocol/tasks"
+ ],
+ ).toBeDefined();
+ });
+
+ it("creates a task, polls tasks/get (no tasks/list or tasks/result), and inlines the completed result", async () => {
+ const started = await startModernTasksServer();
+ const { connected, messages } = await connect(started.url, "modern");
+ const { tools } = await connected.listTools();
+ const tool = tools.find((t) => t.name === "modern_task");
+ expect(tool).toBeDefined();
+
+ messages.length = 0;
+ const invocation = await connected.callToolStream(tool!, {
+ message: "hi",
+ });
+ expect(invocation.success).toBe(true);
+ expect(firstText(invocation.result?.content as ContentBlock[])).toContain(
+ "completed",
+ );
+
+ const methods = methodsSent(messages);
+ expect(methods).toContain("tasks/get");
+ expect(methods).not.toContain("tasks/list");
+ expect(methods).not.toContain("tasks/result");
+
+ // Every task-eligible request declares the extension per-request (SEP-2663).
+ const call = messages.find(
+ (m) =>
+ m.direction === "request" &&
+ "method" in m.message &&
+ m.message.method === "tools/call",
+ );
+ const meta = (
+ call?.message as { params?: { _meta?: Record } }
+ ).params?._meta;
+ expect(
+ (
+ meta?.["io.modelcontextprotocol/clientCapabilities"] as {
+ extensions?: Record;
+ }
+ )?.extensions,
+ ).toHaveProperty("io.modelcontextprotocol/tasks");
+ });
+
+ it("answers an input_required task via tasks/update, then completes", async () => {
+ const started = await startModernTasksServer();
+ const { connected, messages } = await connect(started.url, "modern");
+ const { tools } = await connected.listTools();
+ const tool = tools.find((t) => t.name === "modern_input_task");
+ expect(tool).toBeDefined();
+
+ let pausedAtPendingUi = false;
+ connected.addEventListener("newPendingElicitation", (event) => {
+ pausedAtPendingUi = true;
+ // The task round is tagged distinctly from an MRTR round so the UI can
+ // show the accurate "answer via tasks/update" note (#1631 follow-up).
+ expect(event.detail.origin).toBe("task-input-required");
+ void event.detail.respond({
+ action: "accept",
+ content: { approved: true },
+ });
+ });
+
+ messages.length = 0;
+ const invocation = await connected.callToolStream(tool!, {});
+ expect(invocation.success).toBe(true);
+ expect(pausedAtPendingUi).toBe(true);
+ expect(firstText(invocation.result?.content as ContentBlock[])).toContain(
+ "approved",
+ );
+
+ const methods = methodsSent(messages);
+ expect(methods).toContain("tasks/update");
+ });
+
+ it("auto-polls an unsolicited task handle on the ordinary callTool path (#1631 follow-up)", async () => {
+ const started = await startModernTasksServer();
+ const { connected, messages } = await connect(started.url, "modern");
+ const { tools } = await connected.listTools();
+ const tool = tools.find((t) => t.name === "modern_task")!;
+
+ // The ordinary (non-run-as-task) path: the server answers with a task
+ // handle; callTool must poll it to termination and resolve to the task's
+ // final inline result — NOT the transport's "task created" placeholder.
+ const taskUpdates: string[] = [];
+ connected.addEventListener("requestorTaskUpdated", (event) => {
+ taskUpdates.push(event.detail.taskId);
+ });
+
+ messages.length = 0;
+ const invocation = await connected.callTool(tool, {
+ message: "unsolicited",
+ });
+ expect(invocation.success).toBe(true);
+ expect(firstText(invocation.result?.content as ContentBlock[])).toContain(
+ "completed",
+ );
+ expect(taskUpdates.length).toBeGreaterThan(0);
+ expect(methodsSent(messages)).toContain("tasks/get");
+ });
+
+ it("answers an input_required unsolicited handle via tasks/update on the ordinary path (#1631 follow-up)", async () => {
+ const started = await startModernTasksServer();
+ const { connected, messages } = await connect(started.url, "modern");
+ const { tools } = await connected.listTools();
+ const tool = tools.find((t) => t.name === "modern_input_task")!;
+
+ connected.addEventListener("newPendingElicitation", (event) => {
+ expect(event.detail.origin).toBe("task-input-required");
+ void event.detail.respond({
+ action: "accept",
+ content: { approved: true },
+ });
+ });
+
+ messages.length = 0;
+ const invocation = await connected.callTool(tool, {});
+ expect(invocation.success).toBe(true);
+ expect(firstText(invocation.result?.content as ContentBlock[])).toContain(
+ "approved",
+ );
+ 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");
+ const { tools } = await connected.listTools();
+
+ // A never-completing task: without the cancel-aborts-input fix, cancelling
+ // wouldn't unblock the poll — the modal would re-prompt forever.
+ const tool = tools.find((t) => t.name === "modern_loop_task")!;
+ let capturedTaskId: string | undefined;
+ let cancelledEventTaskId: string | undefined;
+ let cancelPromise: Promise | undefined;
+ connected.addEventListener("requestorTaskUpdated", (event) => {
+ capturedTaskId = event.detail.taskId;
+ });
+ connected.addEventListener("taskCancelled", (event) => {
+ cancelledEventTaskId = event.detail.taskId;
+ });
+ connected.addEventListener("newPendingElicitation", () => {
+ // Cancel the underlying task instead of answering — this must abort the
+ // pending elicitation (close the modal) and let the poll settle.
+ if (capturedTaskId && !cancelPromise) {
+ cancelPromise = connected
+ .cancelRequestorTask(capturedTaskId)
+ .catch(() => {});
+ }
+ });
+
+ messages.length = 0;
+ // The poll must unblock and the call settle (reject) — not hang or loop.
+ await expect(connected.callToolStream(tool, {})).rejects.toThrow();
+ // Let tasks/cancel finish (it dispatches taskCancelled on completion).
+ await cancelPromise;
+ expect(methodsSent(messages)).toContain("tasks/cancel");
+ expect(cancelledEventTaskId).toBe(capturedTaskId);
+ // The pending elicitation was aborted, not left dangling behind a modal.
+ expect(connected.getPendingElicitations()).toHaveLength(0);
+ });
+
+ it("passes a synchronous (non-task) tool result straight through", async () => {
+ const started = await startModernTasksServer();
+ const { connected } = await connect(started.url, "modern");
+ const { tools } = await connected.listTools();
+ const plain = tools.find((t) => t.name === "plain_echo");
+ expect(plain).toBeDefined();
+
+ const invocation = await connected.callToolStream(plain!, {
+ message: "hi",
+ });
+ expect(invocation.success).toBe(true);
+ expect(firstText(invocation.result?.content as ContentBlock[])).toContain(
+ "echo:hi",
+ );
+ });
+
+ it("rejects a tasks/get for an unknown task id (raw-wire error path)", async () => {
+ const started = await startModernTasksServer();
+ const { connected } = await connect(started.url, "modern");
+ await expect(
+ connected.getRequestorTask("does-not-exist"),
+ ).rejects.toThrow();
+ });
+
+ it("modern store refresh re-polls known tasks (no tasks/list)", async () => {
+ const started = await startModernTasksServer();
+ const { connected, messages } = await connect(started.url, "modern");
+ const store = new ManagedRequestorTasksState(connected);
+ const { tools } = await connected.listTools();
+ const tool = tools.find((t) => t.name === "modern_task")!;
+
+ // Running the task seeds the store via requestorTaskUpdated events.
+ await connected.callToolStream(tool, { message: "x" });
+ expect(store.getTasks().length).toBeGreaterThan(0);
+
+ messages.length = 0;
+ await store.refresh();
+ const methods = methodsSent(messages);
+ // Refresh re-polls with tasks/get; it never calls tasks/list on modern.
+ expect(methods).not.toContain("tasks/list");
+ store.destroy();
+ });
+ });
+
+ describe("legacy era", () => {
+ it("does not negotiate the extension and keeps the tasks/list flow", async () => {
+ const started = await startLegacyTasksServer();
+ const { connected, messages } = await connect(started.url, "legacy");
+ expect(connected.isTasksExtensionNegotiated()).toBe(false);
+ expect(connected.getCapabilities()?.tasks).toBeDefined();
+
+ const store = new ManagedRequestorTasksState(connected);
+ messages.length = 0;
+ await store.refresh();
+ expect(methodsSent(messages)).toContain("tasks/list");
+ store.destroy();
+ });
+ });
+});
diff --git a/core/mcp/__tests__/fakeInspectorClient.ts b/core/mcp/__tests__/fakeInspectorClient.ts
index 166665ba4..35111b6aa 100644
--- a/core/mcp/__tests__/fakeInspectorClient.ts
+++ b/core/mcp/__tests__/fakeInspectorClient.ts
@@ -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
diff --git a/core/mcp/inspectorClient.ts b/core/mcp/inspectorClient.ts
index e385dfc53..e594b89f3 100644
--- a/core/mcp/inspectorClient.ts
+++ b/core/mcp/inspectorClient.ts
@@ -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 = 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;
// 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,
+ ): Record {
+ const existingMeta =
+ (params._meta as Record | 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(
+ method: string,
+ params: Record,
+ resultSchema: { parse: (value: unknown) => T },
+ ): Promise {
+ 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((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,
+ ): Promise {
+ 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 | undefined> {
if (!inputRequests) return undefined;
const responses: Record = {};
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 {
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 {
+ 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 = {};
+ 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 },
+ },
+ },
+ };
+ }
+ 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 {
+ 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,
diff --git a/core/mcp/inspectorClientProtocol.ts b/core/mcp/inspectorClientProtocol.ts
index cf8d187e0..d2e44cc75 100644
--- a/core/mcp/inspectorClientProtocol.ts
+++ b/core/mcp/inspectorClientProtocol.ts
@@ -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;
+ /** 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
diff --git a/core/mcp/messageTrackingTransport.ts b/core/mcp/messageTrackingTransport.ts
index d55158800..32b17169d 100644
--- a/core/mcp/messageTrackingTransport.ts
+++ b/core/mcp/messageTrackingTransport.ts
@@ -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 {
@@ -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");
diff --git a/core/mcp/modernTaskSchemas.ts b/core/mcp/modernTaskSchemas.ts
new file mode 100644
index 000000000..885717508
--- /dev/null
+++ b/core/mcp/modernTaskSchemas.ts
@@ -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;
+
+/** `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;
+}
diff --git a/core/mcp/state/managedRequestorTasksState.ts b/core/mcp/state/managedRequestorTasksState.ts
index 1bb3ec102..a75d28d48 100644
--- a/core/mcp/state/managedRequestorTasksState.ts
+++ b/core/mcp/state/managedRequestorTasksState.ts
@@ -45,6 +45,12 @@ export class ManagedRequestorTasksState extends TypedEventTarget();
+ // 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();
constructor(client: InspectorClientProtocol) {
super();
@@ -59,14 +65,21 @@ export class ManagedRequestorTasksState extends TypedEventTarget
+ this.dismissedTaskIds.has(taskId) ||
+ (this.cancelledTaskIds.has(taskId) && status !== "cancelled");
const onTaskStatusChange = (
e: TypedEventGeneric,
): 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,
): 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 {
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 !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 {
+ 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;
} = {};
- if (config.tools !== undefined) {
+ // The modern tasks extension (SEP-2663) needs the tools capability too (its
+ // task-augmented tools list via `tools/list`), even when no `config.tools`
+ // were supplied.
+ if (config.tools !== undefined || config.tasksExtension) {
capabilities.tools = {};
}
if (
@@ -651,6 +676,19 @@ export function createMcpServer(config: ServerConfig): McpServer {
}
}
+ // Modern tasks extension (SEP-2663): advertise it in server/discover and reuse
+ // (or lazily create + cache) the shared runtime so the stateless modern leg's
+ // per-request servers all answer against the same task store.
+ const modernTaskRuntime = config.tasksExtension
+ ? (config.modernTaskRuntime ??= new ModernTaskRuntime())
+ : undefined;
+ if (config.tasksExtension) {
+ capabilities.extensions = {
+ ...(capabilities.extensions ?? {}),
+ [TASKS_EXTENSION_KEY]: {},
+ };
+ }
+
// Create the in-memory task store if tasks are enabled. SDK v2 has no built-in
// task runtime, so the store is owned here and its methods answer the wire.
const taskStore =
@@ -746,9 +784,15 @@ export function createMcpServer(config: ServerConfig): McpServer {
// (returning a task handle), reproducing the deleted SDK task runtime.
const taskTools = new Map();
- // Set up tools
- if (config.tools && config.tools.length > 0) {
- for (const tool of config.tools) {
+ // Set up tools. The modern tasks extension contributes its task-augmented
+ // tools (registered as ordinary tools so they surface in `tools/list`; their
+ // `tools/call` is intercepted by `wireModernTaskHandlers` below).
+ const effectiveTools: (ToolDefinition | TaskToolDefinition)[] = [
+ ...(config.tools ?? []),
+ ...(config.tasksExtension ? createModernTaskTools() : []),
+ ];
+ if (effectiveTools.length > 0) {
+ for (const tool of effectiveTools) {
if (isTaskTool(tool)) {
// Register the task tool as an ordinary tool so it surfaces in
// `tools/list` with its input schema, `_meta`, and `execution` support.
@@ -1254,6 +1298,12 @@ export function createMcpServer(config: ServerConfig): McpServer {
wireTaskHandlers(mcpServer, taskStore, taskTools);
}
+ // Modern tasks extension (SEP-2663): raw tasks/get, tasks/update, tasks/cancel
+ // (no tasks/list, no tasks/result) plus the CreateTaskResult tools/call seam.
+ if (modernTaskRuntime) {
+ wireModernTaskHandlers(mcpServer, modernTaskRuntime);
+ }
+
return mcpServer;
}
diff --git a/test-servers/src/load-config.ts b/test-servers/src/load-config.ts
index 3f0384f0b..21e9da33c 100644
--- a/test-servers/src/load-config.ts
+++ b/test-servers/src/load-config.ts
@@ -56,6 +56,10 @@ export interface ConfigFile {
list?: boolean;
cancel?: boolean;
};
+ /** Advertise the modern (SEP-2663) `io.modelcontextprotocol/tasks` extension
+ * and wire its handlers + `modern_task` / `modern_input_task` tools. Pair with
+ * `transport.modern`. */
+ tasksExtension?: boolean;
maxPageSize?: {
tools?: number;
resources?: number;
diff --git a/test-servers/src/modern-tasks.ts b/test-servers/src/modern-tasks.ts
new file mode 100644
index 000000000..3bb01dc68
--- /dev/null
+++ b/test-servers/src/modern-tasks.ts
@@ -0,0 +1,408 @@
+/**
+ * Modern (2026-07-28) Tasks extension server — SEP-2663
+ * (`io.modelcontextprotocol/tasks`).
+ *
+ * SDK v2 removed all built-in tasks support, and the redesigned extension is a
+ * different wire shape than the deleted 2025-11-25 runtime (`ttlMs`/`pollIntervalMs`,
+ * a polymorphic `DetailedTask` inlining `result`/`error`/`inputRequests`,
+ * `tasks/get` polling, a new `tasks/update`, no `tasks/list`, no blocking
+ * `tasks/result`). This module wires a minimal, poll-driven modern task runtime
+ * by hand so an Inspector connecting with **Protocol Era = Modern** can exercise
+ * the full flow: a task-augmented `tools/call` returns a `CreateTaskResult`
+ * (`resultType: "task"`); the client polls `tasks/get`; an `input_required` task
+ * surfaces an embedded elicitation the client answers via `tasks/update`; and
+ * the completed task inlines its `CallToolResult`.
+ *
+ * The runtime is a single shared `Map` so it survives across the stateless
+ * modern leg's per-request server instances (each `createMcpHandler` request
+ * builds a fresh `McpServer`, but they share one runtime via the config).
+ */
+
+import * as z from "zod/v4";
+import type { RequestHandler } from "express";
+import type { McpServer } from "@modelcontextprotocol/server";
+import type { ToolDefinition } from "./composable-test-server.js";
+
+/** SEP-2133 extension identifier for the redesigned Tasks extension. */
+export const TASKS_EXTENSION_KEY = "io.modelcontextprotocol/tasks";
+
+/** Names of the modern task-augmented tools whose `tools/call` returns a task. */
+export const MODERN_TASK_TOOL_NAMES = new Set([
+ "modern_task",
+ "modern_input_task",
+ // Never completes — stays `input_required` on every poll, so a client keeps
+ // being re-prompted. Used to exercise the client's input-round cap.
+ "modern_loop_task",
+]);
+
+const DEFAULT_TTL_MS = 60_000;
+const DEFAULT_POLL_INTERVAL_MS = 500;
+/** `modern_task` reports `working` for this many `tasks/get` polls before completing. */
+const SIMPLE_WORKING_POLLS = 2;
+
+type ModernTaskStatus =
+ "working" | "input_required" | "completed" | "failed" | "cancelled";
+
+interface ModernTaskEntry {
+ taskId: string;
+ kind: "simple" | "input" | "loop";
+ status: ModernTaskStatus;
+ createdAt: string;
+ lastUpdatedAt: string;
+ args: Record;
+ /** Remaining `working` polls for the simple task before it completes. */
+ pollsRemaining: number;
+ /** Set once the client answered the embedded elicitation via `tasks/update`. */
+ inputSatisfied?: boolean;
+ /** The `inputResponses` the client submitted, echoed back in the result. */
+ inputResponses?: Record;
+}
+
+/** The embedded elicitation an `input_required` modern task surfaces. Shaped as
+ * a standalone `elicitation/create` request so the client's pending-request UI
+ * (reused from the MRTR path) renders it and returns an `ElicitResult`. */
+function confirmInputRequests(): Record {
+ return {
+ confirm: {
+ method: "elicitation/create",
+ params: {
+ message: "Approve this task before it continues?",
+ requestedSchema: {
+ type: "object",
+ properties: {
+ approved: {
+ type: "boolean",
+ title: "Approved",
+ description: "Whether to proceed with the task",
+ },
+ },
+ required: ["approved"],
+ },
+ },
+ },
+ };
+}
+
+function nowIso(): string {
+ return new Date().toISOString();
+}
+
+function newTaskId(): string {
+ return (
+ globalThis.crypto?.randomUUID?.() ??
+ `task-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
+ );
+}
+
+/**
+ * Shared, in-memory modern task runtime. One instance is created per modern
+ * server config and reused across the stateless leg's per-request servers, so a
+ * task created by one `tools/call` request is visible to a later `tasks/get`.
+ */
+export class ModernTaskRuntime {
+ private tasks = new Map();
+
+ /** Handle a task-augmented `tools/call`: durably create the task and return a
+ * `CreateTaskResult` (`resultType: "task"`) seed. */
+ createTask(
+ toolName: string,
+ args: Record,
+ ): Record {
+ const now = nowIso();
+ const kind: ModernTaskEntry["kind"] =
+ toolName === "modern_input_task"
+ ? "input"
+ : toolName === "modern_loop_task"
+ ? "loop"
+ : "simple";
+ const entry: ModernTaskEntry = {
+ taskId: newTaskId(),
+ kind,
+ status: "working",
+ createdAt: now,
+ lastUpdatedAt: now,
+ args,
+ pollsRemaining: SIMPLE_WORKING_POLLS,
+ };
+ this.tasks.set(entry.taskId, entry);
+ return {
+ resultType: "task",
+ ...this.project(entry),
+ statusMessage: "The operation is now in progress.",
+ };
+ }
+
+ /** Serve `tasks/get`: advance the scripted lifecycle and return a
+ * `DetailedTask` (`resultType: "complete"`). Throws on an unknown id. */
+ getTask(taskId: string): Record {
+ const entry = this.requireTask(taskId);
+ this.advance(entry);
+ return { resultType: "complete", ...this.project(entry) };
+ }
+
+ /** Serve `tasks/update`: record the answered `inputResponses` and ack empty. */
+ updateTask(
+ taskId: string,
+ inputResponses: Record | undefined,
+ ): Record {
+ const entry = this.requireTask(taskId);
+ entry.inputSatisfied = true;
+ if (inputResponses) entry.inputResponses = inputResponses;
+ this.touch(entry);
+ return { resultType: "complete" };
+ }
+
+ /** Serve `tasks/cancel`: move the task to `cancelled` and ack empty. */
+ cancelTask(taskId: string): Record {
+ const entry = this.requireTask(taskId);
+ if (
+ entry.status !== "completed" &&
+ entry.status !== "failed" &&
+ entry.status !== "cancelled"
+ ) {
+ entry.status = "cancelled";
+ this.touch(entry);
+ }
+ return { resultType: "complete" };
+ }
+
+ private requireTask(taskId: string): ModernTaskEntry {
+ const entry = this.tasks.get(taskId);
+ if (!entry) throw new Error(`Unknown taskId: ${taskId}`);
+ return entry;
+ }
+
+ private touch(entry: ModernTaskEntry): void {
+ entry.lastUpdatedAt = nowIso();
+ }
+
+ /** Move a task one step along its scripted lifecycle on each poll. */
+ private advance(entry: ModernTaskEntry): void {
+ if (
+ entry.status === "completed" ||
+ entry.status === "failed" ||
+ entry.status === "cancelled"
+ ) {
+ return;
+ }
+ if (entry.kind === "simple") {
+ if (entry.pollsRemaining > 0) {
+ entry.pollsRemaining -= 1;
+ } else {
+ entry.status = "completed";
+ }
+ } else if (entry.kind === "loop") {
+ // Never advances: stays input_required every poll (ignores tasks/update),
+ // so a client is re-prompted indefinitely — exercises its round cap.
+ entry.status = "input_required";
+ } else {
+ // input task: request input, then complete once the client has answered.
+ entry.status = entry.inputSatisfied ? "completed" : "input_required";
+ }
+ this.touch(entry);
+ }
+
+ /** Project the internal entry onto the wire `DetailedTask`, inlining the
+ * status-specific member (`result` / `inputRequests`). */
+ private project(entry: ModernTaskEntry): Record {
+ const base: Record = {
+ taskId: entry.taskId,
+ status: entry.status,
+ createdAt: entry.createdAt,
+ lastUpdatedAt: entry.lastUpdatedAt,
+ ttlMs: DEFAULT_TTL_MS,
+ };
+ // The simple task advertises a poll interval; the input task omits it so a
+ // client falls back to its own default cadence (both paths exercised).
+ if (entry.kind === "simple") {
+ base.pollIntervalMs = DEFAULT_POLL_INTERVAL_MS;
+ }
+ if (entry.status === "input_required") {
+ base.inputRequests = confirmInputRequests();
+ }
+ if (entry.status === "completed") {
+ base.result = this.completedResult(entry);
+ }
+ return base;
+ }
+
+ /** The `CallToolResult` a completed task inlines (SEP-2663: no `tasks/result`). */
+ private completedResult(entry: ModernTaskEntry): Record {
+ const detail =
+ entry.kind === "input"
+ ? `input=${JSON.stringify(entry.inputResponses ?? {})}`
+ : `args=${JSON.stringify(entry.args ?? {})}`;
+ return {
+ content: [
+ {
+ type: "text",
+ text: `Modern task ${entry.taskId} completed (${detail}).`,
+ },
+ ],
+ isError: false,
+ };
+ }
+}
+
+/** The two plain tools whose `tools/call` the modern task handlers intercept.
+ * Their handlers are placeholders — {@link wireModernTaskHandlers} routes these
+ * names to the runtime before the SDK handler runs. */
+export function createModernTaskTools(): ToolDefinition[] {
+ const placeholder: ToolDefinition["handler"] = async () => ({
+ content: [
+ { type: "text" as const, text: "Task tool must be invoked as a task." },
+ ],
+ isError: true,
+ });
+ return [
+ {
+ name: "modern_task",
+ description:
+ "Create a modern (SEP-2663) task that reports progress over a few polls then completes.",
+ inputSchema: {
+ message: z
+ .string()
+ .optional()
+ .describe("Text echoed back in the completed task result."),
+ },
+ handler: placeholder,
+ },
+ {
+ name: "modern_input_task",
+ description:
+ "Create a modern task that pauses at input_required and completes after tasks/update.",
+ inputSchema: {},
+ handler: placeholder,
+ },
+ {
+ name: "modern_loop_task",
+ description:
+ "Create a modern task that never completes — stays input_required every poll, so the client's round cap trips.",
+ inputSchema: {},
+ handler: placeholder,
+ },
+ ];
+}
+
+interface RawHandlerHost {
+ _requestHandlers: Map<
+ string,
+ (request: unknown, ctx: unknown) => Promise
+ >;
+}
+
+interface ToolsCallRequest {
+ params: { name: string; arguments?: Record };
+}
+
+interface TaskMethodRequest {
+ params: {
+ taskId: string;
+ inputResponses?: Record;
+ };
+}
+
+/**
+ * Wire the modern task methods by hand onto an `McpServer`:
+ * - override `tools/call` so a modern task tool returns a `CreateTaskResult`
+ * (`resultType: "task"`) and durably creates the task; ordinary tools fall
+ * through to the SDK handler;
+ * - register raw `tasks/get` / `tasks/update` / `tasks/cancel` handlers backed
+ * by the shared runtime. There is deliberately **no** `tasks/list` and **no**
+ * `tasks/result` (SEP-2663 removed both).
+ *
+ * The `tools/call` override is installed into the private handler registry (not
+ * via `setRequestHandler`) so the `CreateTaskResult` skips the `Server`'s
+ * `tools/call` result-schema validation — the same seam the legacy task server
+ * uses. This goes away when the SDK models the tasks extension natively.
+ */
+export function wireModernTaskHandlers(
+ mcpServer: McpServer,
+ runtime: ModernTaskRuntime,
+): void {
+ const lowLevel = mcpServer.server;
+ const registry = (lowLevel as unknown as RawHandlerHost)._requestHandlers;
+ const sdkToolsCall = registry.get("tools/call");
+
+ registry.set("tools/call", async (request, ctx) => {
+ const req = request as ToolsCallRequest;
+ if (MODERN_TASK_TOOL_NAMES.has(req.params.name)) {
+ return runtime.createTask(req.params.name, req.params.arguments ?? {});
+ }
+ if (!sdkToolsCall) {
+ throw new Error("tools/call handler is not initialized");
+ }
+ return sdkToolsCall(request, ctx);
+ });
+
+ registry.set("tasks/get", async (request) => {
+ const req = request as TaskMethodRequest;
+ return runtime.getTask(req.params.taskId);
+ });
+
+ registry.set("tasks/update", async (request) => {
+ const req = request as TaskMethodRequest;
+ return runtime.updateTask(req.params.taskId, req.params.inputResponses);
+ });
+
+ registry.set("tasks/cancel", async (request) => {
+ const req = request as TaskMethodRequest;
+ return runtime.cancelTask(req.params.taskId);
+ });
+}
+
+const MODERN_TASK_METHODS = new Set([
+ "tasks/get",
+ "tasks/update",
+ "tasks/cancel",
+]);
+
+interface TaskRpcBody {
+ jsonrpc?: string;
+ id?: unknown;
+ method?: string;
+ params?: { taskId?: string; inputResponses?: Record };
+}
+
+/**
+ * Express middleware that answers modern `tasks/get` / `tasks/update` /
+ * `tasks/cancel` POSTs DIRECTLY, before the SDK's `createMcpHandler` sees them.
+ * SDK v2's modern leg era-gates inbound spec methods, so `tasks/*` (spec-method
+ * names removed from the 2026-07-28 era) would be answered `-32601 Method not
+ * found` by the handler — a conformant server can't serve the extension through
+ * the SDK. Intercepting at the HTTP layer is how the test server serves the
+ * extension anyway (mirrors the `specErrorInjector` seam). `tasks` task
+ * creation still flows through `tools/call` (a modern method) inside the handler.
+ */
+export function createModernTaskInterceptor(
+ getRuntime: () => ModernTaskRuntime,
+): RequestHandler {
+ return (req, res, next) => {
+ const body = req.body as TaskRpcBody | undefined;
+ const method = body?.method;
+ if (!method || !MODERN_TASK_METHODS.has(method)) {
+ next();
+ return;
+ }
+ const runtime = getRuntime();
+ const id = body?.id ?? null;
+ const taskId = body?.params?.taskId ?? "";
+ try {
+ let result: Record;
+ if (method === "tasks/get") {
+ result = runtime.getTask(taskId);
+ } else if (method === "tasks/update") {
+ result = runtime.updateTask(taskId, body?.params?.inputResponses);
+ } else {
+ result = runtime.cancelTask(taskId);
+ }
+ res.json({ jsonrpc: "2.0", id, result });
+ } catch (err) {
+ res.json({
+ jsonrpc: "2.0",
+ id,
+ error: { code: -32602, message: (err as Error).message },
+ });
+ }
+ };
+}
diff --git a/test-servers/src/resolve-config.ts b/test-servers/src/resolve-config.ts
index 06b6f7a84..6a7f0cb7d 100644
--- a/test-servers/src/resolve-config.ts
+++ b/test-servers/src/resolve-config.ts
@@ -89,6 +89,7 @@ export function resolveConfig(config: ConfigFile): ServerConfig {
listChanged: config.listChanged,
subscriptions: config.subscriptions,
tasks: config.tasks,
+ tasksExtension: config.tasksExtension,
maxPageSize: config.maxPageSize,
serverType: isHttp
? (transport.type as "sse" | "streamable-http")
diff --git a/test-servers/src/test-server-http.ts b/test-servers/src/test-server-http.ts
index a8cc73d67..6776ae01a 100644
--- a/test-servers/src/test-server-http.ts
+++ b/test-servers/src/test-server-http.ts
@@ -8,6 +8,10 @@ import type {
McpHttpHandler,
} from "@modelcontextprotocol/server";
import { createMcpServer } from "./test-server-fixtures.js";
+import {
+ ModernTaskRuntime,
+ createModernTaskInterceptor,
+} from "./modern-tasks.js";
import { SSEServerTransport } from "@modelcontextprotocol/server-legacy/sse";
import type { Request, Response } from "express";
import express from "express";
@@ -365,6 +369,19 @@ export class TestServerHttp {
? [specErrorInjector]
: [];
+ // Modern tasks extension (SEP-2663): the SDK's modern leg era-gates inbound
+ // `tasks/*` spec methods, so serve them from a middleware ahead of the SDK
+ // handler, backed by the shared runtime (also used by the tools/call task
+ // seam inside `createMcpServer`).
+ if (this.config.tasksExtension) {
+ this.configWithCallback.modernTaskRuntime ??= new ModernTaskRuntime();
+ extraMiddleware.push(
+ createModernTaskInterceptor(
+ () => this.configWithCallback.modernTaskRuntime!,
+ ),
+ );
+ }
+
app.post("/mcp", ...mcpMiddleware, ...extraMiddleware, route);
app.get("/mcp", ...mcpMiddleware, route);
app.delete("/mcp", ...mcpMiddleware, route);