ai-ml

MCP Tasks Extension: A Working TypeScript Server (2026)

September 18, 2026

MCP Tasks Extension: A Working TypeScript Server (2026)

The MCP Tasks extension (io.modelcontextprotocol/tasks), ratified in the 2026-07-28 spec, lets a tool call return a durable handle instead of blocking: clients poll tasks/get, answer mid-task input via tasks/update, and cancel with tasks/cancel12. No official TypeScript SDK package implements that exact shape yet.

That gap isn't obvious from the docs. As of this writing, @modelcontextprotocol/sdk's built-in task support still speaks an older, pre-ratification version, and its newly split v2 packages don't implement Tasks at all in their active wire vocabulary — they only keep that same older version around as dead, non-runtime types. This post builds a server against the ratified spec by hand, verifies it end to end against a real client, and shows exactly where the shipped SDK diverges.

TL;DR

The ratified MCP Tasks extension has three methods — tasks/get, tasks/update, tasks/cancel — and no tasks/list, by design, for security reasons1. @modelcontextprotocol/sdk v1.30.0's experimental/tasks module still implements an earlier shape with tasks/list, tasks/result, and no tasks/update at all3; the new @modelcontextprotocol/{core,server,client} v2.0.0 packages don't implement any Tasks methods in their active wire vocabulary at all, and keep that same earlier shape around only as dead, non-runtime type exports4. The one officially published package that does match the ratified spec, @modelcontextprotocol/ext-tasks, is requester-side only5. This post hand-rolls a spec-correct server against the SDK's low-level Server class, runs it against a real client, and walks through a genuine bug the process caught: returning a stray setTimeout handle in a task record silently hangs the client, because the transport can't serialize it and never reports why.

What you'll learn

  • The exact wire shapes of tasks/get, tasks/update, and tasks/cancel per the ratified 2026-07-28 spec, including the input_required round trip
  • Why @modelcontextprotocol/sdk's own capability-gating code — not just its docs — proves it doesn't know about tasks/update
  • How the split v2 SDK packages (@modelcontextprotocol/core, /server, /client) compare, and where @modelcontextprotocol/ext-tasks fits as the one ratified-spec-aligned package that exists today
  • How to implement all three task methods by hand against the low-level Server class, including the tasks capability declaration the SDK enforces at runtime
  • A real bug caught only by running the code: a non-serializable value in a task record hangs the client with no error on either side
  • What a full task lifecycle looks like end to end, from working through input_required to completed, with real captured output

What the Tasks extension actually specifies

Tasks are a Model Context Protocol extension, not a change to the core protocol — support is negotiated per request, and a server may return a task handle in place of a normal result for any request type the extension covers (currently just tools/call)1. When a server decides to defer a call, it returns a CreateTaskResult — a normal-looking JSON-RPC result with resultType: "task" and a Task object embedded in it:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "resultType": "task",
    "taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
    "status": "working",
    "createdAt": "2025-11-25T10:30:00Z",
    "lastUpdatedAt": "2025-11-25T10:40:00Z",
    "ttlMs": 60000,
    "pollIntervalMs": 5000
  }
}

From there the client owns three operations, and only three1:

MethodPurposeNotable rule
tasks/getPoll current status; terminal states inline their result or error directly in the responseNo separate "fetch the result" call — tasks/get on a completed task returns the result itself
tasks/updateAnswer a task's input_required state via inputResponses, keyed to the inputRequests the server sentAcknowledgement is fire-and-forget; the client resumes polling tasks/get to see the effect
tasks/cancelSignal intent to stop a taskCooperative only — the server may finish anyway, and the client isn't obligated to wait for a cancelled status before discarding local state

A Task moves through five possible statuses: working, input_required, completed, cancelled, failed1. The spec is explicit that there is deliberately no tasks/list:

"Because there is no tasks/list, a server cannot inadvertently leak the existence of one caller's tasks to another. This is an improvement over the 2025-11-25 tasks specification, in which a poorly-scoped list could expose unrelated task IDs."1

That line matters for what follows, because the SDK code currently shipping still has a tasks/list method.

What's actually shipping: three implementations, one spec

Before writing a line of server code, it's worth knowing what's actually installable today (checked 2026-09-18). There are three separate places Tasks logic lives in the TypeScript ecosystem, and they don't agree with each other.

@modelcontextprotocol/sdk@1.30.0 — the long-running v1.x SDK line — ships an ./experimental/tasks module. Installing it and reading the compiled dist/esm/types.js directly shows the literal JSON-RPC method strings it registers: tasks/get, tasks/result, tasks/list, tasks/cancel, and a notification method notifications/tasks/status3. There is no tasks/update anywhere in the package. That's the 2025-11-25 draft shape the spec's own security section describes as superseded, not the ratified one.

