2026-07-15

MCP Tasks: The Export That Finished Twice

A 32-case local durability replay tests task negotiation, restart recovery, idempotency, authorization, cancellation, expiry, and one-way terminal state before a long-running MCP tool goes live.

MCP Tasks: The Export That Finished Twice cover illustration

The export was complete twice. That sentence should be impossible, yet it is the useful failure to rehearse before putting an expensive operation behind MCP tasks. A worker can finish while a cancellation is being committed. A retry can arrive after a process restart. Two messages can carry the same idempotency key but different arguments. If the task record is only a mutable status string, all three races look harmless until two terminal outcomes escape.

A fresh replay of the retained dependency-free fixture exercised that failure again. Thirty-two synthetic events covered task capability negotiation, idempotent creation, restart recovery, authorization isolation, polling, result retrieval, cancellation, expiry, listing, and terminal-state races. The guarded evaluator matched every expected decision. Twelve cases represented changes that an unguarded implementation could accept unsafely, including a stale worker overwriting newer state and a cancelled task becoming completed.

Two finishes were never acceptable.

This is not a benchmark of an MCP SDK or a report from a live export service. It is a small admission test for the state contract underneath one. That distinction matters because MCP tasks are experimental in the 2025-11-25 specification. The protocol gives us a durable, requestor-driven envelope; it does not supply the database transaction, outbox, tenant boundary, or compare-and-swap that makes the envelope honest.

Version boundary: tasks first appeared in MCP 2025-11-25 and remain experimental. Confirm support in the exact host, client, server, and SDK versions you deploy. Keep business execution behind an adapter so protocol changes do not become queue migrations.

The protocol promise is smaller than a job system

A task-augmented request has a two-phase response. The receiver accepts the request and returns a CreateTaskResult with a receiver-generated task ID and lifecycle data. The original result is retrieved later through tasks/result. The requestor polls tasks/get, respecting the advertised pollInterval, until it sees completed, failed, cancelled, or the nonterminal input_required state.

Task support must also be negotiated twice for tools. The peer advertises tasks.requests.tools.call during initialization. Each tool then declares execution.taskSupport as required, optional, or forbidden; absence means forbidden. A server that starts work before rejecting an unsupported combination has already created the orphan it was supposed to prevent.

The specification deliberately leaves storage and execution architecture open. It does not say which database to use, how to atomically dispatch a worker, or how to serialize two terminal writes. Those are implementation responsibilities, and they are where our replay concentrated.

Testing the 32-case replay without a queue

The fixture contains 32 JSON cases grouped into four surfaces: ten creation and negotiation cases, twelve state transitions, eight read or cancellation operations, and two list operations. Each packet carries the stored authorization key, expiry, current version, expected version, current state, requested state, and relevant negotiated capability. The evaluator reads local JSON and returns one exact decision.

The 23 July replay again matched all 32 expected decisions. Five requests were accepted for normal or task execution, one same-payload retry returned the existing task, six legal state changes applied, one terminal replay was idempotent, and the remaining cases returned status, waited, returned a result, cancelled, listed a tenant-filtered view, or failed closed for a specific reason. The retained fixture SHA-256 is a9c477cd004c2a00ff1c1b96890249027cc804d89e877ee39b974aae7e54e5bf.

SurfaceCasesWhat the replay forced
Creation10Global and tool-level negotiation, sync versus task execution, and idempotency-key payload binding.
Transitions12Legal state edges, one-way terminal states, optimistic version checks, tenant binding, and expiry.
Read/cancel8Polling, deferred results, input pauses, idempotent cancellation, and negotiated cancel support.
List2Capability gating and an authorization-filtered task view.

Observed result: 32 of 32 expected decisions matched, with zero mismatches. Twelve hostile mutations were rejected at the exact boundary they targeted. No MCP client, server, SDK, worker, queue, database, credential, tool call, cancellation side effect, or network service was involved.

The restart exposed the real unit of durability

Imagine an export worker writes a file, then loses the process before it records completion. The queue redelivers the message. A second worker sees working and runs the export again. If the task ID points only to volatile worker memory, the client cannot distinguish recovery from a second job. If the idempotency key is not bound to a digest of the normalized request, a retry can quietly change the month or tenant while reusing the same task.

The durable unit is therefore more than a status. It is an admission record: task ID, creator authorization key, wrapped method and request digest, idempotency key, state, monotonic version, timestamps, expiry, cancellation intent, result reference, and audit correlation. Persist that record before dispatch. If dispatch is external, commit an outbox entry in the same transaction and let a dispatcher publish it later.

The worker is disposable. The record is not.

A durable MCP task engine remaining intact after restart while a volatile duplicate execution path dissolves
The task record must outlive the worker. Restart recovery should reattach to durable identity, not manufacture a second operation.
accept(request, auth) {
  assertTaskNegotiated(request)
  digest = canonicalDigest(request.method, request.params)

  return transaction(() => {
    prior = findByIdempotencyKey(auth.key, request.idempotencyKey)
    if (prior && prior.requestDigest !== digest) reject("collision")
    if (prior) return prior

    task = insertTask({ authKey: auth.key, requestDigest: digest, version: 1 })
    insertOutbox({ taskId: task.id, version: task.version })
    return task
  })
}

The replay case with the same idempotency key and same digest returned REPLAY_TASK. The same key with a different digest returned BLOCK_IDEMPOTENCY_COLLISION. Treating both as retries would make the task ID an alias for two different business operations.

One terminal write gets the latch

The most dangerous event in the fixture was deliberately boring: a worker tried to write completed with version 3 while the task record was already version 4. The guard returned BLOCK_STALE_VERSION. Another case began at cancelled and received a late completion; it returned BLOCK_TERMINAL_REWRITE. A repeated completed write against an already completed task returned IDEMPOTENT_TERMINAL.

