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
CEO, SaaSNext
- 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 states —
queued,running,succeeded,failed,cancelled. Every transition bumpsupdatedAtand 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/getandtasks/cancelmust be scoped to the identity or session that created the task. Bind ataskIdto 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
idempotencyKeyin 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.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
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.
Build a Production Azure DevOps MCP Server with Entra OAuth 2.0
Next Story →Autonomous Agentic QA Testing & Automated Browser Interaction Pipeline with Playwright, PydanticAI, and Model Context Protocol
Related Intelligence Analysis
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...
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...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...