@modelcontextprotocol/{core,server,client}@2.0.0 — the newly split v2 line — tells a starker story than a surface grep for method strings suggests. The compiled output is organized into separate versioned "wire era" registries: a legacy wire/rev2025-11-25/registry.ts region, and an active wire/rev2026-07-28/registry.ts region that the SDK's live request dispatch actually consults. tasks/get, tasks/list, tasks/result, tasks/cancel, and notifications/tasks/status do still exist in the compiled output4 — but only inside the legacy registry, and the JSDoc directly above each one reads "@deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only." The active 2026-07-28 registry — the one whose requestMethodKeys/notificationMethodKeys actually back the running SDK's dispatch — has zero tasks/* or notifications/tasks* entries, old shape or new4. So v2 didn't just fail to catch up to the ratified three-method shape; its live wire vocabulary dropped Tasks entirely, and the old shape survives only as inert legacy types that no request handler ever sees. A GitHub issue filed at an MCP maintainer's request while v2 was still in beta confirms this wasn't an accident: "v2 removed experimental tasks per SEP-2663," pending a proper reimplementation once the extension's own package caught up6. As of this writing, v2.0.0 is GA and neither its live registry nor its legacy one implements the ratified shape.

@modelcontextprotocol/ext-tasks@0.1.0 is the one officially published modelcontextprotocol org package that does match. It's the official reference implementation of the ratified extension, published from the modelcontextprotocol/ext-tasks GitHub repository5. Its core/v2 module's compiled schema file contains exactly the ratified method set — tasks/get, tasks/update, tasks/cancel, notifications/tasks — confirmed by grepping the built JavaScript, not just reading the TypeScript source5. But the package is requester-side only: its client entry point (createTaskSessionFromClient, withTasks) drives a task-augmented tool call from the client, and it explicitly requires an @modelcontextprotocol/client v2 Client instance, not the v1.x SDK's5. There's no equivalent server-side helper published. Its separate receiver module binds to the same v2 Client type as the requester side, but is documented as handling 2025-11-25 Tasks requests — the old shape again, and for a different scenario (server-to-client sampling and elicitation, not tools/call).

Put together: if you want a server that speaks the ratified three-method Tasks extension today, no published package hands it to you. You implement it against the low-level protocol primitives, which is what the rest of this post does.

Building the server by hand

The demo server below uses @modelcontextprotocol/sdk@1.30.0's low-level Server class — the SDK's own type declarations mark it @deprecated with the note "Use McpServer instead for the high-level API. Only use Server for advanced use cases," which implementing a not-yet-supported extension qualifies as3. The wire schemas are written from scratch against the spec text quoted above, not imported from experimental/tasks, since that module speaks the wrong methods.

// schemas.ts — Zod schemas transcribed directly from the ratified spec.
import { z } from "zod";

export const TaskStatusSchema = z.enum([
  "working", "input_required", "completed", "cancelled", "failed",
]);

export const TaskSchema = z.object({
  taskId: z.string(),
  status: TaskStatusSchema,
  statusMessage: z.string().optional(),
  createdAt: z.string(),
  lastUpdatedAt: z.string(),
  ttlMs: z.number().nullable(),
  pollIntervalMs: z.number().optional(),
});

export const CreateTaskResultSchema = TaskSchema.extend({
  resultType: z.literal("task"),
});

export const GetTaskRequestSchema = z.object({
  method: z.literal("tasks/get"),
  params: z.object({ taskId: z.string() }),
});

export const UpdateTaskRequestSchema = z.object({
  method: z.literal("tasks/update"),
  params: z.object({
    taskId: z.string(),
    inputResponses: z.record(z.string(), z.object({
      action: z.string(),
      content: z.record(z.string(), z.unknown()).optional(),
    })),
  }),
});

export const CancelTaskRequestSchema = z.object({
  method: z.literal("tasks/cancel"),
  params: z.object({ taskId: z.string() }),
});

(The full file also defines a matching GetTaskResultSchema, UpdateTaskResultSchema, and CancelTaskResultSchema for the three methods' responses, following the same pattern — trimmed here for space.)

The first runtime surprise shows up immediately when you try to register a handler for tasks/get. The low-level Server's setRequestHandler calls an internal assertRequestHandlerCapability(method) check before it'll accept the registration, and its compiled source hardcodes exactly which methods that check recognizes:

// from @modelcontextprotocol/sdk@1.30.0's compiled server/index.js
case 'tasks/get':
case 'tasks/list':
case 'tasks/result':
case 'tasks/cancel':
    if (!this._capabilities.tasks) {
        throw new Error(`Server does not support tasks capability (required for ${method})`);
    }
    break;

Two things fall out of that switch statement. First, you must declare capabilities: { tools: {}, tasks: {} } when constructing the Server, or registering a tasks/get or tasks/cancel handler throws immediately. Second, tasks/update is not in that list at all — the SDK's capability gate has no concept of it, so registering a handler for it neither requires nor benefits from the tasks capability flag. That gap by itself is a working proof that the shipped SDK's task-capability plumbing was built against the pre-tasks/update shape.

// server.ts — advertise tools + tasks, register the standard tool methods,
// then register the three Tasks methods by hand.
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { GetTaskRequestSchema, UpdateTaskRequestSchema, CancelTaskRequestSchema } from "./schemas.js";

const server = new Server(
  { name: "tasks-demo-server", version: "0.1.0" },
  { capabilities: { tools: {}, tasks: {} } },
);

The tool itself, summarize_report, always defers to a task when the caller declares support for the extension, and falls back to an ordinary synchronous result otherwise — the spec is explicit that "a server MUST NOT return CreateTaskResult to a client that did not include the extension capability on its request"1:

function clientDeclaredTasksCapability(meta: unknown): boolean {
  if (!meta || typeof meta !== "object") return false;
  const caps = (meta as Record<string, unknown>)["io.modelcontextprotocol/clientCapabilities"];
  const extensions = caps && typeof caps === "object" ? (caps as any).extensions : undefined;
  return !!extensions && "io.modelcontextprotocol/tasks" in extensions;
}

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const wantsTasks = clientDeclaredTasksCapability((request.params as any)._meta);
  if (!wantsTasks) {
    return { content: [{ type: "text", text: "Summary: (synchronous fallback)" }] };
  }

  const taskId = crypto.randomUUID();
  const created = new Date().toISOString();
  const record = {
    taskId, status: "working" as const,
    createdAt: created, lastUpdatedAt: created,
    ttlMs: 5 * 60 * 1000, pollIntervalMs: 500,
  };
  tasks.set(taskId, record);
  scheduleWork(taskId, (request.params.arguments as any)?.simulateFail); // simulated background work; see below for a bug it exposed
  return { resultType: "task", ...record };
});

The client, and the poll loop

The client declares the extension the same way, in _meta on the tools/call request, then drives its own poll loop against tasks/get — honoring pollIntervalMs and switching behavior on status:

const initial = await client.request(
  {
    method: "tools/call",
    params: {
      name: "summarize_report",
      arguments: { reportId },
      _meta: {
        "io.modelcontextprotocol/clientCapabilities": {
          extensions: { "io.modelcontextprotocol/tasks": {} },
        },
      },
    },
  },
  CallToolOrTaskResultSchema,
);

if (initial.resultType !== "task") { /* handle the synchronous path */ }

let { taskId, pollIntervalMs = 1000 } = initial;
while (true) {
  await sleep(pollIntervalMs);
  const status = await client.request(
    { method: "tasks/get", params: { taskId } },
    GetTaskResultSchema,
  );
  pollIntervalMs = status.pollIntervalMs ?? pollIntervalMs;

  if (status.status === "input_required") {
    await client.request(
      {
        method: "tasks/update",
        params: {
          taskId,
          inputResponses: {
            redaction_level: { action: "accept", content: { level: "partial" } },
          },
        },
      },
      UpdateTaskResultSchema,
    );
    continue;
  }
  if (status.status === "completed") { console.log(status.result); break; }
  if (status.status === "failed") { console.log(status.error); break; }
  if (status.status === "cancelled") break;
}

The input_required round trip

Much of the existing coverage of Tasks stops at the simple case — create a task, poll it, get a result. The more useful part of the spec, and the part that actually distinguishes Tasks from a plain "poll until done" pattern, is input_required: a server can pause a task mid-flight, surface one or more outstanding requests in inputRequests, and wait for the client to answer them through tasks/update before resuming1. The demo server exercises this deliberately: summarize_report's simulated work pauses after ~1.5 seconds to ask which PII redaction level to apply, using the same elicitation/create shape a direct elicitation request would use:

{
  "status": "input_required",
  "inputRequests": {
    "redaction_level": {
      "method": "elicitation/create",
      "params": {
        "mode": "form",
        "message": "Report Q3-vendor-audit: how should PII be redacted in the summary?",
        "requestedSchema": {
          "type": "object",
          "properties": { "level": { "type": "string", "enum": ["none", "partial", "full"] } },
          "required": ["level"]
        }
      }
    }
  }
}

