Skip to content

MCP 2026-07-28: tasks extension (io.modelcontextprotocol/tasks) - #231

Open
simonx1 wants to merge 47 commits into
mcp-2026/cacheable-resultsfrom
mcp-2026/tasks-extension
Open

MCP 2026-07-28: tasks extension (io.modelcontextprotocol/tasks)#231
simonx1 wants to merge 47 commits into
mcp-2026/cacheable-resultsfrom
mcp-2026/tasks-extension

Conversation

@simonx1

@simonx1 simonx1 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

PR 8 of the MCP 2026-07-28 series (stacked on #230). Implements the tasks extension: https://modelcontextprotocol.io/specification/2026-07-28/extensions/tasks

What changes

  • Opt-in extension declaration. MCPClient::Client.new(extensions: ['io.modelcontextprotocol/tasks']) (identifiers, or identifier => settings) declares extensions in every request's clientCapabilities; Client#tasks_extension? reports it.
  • Polymorphic results. Transports accept resultType "task" only once the extension is declared, only from a 2026-07-28 server, and only for tools/call; a CreateTaskResult on any other request (including tasks/get) is an InvalidResultError.
  • Transparent tasks in call_tool. A CreateTaskResult is driven to its final result: tasks/get polling at the server's pollIntervalMs (capped at 60 s), tasks/update for input_required states through the registered elicitation / sampling / roots handlers (each inputRequests key answered once across polls), the createdAt + ttlMs backstop, and the failed task's JSON-RPC error surfaced as a ServerError with its code (TaskError for a cancelled task).
  • Task lifecycle API (MCPClient::Client::TaskSupport). call_tool_as_task returns the MCPClient::Task handle (a locally completed task when the server answered synchronously); get_task returns the DetailedTask (input_requests, result, error); wait_for_task(task, timeout:); update_task(task, input_responses); cancel_task sends tasks/cancel (acknowledgement only, eventually consistent); get_task_result waits and returns the result; list_tasks raises on 2026-07-28 servers (tasks/list was removed). MCPClient::Task reads ttlMs / pollIntervalMs and gained completed? / failed? / cancelled? / remote? / ttl_elapsed?.
  • Gates and errors. Task requests require both the client declaration and the server's capabilities.extensions entry (CapabilityError); -32602 on tasks/get maps to TaskNotFound; -32021 propagates as MissingRequiredClientCapabilityError. notifications/tasks reach the client's notification listeners; listen(notifications: { task_ids: [...] }) without the extension is a CapabilityError. On Streamable HTTP, tasks/get / tasks/update / tasks/cancel carry Mcp-Name: <taskId>.
  • Legacy (2025-11-25) task behaviour is unchanged.

Tests

spec/lib/mcp_client/tasks_extension_2026_spec.rb (28 examples) covers the Task shape, result-type gating per era/extension/method, the client flow (polling cadence, input_required round trips with key deduplication, failed/cancelled outcomes, TTL and timeout), the lifecycle API, capability gates, error mapping, task notifications and HTTP routing headers. Full suite green; rubocop clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@simonx1
simonx1 force-pushed the mcp-2026/tasks-extension branch from 6427504 to fb2f6e0 Compare September 1, 2026 20:58
@simonx1
simonx1 force-pushed the mcp-2026/tasks-extension branch 14 times, most recently from 9c80538 to 82fdfe6 Compare September 2, 2026 16:57
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from 7a1d530 to 8fc3645 Compare September 2, 2026 18:04
@simonx1
simonx1 force-pushed the mcp-2026/tasks-extension branch from 82fdfe6 to 398841c Compare September 2, 2026 18:06
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from 8fc3645 to bb20181 Compare September 2, 2026 19:02
@simonx1
simonx1 force-pushed the mcp-2026/tasks-extension branch from 398841c to 3f88d56 Compare September 2, 2026 19:04
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from bb20181 to 24cdb41 Compare September 2, 2026 19:51
@simonx1
simonx1 force-pushed the mcp-2026/tasks-extension branch from 3f88d56 to 9fd61c3 Compare September 2, 2026 19:52
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from 24cdb41 to 82a13af Compare September 2, 2026 20:32
@simonx1
simonx1 force-pushed the mcp-2026/tasks-extension branch from 9fd61c3 to e96e225 Compare September 2, 2026 20:34
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from 82a13af to afb48ef Compare September 2, 2026 21:17
@simonx1
simonx1 force-pushed the mcp-2026/tasks-extension branch from e96e225 to f98d44c Compare September 2, 2026 21:19
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from afb48ef to b966843 Compare September 2, 2026 21:58
@simonx1
simonx1 force-pushed the mcp-2026/tasks-extension branch from f98d44c to 3be0550 Compare September 2, 2026 22:00
@simonx1
simonx1 force-pushed the mcp-2026/cacheable-results branch from b966843 to 044d42d Compare September 2, 2026 23:00
simonx1 and others added 28 commits September 5, 2026 15:59
…handle

A 2026-07-28 CreateTaskResult is the flat Task itself, so the handle is
built from the object that was just validated; an extra `task` property
(the legacy 2025 wrapper) never replaces it, and Task.from_json reports a
non-object or an unknown status as an InvalidResultError instead of a
NoMethodError or ArgumentError.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… task payloads are invalid

- wait_for_task's timeout bounds the capability probe itself: a spent
  budget performs no I/O, and a probe that outlives the remaining budget
  ends the wait with the timed-out TaskError (the transports take no
  per-call handshake budget, so the probe runs on its own thread).
- Task.from_json rejects nil/false as an invalid result, so a task
  notification without params is a parse failure rather than a working
  task with no id.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…result types rejected, streamed results validated against the refreshed tool

- An input handler runs within what is left of the wait (on its own
  thread when there is a deadline; a handler that outlives the budget
  ends the wait with the timed-out TaskError and its answer is dropped),
  the deadline is enforced before delivery, and answers produced while
  the server session restarted are discarded and the task polled again.
- One capability probe runs per server at a time: a wait that timed out
  on the handshake leaves it running and the next wait joins it, so HTTP
  transports never tear down the session the first handshake built.
- A completed task's result with an explicit resultType null is invalid.
- A streamed task result is validated against the tool a mid-stream
  HeaderMismatch refresh replaced.
- A notifications/tasks whose params are not a DetailedTask is a logged
  parse failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…und, legacy status notifications kept, task.rb requires errors

- A tasks/update rejection about the supplied inputResponses is a
  TaskError, never TaskNotFound: the task still exists.
- notifications/tasks/status keeps the flat 2025 Task shape while the
  modern notifications/tasks requires a DetailedTask.
- An input handler is bounded by the task's TTL as well as the caller's
  timeout, and the whole deadline is enforced before delivery.
- A wait reads the server's session epoch and the answered set it points
  at in one step under the registry lock.
- mcp_client/task requires the error definitions it raises.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… in-flight keys are not re-presented

- A handler round that outlived the wait spends no input round, so
  retries of a timed-out wait cannot exhaust the per-task budget on one
  outstanding request.
- The keys an abandoned handler still presents stay reserved until it
  finishes: a retry polls instead of asking the host again, and the late
  answer is dropped.
- A -32602 on tasks/update or tasks/cancel is TaskNotFound only on an
  explicit indication; a nested result whose resultType is present but
  not "complete" is invalid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… other forgets

- The keys an abandoned input handler is still presenting are held apart
  from the task's bookkeeping, so the TTL backstop, a gone task or a
  terminal lookup cannot let a retry present them again while the host
  is still answering; the hold ends when the handler finishes.
- A round whose delivery the deadline forbade, or whose handler failed,
  is refunded like an abandoned one.
- An explicit missing-task message is TaskNotFound even when it also
  mentions params (a rejected inputResponses key stays a TaskError);
  every task payload needs a string taskId.
- tasks/update goes out without a timeout keyword when no bound applies,
  so transports implementing only rpc_request(method, params) work.
- The task shape checks moved to Client::TaskShape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The task lifecycle API (call_tool_as_task, get_task, get_task_result,
list_tasks, cancel_task) and its era / capability helpers now live in
MCPClient::Client::TaskApi, mixed into Client like TaskSupport. No
behaviour change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… it gone

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…he watcher that took them

- The hold an abandoned input handler leaves is the in-flight set of the
  session the handler started in (fixed before the handler runs); its
  watcher releases that very set, never one a later session's retry
  filled under the same task id and key after a restart, and a handler
  that times out after a restart holds keys in its own session.
- A read of the in-flight registry allocates nothing; emptied entries
  are dropped.
- A fresh CreateTaskResult is a new task lifetime: earlier bookkeeping
  under that id is forgotten.
- tasks/get and tasks/update go through a transport that implements only
  rpc_request(method, params): the timeout keyword is sent only to
  transports that accept it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… touch only their own entry

- The keys a running input handler presents are in flight from the
  moment it starts, and a watcher drops only the registry entry it owns
  once that set is empty, so another task's finishing handler can never
  let a TTL retry present a key the host is still answering.
- A tasks/update binds its answered keys and its pending payload to the
  same session state, so a restart mid-update cannot split them.
- A synchronous answer to call_tool_as_task on a 2026-07-28 server is
  validated against the tool's outputSchema like call_tool.
- An overflowing ttlMs means no backstop rather than a raw exception.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…shed tool, ttl_elapsed? tolerates overflow

- A synchronous answer to call_tool_as_task on a 2026-07-28 server is
  validated against the tool definition a mid-call HeaderMismatch refresh
  replaced, exactly like call_tool (tools_generation check, refreshed_tool).
- Task#ttl_elapsed? treats an overflowing ttlMs (or an unparseable
  timestamp) as no backstop instead of raising, like ttl_remaining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
A peer-supplied pollIntervalMs that does not fit a Float (Infinity) or
is merely enormous is bounded to MAX_TASK_POLL_INTERVAL (one hour) and
still clamped to what is left of the caller's timeout and the TTL, so a
wait without a caller timeout keeps polling instead of raising from
sleep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ock RPC bound, unrepresentable TTL extensions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…tate released, sessions refreshed before TTL

A tasks/update now establishes the transport's session before it compares
the session epoch, so a reconnect inside rpc_request (ensure_initialized)
cannot slip an ended session's inputResponses into the next one; the
comparison stays as late as the payload can be held back.

Every mutation an update makes lands in the state it was built from: a
definite rejection releases its keys there, and a task the server reports
gone forgets that bookkeeping only, never what a new session (or a new
lifetime of a reused task id) recorded meanwhile. Task states carry their
registry key so a late request can drop exactly what it was working on.

A wait refreshes its session before any TTL enforcement and drops the
previous session's backstop and last observation when the epoch moved, so
a restart during a poll or the sleep between two of them no longer ends
the wait on a task that no longer exists.

A tasks/get or tasks/update abandoned on the caller's wall clock keeps the
accounting consistent: the answers are pending from the moment they reach
the transport and the task's update lock is replaced, so a retry of
wait_for_task(timeout:) retransmits them without presenting the keys to
the host again, while the abandoned call's late completion touches only
the state it captured.

The tasks/update delivery path moves to Client::TaskUpdates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
… discarded, the epoch holds at the send

A tasks/get answer that came back after the session ended is no longer
acted on: the wait joins the replacement session and polls again instead
of presenting the dead session's inputRequests, enforcing its TTL
backstop (which forgot the new session's bookkeeping) or pacing the next
poll by its pollIntervalMs; a handle kept across a restart no longer
seeds a wait. A tasks/update is pinned to the session its answers belong
to, so a reconnect inside rpc_request's own ensure_initialized /
ensure_connected makes the transport refuse the write rather than
answering an unrelated request in the next session, and a failure to
establish the session is no longer swallowed. Answers queued behind a
hanging update are pending before the update lock is taken, a confirmed
delivery clears only the keys it carried, a handler's keys and its input
round are reserved in the state the wait captured, and the session epoch
is read under the registry lock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…minal results never cross it

tasks/get now carries the session epoch of the wait it belongs to, so a
reconnect inside rpc_request (ensure_initialized / ensure_connected) makes
the transport refuse the poll instead of asking the replacement session
about a reusable task id; the wait counts it as a lost poll and asks the
live session again.

Every observation is re-checked against the session before it is acted on,
terminal ones included: another lifetime's result — or error — can no
longer become the outcome of call_tool / wait_for_task. The check runs once
more after a pending update is retransmitted, so a session that ended under
that round trip cannot put a dead task's inputRequests to the host.

Bookkeeping is forgotten only in the session it belongs to: a terminal poll
and a terminal task handle kept across a restart no longer wipe the
replacement session's answered keys for a reused id. An explicit
update_task / cancel_task naming a task handle is pinned to that handle's
session and refused when the session has ended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
An HTTP 404 that invalidates a session starts a new one without going
through cleanup, so the session epoch never moved: the task bookkeeping
keyed by it (answered keys, pending answers, in-flight holds, rounds)
survived into a session where the server may reuse task ids and input
keys. Bump the epoch once the replacement handshake is through, and
refuse to resend a request pinned to the session the 404 ended.

Every transport now checks the pin in the same critical section that
picks the session the request goes out on (the HTTP session id, the
stdio pipe, the SSE endpoint), so a cleanup completing after the check
cannot put the request on the replacement session's wire.

A wait no longer follows a restart into the new session: a terminal
payload from a poll pinned to the wait's own session is the task's
outcome even when the session ends right after, and short of that the
wait fails rather than polling an id the replacement session may have
reused. wait_for_task, get_task and the legacy tasks/result refuse a
handle whose session has ended, as update_task and cancel_task already
did. A dropped tasks/update gives its keys back in the state the answers
were built in, never in the replacement session's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…captured session id is always sent

A Task is stamped with the session epoch its request was pinned to, rather
than sampling the server when the handle is built: a session that ends
between the answer and the object (a stdio child exiting, a concurrent HTTP
404 recovery) no longer stamps the handle with the successor session, whose
reused task id names an unrelated task. A wait accepts a terminal payload
only when it is stamped with the session it polled, and task requests that
name their task with a bare id are pinned to the session live at the call,
so a 404 recovery cannot replay tasks/get, tasks/result or tasks/cancel into
the replacement session. get_task reports such a session change as the
documented TaskError, and update_task reports answers the pin dropped
instead of returning true for a delivery that never went out.

On the HTTP transports the captured session id is now attached to every
request unconditionally, so a concurrent recovery clearing @session_id
cannot send an in-flight pinned request with no session header; and a 404
moves the session epoch when it ends the session, before the replacement
handshake (sent with the pin lifted) rather than after it, with a failed
handshake leaving the transport uninitialized instead of leaving requests
treating the dead session as current.

A tools/call answered with a task is validated against the tool definition
in force when the call was made: an unrelated tools/list_changed refresh
landing during a wait that may take minutes no longer changes the schema the
result is checked against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Rebasing the tasks extension onto the current cacheable-results tip pushed
JsonRpcCommon past Metrics/ModuleLength. The fulfilment of an
`input_required` result's input requests is a self-contained piece of the
multi-round tool request flow, so it moves to MCPClient::InputRoundTrips
and is mixed back in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The 404 recovery moves the session epoch (round 32), but it is not the only
way an HTTP session is replaced without going through `cleanup`.
`terminate_session` — the host's own DELETE, and the one `cleanup` sends —
ended a session while leaving the counter where it was, so a task wait could
carry the previous session's answered and pending input registry into the
session the next request establishes and, on a reused task id and input key,
suppress a handler or retransmit a stale `inputResponses`. It now ends the
epoch whatever the DELETE answers, and so does a handshake that lands a
different session id on a live one, wherever it comes from.

Verified against the tip and covered as regressions, already correct:

- a 404 restart that succeeds without a cleanup moves the epoch;
- `task_poll_delay` bounds a `pollIntervalMs` that converts to infinity, so
  an unlimited task with no caller timeout keeps polling instead of handing
  `Inf` to sleep;
- `Task#ttl_elapsed?` rescues the `FloatDomainError` an oversized `ttlMs`
  raises, exactly as `#ttl_remaining` does;
- a `tasks/update` rejection (definite error or not-found) is applied to the
  state it was sent for, never to what the session that replaced it has
  recorded under the same task id.

Two timing-dependent examples are made deterministic. The poll-timeout one
froze on a 1ms budget that a loaded suite could spend before the poll went
out; it now drives the wait's clock and asserts the same arithmetic against
a transport whose own read timeout is far wider. The two round-22 examples
assumed a handler that slept 0.3s was finished after a 0.35s margin; the
handler now blocks on a latch, and the examples wait on the key release
itself instead of on wall-clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…er session

Two review findings, and a third weighed rather than applied as proposed.

1. A task id a fresh CreateTaskResult hands out again inside one session
   started nothing isolated: the teardown dropped @task_states, but
   @in_flight_keys and the wait's own state stayed keyed by server, epoch and
   id alone. An identical input key on the new task was suppressed as already
   in flight, and a still-running handler of the previous task delivered its
   answer to the new one through tasks/update.

   The bookkeeping now carries a per-creation lifetime. A task id is unique
   within a session, so a server answering with an id whose previous task is
   still on our books has ended that task and started another: the id's
   generation moves and the new task gets an answered set, an in-flight
   registry entry and a pending update of its own. A wait is bound to the
   lifetime it joined and ends with a TaskError when the id is taken over
   (never reporting the new task's outcome as the old one's), answers a
   handler produced for the previous lifetime are discarded rather than sent,
   and tasks/update refuses at the same place the session guard sits — before
   the session is even established, since the check needs nothing from the
   wire. A Task built from a CreateTaskResult now names the task that creation
   started, so wait_for_task, get_task, get_task_result, update_task and
   cancel_task refuse a handle whose task was replaced, exactly as they
   already refuse one whose session ended. Only an id whose previous lifetime
   is still around is counted, so nothing is recorded for a well-behaved
   server.

2. call_tool_as_task's synchronous path already re-resolves the tool when the
   transport's tools_generation moved during the call (round 26, covered by
   the round 27 spec): no change, the finding no longer applies to the tip.

3. Codex proposed skipping the cleanup epoch bump for modern HTTP transports;
   round 35 had moved the other way. What the epoch marks is the end of the
   namespace a task id and an input key live in — the MCP session — so the
   question is which events end a session and which only drop a connection.

   A legacy transport's session is the one initialize opened (named by
   Mcp-Session-Id when the server assigned one): closing the connection ends
   it, the next request opens another with a fresh handshake, and the ids of
   the old one may be handed out again. cleanup there still moves the epoch,
   as do terminate_session, a handshake landing a different session id, the
   404 recovery, a restarted stdio process and an ended SSE stream — round
   35's fixes are untouched, and its specs (all on legacy transports) still
   pass.

   MCP 2026-07-28 removed the handshake and the session with it. A modern HTTP
   transport is sessionless: it never sends an Mcp-Session-Id (this client only
   ever captures one from an initialize response, which a modern server does
   not send), a task lives for its own ttlMs in the server's own id namespace,
   and polling a long-running task across a reconnect is the whole point of the
   extension. Closing that connection resets no namespace, so ServerHTTP and
   ServerStreamableHTTP no longer bump the epoch when there is no session to
   end. Bumping it discarded the answered keys and the undelivered
   tasks/update of a task that is still perfectly alive — and ensure_connected
   runs a cleanup/connect after any transient failure — so a handler could be
   asked to answer the same input request twice, an unconfirmed answer was
   never retransmitted, and the task's own handles were refused for a session
   that never existed. The protection the bump was standing in for (a server
   handing a task id out again) is now handled where it actually happens, by
   finding 1's per-creation lifetime, which does not need a connection to end
   to notice it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…ating call

