MCP Tutorials

How to build an MCP server that runs long tasks without timing out (Tasks extension)

A tested walkthrough of the MCP Tasks extension (2026-07-28 spec): return a task handle from tools/call, poll tasks/get, and stop blocking slow tools until they time out.

Mark

Content, MCPOrbit

Published
Updated
· Updated
Read time
· 15 min read
Diagram: a naive blocking tool call timing out at a proxy deadline versus a Tasks-backed tool returning a task handle the client polls to completion.

If an MCP tool call takes longer than a few seconds - a data export, a deploy, a video render, a deep search - don't block the response. Under the 2026-07-28 spec, answer the tools/call with a task handle instead of a result, and let the client drive the work with tasks/get. The tool returns immediately, the model keeps its turn, and the client polls for completion. This is the Tasks extension, and it's the supported way to run long work without holding a request open until it times out.

This post shows the exact protocol, then builds a server that does it - with a wire trace proving a naive tool times out where a Tasks-backed tool doesn't. Every JSON-RPC exchange below is captured from the reference implementation inlined at the end of this post, not hand-written. Save those files and you get the same trace.

What the Tasks extension actually changes

Before Tasks, a slow tool had two bad options: block the HTTP response (fragile - it times out behind proxies and load balancers) or fake async with your own polling tool and out-of-band state (works, but every server reinvents it and no client understands it). Tasks makes the async pattern first-class and uniform.

History worth pinning: Tasks shipped as a core feature in the 2025-11-25 spec, and production use surfaced enough redesign needs that in 2026-07-28 it moved out of core into an opt-in Extension. So "does this server support Tasks?" is now a capability you negotiate, not something you can assume. (Source: MCP 2026-07-28 Release Candidate blog, blog.modelcontextprotocol.io.)

Why this pairs with the stateless core

The same spec drops sessions and goes stateless - any request can hit any server instance. That is exactly why polling beats a held-open connection: with no sticky session, you can't rely on the same replica holding your in-flight work behind an open socket. A task handle is durable, server-owned state the client can poll from any replica. Tasks and stateless aren't two unrelated features shipping the same day - Tasks is how you do long work now that you can't lean on a sticky connection. (We shipped the stateless build-it at /blog/make-an-mcp-server-stateless; this is its async companion.)

The protocol, end to end

  • Client calls a long-running tool via `tools/call`.
  • Server returns a task handle (task id + status `working`) instead of a result.
  • Client polls `tasks/get` with the task id; the server returns `working` until the job finishes, then returns the completed result.
  • If the task needs input mid-flight, the server surfaces that and the client answers with `tasks/update`.
  • The client can `tasks/cancel` at any point.

Here is that exchange on the wire, captured from the reference implementation's npm run demo (inlined in full below). First, initialize - the server advertises the opt-in Tasks extension as a negotiated capability:

→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
← {"jsonrpc":"2.0","id":1,"result":{
    "protocolVersion":"2026-07-28",
    "serverInfo":{"name":"mcp-tasks-example","version":"1.0.0"},
    "capabilities":{
      "tools":{},
      "extensions":{"io.modelcontextprotocol/tasks":{"version":"1"}}
    }}}

The naive tool times out at the client deadline

The blocking tool needs ~3s of real work. The client enforces a 1s deadline - the same thing a proxy or load-balancer idle timeout does - so the request is aborted before it ever returns. This is the failure the Tasks extension removes:

→ tools/call export_report_blocking { rows: 250000 }   (client deadline: 1000ms)
✗ request aborted after ~1000ms: AbortError (deadline exceeded)
  → the tool would have needed ~3000ms; behind a proxy this is a dropped request.

The Tasks-backed tool returns a handle and the client polls to completion

The same work, re-implemented to return a task handle, comes back instantly. The client then polls tasks/get - working, working, then completed with the real result:

→ {"jsonrpc":"2.0","id":3,"method":"tools/call",
    "params":{"name":"export_report","arguments":{"rows":250000}}}
← {"jsonrpc":"2.0","id":3,"result":{
    "task":{"taskId":"task_ba3a0753…","status":"working","pollInterval":400}}}

→ {"jsonrpc":"2.0","id":4,"method":"tasks/get","params":{"taskId":"task_ba3a0753…"}}
← {"jsonrpc":"2.0","id":4,"result":{"taskId":"task_ba3a0753…","status":"working"}}

→ {"jsonrpc":"2.0","id":6,"method":"tasks/get","params":{"taskId":"task_ba3a0753…"}}
← {"jsonrpc":"2.0","id":6,"result":{"taskId":"task_ba3a0753…","status":"completed",
    "result":{"content":[{"type":"text",
      "text":"Exported 250000 rows to report.csv (2 columns, 1 file)."}],
      "isError":false}}}