The client answers with a matching key in inputResponses on a tasks/update call. The acknowledgement is intentionally empty and eventually consistent — the spec allows the server to accept the response and ack before the task's polled status reflects it — so the client's only correct move is to keep polling tasks/get, not to treat the tasks/update response itself as confirmation that anything changed1.

Real output

Running the client against the server end to end, with no mocking, produces this (redaction level partial, then a second call that deliberately exercises the failed path):

--- calling summarize_report(reportId=Q3-vendor-audit, simulateFail=false) ---
Task created: e08594eb-1dd4-4066-b120-d35b442513db (status=working, pollIntervalMs=500)
  poll 1: status=working
  poll 2: status=working
  poll 3: status=input_required
  server needs input: {
  mode: 'form',
  message: 'Report Q3-vendor-audit: how should PII be redacted in the summary?',
  requestedSchema: { type: 'object', properties: { level: [Object] }, required: [ 'level' ] }
}
  sent tasks/update with redaction level = partial
  poll 4: status=working
  poll 5: status=completed
  completed. result: {
  "content": [
    { "type": "text", "text": "Summary ready (redaction=partial): report is 3 pages, 2 action items, 1 blocked dependency." }
  ],
  "isError": false
}

--- calling summarize_report(reportId=Q3-vendor-audit-2, simulateFail=true) ---
Task created: 41652027-6ec0-43cb-b48b-074319c2bb1c (status=working, pollIntervalMs=500)
  poll 1: status=working
  poll 2: status=working
  poll 3: status=failed
  failed: { code: -32603, message: 'Upstream summarizer timed out' }

A separate run also confirmed the two edge cases the spec calls out explicitly: a client that omits the capability declaration gets the plain synchronous fallback result, never a CreateTaskResult1, and sending tasks/cancel against a working task moves it to cancelled on the next poll, consistent with cancellation being cooperative rather than guaranteed1.

The bug that will hang your client silently

The first version of the server's tool handler stored each task's setTimeout handle directly on the same object it returned to the client, to make cleanup on cancellation easy:

const record = { taskId, status: "working", /* ...*/ };
tasks.set(taskId, record);
record._timer = setTimeout(() => { /* advance the task */ }, 1500);
return { resultType: "task", ...record }; // _timer included by the spread

Running the client against this version didn't throw an error on either side — it just hung forever on the first tools/call. The server's handler ran and returned successfully; the client never received a response. The cause: a NodeJS.Timeout object contains internal circular references, so serializing it as part of the JSON-RPC result fails inside the transport's write path, and that failure isn't surfaced back to the request that triggered it. The fix is to destructure the non-serializable field out before returning:

const { _timer, ...publicRecord } = record;
return { resultType: "task", ...publicRecord };

This is disclosed here because it's exactly the kind of failure mode that's invisible from reading documentation and only shows up by actually running task-augmented code against a real client — which none of the current SDK-vs-spec writeups this post found seem to have done.

Three implementations, side by side

sdk v1.30.0 experimental/taskscore/server/client v2.0.0 (built in)ext-tasks v0.1.0 core/v2
Matches ratified 2026-07-28 specNo — 2025-11-25 shapeNo — active wire registry has no Tasks methods at all4Yes5
Methods presenttasks/get, tasks/list, tasks/result, tasks/cancel3None active — the same four exist only as deprecated, non-runtime legacy types4tasks/get, tasks/update, tasks/cancel5
tasks/update / input_required supportNoNoYes, client-side
Server-side helper publishedYes (wrong shape)No — legacy helper types exist but carry no SDK runtime4No — requester-side only
Usable today for a ratified-spec serverNoNoNo (client only)

Verification

Every version number, method name, and capability-gating behavior in this post was confirmed by installing the actual packages and reading their compiled output, not by reading documentation alone. @modelcontextprotocol/sdk@1.30.0 was resolved as npm's current latest tag at the time of writing; its dist/esm/types.js and dist/esm/server/index.js were grepped directly for the JSON-RPC method literals and the capability-gating switch statement quoted above. @modelcontextprotocol/core@2.0.0, /server@2.0.0, and /client@2.0.0 were installed the same way and grepped for the same strings, which led to reading the surrounding compiled code directly — that's what surfaced the two separate wire-era registries and the @deprecated ... no SDK runtime JSDoc on the legacy one. @modelcontextprotocol/ext-tasks@0.1.0 was installed and its core/v2, client, and receiver .d.ts and compiled .js files were read directly to confirm which methods and which client type each entry point targets. The GitHub issue on the v2 beta's task removal was read in full, not summarized secondhand.

