← Kembali ke Blog

MCP Tasks: Tool yang Lama Tanpa Bikin Agent Nunggu

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/server dan @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_job mengembalikan 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: true plus 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. failed khusus untuk error JSON-RPC saat eksekusi. Tool yang mengembalikan isError: true tetap sampai ke completed dengan hasil itu di dalamnya, jadi modelnya tetap baca pesannya dan mencoba lagi.
  • Pembatalan bersifat kooperatif. tasks/cancel cuma dapat acknowledgement, dan server nggak wajib menghentikan kerjanya atau sampai ke status cancelled. Jangan pakai notifications/cancelled buat 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/progress dan notifications/message nggak boleh dikirim di stream subscription untuk task dan nggak didukung di task sama sekali. Sinyalnya lewat status message dan inputRequests.
  • Di Streamable HTTP, pasang header routing-nya. tasks/get, tasks/update, dan tasks/cancel harus mengirim Mcp-Name berisi 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/get legacy 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.

Referensi

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis