본문으로 건너뛰기

MCP Tasks: 세션 없이 장기 실행 작업 처리하기

고급

stateless MCP 2026-07-28 스펙은 세션을 없애 수평 확장 문제를 해결했지만, "도구 실행에 20분이 걸리면 어떡하지?"라는 쉬운 답도 함께 사라졌습니다. 그 답이 바로 Tasks 확장(io.modelcontextprotocol/tasks, SEP-2663)입니다: 서버는 블로킹 대신 지속 가능한 태스크 핸들을 반환하고, 클라이언트가 tasks/get, tasks/update, tasks/cancel로 작업을 주도합니다. 2026년 후반기에 진지한 장기 실행 MCP 서버라면 모두 이 패턴을 채택합니다.

What you'll learn
  • 서버가 로드 밸런서나 서버리스 런타임 뒤에 배치되는 순간 요청 블로킹이 왜 무너지는가
  • 다섯 가지 태스크 상태 — working, input_required, completed, failed, cancelled — 그리고 어떤 전환이 합법인가
  • 와이어 프로토콜: 기능 협상, CreateTaskResult, tasks/get 폴링, notifications/tasks 푸시
  • input_required가 지속 연결 없이 어떻게 구식 elicitation을 대체하는가
  • 2025-11-25 실험적 Tasks API에서 마이그레이션 — 왜 업그레이드가 아니라 재작성인가
  • 함정들: 협력적 취소, 의도적으로 사라진 tasks/list, TTL 만료, 크로스 테넌트 누출

한 문단 요약

stateless MCP 서버는 오래 유지되는 연결에 의존할 수 없습니다: HTTP 중간 장치가 끊고, 로드 밸런서가 클라이언트를 새 인스턴스로 재배치하고, 모바일 네트워크가 깜빡입니다. Tasks는 장기 도구 호출을 지속 가능한 리소스로 바꿉니다 — 서버가 첫 요청에 답하기도 전에 지속화하는 taskId입니다. 클라이언트는 서버가 제안한 간격으로 tasks/get(taskId)를 폴링합니다; 상태가 completed, failed, cancelled로 뒤집히면 폴링 응답은 동기 호출이 반환했을 것과 동일한 페이로드를 담고 있습니다. 실행 중, 서버는 input_required로 이동해 질문할 수 있습니다 — 클라이언트는 tasks/update로 응답하고 폴링이 재개됩니다. 이것이 전체 모델입니다.

왜 그냥 블록하지 않는가?

작업이 끝날 때까지 연결을 열어둘 수 있습니다. MCP 워킹 그룹은 이를 고려했지만 거부했습니다 — 모든 서버리스 개발자가 이미 알고 있는 이유로:

Watch out
  • 타임아웃. AWS API Gateway는 29초에서 끊깁니다. Cloudflare Workers는 CPU 30초 + 벽시계 6분. Vercel Functions는 5분. 어떤 것을 통해서든 배치 임포트를 롱 폴링하면 도중에 504를 받습니다.
  • 크래시 복원력. 클라이언트 탭이 다시 로드되거나 네트워크가 끊기면 블록된 호출은 결과를 잃습니다. taskId는 지속적입니다 — 같은 클라이언트가 몇 분 후에 폴링을 재개할 수 있습니다.
  • 로드 밸런서 스티키니스. 블로킹은 요청을 하나의 서버 인스턴스에 고정합니다. 작업 중의 모든 스케일-인 이벤트가 호출을 죽입니다.
  • 진행 가시성. 블록된 호출은 끝날 때까지 아무것도 주지 않습니다. 태스크는 진행률 표시줄로 렌더링할 수 있는 상태 메시지를 담고 있습니다.
  • 실행 중 입력. 도구에 사용자 확인이 필요하다면, 블록된 호출은 요청되지 않은 서버 → 클라이언트 메시지 없이 물어볼 방법이 없습니다 — stateless 스펙이 금지하는 바로 그것입니다.

다섯 가지 상태 라이프사이클

모든 태스크는 이 상태들 중 정확히 하나에 속합니다. completed, failed, cancelled종단입니다 — 한 번 도달하면 상태가 변하지 않습니다:

