A tool call that shells out to docker build runs for four minutes on a good day. Hold the request open and the client's timeout fires first: the model sees a failure while the build keeps running with nobody watching it. Return started immediately instead and you get a different problem, because there is no standard way for the client to come back later and collect the result.
The MCP Tasks extension covers that shape of work. The server answers tools/call with a durable task handle instead of a final result, and the client polls until the work finishes. Tasks graduated to a first-class MCP extension in the 2026-07-28 specification revision, contributed by AWS, and it is now the official answer for CI pipelines, batch jobs, and approval gates that take minutes rather than milliseconds.
Prerequisites
- Node.js 20 or later (
node --version) - The v2 MCP TypeScript SDK:
@modelcontextprotocol/serverand@modelcontextprotocol/client, both at 2.0.0 as of this writing - A host to test against manually, such as Claude Code, VS Code, or the MCP Inspector
- Optional: .NET SDK 8 or newer if you want the C# route, which is the one SDK that wires tasks for you today
What the extension defines
The identifier is io.modelcontextprotocol/tasks. It adds three methods (tasks/get, tasks/update, tasks/cancel), one discriminator, and one object shape.
The discriminator is resultType. An ordinary result carries "complete"; a task handle carries "task". A server must not set "task" on anything except a CreateTaskResult, and a client that declares support has to be ready for either shape on any tools/call it issues.
| Status | Meaning |
|---|---|
working |
The request is in progress |
input_required |
The server needs client input; inputRequests lists what is outstanding |
completed |
Finished; result holds what the original request would have returned |
failed |
A JSON-RPC error during execution; error holds it |
cancelled |
Cancelled before completion |
The last three are terminal, and a task does not leave them.
A Task carries taskId, status, statusMessage, createdAt, lastUpdatedAt, ttlMs, and pollIntervalMs. Two of those matter in production. ttlMs is the window in which the server promises to keep the task; past it the server may mark the task failed and delete it, and clients may stop trusting the handle. pollIntervalMs is the cadence the server suggests, and a server may rate-limit anything faster.
Task creation is server-directed. The client signals support once per request, in _meta:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "run_pipeline",
"arguments": { "kind": "build" },
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
}
If a server needs a task to serve a request and the client never declared the extension, the server returns a Missing Required Client Capability error instead. Worth knowing before you go debugging: the extension overview page shows that error as -32003, while the 2026-07-28 specification text uses -32021. Handle both, and check which one your SDK emits.
The pattern you can ship today
Host support varies, so the portable version of the same idea is two tools and one job store. One tool starts the work and returns an id, the second reports status. Every host that can call a tool can drive it, and the model usually works out the polling loop on its own when the tool description tells it to.
mkdir job-runner && cd job-runner
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/client zod tsx
mkdir src
type=module is not optional. The v2 SDK ships ES modules only.
Now src/index.ts:
import { McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import * as z from 'zod/v4';
// Replace these with your real pipeline commands. They are slow on purpose.
const COMMANDS = {
test: 'sleep 6 && echo "42 tests passed"',
build: 'sleep 12 && echo "image pushed: registry.example.com/app:latest"',
} as const;
type JobStatus = 'working' | 'completed' | 'failed';
interface Job {
id: string;
kind: keyof typeof COMMANDS;
status: JobStatus;
output: string[];
exitCode: number | null;
startedAt: string;
updatedAt: string;
}
const jobs = new Map<string, Job>();
function createServer(): McpServer {
const server = new McpServer({ name: 'job-runner', version: '1.0.0' });
server.registerTool(
'start_job',
{
title: 'Start a pipeline job',
description:
'Kick off a long-running pipeline job and return a job id immediately. Follow it with job_status.',
inputSchema: z.object({
kind: z.enum(['test', 'build']).describe('Which pipeline to run'),
}),
outputSchema: z.object({ jobId: z.string(), status: z.string() }),
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
},
async ({ kind }) => {
const id = randomUUID();
const now = new Date().toISOString();
const record: Job = {
id,
kind,
status: 'working',
output: [],
exitCode: null,
startedAt: now,
updatedAt: now,
};
jobs.set(id, record);
const child = spawn('bash', ['-lc', COMMANDS[kind]], { cwd: process.cwd() });
child.stdout.on('data', (chunk) => record.output.push(String(chunk)));
child.stderr.on('data', (chunk) => record.output.push(String(chunk)));
child.on('close', (code) => {
record.exitCode = code;
record.status = code === 0 ? 'completed' : 'failed';
record.updatedAt = new Date().toISOString();
});
return {
content: [
{
type: 'text',
text: `Job ${id} (${kind}) is ${record.status}. Poll job_status with this id every few seconds.`,
},
],
structuredContent: { jobId: id, status: record.status },
};
}
);
server.registerTool(
'job_status',
{
title: 'Check a pipeline job',
description: 'Report the status, exit code, and recent output of a job started by start_job.',
inputSchema: z.object({ jobId: z.string().describe('The job id returned by start_job') }),
outputSchema: z.object({
status: z.string(),
exitCode: z.number().nullable(),
output: z.string(),
}),
},
async ({ jobId }) => {
const record = jobs.get(jobId);
if (!record) {
return { content: [{ type: 'text', text: `Unknown job id: ${jobId}` }], isError: true };
}
const tail = record.output.join('').trim().split('
').slice(-20).join('
');
const payload = {
status: record.status,
exitCode: record.exitCode,
output: tail || '(no output yet)',
};
return {
content: [
{
type: 'text',
text: `status: ${payload.status}
exitCode: ${payload.exitCode ?? '-'}
output:
${payload.output}`,
},
],
structuredContent: payload,
};
}
);
return server;
}
void serveStdio(createServer);
console.error('job-runner MCP server running on stdio');
Four decisions carry the pattern:
start_jobreturns an id and nothing else. No waiting, no partial result.- The job store lives at module scope, so it survives the tool call that created it.
- The description names the follow-up tool and the cadence. That sentence is the whole reason the model polls instead of giving up.
- An unknown id comes back as
isError: truewith a readable message, which the model can act on without a protocol failure.
Drive it with a client to watch the loop. Install the client package, then run this as src/client.ts:
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const client = new Client({ name: 'job-runner-client', version: '1.0.0' });
await client.connect(new StdioClientTransport({ command: 'npx', args: ['tsx', 'src/index.ts'] }));
const started = await client.callTool({ name: 'start_job', arguments: { kind: 'test' } });
const jobId = (started.structuredContent as { jobId: string }).jobId;
for (let i = 0; i < 10; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const status = await client.callTool({ name: 'job_status', arguments: { jobId } });
const block = status.content.find((part) => part.type === 'text');
console.log(block && 'text' in block ? block.text.replace(/
/g, ' | ') : status.content);
if (status.structuredContent && status.structuredContent.status === 'completed') break;
}
await client.close();
npx tsx src/client.ts on the server above prints this:
job-runner MCP server running on stdio
start_job -> Job 6f0a1c74 (test) is working. Poll job_status with this id every few seconds.
poll 1 -> status: working | exitCode: - | output: | (no output yet)
poll 2 -> status: working | exitCode: - | output: | (no output yet)
poll 3 -> status: completed | exitCode: 0 | output: | 42 tests passed
You can also poke it by hand without writing a client:
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts --method tools/list
Where SDK support stands
| SDK | Tasks support | What that means for you |
|---|---|---|
| TypeScript 2.0.0 | Types only | The package ships TaskSchema, CreateTaskResultSchema, GetTaskRequestSchema, CancelTaskRequestSchema, TaskStatusNotificationSchema, and the isTaskAugmentedRequestParams guard. There is no task store and no handler that serves the three tasks/* methods, so that wiring is yours. I checked the package exports directly rather than trusting the docs on this one. |
| C# 2.0 | Complete | The ModelContextProtocol.Extensions.Tasks package handles it end to end. .WithTasks(new InMemoryMcpTaskStore()) wires tasks/get, tasks/update, and tasks/cancel, advertises the extension, and offloads each tool to a background task. On the client side, CallToolWithPollingAsync injects the capability, polls at the server's cadence, and deduplicates input requests. |
| Python 2.2.0 | Not yet | The release notes list the tasks extension as an unimplemented gap, tracked in the SDK repository's roadmap. |
If you are on TypeScript and want native tasks, you are writing the three method handlers by hand. That is a legitimate weekend of work, and the job store you built above is most of the state machine already.
Moving to native tasks
When your host and SDK are ready, the creation response changes shape. Instead of a CallToolResult, the server returns this:
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "task",
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "working",
"statusMessage": "The operation is now in progress.",
"createdAt": "2026-09-23T05:10:00Z",
"lastUpdatedAt": "2026-09-23T05:10:00Z",
"ttlMs": 3600000,
"pollIntervalMs": 5000
}
}
The task has to be durable before that response goes out, which means a tasks/get for that id must resolve. In an eventually consistent setup you wait for the write to land first. That requirement is what removes the need for clients to poll speculatively just to find out whether the task exists.
Then tasks/get returns the task with its payload inlined:
{
"jsonrpc": "2.0",
"id": 8,
"result": {
"resultType": "complete",
"taskId": "786512e2-9e0d-44bd-8f29-789f320fe840",
"status": "completed",
"createdAt": "2026-09-23T05:10:00Z",
"lastUpdatedAt": "2026-09-23T05:12:40Z",
"ttlMs": 3600000,
"pollIntervalMs": 5000,
"result": {
"content": [{ "type": "text", "text": "image pushed: registry.example.com/app:latest" }],
"isError": false
}
}
}
resultType is "complete" on tasks/get, tasks/update, and tasks/cancel, because those are ordinary results for those methods. Only the creation response carries "task".
Gotchas that cost an afternoon
- Tool errors are not task failures.
failedis reserved for JSON-RPC errors during execution. A tool that returnsisError: truereachescompletedwith that result inlined, so the model still reads the message and retries. - Cancellation is cooperative.
tasks/cancelgets an acknowledgement, and the server is not obliged to stop the work or to ever reachcancelled. Do not usenotifications/cancelledfor task cancellation, since the spec reserves that for plain requests. - There is no
tasks/list. That is deliberate, so one caller cannot discover another caller's task ids. It also means you persist task ids yourself if polling has to survive a client restart. - Progress notifications are not available on a task.
notifications/progressandnotifications/messagemust not be sent on the subscription stream for a task and are not supported for tasks at all. Status messages andinputRequestscarry the signal instead. - Over Streamable HTTP, set the routing header.
tasks/get,tasks/update, andtasks/cancelmust sendMcp-Nameset to the task id, so a load balancer can route to the instance holding that task's state. - Treat task ids as secrets. They can act as bearer tokens for stored state, so generate them with real entropy and re-check authorization on every task-related request.
- v1 and v2 tasks do not talk to each other. The experimental tasks from the 2025-11-25 revision are replaced and incompatible at both the API and protocol level. A v2 client against a v1 server gets an ordinary tool result; legacy
tasks/getagainst a v2 server fails with method-not-found. Upgrade both ends together.
When to use which
Block when the work finishes in a few seconds, because a task handle is more moving parts for no benefit. Use progress notifications when the client should show a spinner and the runtime is bounded. Reach for tasks when the operation outlives the client's request timeout, when it has to survive a disconnect, when a human has to approve something in the middle, or when you are wrapping an external job system that already hands you job ids.
Until your host supports the extension, the two-tool pattern is the honest fallback, and it is worth keeping afterwards. It is the same state machine minus the protocol.
Next steps
Point the job runner at your real build command and watch what the model does when a job runs longer than it expects. Add a TTL to the job store so finished jobs stop piling up in memory, and move the store to Redis or Postgres if more than one server instance will serve the same client. When your host ships tasks support, the migration is mechanical: return CreateTaskResult from start_job, serve the three tasks/* methods, and delete the polling tool.