DEV Community

Cover image for Claude Code Now Runs MCP Tasks in the Background. How MCP Tasks Actually Work
Gabriel Anhaia
Gabriel Anhaia

Posted on Edited on AI-assisted

Claude Code Now Runs MCP Tasks in the Background. How MCP Tasks Actually Work


Your MCP server exposes a report export that takes twenty minutes. Until this week, in most Claude Code sessions, Claude called it and the session sat inside that call. A fallback moves a call to the background once it has run for two minutes, so for those two minutes you waited.

Claude Code 2.1.319, released November 6, changes that. The changelog: "when an MCP server runs a tool call as a task, Claude Code moves it to the background right away and tells Claude the result when it finishes."

CLAUDE_CODE_DISABLE_MCP_TASK_BACKGROUND=1 makes Claude Code "wait for an MCP task inside its tool call instead of moving it to the background right away." CLAUDE_CODE_DISABLE_MCP_TASKS=1 turns tasks off. Tasks had been a public beta since v2.1.304, gated on feature flags. This release drops that dependency, so Bedrock, Vertex, Foundry and LLM gateway setups get tasks too.

The protocol under this is younger than it looks. MCP tasks shipped as an experiment in the November 2025 spec and were redesigned in the July 2026 revision, and the two versions don't speak to each other on the wire.

What Claude Code does when a server returns a task

Per the MCP docs, Claude's tool call returns as soon as the server accepts the job, and Claude keeps working. The footer shows a count such as 1 MCP task, and /tasks lists it with its status. Select it and press x to ask the server to cancel. The server may still finish, and Esc doesn't stop a background task.

When the job ends, the transcript shows a line in this form:

MCP task <server task id> (<server>/<tool>) completed.
Enter fullscreen mode Exit fullscreen mode

The result goes to Claude, not into the transcript. If Claude is idle when it arrives, it starts on the result unprompted.

Plenty of sessions won't get tasks at all:

  • Only calls from the main conversation count. A subagent's MCP calls don't become tasks.
  • A one-shot claude -p "<prompt>" run doesn't get them. Interactive sessions do. Agent SDK sessions and claude -p --input-format stream-json runs get them only with CLAUDE_AUTO_BACKGROUND_TASKS=1 set, the background opt-outs off and the input kept open.
  • The server has to be connected on protocol revision 2026-07-28, through Claude Code's v2 MCP runtime, built on MCP TypeScript SDK 2.0.
  • The VS Code extension and the desktop app don't have MCP tasks.

MCP_TOOL_TIMEOUT and the per-server timeout apply only until the server answers with the task. After that, by default, Claude Code sets no time limit. A remote task keeps running after you exit. A stdio server's process stops when Claude Code exits, so a server that keeps its tasks in memory, like the one below, loses them.

A terminal window keeps running while a large crate slides away on a side track, tied back to the terminal by a thin orange thread that ends in a small ticket
A task handle: the tool call returns a ticket right away, and the job runs somewhere else.

Version one: tasks in the 2025-11-25 spec

Tasks first appeared in the 2025-11-25 specification, marked experimental, with three layers of opt-in. The server declared tasks.requests.tools.call in its capabilities. Each tool in tools/list declared execution.taskSupport as "required", "optional" or "forbidden", forbidden by default. Then the client added a task field to the call, as in the spec's own example:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "city": "New York" },
    "task": { "ttl": 60000 }
  }
}
Enter fullscreen mode Exit fullscreen mode

The server answered at once with a CreateTaskResult holding a nested task object: a taskId, status: "working", the ttl it granted and a suggested pollInterval. The client polled tasks/get until the status was terminal, then fetched the tool output with tasks/result, which blocked until the task finished. Statuses were working, input_required, completed, failed and cancelled, and there were tasks/list and tasks/cancel too.

SEP-2663, the proposal that replaced it, is blunt about the problems. "The handshake is fragile": a client had to call tools/list before it knew whether it could attach task to a call. "tasks/result is a blocking trap": a task that needed input forced the client to open tasks/result early, so the server had a stream to ask on, and that held a connection open until the job ended.

Version two: the io.modelcontextprotocol/tasks extension

The 2026-07-28 changelog moves tasks "out of the core protocol and into an official extension (io.modelcontextprotocol/tasks)." The full text now lives in the ext-tasks repository.

The handshake is one flag. The same revision removed initialize, so every request carries the client's capabilities in _meta, and a client that handles tasks lists the extension there. The server advertises it in its server/discover reply. From then on, per SEP-2663, "the server is the sole decider." Any tools/call can come back as a normal result or as a task, and the client must handle both.

A handle now looks like this, from Claude Code's notes for server authors:

{
  "resultType": "task",
  "taskId": "build-7f3a9c2e",
  "status": "working",
  "statusMessage": "Compiling, 40 of 212 files done",
  "createdAt": "2026-10-22T14:03:07Z",
  "lastUpdatedAt": "2026-10-22T14:03:07Z",
  "ttlMs": null,
  "pollIntervalMs": 5000
}
Enter fullscreen mode Exit fullscreen mode

resultType is the discriminator. Every 2026-07-28 result carries one, and ordinary results say "complete". When the task finishes, tasks/get returns it as completed with the tool's output inline in result.

2025-11-25 (core, experimental) 2026-07-28 (extension)
Opt-in Capability, per-tool taskSupport, task param Extension in per-request _meta
Who decides Client, per call Server, per call
Handle Nested task, ttl, pollInterval Flat, resultType: "task", ttlMs, pollIntervalMs
Final output tasks/result, blocks Inline in tasks/get
Mid-task input input_required, then tasks/result inputRequests in tasks/get, answer with tasks/update
Listing tasks/list Removed
Cancel Returns the task Empty ack, cooperative
Tool returns isError: true failed completed, error in result

The last row catches people. Under the extension, failed means a JSON-RPC error. A tool that ran and reported an error is a completed task whose result says isError: true. The SEP also requires that a server not return the handle "until the task is durably created," so the first tasks/get always finds it.

A tiny server you can point Claude Code at

You'd expect to use the official SDK. When issue #2637 was filed against the 2.0.0 TypeScript server, registerTool() typed callbacks as CallToolResult | InputRequiredResult, and the client rejected resultType: "task". The tracking issue, #2189, is still open. So this server speaks newline-delimited JSON-RPC over stdio by hand, no dependencies, on Node.js 18 or later. It only speaks 2026-07-28 and skips version checks to stay short.

Save the three blocks below, in order, as one file named slow-build.mjs. The first holds the constants and the capability check:

// slow-build.mjs: an MCP server that returns a task
import { createInterface } from "node:readline";
import { randomUUID } from "node:crypto";

const EXT = "io.modelcontextprotocol/tasks";
const CAPS = "io.modelcontextprotocol/clientCapabilities";
const BUILD_MS = 90_000;
const tasks = new Map();

const send = (msg) =>
  process.stdout.write(JSON.stringify(msg) + "\n");
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const now = () => new Date().toISOString();

const TOOL = {
  name: "slow_build",
  description: "Simulated build. Takes 90 seconds.",
  inputSchema: { type: "object", properties: {} },
};

function wantsTasks(params) {
  return Boolean(params?._meta?.[CAPS]?.extensions?.[EXT]);
}

function findTask(params) {
  if (!wantsTasks(params)) {
    throw { code: -32021, message: "Tasks extension not declared" };
  }
  const task = tasks.get(params.taskId);
  if (!task) throw { code: -32602, message: "Task not found" };
  return task;
}
Enter fullscreen mode Exit fullscreen mode

There is no initialize in 2026-07-28, so wantsTasks reads the client's capabilities from each request's _meta. A task call from a client that never declared the extension gets error -32021. The second block holds the job and the method handlers:

function startBuild() {
  const task = {
    taskId: randomUUID(),
    status: "working",
    statusMessage: "Building",
    createdAt: now(),
    lastUpdatedAt: now(),
    ttlMs: 3_600_000,
    pollIntervalMs: 5000,
  };
  tasks.set(task.taskId, task);
  const done = sleep(BUILD_MS).then(() => {
    if (task.status !== "working") return;
    task.status = "completed";
    task.statusMessage = "Build finished";
    task.lastUpdatedAt = now();
    task.result = {
      content: [{ type: "text", text: "Simulated build done." }],
      isError: false,
    };
  });
  return { task, done };
}

const handlers = {
  "server/discover": () => ({
    supportedVersions: ["2026-07-28"],
    capabilities: { tools: {}, extensions: { [EXT]: {} } },
    _meta: {
      "io.modelcontextprotocol/serverInfo": {
        name: "slow-build",
        version: "0.1.0",
      },
    },
  }),
  "tools/list": () => ({
    tools: [TOOL],
    ttlMs: 60_000,
    cacheScope: "private",
  }),
  "tools/call": async (params) => {
    if (params.name !== TOOL.name) {
      throw { code: -32602, message: "Unknown tool" };
    }
    const { task, done } = startBuild();
    if (wantsTasks(params)) return { ...task, resultType: "task" };
    await done;
    return task.result;
  },
  "tasks/get": (params) => findTask(params),
  "tasks/cancel": (params) => {
    const task = findTask(params);
    if (task.status === "working") {
      task.status = "cancelled";
      task.lastUpdatedAt = now();
    }
    return {};
  },
};
Enter fullscreen mode Exit fullscreen mode

tasks/get returns the task record itself, with result inline once the build completes. tasks/cancel flips the status and acks with an empty object, the cooperative cancel from the table. The third block reads stdin one line at a time:

createInterface({ input: process.stdin })
  .on("line", async (line) => {
    let msg;
    try { msg = JSON.parse(line); } catch { return; }
    if (msg.id === undefined) return; // a notification
    const handler = handlers[msg.method];
    try {
      if (!handler) {
        throw { code: -32601, message: "Method not found" };
      }
      const result = await handler(msg.params ?? {});
      send({
        jsonrpc: "2.0",
        id: msg.id,
        result: { resultType: "complete", ...result },
      });
    } catch (err) {
      const error = { code: err.code ?? -32603 };
      error.message = String(err.message);
      send({ jsonrpc: "2.0", id: msg.id, error });
    }
  })
  .on("close", () => process.exit(0));
Enter fullscreen mode Exit fullscreen mode

The line that matters is in tools/call. If the request declares the extension, the server hands back the task at once. If not, the same handler waits 90 seconds and returns a plain result. One tool, two result shapes, and the client's capabilities pick. The send step stamps resultType: "complete" on everything except the handle, which keeps "task".

Register it, then start Claude Code with the v2 runtime pinned:

claude mcp add --transport stdio slow-build \
  -- node "$PWD/slow-build.mjs"
MCP_SDK_GENERATION=v2 claude
Enter fullscreen mode Exit fullscreen mode

Ask Claude to run slow_build and to read your package.json while it runs. By the docs, the footer should show 1 MCP task, /tasks should list it, and about 90 seconds later the completion line lands and Claude picks up "Simulated build done." I haven't run this server against Claude Code myself, so if a step behaves differently, the docs page wins.

The 90 seconds is deliberate. With CLAUDE_CODE_DISABLE_MCP_TASK_BACKGROUND=1, Claude waits inside the call, but the two-minute automatic backgrounding still applies. A 90-second build finishes in place. Push BUILD_MS past 120,000 and the call moves to the background anyway, as a plain background call rather than an MCP task.

Before you ship a task-returning server

The toy keeps tasks in one Map forever. The extension's security and polling rules ask for more:

  • Unguessable IDs. Task IDs can act as bearer tokens. randomUUID() draws from a cryptographic random generator.
  • Auth on every task call. Check the caller on each tasks/get, tasks/update and tasks/cancel.
  • Honor the TTL. A server may mark a task failed after ttlMs and delete it later. Clean up, or memory only grows.
  • Durable storage. Clients are told to persist task IDs and resume after a restart. That only helps if your tasks survive one too.
  • Route by task ID. Over Streamable HTTP, clients set the Mcp-Name header to the task ID so a load balancer can send every poll to the instance that owns it.

Need a confirmation mid-task? Put it in inputRequests on tasks/get and read the answer from tasks/update. Claude Code shows it as an elicitation dialog, and in non-interactive sessions it asks the server to cancel any task that needs input.

Running it from Hermes IDE

I build Hermes IDE, so weigh this section with that in mind. Hermes runs each agent's own interface in a real terminal, exactly as it would anywhere else, so a vendor's new commands and modes work there the day they ship. Nothing in Hermes's release notes touches MCP. The footer count, /tasks and the env vars in your settings.json are all Claude Code's own.

What Hermes adds sits around the terminals. Each task gets its own hermes/ branch and worktree, so one Claude Code session can wait on a long MCP job while another agent works elsewhere in the repo. On macOS and Linux, agents keep working while Hermes quits, updates or crashes, which matters here: a stdio server's process, and any task it holds in memory, stops when the Claude Code process that started it exits.

Two real agent sessions side by side in Hermes IDE, Codex on orders-types and Claude Code on orders-api, each in its own hermes/ worktree, with the sessions sidebar on the left
Two real agent sessions side by side: Codex on orders-types, Claude Code on orders-api, each in its own hermes/ worktree, with status, context use, cost and branch in the sidebar.

Hermes is free to download, source-available under BSL 1.1, and runs on macOS, Windows and Linux, with the code on GitHub. It isn't affiliated with Anthropic, OpenAI, Nous Research or any other agent vendor.


If this was useful

A task handle is an old distributed-systems move under a new name: accept the job, hand back an ID, let the caller poll at a pace you set, and expire the record when nobody comes back for it. If you build the servers on the other end of these calls, the System Design Pocket Guide: Fundamentals covers the building blocks around that pattern: caching, rate limits, load balancing and availability. And if you want to watch a long MCP job in one agent while another works next to it, try it in Hermes IDE.

System Design Pocket Guide: Fundamentals — Core Building Blocks for Scalable Systems

Top comments (0)