The server and client code shown above was written to a real project (Node.js v22.23.2, TypeScript 7.0.2, tsx 4.23.13, Zod 4.6.5), type-checked with tsc --strict --noEmit, and executed end to end over a real stdio transport between two separate processes — not mocked. Every status transition shown in the "Real output" section, including the input_required/tasks/update round trip, the completed and failed terminal states, the synchronous-fallback path, and cancellation, was exercised by running the code and capturing its actual console output, reproduced above with only whitespace reflowed for line width. The _timer serialization bug was found this way, not invented for the post.

Bottom line

The ratified MCP Tasks extension is a clean three-method design — tasks/get, tasks/update, tasks/cancel — with a real, useful mid-task input flow. What isn't yet true, as of September 2026, is that installing the official SDK gets you an implementation of it: the stable v1.x line still speaks the extension's earlier, pre-ratification shape, the new v2 packages don't speak any version of it in their active wire vocabulary at all, and the one officially published package that does match the ratified spec only covers the client side. Building a compliant server today means going around the SDK's experimental/tasks helpers and implementing the three methods directly against the documented wire format — which, once you know the capability-gating switch statement only recognizes the old method names, is a few dozen lines of code, not a framework.

Footnotes

  1. Model Context Protocol, "Tasks" (MCP Tasks Extension, spec version 2026-07-28) — https://tasks.extensions.modelcontextprotocol.io/specification/draft/tasks (three methods, Task status enum, capability negotiation, input_required/inputRequests/inputResponses flow, security considerations including the removal of tasks/list, cancellation semantics; fetched 2026-09-18) 2 3 4 5 6 7 8 9 10 11 12 13 14

  2. Model Context Protocol, "Specification" (2026-07-28) — https://modelcontextprotocol.io/specification/2026-07-28 (lists Tasks as an official extension of the 2026-07-28 spec release; fetched 2026-09-18)

  3. @modelcontextprotocol/sdk v1.30.0 on npm — https://registry.npmjs.org/@modelcontextprotocol/sdk/latest (resolved as latest; experimental/tasks export path, method literals tasks/get/tasks/result/tasks/list/tasks/cancel/notifications/tasks/status confirmed in compiled dist/esm/types.js; capability-gating switch statement confirmed in dist/esm/server/index.js; Server class documented @deprecated; installed and inspected directly, 2026-09-18) 2 3 4 5

  4. @modelcontextprotocol/core, @modelcontextprotocol/server, @modelcontextprotocol/client, all v2.0.0 on npm (installed and inspected directly, 2026-09-18) — compiled dist output contains two versioned wire-era registries; the wire/rev2025-11-25/registry.ts region defines tasks/get, tasks/cancel, tasks/list, tasks/result, and notifications/tasks/status, each documented @deprecated 2025-11-25 wire vocabulary with no SDK runtime; kept importable for interoperability only; the active wire/rev2026-07-28/registry.ts region's requestMethodKeys/notificationMethodKeys — which the SDK's live dispatch consults — contain no tasks/* or notifications/tasks* entries at all; tasks/update is absent from both registries 2 3 4 5 6 7

  5. @modelcontextprotocol/ext-tasks v0.1.0 on npm, reference implementation from https://github.com/modelcontextprotocol/ext-tasks (based on SEP-2663; Apache-2.0; installed and inspected directly, 2026-09-18) — core/v2/schemas.js method literals tasks/get/tasks/update/tasks/cancel/notifications/tasks confirmed matching the ratified spec; client entry point requires @modelcontextprotocol/client (v2); receiver entry point documented as handling 2025-11-25-era sampling/elicitation task requests 2 3 4 5 6 7

  6. GitHub, modelcontextprotocol/modelcontextprotocol issue #3051, "docs: update or freeze SEP-1686 tasks code examples for the v2 SDK" — https://github.com/modelcontextprotocol/modelcontextprotocol/issues/3051 (filed 2026-07-08 at maintainer David Soria Parra's request; states TypeScript SDK v2 was then at 2.0.0-beta.2 as split @modelcontextprotocol/{client,server,core,node,express,hono,server-legacy} packages and that "v2 removed experimental tasks per SEP-2663"; fetched 2026-09-18)

Frequently Asked Questions

tasks/get (poll status, with the result or error inlined once terminal), tasks/update (answer an input_required request), and tasks/cancel (request cancellation). There is deliberately no tasks/list 1 .