That is the difference between idempotency and permissiveness. Repeating the same terminal fact is safe. Replacing one terminal fact with another is not. Use a database transaction or compare-and-swap predicate over the task ID, authorization key, expected version, nonterminal current state, and unexpired record. If the update affects zero rows, read the stored fact and classify the race; do not blindly retry the write.

One race, one winner, one stored fact.

Two competing MCP task terminal transitions reaching one mechanical latch where only one state can win
Cancellation and completion can race. A one-way terminal latch turns the loser into a read, not a second outcome.
UPDATE tasks
SET status = :next, version = version + 1, updated_at = :now
WHERE task_id = :task_id
  AND auth_key = :auth_key
  AND version = :expected_version
  AND status IN ('working', 'input_required')
  AND expires_at > :now;

This predicate is local policy, not text copied from the MCP specification. The protocol defines task states and operations; the implementation needs a concurrency rule that preserves them under crash and replay.

Polling is recovery, notifications are latency

The receiver may send notifications/tasks/status, but the specification says requestors must not rely on those notifications arriving. Polling tasks/get remains the recovery path. Respect pollInterval, add jitter when many tasks share the same cadence, and keep the task ID stable across reconnects.

tasks/result is separate for a reason. Its result shape matches the wrapped request: a task created from tools/call eventually returns a CallToolResult, not a custom job-result envelope. While the task is working, our replay returned WAIT_TERMINAL; at input_required, it returned WAIT_INPUT; at completed, it returned RETURN_RESULT.

A host can display progress or return control to the model while a task runs, but presentation should not alter lifecycle truth. If a notification says completed and a subsequent authenticated tasks/get says working, the stored task record wins.

Notifications can lie by omission.

The task ID is a locator, not permission

Receiver-generated random IDs reduce accidental collisions and guessing. They do not authorize tasks/get, tasks/result, tasks/cancel, or tasks/list. Every operation must reapply the creator's subject, tenant, and relevant scope. Our wrong-tenant transition and wrong-tenant poll both returned BLOCK_AUTH before state was exposed or changed.

A UUID proves nothing.

Expiry deserves the same ordering. A worker that reports after expiresAt should not revive a record merely because it holds an old queue message. Cancellation also needs negotiated support: the fixture rejected a cancel request when tasks.cancel had not been declared, and treated a cancel against an already failed task as ALREADY_TERMINAL.

For listing, apply authorization in the query rather than filtering a global result in application memory. Pagination cursors should remain bound to the same authorization view. The replay's allowed list case returned only a tenant-filtered view; its second list case failed because the capability was absent.

Failure modes: twelve rejections worth keeping in CI

The 12 hostile mutations were not random malformed JSON. Each removed one durability or authority condition that a real implementation can accidentally omit:

  • a required task tool called synchronously;
  • a forbidden task tool called with augmentation;
  • a task request sent without the global capability;
  • an idempotency key reused for a different request digest;
  • a stale worker writing against the wrong version;
  • a cancelled task rewritten as completed;
  • a completed task rewritten as failed;
  • another tenant finishing the task;
  • an expired task accepting late work;
  • another tenant polling task state;
  • cancellation attempted without negotiated support;
  • listing attempted without negotiated support.

Keep these as contract tests close to the state-store adapter. SDK conformance tests can confirm message shapes; they will not prove that your tenant predicate, transaction boundary, or terminal-state latch survived a refactor.

Troubleshooting starts with the first stored contradiction

  • The client receives two task IDs: inspect idempotency-key normalization and whether the task row commits before dispatch. Do not merge the records after both workers start.
  • A cancelled task later completes: inspect the terminal update predicate and worker version. A compensating status write is not a substitute for preventing the rewrite.
  • Polling works until reconnect: check whether the task is stored outside the process and whether the authorization key is reconstructed identically after session recovery.
  • tasks/result invents a new schema: return the wrapped request's result shape and attach related-task metadata rather than wrapping it again.
  • One tenant can infer another tenant's task: move authorization into every get, result, cancel, and list query. A hard-to-guess ID is not a tenant filter.
  • The queue keeps running expired work: check expiry before transition and make worker side effects independently idempotent. Task expiry does not magically undo an external operation.

What this replay proves—and what it does not

The local artifact proves that one explicit admission and transition policy produced the expected decision for 32 stored packets. It preserves the fixture hash, every expected and actual decision, and the exact evidence boundary. It does not prove SDK interoperability, transport resumption, database isolation, worker delivery semantics, external side-effect idempotency, throughput, latency, or production recovery.

The practical release bar is layered. First validate MCP message and capability behavior against the current specification and chosen SDK. Then run the state replay against the real persistence adapter under concurrent transactions. Finally exercise the business operation in a disposable environment, including a kill between side effect and completion write. A green local policy probe is the start of that work, not the end.

Operator's conclusion: MCP tasks become durable only when the task ID names one request digest, one authorization context, one monotonic record, and one terminal fact. Everything else—polling, notifications, progress, and deferred results—depends on that invariant.

Four documents fence the task contract

  • MCP 2025-11-25: Tasks — experimental lifecycle, capabilities, creation, polling, result retrieval, notifications, listing, cancellation, states, and security guidance.
  • MCP 2025-11-25: Tools — tool discovery, invocation, execution.taskSupport, result shape, and trust guidance.
  • MCP 2025-11-25 key changes — tasks added as experimental support for durable requests with polling and deferred results.
  • SEP-1686 — design record linked from the specification release.

Evidence note: the fixture, fresh replay, current source hashes, findings report, visual provenance, and body-bound editorial brief are preserved in the batch-114 remediation package. The replay used only local deterministic JavaScript and synthetic records; the four claim-level primary sources were fetched successfully on 23 July 2026.