Tool yang manggil docker build jalan empat menit kalau lagi bagus. Koneksinya kamu tahan terbuka, dan yang duluan mati justru timeout di sisi client: modelnya lihat error, sementara build-nya tetap jalan tanpa ada yang ngawasi. Balikin started langsung juga bukan solusi, karena nggak ada cara standar buat client balik lagi dan ngambil hasilnya.
Extension MCP Tasks nutup celah itu. Server menjawab tools/call dengan task handle yang tahan disconnect, bukan hasil final, dan client polling sampai kerjanya selesai. Tasks naik jadi extension resmi MCP di revisi spesifikasi 2026-07-28, dikontribusi AWS, dan sekarang jadi jawaban resmi buat CI pipeline, batch job, dan approval gate yang makan waktu menit, bukan milidetik.
Yang perlu kamu siapkan
- Node.js 20 atau lebih baru (
node --version) - TypeScript SDK MCP v2:
@modelcontextprotocol/serverdan@modelcontextprotocol/client, dua-duanya di 2.0.0 saat artikel ini ditulis - Satu host buat tes manual, misalnya Claude Code, VS Code, atau MCP Inspector
- Opsional: .NET SDK 8 atau lebih baru kalau mau jalur C#, karena itu satu-satunya SDK yang merangkai tasks buat kamu sekarang
Apa yang didefinisikan extension ini
Identifier-nya io.modelcontextprotocol/tasks. Extension ini nambah tiga method (tasks/get, tasks/update, tasks/cancel), satu discriminator, dan satu bentuk objek.
Discriminator-nya resultType. Hasil biasa membawa "complete", task handle membawa "task". Server nggak boleh menaruh "task" di selain CreateTaskResult, dan client yang menyatakan dukungan harus siap menerima dua bentuk itu di setiap tools/call yang dia kirim.
| Status | Arti |
|---|---|
working |
Request sedang diproses |
input_required |
Server butuh input dari client; inputRequests berisi yang belum dipenuhi |
completed |
Selesai; result berisi apa yang seharusnya dikembalikan request aslinya |
failed |
Ada error JSON-RPC saat eksekusi; detailnya di error |
cancelled |
Dibatalkan sebelum selesai |
Tiga yang terakhir bersifat terminal, dan task nggak keluar dari situ.
Sebuah Task membawa taskId, status, statusMessage, createdAt, lastUpdatedAt, ttlMs, dan pollIntervalMs. Dua di antaranya penting di production. ttlMs adalah jendela waktu server berjanji menyimpan task-nya; lewat dari itu server boleh menandai task gagal lalu menghapusnya, dan client boleh berhenti mempercayai handle tersebut. pollIntervalMs adalah cadence yang disarankan server, dan server boleh membatasi client yang polling lebih cepat.
Pembuatan task ditentukan server. Client menyatakan dukungan sekali per request, di _meta:
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "run_pipeline",
"arguments": { "kind": "build" },
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": { "io.modelcontextprotocol/tasks": {} }
}
}
}
}
Kalau server butuh task untuk melayani sebuah request sementara client-nya nggak pernah menyatakan extension ini, server balikin error Missing Required Client Capability. Satu hal yang perlu kamu tahu sebelum sibuk debugging: halaman overview extension menampilkan error itu sebagai -32003, sedangkan teks spesifikasi 2026-07-28 memakai -32021. Tangani dua-duanya, dan cek SDK kamu mengeluarkan yang mana.
Pola yang bisa kamu pakai sekarang
Dukungan host berbeda-beda, jadi versi portabel dari ide yang sama adalah dua tool dan satu job store. Tool pertama memulai kerja dan mengembalikan id, tool kedua melaporkan status. Semua host yang bisa memanggil tool bisa menjalankannya, dan modelnya biasanya menemukan sendiri loop polling-nya asal deskripsi tool-nya jelas.
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 bukan pilihan. SDK v2 cuma mengirim ES modules.
Sekarang 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';
// Ganti dengan perintah pipeline kamu sendiri. Sengaja lambat.
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');
Empat keputusan yang menopang pola ini:
start_jobmengembalikan id dan nggak lebih. Tanpa menunggu, tanpa hasil sebagian.- Job store-nya ada di scope module, jadi hidupnya lebih panjang dari tool call yang membuatnya.
- Deskripsinya menyebut tool lanjutan dan jeda polling-nya. Kalimat itu yang bikin model polling alih-alih menyerah.
- Id yang nggak dikenal dibalas dengan
isError: trueplus pesan yang bisa dibaca, jadi modelnya bisa bertindak tanpa kegagalan protokol.
Jalankan dengan client biar kelihatan loop-nya. Install paket client, lalu simpan ini sebagai 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 di server di atas mencetak ini:
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
Bisa juga dicoba manual tanpa nulis client:
npx @modelcontextprotocol/inspector --cli npx tsx src/index.ts --method tools/list
Status dukungan SDK
| SDK | Dukungan tasks | Artinya buat kamu |
|---|---|---|
| TypeScript 2.0.0 | Tipe saja | Paketnya membawa TaskSchema, CreateTaskResultSchema, GetTaskRequestSchema, CancelTaskRequestSchema, TaskStatusNotificationSchema, dan guard isTaskAugmentedRequestParams. Nggak ada task store dan nggak ada handler yang melayani tiga method tasks/*, jadi rangkaian itu kerjaan kamu. Saya cek langsung export paketnya, bukan cuma percaya dokumentasinya. |
| C# 2.0 | Lengkap | Paket ModelContextProtocol.Extensions.Tasks mengurusnya dari ujung ke ujung. .WithTasks(new InMemoryMcpTaskStore()) merangkai tasks/get, tasks/update, dan tasks/cancel, mengiklankan extension-nya, dan memindahkan tiap tool ke background task. Di sisi client, CallToolWithPollingAsync menyuntikkan capability, polling sesuai cadence server, dan men-deduplikasi input request. |
| Python 2.2.0 | Belum | Catatan rilis menyebut extension tasks sebagai celah yang belum diimplementasi, dilacak di roadmap repo SDK-nya. |
Kalau kamu di TypeScript dan mau tasks native, kamu yang nulis tiga handler method itu. Itu kerjaan akhir pekan yang sah, dan job store yang kamu bangun di atas sudah jadi sebagian besar state machine-nya.
Pindah ke tasks native
Begitu host dan SDK kamu siap, bentuk respons pembuatannya berubah. Bukan CallToolResult, tapi ini:
{
"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
}
}
Task-nya harus sudah durable sebelum respons itu keluar, artinya tasks/get dengan id tersebut harus sudah bisa dijawab. Kalau sistemnya eventually consistent, kamu tunggu write-nya masuk dulu. Aturan itulah yang menghapus kebutuhan client polling spekulatif cuma buat tahu task-nya sudah ada atau belum.
Lalu tasks/get mengembalikan task dengan payload-nya ikut di dalam:
{
"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 bernilai "complete" di tasks/get, tasks/update, dan tasks/cancel, karena ketiganya hasil biasa untuk method tersebut. Cuma respons pembuatan task yang membawa "task".
Jebakan yang bisa buang seharian
- Error tool bukan kegagalan task.
failedkhusus untuk error JSON-RPC saat eksekusi. Tool yang mengembalikanisError: truetetap sampai kecompleteddengan hasil itu di dalamnya, jadi modelnya tetap baca pesannya dan mencoba lagi. - Pembatalan bersifat kooperatif.
tasks/cancelcuma dapat acknowledgement, dan server nggak wajib menghentikan kerjanya atau sampai ke statuscancelled. Jangan pakainotifications/cancelledbuat membatalkan task, karena spesifikasinya menyimpan itu untuk request biasa. - Nggak ada
tasks/list. Itu memang disengaja, biar satu pemanggil nggak bisa menemukan task id milik pemanggil lain. Konsekuensinya, kamu sendiri yang harus menyimpan task id kalau polling harus selamat dari restart client. - Progress notification nggak tersedia di task.
notifications/progressdannotifications/messagenggak boleh dikirim di stream subscription untuk task dan nggak didukung di task sama sekali. Sinyalnya lewat status message daninputRequests. - Di Streamable HTTP, pasang header routing-nya.
tasks/get,tasks/update, dantasks/cancelharus mengirimMcp-Nameberisi task id, biar load balancer bisa mengarahkan ke instance yang memegang state task itu. - Perlakukan task id seperti rahasia. Dia bisa berperan sebagai bearer token untuk state yang tersimpan, jadi generate dengan entropi yang benar dan periksa ulang otorisasi di setiap request task.
- Tasks v1 dan v2 nggak saling bicara. Tasks eksperimental dari revisi 2025-11-25 sudah digantikan dan nggak kompatibel di level API maupun protokol. Client v2 ke server v1 dapat hasil tool biasa;
tasks/getlegacy ke server v2 gagal dengan method-not-found. Upgrade dua sisinya bersamaan.
Kapan pakai yang mana
Blocking kalau kerjanya selesai dalam beberapa detik, karena task handle cuma nambah moving parts tanpa manfaat. Pakai progress notification kalau client perlu menampilkan indikator dan durasinya terbatas. Pilih tasks kalau operasinya melewati timeout request client, kalau harus selamat dari disconnect, kalau ada manusia yang perlu menyetujui di tengah proses, atau kalau kamu membungkus sistem job eksternal yang memang sudah memberi job id.
Sampai host kamu mendukung extension ini, pola dua tool adalah fallback yang jujur, dan tetap layak dipertahankan setelah upgrade. State machine-nya sama, cuma tanpa protokolnya.
Langkah berikutnya
Arahkan job runner-nya ke perintah build kamu yang asli dan perhatikan apa yang model lakukan waktu job-nya lebih lama dari dugaannya. Tambahkan TTL di job store biar job yang sudah selesai nggak menumpuk di memori, dan pindahkan store-nya ke Redis atau Postgres kalau lebih dari satu instance server melayani client yang sama. Begitu host kamu mendukung tasks, migrasinya mekanis: kembalikan CreateTaskResult dari start_job, layani tiga method tasks/*, lalu hapus tool polling-nya.