Round 36 gave each CreateTaskResult its own lifetime but only recorded one
when the id was already on the books, so several holes stayed open. Both
reviewers found the same three.

- Every observed creation now moves the id's generation, not only one whose
  previous lifetime survives. Two creations with no wait between them, or one
  made after a terminal poll, a TTL expiry or a TaskNotFound forgot the id,
  used to leave both handles naming the same lifetime, so the older one could
  still update, cancel or wait for the task that replaced it. The legacy
  call_tool_as_task path counts its creations too.
- get_task propagates the lifetime of the handle it was asked about; a
  refreshed handle used to drop it and pass check_handle_lifetime! unchecked.
- The task-producing tools/call is pinned to the epoch sampled for it, on the
  modern call_tool, call_tool_as_task and streaming paths. A transport that
  reconnects inside the request could otherwise run the tool in the
  replacement session while the task carried the sampled one, and the wait
  would then refuse a task whose non-idempotent tool had already run.
- final_task_handle no longer deletes the live state of a lifetime a detailed
  terminal handle does not belong to.
- cancel_task's TaskNotFound path forgets the pinned session's keys.
- A definitely rejected tasks/update gives back only the keys whose pending
  value it still owns, so a newer answer queued while it was on the wire keeps
  its payload and its answered/submitted markers.
- task_rpc bounds a request on the wall clock when the transport takes no
  timeout keyword, applying the pin inside that bound; a hung tasks/get no
  longer blocks a wait that has no caller deadline.