상태의미채우는 필드
working작업 진행 중. 서버가 진행에 따라 선택적 상태 메시지를 업데이트합니다.statusMessage
input_required서버가 클라이언트 입력을 기다리며 블록됨. 요청을 표시하고 tasks/update로 제출.inputRequests
completed작업이 성공적으로 완료됨. result가 동기 호출이 반환했을 값을 담음.result
failed실행 중 JSON-RPC 오류 발생.error
cancelled클라이언트가 취소를 요청했고 서버가 이를 수용함. 모든 요청에서 보장되지 않음.

합법적인 전환: working ↔ input_required, working → completed | failed | cancelled, input_required → working | failed | cancelled. 그 외에는 서버 버그입니다.

와이어 프로토콜

1. 양쪽 모두 옵트인

Tasks는 코어가 아닌 확장입니다 — 양쪽 모두 이를 알려야 합니다. 클라이언트는 모든 요청의 _meta에 이를 넣고; 서버는 server/discover에서 이를 반환합니다:

// 클라이언트 → 서버, 태스크로 돌아올 수 있는 모든 요청에서:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "run_ci_pipeline",
"arguments": { "commit": "abc123" },
"_meta": {
"io.modelcontextprotocol/clientCapabilities": {
"extensions": {
"io.modelcontextprotocol/tasks": {}
}
}
}
}
}

클라이언트가 지원을 선언하지 않았다면, 서버는 태스크를 반환해서는 안 됩니다 — 블록하거나, 오류를 반환하거나, 작업을 거부해야 합니다. 옵트인하지 않은 클라이언트에게 CreateTaskResult를 절대 보내지 마세요.

2. 서버가 태스크 핸들을 반환

일반적인 CallToolResult 대신, 서버는 resultType: "task"로 응답합니다:

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "task",
"task": {
"taskId": "tsk_01HZY7...",
"status": "working",
"statusMessage": "Cloning repo",
"ttlMs": 3600000,
"pollIntervalMs": 2000
}
}
}

태스크는 서버가 이 응답을 보내기 전에 지속적으로 저장되어야 합니다(Postgres, AOF가 있는 Redis, DynamoDB — pod 재시작을 견디는 무엇이든). 서버가 요청 수락과 태스크 지속화 사이에 크래시하면, 클라이언트는 정상적인 오류를 받고 재시도할 수 있습니다. 그 이후에 크래시하면, taskId는 여전히 모든 복제본에서 해석 가능합니다.

3. 클라이언트가 tasks/get을 폴링

// 클라이언트 → 서버, 매 pollIntervalMs마다:
{ "jsonrpc": "2.0", "id": 2, "method": "tasks/get", "params": { "taskId": "tsk_01HZY7..." } }

// 서버 → 클라이언트, 아직 진행 중:
{ "jsonrpc": "2.0", "id": 2, "result": { "taskId": "tsk_01HZY7...", "status": "working", "statusMessage": "Running tests (128/342)" } }

// 서버 → 클라이언트, 종단:
{ "jsonrpc": "2.0", "id": 2, "result": { "taskId": "tsk_01HZY7...", "status": "completed", "result": { "content": [{ "type": "text", "text": "All 342 tests passed in 4m12s" }] } } }

pollIntervalMs제안입니다 — 클라이언트는 이를 하한으로 존중하고, 반복되는 working 응답에서는 백오프해야 하며, 서버가 요청한 것보다 빠르게 폴링해서는 안 됩니다.

4. 실행 중 입력

도구에 사용자 확인이 필요하다면("47개 파일을 삭제합니다, 계속할까요?"), 서버는 input_required로 뒤집고 inputRequests 맵을 첨부합니다 — pre-stateless 세계에서 elicitation이 취했을 것과 같은 형태입니다:

// tasks/get 응답:
{
"taskId": "tsk_01HZY7...",
"status": "input_required",
"inputRequests": {
"confirm_delete": {
"type": "elicitation",
"message": "Delete 47 files matching *.tmp?",
"schema": { "type": "object", "properties": { "confirm": { "type": "boolean" } } }
}
}
}

클라이언트가 프롬프트를 표시한 다음 tasks/update로 응답합니다:

{
"jsonrpc": "2.0",
"id": 5,
"method": "tasks/update",
"params": {
"taskId": "tsk_01HZY7...",
"inputResponses": { "confirm_delete": { "confirm": true } }
}
}

서버는 빈 결과로 ack합니다; 상태가 working으로 다시 이동합니다. 알 수 없거나 이미 만족된 키에 대한 응답은 무시되어야 합니다 — 이렇게 하면 재시도가 안전합니다.

5. 협력적 취소

{ "jsonrpc": "2.0", "id": 9, "method": "tasks/cancel", "params": { "taskId": "tsk_01HZY7..." } }

서버는 빈 결과로 ack합니다. 스펙의 문구에 주의하세요: 취소는 협력적입니다 — 서버는 의도를 인정하지만 작업을 멈출 의무는 없습니다. completed에 도달하려는 태스크에 대한 tasks/cancel은 여전히 completed로 착륙할 수 있습니다. 클라이언트 UI를 "취소됨"이 아니라 "취소 요청됨, 확인 대기 중"으로 설계하세요. 이는 마이그레이션 중 사용자에게 보이는 버그의 가장 흔한 단일 원인입니다.

폴링 대신 알림

폴링은 기본값이며 항상 작동합니다. 서버가 알림을 지원할 , 클라이언트는 한 번 구독하고 폴링 루프를 완전히 건너뛸 수 있습니다:

// 클라이언트가 태스크 변경 이벤트를 구독:
{ "jsonrpc": "2.0", "id": 3, "method": "subscriptions/listen", "params": { "notifications": ["notifications/tasks"] } }

// 서버가 매 상태 변경마다 전체 태스크 스냅샷을 푸시:
{ "jsonrpc": "2.0", "method": "notifications/tasks", "params": { "task": { "taskId": "tsk_01HZY7...", "status": "completed", "result": { "..." : "..." } } } }

각 푸시는 전체 태스크 상태를 담습니다 — 클라이언트는 후속 tasks/get이 절대 필요 없습니다. subscriptions/listen이 "not supported"를 반환하거나 스트림이 끊어지면 폴링으로 폴백하세요.

Tasks를 언제 사용할까 (그리고 언제 사용하지 말아야 할까)

Guided walkthrough1 of 6
  1. CI 파이프라인, 배치 임포트, 모델 훈련, 비디오 인코딩, 대규모 리팩터, 배포. p99이 ~10초를 넘으면 이미 Tasks를 원하는 것이며; p99이 30초를 넘으면 Tasks 없이는 이미 망가진 것입니다.

2025-11-25 실험적 Tasks API에서 마이그레이션

pre-stateless 스펙의 옛 tasks/create / tasks/status 형태는 SEP-2663과 호환되지 않습니다. 버전 업이 아니라 재작성으로 취급하세요:

Watch out
  • 옛것: 클라이언트가 명시적으로 tasks/create를 호출. 새것: 어떤 tools/call도 태스크로 돌아올 수 있음 — 클라이언트는 매 요청마다 다형적 결과를 처리해야 함.
  • 옛것: tasks/list가 세션의 태스크를 열거. 새것: tasks/list는 의도적으로 제거됨 — stateless 서버는 범위를 지정할 세션이 없고, 테넌트를 넘나드는 목록화는 데이터 누출임. 자체 태스크 ID를 클라이언트 측이나 제품 데이터베이스에서 추적하세요.
  • 옛것: elicitation은 별도의 서버 → 클라이언트 푸시였음. 새것: elicitation이 태스크의 input_required로 접혀 들어감 — 요청되지 않은 푸시가 필요 없음.
  • 옛것: 상태는 {pending, running, done, error} 중 하나. 새것: {working, input_required, completed, failed, cancelled}. error → failed로 매핑하고 새 input_required 분기를 추가.
  • 폐기 시계: 실험적 API는 최소 2027년 7월 28일까지 계속 작동함. SEP-2663에 맞춰 재작성하고, 두 엔드포인트를 병렬로 실행하고, 자신의 일정으로 컷오버하세요.

서버 구현 체크리스트

Guided walkthrough1 of 6
  1. CreateTaskResult는 클라이언트가 폴링할 수 있다는 약속입니다. DB 쓰기가 HTTP 응답 이후에 일어난다면, 둘 사이의 크래시는 그 약속을 깹니다. Write-through 후 응답하세요.

클라이언트 구현 체크리스트

Guided walkthrough1 of 5
  1. Tasks에 옵트인하는 순간, 모든 도구 호출이 태스크로 돌아올 수 있습니다. 단 하나의 무시된 resultType: task 분기가 결과를 조용히 떨어뜨립니다.

실제 예시: run_migration 도구

서버 의사 코드 — 5-30분 DB 마이그레이션을 실행하는 도구

// tools/call handler
async function handleToolCall(req) {
const supportsTasks = req.params._meta
  ?.["io.modelcontextprotocol/clientCapabilities"]
  ?.extensions?.["io.modelcontextprotocol/tasks"];

if (req.params.name === "run_migration") {
  if (!supportsTasks) {
    return jsonRpcError(req.id, -32603, "run_migration requires Tasks extension");
  }
  const taskId = "tsk_" + ulid();
  await db.tasks.insert({
    id: taskId, tenant: req.auth.tenant, status: "working",
    createdAt: Date.now(), ttlMs: 24 * 3600 * 1000,
  });
  // Kick off the actual work OUT OF BAND — do not await it here.
  queue.enqueue({ taskId, migration: req.params.arguments.name });
  return {
    resultType: "task",
    task: { taskId, status: "working", ttlMs: 24 * 3600 * 1000, pollIntervalMs: 5000 },
  };
}
}

// tasks/get handler — scoped by authenticated tenant
async function handleTasksGet(req) {
const t = await db.tasks.findOne({ id: req.params.taskId, tenant: req.auth.tenant });
if (!t) return jsonRpcError(req.id, -32602, "unknown taskId");
if (Date.now() > t.createdAt + t.ttlMs) return jsonRpcError(req.id, -32602, "task expired");
return { taskId: t.id, status: t.status, statusMessage: t.statusMessage,
         ...(t.status === "completed" && { result: t.result }),
         ...(t.status === "failed" && { error: t.error }) };
}

대부분의 팀이 첫 주에 부딪히는 함정들

Watch out
  • 'tasks/list가 없다.' 네 — 의도적으로. 범위를 지정할 세션이 없습니다. 자체 제품 데이터베이스에서 태스크 ID를 추적하세요.
  • '내 취소 버튼이 거짓말을 한다.' 항상 그럴 겁니다. '취소 요청'으로 이름을 바꾸거나 상태 변경을 서버의 종단 ack에 게이팅하세요.
  • '폴링할 때만 결과를 얻는다.' 맞습니다 — notifications/tasks + subscriptions/listen도 구현할 때까지는요. 두 경로 모두, 항상.
  • 'Python SDK에는 아직 이를 위한 헬퍼가 없다.' 일부 Tier 1 SDK 헬퍼는 아직 안정화 중입니다. 원시 JSON-RPC를 손으로 구현할 수도 있습니다 — 와이어 포맷은 완전히 명시되어 있습니다.
  • '한 사용자가 ID를 추측해서 다른 사용자의 태스크를 가져왔다.' tasks/get에서 테넌트로 범위를 지정하는 걸 잊었기 때문입니다. 모든 핸들러는 반드시 인증된 주체로 필터링해야 합니다.

퀴즈

Check yourself

0/4
  1. 당신의 클라이언트가 요청 _meta에 io.modelcontextprotocol/tasks를 포함하지 않았습니다. 호출된 도구는 20분이 걸립니다. 서버는 무엇을 해야 하나요?
  2. 사용자가 태스크 완료 100ms 전에 '취소'를 눌렀습니다. 서버가 취소와 완료를 동시에 처리합니다. 태스크는 합법적으로 어떤 상태로 끝날 수 있나요?
  3. 2025-11-25 실험적 Tasks API에서 마이그레이션 중입니다. 옛 코드는 큐를 보여주기 위해 tasks/list를 호출합니다. 올바른 수정은 무엇인가요?
  4. 다음 중 Tasks 대신 MRTR (SEP-2322)에 손을 뻗어야 하는 옳은 이유는 무엇인가요?

플래시카드

Enter 또는 스페이스 키를 눌러 카드를 뒤집습니다. 좌우 화살표 키로 카드를 이동할 수 있습니다.용어가 표시되었습니다.
1 / 8

출처 및 추가 자료