Skip to main content

ByLast reviewed

MCP Async Tasks and Temporal Efficiency (Pattern 10)

This example demonstrates the Tasks primitive introduced in the 2025-11-25 Model Context Protocol (MCP) specification.

Status: Tasks is now an official extension. Tasks shipped as an experimental core feature in the 2025-11-25 spec (SEP-1686). The 2026-07-28 revision (SEP-2663) is now the current protocol version, and it moves Tasks out of the core protocol into an official extension, io.modelcontextprotocol/tasks, that versions independently of the core protocol — and redesigns the API. The example below still targets the superseded 2025-11-25 surface; read the migration notes first, and treat the sample as a reference for servers still pinned to that revision rather than as the shape to build new work against.

What changed in the 2026-07-28 extension redesign

  • The blocking tasks/result method is removed. Result retrieval folds into polling: the terminal tasks/get response carries the result.
  • tasks/list is removed.
  • A new tasks/update method carries client-to-server input — e.g. answering a task that has entered input_required.
  • Task creation is server-directed. The client opts in once via the extension capability rather than flagging each request; the server then decides per request whether to return a CreateTaskResult. Servers must never return a task to a client that did not declare support.

Source: the io.modelcontextprotocol/tasks extension specification. The 2025-11-25 surface shown below is superseded — implement against the extension for new work.

Benchmarks like OSWorld-Human (arXiv:2506.16042) show that computer-use agents lose most of their time to end-to-end latency, with even top performers taking 1.4-2.7x more steps than a human reference trajectory. Long synchronous tool calls make this worse: the agent blocks, and the transport risks timing out before the work finishes.

How Tasks actually work

Tasks are not a new kind of tool you register. They are an augmentation of existing requests that enables a "call-now, fetch-later" flow:

  1. The client sends a normal tools/call, augmented with a task (it signals "this may take a while").
  2. The server creates a task, stores its state, starts the work in the background, and immediately returns a task handle ({ task }) instead of a result.
  3. The client polls tasks/get to read status, which moves through working ->input_required -> completed / failed / cancelled.
  4. Once the task is terminal, the client calls tasks/result to retrieve theCallToolResult. (This step no longer exists under the current 2026-07-28 extension — the terminal tasks/get response carries the result in its result field, or the JSON-RPC error in error.)

On the 2025-11-25 surface shown below, the server opts in by declaring the tasks capability and marking the tool as task-augmentable via its execution field. Under the extension, the client instead declares io.modelcontextprotocol/tasks in its per-request capabilities and the server advertises it via server/discover. Task state is persisted through aTaskStore; the SDK ships InMemoryTaskStore for reference. Use a durable store (Redis, Postgres, an external job API) in production.

Example Implementation (targets 2025-11-25, SDK v1)

This server implements the superseded 2025-11-25 core surface using the v1-era TypeScript SDK. It is accurate for that combination and useful if you are maintaining a server pinned to it. Two things have moved since, and both matter before you copy it forward:

  • The wire protocol. 2026-07-28 moved Tasks into the io.modelcontextprotocol/tasks extension and changed the method set — see the migration box above for the specifics.
  • The SDK. @modelcontextprotocol/server and its sibling packages published v2.0.0 on 27 July 2026 with 2026-07-28 support. The v2 API is shaped differently — the documented minimal server uses server.registerTool() rather than the server.setRequestHandler(...) pattern shown below.

Why this sample has not been rewritten yet. The 2026-07-28 specification defines the wire protocol for the Tasks extension — CreateTaskResult, tasks/get, tasks/update, tasks/cancel — but the extension repository is still marked under development and does not yet publish a TypeScript SDK surface. Writing a replacement sample would mean inventing method names for the task store and handler registration. This site does not publish invented APIs, so the sample stays on the surface that is actually documented, clearly labelled, until the extension SDK ships.

import { randomUUID } from 'node:crypto';

import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import {
  InMemoryTaskStore,
  isTerminal,
  Server,
} from '@modelcontextprotocol/server';
import type {
  CallToolResult,
  CreateTaskOptions,
  CreateTaskResult,
  GetTaskPayloadResult,
  GetTaskResult,
  Tool,
} from '@modelcontextprotocol/server';

// Persists task metadata and results. Swap for a durable store in production.
const taskStore = new InMemoryTaskStore();

// Your long-running work. Replace with real aggregation/analysis.
async function runAudit(args: {
  startDate: string;
  endDate: string;
  departments: string[];
}): Promise<{ summary: string }> {
  // ...expensive cross-department aggregation...
  return {
    summary:
      `Audit complete for ${args.departments.join(', ')} from ` +
      `${args.startDate} to ${args.endDate}. Found 3 actionable insights.`,
  };
}

function createServer(): Server {
  const server = new Server(
    { name: 'enterprise-reporting-agent', version: '2.1.0' },
    {
      capabilities: {
        tools: {},
        // Declare task support so clients know they may augment calls.
        tasks: { requests: { tools: { call: {} } } },
      },
    }
  );

  // Advertise the tool and mark it task-augmentable.
  // 'required' forces task mode; use 'optional' to also allow sync calls.
  server.setRequestHandler(
    'tools/list',
    async (): Promise<{ tools: Tool[] }> => ({
      tools: [
        {
          name: 'generate_comprehensive_audit',
          description:
            'Runs a long audit across departments and returns a report',
          inputSchema: {
            type: 'object',
            properties: {
              startDate: { type: 'string' },
              endDate: { type: 'string' },
              departments: { type: 'array', items: { type: 'string' } },
            },
            required: ['startDate', 'endDate', 'departments'],
          },
          execution: { taskSupport: 'required' },
        },
      ],
    })
  );

  // tools/call: create the task, return its handle now, run work in background.
  server.setRequestHandler(
    'tools/call',
    async (request, ctx): Promise<CallToolResult | CreateTaskResult> => {
      const { name, arguments: args } = request.params;
      const taskParams = (request.params._meta?.task ?? request.params.task) as
        | { ttl?: number; pollInterval?: number }
        | undefined;

      if (name !== 'generate_comprehensive_audit') {
        throw new Error(`Unknown tool: ${name}`);
      }
      if (!taskParams) {
        // This tool is task-only; reject a plain synchronous call.
        throw new Error(`Tool ${name} requires task mode`);
      }

      const options: CreateTaskOptions = {
        ttl: taskParams.ttl,
        pollInterval: taskParams.pollInterval ?? 2000,
      };
      const task = await taskStore.createTask(
        options,
        ctx.mcpReq.id,
        request,
        ctx.sessionId
      );

      // Run in the background. Do NOT await before returning the handle.
      void (async () => {
        try {
          await taskStore.updateTaskStatus(
            task.taskId,
            'working',
            'Aggregating records...'
          );
          const report = await runAudit(args as Parameters<typeof runAudit>[0]);
          const result: CallToolResult = {
            content: [{ type: 'text', text: report.summary }],
          };
          await taskStore.storeTaskResult(task.taskId, 'completed', result);
        } catch (error) {
          await taskStore.storeTaskResult(task.taskId, 'failed', {
            content: [{ type: 'text', text: `Audit failed: ${String(error)}` }],
            isError: true,
          });
        }
      })();

      // Immediate response: the task handle, not the result.
      return { task };
    }
  );

  // tasks/get: report current status so the client can poll.
  server.setRequestHandler(
    'tasks/get',
    async (request): Promise<GetTaskResult> => {
      const task = await taskStore.getTask(request.params.taskId);
      if (!task) throw new Error(`Task ${request.params.taskId} not found`);
      return task;
    }
  );

  // tasks/result: return the stored result once the task is terminal.
  // NOTE: removed by the 2026-07-28 extension — result retrieval folds into
  // the terminal tasks/get response. Valid for 2025-11-25 only.
  server.setRequestHandler(
    'tasks/result',
    async (request): Promise<GetTaskPayloadResult> => {
      const { taskId } = request.params;
      const task = await taskStore.getTask(taskId);
      if (!task) throw new Error(`Task ${taskId} not found`);
      if (!isTerminal(task.status)) {
        throw new Error(
          `Task ${taskId} is ${task.status}; poll tasks/get until terminal`
        );
      }
      return taskStore.getTaskResult(taskId);
    }
  );

  return server;
}

// --- Standard Streamable HTTP transport + per-session wiring ---
// (Boilerplate shared by every MCP server; see the SDK's
// simpleStreamableHttp.ts for the fully hardened version.)
const app = createMcpExpressApp();
const transports: Record<string, NodeStreamableHTTPServerTransport> = {};

app.post('/mcp', async (req, res) => {
  const sessionId = req.headers['mcp-session-id'] as string | undefined;
  let transport = sessionId ? transports[sessionId] : undefined;

  if (!transport) {
    transport = new NodeStreamableHTTPServerTransport({
      sessionIdGenerator: () => randomUUID(),
      onsessioninitialized: (sid) => {
        transports[sid] = transport!;
      },
    });
    await createServer().connect(transport);
  }

  await transport.handleRequest(req, res, req.body);
});

app.listen(8000, () => {
  console.log('MCP server on http://localhost:8000/mcp');
});

Why This Works for Agents

  • Latency reduction: the agent fires one request, gets a handle, and is free to reason about other work while the task runs.
  • Reliability: no hanging connection or synchronous RPC timeout while a multi-minute job completes.
  • UX alignment: mirrors how humans offload long jobs to the background instead of watching a spinner.

Notes and caveats

  • Superseded surface. Server, the tasks capability shape, and thetasks/* handlers shown above all changed with the 2026-07-28 extension (see the migration box above). The SDK also offers a higher-level helper,server.experimental.tasks.registerToolTask(...), documented in the MCP TypeScript SDK server guide. It is likewise experimental.
  • Durable storage. InMemoryTaskStore loses all tasks on restart. Back tasks with persistent storage if results must survive a deploy.
  • In-call progress is a different mechanism. If you only need progress updates within a single synchronous call (not deferred retrieval), use aprogressToken from the request _meta and emit notifications/progress, rather than a task.