- answers_still_this_task? checks the captured state is still the registry's,
  so answers whose task another waiter saw gone are not delivered.
- task_shape_problem validates pollIntervalMs as a non-negative integer.
- The lifetime counters, which outlive their tasks by design, are bounded:
  the oldest ids are pruned past 4096, never one with live bookkeeping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
…time bound to the request

The streaming pin only wrapped the construction of a lazy Enumerator, so the
tool ran unpinned when the host enumerated it; the call now goes out under a
pin taken inside the enumeration, in the consuming thread.

The lifetime guard was a preflight a concurrent CreateTaskResult could slip
past. Every task request now carries a lifetime pin the transport checks
immediately before the wire (where the session pin is checked) and the caller
re-checks before acting on the answer, and update_task resolves the state its
answers belong to in the same locked step as the check. A terminal or
TaskNotFound answer forgets only the bookkeeping of the lifetime it asked
about, so a bare-id tasks/get and a terminal handle that names no lifetime no
longer delete a replacement's live keys.

Lifetimes are numbers of a monotonic per-session counter rather than a per-id
count restarting at 0, so a prune cannot let a re-created id read as the
lifetime a stale handle names; establishing one and reading it back is a single
locked step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The client's own notification processing — which logs a task's status — and
the host's listeners now hang off two different transport hooks, so calling
@notification_callback by hand exercises only half of what this example
asserts. It drives route_notification instead, which is the path a real
notification takes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Four things a verification pass found in the tasks extension.

A creation recorded its id's lifetime but no bookkeeping for the task it
started, and the prune exempts only ids whose bookkeeping is live: a
handle retained across 4096 further creations — ordinary batch
submission, the legacy call_tool_as_task included — was crowded out, and
get_task, update_task and cancel_task through it raised
TaskReplacedError although the server had neither expired nor replaced
the task. A creation now records the task's own state, so the cap bounds
the ids of tasks this client no longer tracks and nothing else.

The legacy message heuristic ran before the error code, so a tasks/get
answered {"code":-32603,"message":"Upstream credential expired"} read as
TaskNotFound and deleted the task's bookkeeping — the pending payload
and answered keys of an unconfirmed update with it, leaving a resumed
wait unable to retransmit and liable to prompt the host again. On a
2026-07-28 server the code decides now: -32602 is the revision's
missing-task answer, every other code a failed request. Legacy servers,
and errors carrying no code, keep the message heuristic.

Neither call_tool_as_task on a modern server nor a task chunk of
call_tool_streaming opened the slot holding the definition the
tools/call went out under, so the transport's record died with its own
call and the re-resolve listed again — validating against a definition
newer than the call's own. Both now wrap the call and its re-resolve as
call_tool does.

fulfil_input_requests answered the requests in order and threw the whole
map away when one failed, and the wait gave every key back: a retry put
a request a person had already answered to the host a second time. The
answers produced before the failure now travel with the
InputRequiredError, and the wait keeps them — recorded, and pending for
the next tasks/update — handing back only the keys nobody answered.

Three weak assertions from the same pass are replaced with examples that
gate on the behaviour: the pending payload and its answered marker are
both asserted retained rather than compared to each other; the update
lock's boundary is gated at real contention, so moving the pending read
outside the lock fails it; and the lifetime pin is exercised through a
real transport's pre-write check rather than one a mock calls itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
The re-resolve every task path does — the definition a call's result is
validated against — reads the record the transport kept of the request
it answered under, instead of comparing tool-list generations: the
generation counter is gone from the stack below, and a comparison
against a helper that no longer exists would have skipped the
re-resolve outright.

The reads keep what the generation comparison guarded: call_tool and
call_tool_streaming take the definition before the task's result is
waited for, so a refresh landing during a wait that may take minutes
does not colour the validation, and the streaming path reads once
because every chunk belongs to the one request the stream carries.

The input round-trip module carries the sampling.tools refusal that
lives inside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
A review round on the tasks extension, driven by its findings:

- Client#cleanup dropped the per-session counters that number task
  lifetimes. A sessionless 2026-07-28 HTTP connection outlives the
  cleanup, so the next creation was numbered from zero again and a handle
  retained across it named the task that replaced its own — cancel_task
  through it cancelled the replacement, while before any replacement
  existed get_task through it refused a task still running. What an id
  names is no longer bookkeeping a cleanup forgets.
