MCP Tasks: 세션 없이 장기 실행 작업 처리하기
stateless MCP 2026-07-28 스펙은 세션을 없애 수평 확장 문제를 해결했지만, "도구 실행에 20분이 걸리면 어떡하지?"라는 쉬운 답도 함께 사라졌습니다. 그 답이 바로 Tasks 확장(io.modelcontextprotocol/tasks, SEP-2663)입니다: 서버는 블로킹 대신 지속 가능한 태스크 핸들을 반환하고, 클라이언트가 tasks/get, tasks/update, tasks/cancel로 작업을 주도합니다. 2026년 후반기에 진지한 장기 실행 MCP 서버라면 모두 이 패턴을 채택합니다.
- 서버가 로드 밸런서나 서버리스 런타임 뒤에 배치되는 순간 요청 블로킹이 왜 무너지는가
- 다섯 가지 태스크 상태 — 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 워킹 그룹은 이를 고려했지만 거부했습니다 — 모든 서버리스 개발자가 이미 알고 있는 이유로:
- 타임아웃. 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를 언제 사용할까 (그리고 언제 사용하지 말아야 할까)
- CI 파이프라인, 배치 임포트, 모델 훈련, 비디오 인코딩, 대규모 리팩터, 배포. p99이 ~10초를 넘으면 이미 Tasks를 원하는 것이며; p99이 30초를 넘으면 Tasks 없이는 이미 망가진 것입니다.
- AWS Batch, GitHub Actions, Kubernetes Jobs, Temporal 워크플로. 작업이 생성될 때 태스크를 반환하고, 작업이 완료될 때 이를 해결합니다. taskId는 문자 그대로 업스트림 작업 id를 포함할 수 있습니다.
- 승인 게이트, 리뷰 단계, 확인을 위해 일시 정지하는 모든 것. 태스크를 input_required 또는 종단 상태로 뒤집는 '승인/거부' 버튼이 있는 Slack 알림이 자연스럽게 작동합니다.
- 모바일, 태블릿, 비행기의 노트북. 크래시한 클라이언트는 지속 가능한 taskId에서 폴링을 재개할 수 있습니다 — 크래시한 동기 호출은 모든 것을 잃습니다.
- 모든 태스크는 폴링 왕복을 수반합니다. 날씨 조회나 통화 변환은 여전히 block-and-return이어야 합니다. 추가 지연 시간을 실제로 벌어들이는 호출에 Tasks를 아껴두세요.
- MRTR (SEP-2322)은 현재 호출을 계속하는 데 필요한 입력을 다룹니다 — 한 번의 왕복, 지속성 없음. Tasks는 요청보다 오래 지속되는 지속 가능한 작업을 다룹니다. MRTR의 두 왕복 사이의 비행기 크래시가 부분적으로 입력된 폼만 잃는다면 MRTR을 사용하세요. 두 시간짜리 배포를 잃는다면 Tasks를 사용하세요.
2025-11-25 실험적 Tasks API에서 마이그레이션
pre-stateless 스펙의 옛 tasks/create / tasks/status 형태는 SEP-2663과 호환되지 않습니다. 버전 업이 아니라 재작성으로 취급하세요:
- 옛것: 클라이언트가 명시적으로 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에 맞춰 재작성하고, 두 엔드포인트를 병렬로 실행하고, 자신의 일정으로 컷오버하세요.
서버 구현 체크리스트
- CreateTaskResult는 클라이언트가 폴링할 수 있다는 약속입니다. DB 쓰기가 HTTP 응답 이후에 일어난다면, 둘 사이의 크래시는 그 약속을 깹니다. Write-through 후 응답하세요.
- 100ms는 안 됩니다 — 레이트 리미트에 걸립니다. 60초도 안 됩니다 — 사용자가 UI가 멈췄다고 생각합니다. 작업의 중간 진행 속도에 맞추세요: CI 작업? 2-5초. 배치 임포트? 10-30초. 밤샘 훈련? 60초.
- 스펙은 완료된 태스크를 얼마나 오래 유지해야 하는지 아무 말도 하지 않습니다. 정책을 정하고(24시간이 흔함), ttlMs에 알리고, 만료된 ID에 대한 tasks/get을 -32602로 거부하세요. 그렇지 않으면 저장소를 영원히 누출합니다.
- 클라이언트는 재시도합니다. 같은 inputResponse를 두 번 수락하고, 이미 만족된 키는 무시하고, 상태 머신을 이중으로 진전시키지 마세요.
- taskId는 비밀이 아닙니다. 모든 tasks/get / tasks/update / tasks/cancel을 호출자의 인증된 신원으로 범위 지정하세요 — 다른 사용자에게 속한 태스크를 가져오는 것은 -32602를 반환해야 합니다(태스크도 아니고, 존재를 확인하는 인증 오류도 아닙니다).
- 협력적이라는 것은 끝낼 수 있다는 뜻이지, 끝내야 한다는 뜻이 아닙니다. 취소 플래그에 대한 ~1초마다의 체크인은 UX를 훨씬 좋게 만듭니다.
클라이언트 구현 체크리스트
- Tasks에 옵트인하는 순간, 모든 도구 호출이 태스크로 돌아올 수 있습니다. 단 하나의 무시된 resultType: task 분기가 결과를 조용히 떨어뜨립니다.
- 브라우저의 LocalStorage, CLI의 sqlite, 백엔드의 제품 DB. taskId를 잃은 크래시한 클라이언트는 재개할 수 없습니다.
- 10-20% 무작위 지터를 추가하지 않으면 같은 태스크를 같은 간격으로 폴링하는 수천 개의 클라이언트가 서버를 두들깁니다.
- 종단 상태를 기다리는 동안 '취소됨'이 아니라 '취소 중...'을 렌더링하세요. 어쨌든 completed로 착륙하면 설명하세요.
- notifications/tasks는 최적화입니다. 모든 클라이언트는 여전히 폴링 경로를 처리해야 합니다 — 그렇지 않으면 구독 문제가 있을 때마다 결과를 잃습니다.
실제 예시: 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 }) };
}대부분의 팀이 첫 주에 부딪히는 함정들
- 'tasks/list가 없다.' 네 — 의도적으로. 범위를 지정할 세션이 없습니다. 자체 제품 데이터베이스에서 태스크 ID를 추적하세요.
- '내 취소 버튼이 거짓말을 한다.' 항상 그럴 겁니다. '취소 요청'으로 이름을 바꾸거나 상태 변경을 서버의 종단 ack에 게이팅하세요.
- '폴링할 때만 결과를 얻는다.' 맞습니다 — notifications/tasks + subscriptions/listen도 구현할 때까지는요. 두 경로 모두, 항상.
- 'Python SDK에는 아직 이를 위한 헬퍼가 없다.' 일부 Tier 1 SDK 헬퍼는 아직 안정화 중입니다. 원시 JSON-RPC를 손으로 구현할 수도 있습니다 — 와이어 포맷은 완전히 명시되어 있습니다.
- '한 사용자가 ID를 추측해서 다른 사용자의 태스크를 가져왔다.' tasks/get에서 테넌트로 범위를 지정하는 걸 잊었기 때문입니다. 모든 핸들러는 반드시 인증된 주체로 필터링해야 합니다.
퀴즈
Check yourself
0/4플래시카드
출처 및 추가 자료
- MCP Tasks extension overview — modelcontextprotocol.io — 정식 스펙 페이지, 전체 라이프사이클 다이어그램과 양쪽 구현 가이드 포함.
- ext-tasks 저장소 (SEP-2663) — 스키마, 생성된 타입, 그리고 작업 중인 스펙 텍스트.
- MCP 2026-07-28 스펙 발표 — Tasks를 AWS가 기여한 퍼스트파티 확장으로 명명한 릴리스 블로그.
- Anthropic: Bringing MCP 2026-07-28 to Claude — Claude 호스트 롤아웃 노트.
- Composio: The 2026-07-28 update, plain-language — Tasks vs MRTR을 언제 선택할지에 대한 실용적 프레이밍.
- AILmanac 관련 문서: MCP 2026-07-28: The Stateless Spec, MCP Apps: Interactive UIs, Managed Agents, Long-running agent harnesses.