And a cancel - start a task, then stop it before it finishes with tasks/cancel:

→ {"jsonrpc":"2.0","id":8,"method":"tasks/cancel","params":{"taskId":"task_49d3f132…"}}
← {"jsonrpc":"2.0","id":8,"result":{"taskId":"task_49d3f132…","status":"cancelled"}}

Build it: the server, step by step

The reference server (inlined in full at the end of this post) is one dependency-free file. It implements the extension's JSON-RPC surface over a single POST endpoint so it runs as written. Start with capability negotiation - initialize advertises the Tasks extension, and the Tasks-backed tool declares that it may answer with a handle:

case "initialize":
  return rpcResult(id, {
    protocolVersion: "2026-07-28",
    serverInfo: { name: "mcp-tasks-example", version: "1.0.0" },
    capabilities: {
      tools: {},
      // Advertise the opt-in Tasks extension so clients negotiate it.
      extensions: { "io.modelcontextprotocol/tasks": { version: "1" } },
    },
  });

The naive tool blocks; the Tasks tool starts background work and returns a handle immediately. Task state lives in a store the server owns - here an in-process Map, in production a shared store (see the last section):

case "tools/call": {
  const name = params.name;
  const rows = params.arguments?.rows ?? 1000;

  if (name === "export_report_blocking") {
    // Blocks for 3s — a proxy/LB idle timeout fires long before this resolves.
    return new Promise((resolve) => setTimeout(() => resolve(
      rpcResult(id, { content: [{ type: "text",
        text: `Exported ${rows} rows to report.csv.` }], isError: false })
    ), 3000));
  }

  if (name === "export_report") {
    // Return a task handle instead of a result. The client drives it.
    const taskId = startExport(rows);
    return rpcResult(id, { task: { taskId, status: "working", pollInterval: 400 } });
  }
}

tasks/get reports working until the background job finishes, then returns the completed tool result on a later poll. tasks/cancel stops it. An unknown task id is a clean JSON-RPC error, not a crash:

case "tasks/get": {
  const t = tasks.get(params.taskId);
  if (!t) return rpcError(id, -32001, `Unknown taskId: ${params.taskId}`);
  if (t.status === "completed")
    return rpcResult(id, { taskId: params.taskId, status: "completed", result: t.result });
  return rpcResult(id, { taskId: params.taskId, status: t.status });
}

case "tasks/cancel": {
  const t = tasks.get(params.taskId);
  if (!t) return rpcError(id, -32001, `Unknown taskId: ${params.taskId}`);
  t.cancelled = true; t.status = "cancelled"; clearTimeout(t.timer);
  return rpcResult(id, { taskId: params.taskId, status: "cancelled" });
}

One command runs the whole thing. Save the files from the "Run it yourself" section below, then npm run demo boots the server, runs the client scenario end to end, and prints the wire trace above:

npm run demo   # server + client: naive timeout, task poll to completion, cancel
npm test       # 5 guard tests: initialize, timeout, poll, cancel, error

Common mistakes


Production note: back task state with a shared store

The demo keeps task state in a process Map so it runs in one command. Because the 2026-07-28 spec also drops sessions (stateless core), any request can hit any replica - so in production you back the task store with Redis or Postgres. Then a tasks/get that lands on a different replica than the one that started the task still resolves, and cancellation and expiry are consistent across the fleet.

Run it yourself: the full reference implementation

Every trace above came from these files. Create a folder, save each file below, then run npm run demo and npm test. There are zero runtime dependencies, so there is nothing to install - Node 20+ is all you need.

{
  "name": "mcp-tasks-example",
  "version": "1.0.0",
  "private": true,
  "description": "Minimal MCP server demonstrating the Tasks extension (2026-07-28 spec): long-running tools/call returns a task handle; client drives it with tasks/get and tasks/cancel.",
  "type": "module",
  "engines": {
    "node": ">=20"
  },
  "scripts": {
    "start": "node server.mjs",
    "demo": "node demo.mjs",
    "test": "node test.mjs"
  }
}

The server - capability negotiation, the naive blocking tool, the Tasks-backed tool, and the tasks/get / tasks/cancel handlers over a single POST endpoint:

// A minimal, dependency-free MCP server that demonstrates the Tasks extension
// wire protocol from the 2026-07-28 MCP spec: a long-running tools/call returns
// a *task handle* instead of a synchronous result, and the client drives the
// work with tasks/get (poll) and tasks/cancel.
//
// This is a reference implementation of the extension's JSON-RPC surface over a
// single Streamable-HTTP-style POST endpoint. It intentionally has no runtime
// dependencies so `node server.mjs` runs as written on Node >= 20.
//
// Methods implemented:
//   initialize       - capability negotiation (advertises the tasks extension)
//   tools/list       - two tools: export_report_blocking (naive) + export_report (tasks)
//   tools/call       - blocking tool returns synchronously; tasks tool returns a handle
//   tasks/get        - poll a task: { status: "working" } until done, then the result
//   tasks/cancel     - cancel an in-flight task
//
// Task state lives in a process-shared Map. In production (stateless core) this
// would be a shared store (Redis/Postgres) so any replica can answer a poll.

import { createServer } from "node:http";
import { randomUUID } from "node:crypto";

const PORT = Number(process.env.PORT || 8931);

// --- task store -------------------------------------------------------------
/** @type {Map<string, {status:string, result?:any, timer?:any, cancelled?:boolean}>} */
const tasks = new Map();

function startExport(rows) {
  const taskId = "task_" + randomUUID();
  const entry = { status: "working" };
  tasks.set(taskId, entry);
  // Simulate real long work (an export that would blow past a proxy idle timeout).
  entry.timer = setTimeout(() => {
    if (entry.cancelled) return;
    entry.status = "completed";
    entry.result = {
      content: [
        {
          type: "text",
          text: `Exported ${rows} rows to report.csv (2 columns, 1 file).`,
        },
      ],
      isError: false,
    };
  }, 1200);
  return taskId;
}

// --- JSON-RPC dispatch ------------------------------------------------------
function rpcResult(id, result) {
  return { jsonrpc: "2.0", id, result };
}
function rpcError(id, code, message) {
  return { jsonrpc: "2.0", id, error: { code, message } };
}

function handle(msg) {
  const { id, method, params = {} } = msg;
  switch (method) {
    case "initialize":
      return rpcResult(id, {
        protocolVersion: "2026-07-28",
        serverInfo: { name: "mcp-tasks-example", version: "1.0.0" },
        capabilities: {
          tools: {},
          // Advertise the opt-in Tasks extension so clients negotiate it.
          extensions: { "io.modelcontextprotocol/tasks": { version: "1" } },
        },
      });

    case "tools/list":
      return rpcResult(id, {
        tools: [
          {
            name: "export_report_blocking",
            description:
              "Naive: blocks the response until the export finishes (times out behind a proxy).",
            inputSchema: {
              type: "object",
              properties: { rows: { type: "integer" } },
            },
          },
          {
            name: "export_report",
            description:
              "Tasks-backed: returns a task handle immediately; poll tasks/get for the result.",
            inputSchema: {
              type: "object",
              properties: { rows: { type: "integer" } },
            },
            // Declares this tool may answer with a task handle.
            _meta: { "io.modelcontextprotocol/tasks": { taskSupport: true } },
          },
        ],
      });

    case "tools/call": {
      const name = params.name;
      const rows = params.arguments?.rows ?? 1000;
      if (name === "export_report_blocking") {
        // Block for 3s to represent real work; a proxy/LB idle timeout (or the
        // client's own deadline) fires long before this resolves.
        return new Promise((resolve) => {
          setTimeout(() => {
            resolve(
              rpcResult(id, {
                content: [
                  { type: "text", text: `Exported ${rows} rows to report.csv.` },
                ],
                isError: false,
              })
            );
          }, 3000);
        });
      }
      if (name === "export_report") {
        // Return a task handle instead of a result. The client drives it.
        const taskId = startExport(rows);
        return rpcResult(id, {
          task: { taskId, status: "working", pollInterval: 400 },
        });
      }
      return rpcError(id, -32602, `Unknown tool: ${name}`);
    }

    case "tasks/get": {
      const t = tasks.get(params.taskId);
      if (!t) return rpcError(id, -32001, `Unknown taskId: ${params.taskId}`);
      if (t.status === "completed")
        return rpcResult(id, { taskId: params.taskId, status: "completed", result: t.result });
      return rpcResult(id, { taskId: params.taskId, status: t.status });
    }

    case "tasks/cancel": {
      const t = tasks.get(params.taskId);
      if (!t) return rpcError(id, -32001, `Unknown taskId: ${params.taskId}`);
      t.cancelled = true;
      t.status = "cancelled";
      clearTimeout(t.timer);
      return rpcResult(id, { taskId: params.taskId, status: "cancelled" });
    }

    default:
      return rpcError(id, -32601, `Method not found: ${method}`);
  }
}

// --- HTTP transport (single POST endpoint) ----------------------------------
export function createTasksServer() {
  return createServer((req, res) => {
    if (req.method !== "POST") {
      res.writeHead(405).end();
      return;
    }
    let body = "";
    req.on("data", (c) => (body += c));
    req.on("end", async () => {
      let out;
      try {
        out = await handle(JSON.parse(body));
      } catch (e) {
        out = rpcError(null, -32700, "Parse error");
      }
      res.writeHead(200, { "content-type": "application/json" });
      res.end(JSON.stringify(out));
    });
  });
}

if (import.meta.url === `file://${process.argv[1]}`) {
  createTasksServer().listen(PORT, () => {
    console.log(`mcp-tasks-example listening on http://127.0.0.1:${PORT}`);
  });
}

The client - drives the two scenarios and prints the wire trace: a naive call that aborts at a 1s deadline, then a Tasks call that returns a handle, polls to completion, and cancels:

// Demo client: proves the naive blocking tool times out where the Tasks-backed
// tool completes cleanly. Prints the real JSON-RPC request/response pairs on the
// wire so the blog's wire-trace section is captured output, not fabricated.

const URL = process.env.SERVER_URL || "http://127.0.0.1:8931";
let nextId = 1;

// Send one JSON-RPC message; optional AbortSignal to enforce a client deadline
// (stands in for a proxy / load-balancer idle timeout).
async function rpc(method, params, { signal, label } = {}) {
  const req = { jsonrpc: "2.0", id: nextId++, method, params };
  const started = Date.now();
  const res = await fetch(URL, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(req),
    signal,
  });
  const json = await res.json();
  const ms = Date.now() - started;
  print(label || method, req, json, ms);
  return json;
}

function print(label, req, res, ms) {
  console.log(`\n── ${label} ${ms != null ? `(${ms}ms)` : ""} ─────────────`);
  console.log("→ " + JSON.stringify(req));
  console.log("← " + JSON.stringify(res));
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function main() {
  await rpc("initialize", {});

  // 1) NAIVE TOOL — client enforces a 1s deadline (a real proxy idle timeout).
  //    The tool needs 3s, so the request is aborted: this is the timeout we fix.
  console.log("\n### 1. Naive blocking tool — times out at the client deadline");
  const ac = new AbortController();
  const deadline = setTimeout(() => ac.abort(), 1000);
  try {
    await rpc(
      "tools/call",
      { name: "export_report_blocking", arguments: { rows: 250000 } },
      { signal: ac.signal, label: "tools/call export_report_blocking" }
    );
    console.log("!! unexpected: blocking call returned before the deadline");
    process.exitCode = 1;
  } catch (e) {
    clearTimeout(deadline);
    console.log(`✗ request aborted after ~1000ms: ${e.name} (${e.cause?.code || "deadline exceeded"})`);
    console.log("   → this is exactly the timeout the Tasks extension removes.");
  }

  // 2) TASKS-BACKED TOOL — returns a handle immediately; client polls tasks/get.
  console.log("\n### 2. Tasks-backed tool — returns a handle, client polls to completion");
  const call = await rpc(
    "tools/call",
    { name: "export_report", arguments: { rows: 250000 } },
    { label: "tools/call export_report" }
  );
  const taskId = call.result?.task?.taskId;
  if (!taskId) throw new Error("expected a task handle from tools/call");

  let status = call.result.task.status;
  let polls = 0;
  while (status === "working") {
    await sleep(400);
    const got = await rpc("tasks/get", { taskId }, { label: "tasks/get" });
    status = got.result.status;
    polls++;
    if (status === "completed") {
      console.log("✓ task completed; result:", JSON.stringify(got.result.result.content[0].text));
    }
    if (polls > 20) throw new Error("task never completed");
  }

  // 3) CANCEL — start another export and cancel it mid-flight.
  console.log("\n### 3. Cancel — start a task and cancel it before it finishes");
  const call2 = await rpc(
    "tools/call",
    { name: "export_report", arguments: { rows: 999999 } },
    { label: "tools/call export_report (to cancel)" }
  );
  const cancelId = call2.result.task.taskId;
  const cancel = await rpc("tasks/cancel", { taskId: cancelId }, { label: "tasks/cancel" });
  console.log(`✓ task ${cancel.result.status}`);

  console.log("\nDONE — naive tool timed out; Tasks tool completed and cancelled cleanly.");
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((e) => {
    console.error("demo failed:", e);
    process.exit(1);
  });
}

The demo entrypoint - boots the server, runs the client, and exits:

// One-command demo runner: boots the server in-process, runs the client to
// completion, then shuts down. `npm run demo`.
import { createTasksServer } from "./server.mjs";
import { main } from "./client.mjs";

const server = createTasksServer();
await new Promise((r) => server.listen(8931, r));
try {
  await main();
} finally {
  server.close();
}

And the 5 guard tests that keep every wire behavior honest (npm test → 5/5):

// Guard tests — the "tested repo" bar. Boots the server in-process and asserts
// the four wire behaviors the blog claims. Run: `node test.mjs`.
import assert from "node:assert/strict";
import { createTasksServer } from "./server.mjs";

const PORT = 8945;
const URL = `http://127.0.0.1:${PORT}`;
let id = 1;
const rpc = async (method, params, opts = {}) => {
  const res = await fetch(URL, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ jsonrpc: "2.0", id: id++, method, params }),
    signal: opts.signal,
  });
  return res.json();
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const server = createTasksServer();
await new Promise((r) => server.listen(PORT, r));
let passed = 0;
const ok = (name) => { console.log(`✓ ${name}`); passed++; };

try {
  // 1. initialize advertises the tasks extension capability
  const init = await rpc("initialize", {});
  assert.equal(init.result.protocolVersion, "2026-07-28");
  assert.ok(init.result.capabilities.extensions["io.modelcontextprotocol/tasks"]);
  ok("initialize advertises the tasks extension");

  // 2. naive blocking tool exceeds a 1s client deadline (times out)
  const ac = new AbortController();
  const to = setTimeout(() => ac.abort(), 1000);
  let aborted = false;
  try {
    await rpc("tools/call", { name: "export_report_blocking", arguments: { rows: 10 } }, { signal: ac.signal });
  } catch (e) {
    aborted = e.name === "AbortError";
  } finally {
    clearTimeout(to);
  }
  assert.equal(aborted, true, "blocking call should abort at the deadline");
  ok("naive blocking tool times out at the client deadline");

  // 3. tasks tool returns a handle, poll goes working -> completed with a result
  const call = await rpc("tools/call", { name: "export_report", arguments: { rows: 42 } });
  const taskId = call.result.task.taskId;
  assert.match(taskId, /^task_/);
  assert.equal(call.result.task.status, "working");
  let status = "working";
  for (let i = 0; i < 20 && status === "working"; i++) {
    await sleep(400);
    const got = await rpc("tasks/get", { taskId });
    status = got.result.status;
    if (status === "completed") {
      assert.match(got.result.result.content[0].text, /Exported 42 rows/);
    }
  }
  assert.equal(status, "completed");
  ok("tasks tool returns a handle and completes via tasks/get polling");

  // 4. cancel stops an in-flight task
  const call2 = await rpc("tools/call", { name: "export_report", arguments: { rows: 9 } });
  const cancel = await rpc("tasks/cancel", { taskId: call2.result.task.taskId });
  assert.equal(cancel.result.status, "cancelled");
  ok("tasks/cancel cancels an in-flight task");

  // 5. unknown taskId is a clean JSON-RPC error, not a crash
  const bad = await rpc("tasks/get", { taskId: "task_nope" });
  assert.equal(bad.error.code, -32001);
  ok("unknown taskId returns a JSON-RPC error");

  console.log(`\n${passed}/5 passed`);
} catch (e) {
  console.error("TEST FAILED:", e.message);
  process.exitCode = 1;
} finally {
  server.close();
}

Frequently asked questions

When should an MCP tool return a task instead of a result?
When the work can outlast a normal request - anything from several seconds to minutes (exports, deploys, renders, deep search). If it's fast, return synchronously; Tasks adds round-trips you don't need for quick calls.
How does the client know a task is done?
It polls tasks/get with the task id. The server returns a working status until the job finishes, then returns the completed result on a subsequent poll.
What replaced tasks/result in the 2026-07-28 spec?
Polling via tasks/get. The blocking tasks/result call was removed in the redesign, along with tasks/list.
Can a server start a task the client didn't ask for?
Yes. In the redesign a server can return a task handle unsolicited - it can decide a given tools/call is long-running and hand back a handle even if the client didn't request async.
Is Tasks part of the core MCP spec?
No longer. It shipped in core in 2025-11-25, then moved to an opt-in Extension in 2026-07-28 after production feedback. Clients and servers negotiate it as a capability.
Do I need Tasks if my server is stateless?
That's exactly when you want it. Without sticky sessions you can't hold work open on one replica; a pollable task handle is how long-running work survives a round-robin load balancer.

About the author

Mark

Content, MCPOrbit

Share this post

MCPOrbit

Test an MCP server in 60 seconds.

Download MCPOrbit for free. No signup, no telemetry. Hear about a server and test it before the curiosity wears off.

macOS 14+ · Apple Silicon & Intel · No account needed