- call_tool_as_task handed back a handle without the definition its
  tools/call went out under, so get_task_result returned structured
  content the tool's outputSchema forbids. The handle carries it now, and
  the legacy tasks/result path validates too.
- A successful legacy tasks/result, and a tasks/cancel answered with a
  terminal task, left the task registered as live: both registries then
  grew without bound. Both release it, as a terminal poll does.
- A modern -32602 that says the task expired is a missing task, as the
  legacy matcher has always read it.
- A pollIntervalMs above an hour was shortened; the backstop is a day.
- A lifetime guard's refusal at the wire was wrapped into a transport
  failure by all three transports, so update_task treated a definite
  refusal as ambiguous. They re-raise it, as they do a session change.

The examples the review found vacuous now assert what they claim: the
first of two waits answers a key (a clock that only moves when the wait
sleeps), the round count is asserted across both waits, the standalone
require drives a public wait, the "real transport" helper substitutes
only the pipe, the retry example counts requests, and the registry lock
is observed being held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
A task's request is pinned to the session it was cleared for, so the
header on the wire matches that session rather than whatever @session_id
says by the time the request is built. A modern session has none at all —
the client MUST NOT send Mcp-Session-Id — so what such a request was
cleared for is "no session", and the header is removed rather than
written. A non-conforming server that got an id recorded anyway still
never sees it echoed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
@simonx1
simonx1 force-pushed the mcp-2026/tasks-extension branch from b67de03 to d377326 Compare September 5, 2026 14:09
Round 40 of the tasks-extension review, from two independent passes over the
same tip.

- A handle of a running task carries the definition its creation went out
  under, whichever way the caller came by it. `get_task` and `wait_for_task`
  built their handle from the answer alone, so the documented
  `task = client.get_task(task)` refresh silently turned strict
  structured-content validation off: a `structuredContent` the tool's
  `outputSchema` forbids came back unchecked. The legacy `tasks/result` path
  is covered too; a bare task id still names no tool and is unvalidated.
- `Client#cleanup` keeps the bookkeeping of the sessions that survive it.
  Closing a sessionless 2026-07-28 HTTP connection ends no session and its
  tasks outlive the cleanup; their answered keys and their unconfirmed
  answers now outlive it as well, so a wait resumed afterwards no longer puts
  an answered input request to the host a second time and no longer drops an
  update the server never acknowledged. What an ended session left behind is
  dropped as before.
- A definite rejection of a `tasks/update` decides which keys it still owns
  and gives them back in one critical section. Split across three, an answer
  another delivery queued in between was unmarked and dropped by the older
  one: it went out, nothing recorded it as answered, and the next poll asked
  the host again.
- A legacy `tasks/result` that reports the task gone (`-32602 Task has
  expired`) releases its bookkeeping, so the lifetime prune can reach the id;
  a transient or internal failure still leaves the task and its books alone.
- `pollIntervalMs` is honoured whatever its size. The bound exists only
  because `sleep` refuses an interval the clock cannot represent, and now
  sits far beyond any pace a server could mean; a wait is bounded by the
  caller's timeout and the task's TTL instead.

The new examples also pin the wire the rounds so far stubbed past: the
extension declared on `tasks/get`, `tasks/update` and `tasks/cancel` (a
mutation that dropped it there survived every existing example), a plain
`tools/call` with no 2025 `task` field, `tasks/get` rather than
`tasks/result`, `tasks/cancel` and never `notifications/cancelled`, `-32021`
kept typed on creation as well as update and cancel, the 2025-only methods
refusing a 2025 server without sending anything, an `inputRequests` entry
refused wherever the standalone request would be, a successful `taskIds`
listen delivering `notifications/tasks`, an MRTR exchange finished before the
same call becomes a task (its `requestState` stays with the retried request
and never reaches `tasks/update`), and the shape checks that make a
malformed `ttlMs` or timestamp a malformed task. Five examples that
overstated what they proved — the elicitation key test, the two polling-pace
ones, the TTL overflow, the legacy creation handle — were fixed rather than
duplicated, and the prune's "a handler still holds these keys" branch is
executed at last.

A wait that runs out of the caller's time still leaves the task running and
says so: only the host knows whether the result is still wanted, and the
handle stays usable for another wait, a poll, or `cancel_task`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MoErzDypnq7hhuFBtueML7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant