Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe
Front Page / AI Tools / Deep Dive

MCP Tasks Server: Long-Running Background Jobs for Claude Desktop

The MCP Tasks extension (SEP-2663) finally lets tools return a taskId while a job runs in the background. Build a server that queues, polls, and reports long-running jobs to Claude Desktop and Cursor - durable and stateless-friendly.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 09, 2026 Published
|
Aug 09, 2026 Updated
|
8 Minutes Reading Time
Core Takeaways for Founders & Builders
  • MCP Tasks separates start from completion via an async taskId.
  • Durable job stores make resumes and retries transparent to the client.
  • Webhooks plus polling cover both always-on and sleep-wake UIs.

Build a MCP Tasks Extension Server: Long-Running Background Jobs for Claude Desktop

By Deepak Bagada, CEO at SaaSNext & AI Principal Architect.

On July 28, 2026, the MCP Tasks extension shipped as an official spec proposal under SEP-2663. It gives the MCP protocol what CI systems have had for decades: first-class long-running operations. A tool call no longer has to block until its result is ready. The server acknowledges the job, returns a taskId, and the client polls tasks/get, listens for progress notifications, or receives a webhook when work completes. Claude Desktop and Cursor both ship client support.

This guide builds a production-ready MCP Tasks extension server in TypeScript: a stateless core, a durable task store abstraction, the tasks/get and tasks/cancel request handlers, progress notifications, webhook delivery, and the mcpServers config you paste into Claude Desktop and Cursor. For more server patterns, see the MCP Directory; for orchestrating these long-running jobs into agentic workflows, see AI Workflows.

The problem synchronous tools can't solve

Classic MCP tools are request/response. The client sends tools/call, the server does the work, and the client gets content back. That works for a WIQL query or a DNS lookup, but it falls apart the moment a tool takes minutes. Video rendering, model fine-tuning, bulk embedding jobs, document conversion, and deploy pipelines all exceed client timeout budgets. The traditional escape hatch — "just keep the tool synchronous" — is wrong twice over: it blocks the agent's reasoning loop, and it couples server capacity to client patience.

SEP-2663 attacks the problem at the protocol layer instead of patching individual servers. It introduces a task lifecycle, a way to inspect a task, a way to cancel it, server-initiated progress events, and out-of-band webhooks for completion.

What the Tasks extension defines

  • taskId — a server-generated unique identifier returned from a long-running tool call.
  • Task statesqueued, running, succeeded, failed, cancelled. Every transition bumps updatedAt and may emit a notification.
  • tasks/get — a client-to-server request that returns current state, progress (0..1), result, and error.
  • tasks/cancel — a client-to-server request that aborts a task and moves it to cancelled.
  • notifications/tasks/progress — a server-to-client event carrying a progress value and human-readable message.
  • notifications/tasks/state_changed — a server-to-client event fired on every state transition.
  • Webhooks — the server can push completion to a URL out of band, so agents and external services never have to poll.

A stateless core, a durable store

The design principle behind the extension is a stateless core. The MCP server itself holds no job state; it validates requests, applies auth, and delegates to a task store plus a worker pool. For a single process the store can be a Map. In production it should be Redis, DynamoDB, or Postgres — anything durable and shareable across replicas. That separation is what lets you scale workers independently of MCP sessions and survive restarts. The only thing MCP needs to keep is the short-lived session-to-task mapping for notifications.

The TypeScript server

The example renders slide decks in the background. It uses the official SDK with a StdioServerTransport for local agents, so you can run it from Claude Desktop and Cursor today.

import { createHmac, randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "tasks-renderer", version: "0.3.0" });
const WEBHOOK_URL = process.env.TASK_WEBHOOK_URL;
const WEBHOOK_SECRET = process.env.TASK_WEBHOOK_SECRET ?? "dev-secret";
const CONCURRENCY = Number(process.env.WORKER_CONCURRENCY ?? 4);

type TaskStatus = "queued" | "running" | "succeeded" | "failed" | "cancelled";

interface TaskRecord {
  id: string;
  status: TaskStatus;
  progress: number;
  message: string;
  result?: unknown;
  error?: string;
  createdAt: number;
  updatedAt: number;
  signal?: AbortController;
}

const tasks = new Map<string, TaskRecord>();
const queue: TaskRecord[] = [];
let active = 0;

function enqueue(task: TaskRecord) {
  queue.push(task);
  pump();
}

async function pump() {
  while (active < CONCURRENCY && queue.length > 0) {
    const task = queue.shift()!;
    active++;
    processTask(task).finally(() => {
      active--;
      pump();
    });
  }
}

The long-running tool declaration plus the task request handlers:

server.registerTool(
  "render_slide",
  {
    title: "Render Slide",
    description:
      "Queue a slide deck render and return immediately. Poll tasks/get or wait for a webhook.",
    mode: "long-running",
    inputSchema: {
      deckId: z.string(),
      format: z.enum(["mp4", "gif"]).default("mp4"),
      resolution: z.enum(["1080x1920", "1080x1080"]).default("1080x1920"),
    },
  },
  async ({ deckId, format, resolution }) => {
    const task: TaskRecord = {
      id: randomUUID(),
      status: "queued",
      progress: 0,
      message: "queued",
      createdAt: Date.now(),
      updatedAt: Date.now(),
      signal: new AbortController(),
    };
    tasks.set(task.id, task);
    enqueue(task);
    return {
      content: [{ type: "text", text: `Render queued as ${task.id}` }],
      taskId: task.id,
      status: task.status,
    };
  }
);

server.registerRequestHandler("tasks/get", async (params) => {
  const task = tasks.get((params as { taskId: string }).taskId);
  if (!task) throw new Error("Unknown task");
  return {
    taskId: task.id,
    status: task.status,
    progress: task.progress,
    message: task.message,
    result: task.result,
    error: task.error,
  };
});

server.registerRequestHandler("tasks/cancel", async (params) => {
  const task = tasks.get((params as { taskId: string }).taskId);
  if (!task) throw new Error("Unknown task");
  task.signal?.abort();
  task.status = "cancelled";
  task.updatedAt = Date.now();
  emitProgress(task);
  return { taskId: task.id, status: task.status };
});

The worker loop, progress notifications, and signed webhook delivery:

async function processTask(task: TaskRecord) {
  task.status = "running";
  task.message = "started";
  emitProgress(task);
  try {
    for (const step of [0.15, 0.45, 0.75, 1]) {
      if (task.signal?.signal.aborted) {
        task.status = "cancelled";
        return;
      }
      task.progress = step;
      task.message = `step ${String(Math.round(step * 100))}%`;
      task.updatedAt = Date.now();
      emitProgress(task);
      await new Promise((r) => setTimeout(r, 2000));
    }
    task.result = {
      url: `https://cdn.example.com/renders/${task.id}.mp4`,
      durationSec: 42,
    };
    task.status = "succeeded";
    task.progress = 1;
  } catch (err) {
    task.status = "failed";
    task.error = err instanceof Error ? err.message : String(err);
  }
  task.updatedAt = Date.now();
  emitProgress(task);
  server.notification({
    method: "notifications/tasks/state_changed",
    params: { taskId: task.id, status: task.status },
  });
  await emitWebhook(task);
}

function emitProgress(task: TaskRecord) {
  server.notification({
    method: "notifications/tasks/progress",
    params: { taskId: task.id, progress: task.progress, message: task.message },
  });
}

async function emitWebhook(task: TaskRecord) {
  if (!WEBHOOK_URL) return;
  const body = JSON.stringify({
    taskId: task.id,
    status: task.status,
    result: task.result,
    error: task.error,
  });
  const signature = createHmac("sha256", WEBHOOK_SECRET)
    .update(body)
    .digest("hex");
  await fetch(WEBHOOK_URL, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "x-mcp-tasks-signature": signature,
    },
    body,
  });
}

const transport = new StdioServerTransport();
await server.connect(transport);

The task state the client sees while polling looks like this:

{
  "taskId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "status": "running",
  "progress": 0.45,
  "message": "step 45%",
  "result": null
}

The inputSchema contract

The tool's JSON schema — what the agent sees — declares long-running mode so the client knows the response will carry a taskId:

{
  "name": "render_slide",
  "description": "Queue a slide deck render and return immediately. Poll tasks/get or wait for a webhook.",
  "mode": "long-running",
  "inputSchema": {
    "type": "object",
    "properties": {
      "deckId": { "type": "string" },
      "format": { "type": "string", "enum": ["mp4", "gif"] },
      "resolution": { "type": "string", "enum": ["1080x1920", "1080x1080"] }
    },
    "required": ["deckId", "format"]
  }
}

Configuring Claude Desktop and Cursor

The server runs locally via stdio, so the config is a command plus environment variables:

{
  "mcpServers": {
    "tasks-renderer": {
      "command": "npx",
      "args": ["-y", "@acme/tasks-renderer-mcp"],
      "env": {
        "TASK_WEBHOOK_URL": "https://hooks.example.com/mcp-tasks",
        "TASK_WEBHOOK_SECRET": "whsec_replace_with_a_real_secret"
      }
    }
  }
}

Claude Desktop loads this from claude_desktop_config.json. Cursor accepts the same shape in its MCP settings and also lets you add it with cursor mcp add tasks-renderer --stdio. When the agent calls render_slide, the client sees the taskId, polls tasks/get, and merges progress notifications into the conversation — so the user watches the job move without the agent spinning on a timeout.

A production webhooks capability block is declared on the server so clients and operators know which events get pushed:

{
  "webhooks": {
    "events": ["notifications/tasks/progress", "notifications/tasks/state_changed"],
    "url": "https://hooks.example.com/mcp-tasks"
  }
}

Security considerations

  • Ownership. tasks/get and tasks/cancel must be scoped to the identity or session that created the task. Bind a taskId to its creator at enqueue time and reject foreign lookups.
  • Webhook signatures. Sign the payload with an HMAC and a shared secret, and verify it on the receiving side with a constant-time compare. Never trust an unsigned completion payload that may contain user content.
  • Idempotency. Long-running jobs get retried by clients. Support an idempotencyKey in the tool input so a duplicated call enqueues the job once rather than twice.
  • Store minimal secrets. The task store keeps resource IDs, not credentials. If a render job needs an API key, load it inside the worker from a vault.
  • Time to live. Expire task records after completion and cap queue depth and polling rate so a runaway agent cannot exhaust your worker pool.
  • Remote deployments. For a streamable HTTP deployment, layer OAuth 2.0 on top exactly as described in the Azure DevOps MCP walkthrough — the Tasks extension does not change the transport or auth story, which is covered in our MCP Directory.

The OAuth 2.0 Uri and token story

Even though the example runs over stdio, the extension is transport-agnostic. If you expose the same server over streamable HTTP, token scopes need to include task membership: a long-running operation can outlive the session that started it, so tokens must carry the ability to call tasks/get and tasks/cancel for the tasks the caller owns. Follow the same pattern — validate the token on every tasks/get call, never in the worker. Refresh tokens stay in the client's secure storage; the worker never sees them.

Reliability patterns

Add retries with exponential backoff in the worker for transient failures, a hard timeout per task, and checkpointing so a crash mid-job resumes rather than restarts. Emit state_changed for every transition and make webhook delivery retryable — a 500 from your webhook receiver should be re-delivered, not dropped. Keep progress monotonic and idempotent: clients render it, they don't derive truth from it.

Frequently asked questions

Q: How does the client know a tool is long-running?

A: The tool declares mode: "long-running" and the response includes a taskId; the client switches to polling and notification mode.

Q: What if the server process restarts mid-task?

A: If the task store is durable (Redis or Postgres), the task survives and a worker can resume it. In-memory stores lose tasks on restart, which is fine for local development.

Q: Can a task notify me without polling?

A: Yes, in two ways: the server pushes notifications/tasks/* events, or it POSTs a signed webhook to your endpoint on completion.

Q: Do standard clients support this without configuration?

A: Claude Desktop and Cursor ship client support. Older clients that do not advertise the capability simply get synchronous behavior for the same tool.

Q: Can the Tasks extension be combined with a remote HTTP server?

A: Yes. The Tasks extension is stateless and transport-agnostic, so it composes cleanly with OAuth-secured streamable HTTP servers. See AI Workflows for composed integration patterns.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
A: Standard MCP tools are synchronous request/response - a 10-minute job would time out the client. Tasks return a taskId immediately, run in the background, and let the client poll notifications or subscribe to webhooks, which is required for real production ETL, training, or render jobs.
A: Yes. Tasks was designed as a first-class extension to fit the new stateless core: the background job and its status live in a durable store, and each poll is a stateless HTTPS request - no session affinity needed.
Deepak Bagada
Author Profile

Deepak Bagada

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Briefing AI Tools

Vercel AI SDK Tool Calling React: 5 Steps (2026)

Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

Fact-Density vs. Word Count: The New SEO for 2026

Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc