MCP 2026-07-28: tasks extension (io.modelcontextprotocol/tasks) - #231
Open
simonx1 wants to merge 47 commits into
Open
MCP 2026-07-28: tasks extension (io.modelcontextprotocol/tasks)#231simonx1 wants to merge 47 commits into
simonx1 wants to merge 47 commits into
Conversation
|
Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits. |
simonx1
force-pushed
the
mcp-2026/tasks-extension
branch
from
September 1, 2026 20:58
6427504 to
fb2f6e0
Compare
simonx1
force-pushed
the
mcp-2026/tasks-extension
branch
14 times, most recently
from
September 2, 2026 16:57
9c80538 to
82fdfe6
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 2, 2026 18:04
7a1d530 to
8fc3645
Compare
simonx1
force-pushed
the
mcp-2026/tasks-extension
branch
from
September 2, 2026 18:06
82fdfe6 to
398841c
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 2, 2026 19:02
8fc3645 to
bb20181
Compare
simonx1
force-pushed
the
mcp-2026/tasks-extension
branch
from
September 2, 2026 19:04
398841c to
3f88d56
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 2, 2026 19:51
bb20181 to
24cdb41
Compare
simonx1
force-pushed
the
mcp-2026/tasks-extension
branch
from
September 2, 2026 19:52
3f88d56 to
9fd61c3
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 2, 2026 20:32
24cdb41 to
82a13af
Compare
simonx1
force-pushed
the
mcp-2026/tasks-extension
branch
from
September 2, 2026 20:34
9fd61c3 to
e96e225
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 2, 2026 21:17
82a13af to
afb48ef
Compare
simonx1
force-pushed
the
mcp-2026/tasks-extension
branch
from
September 2, 2026 21:19
e96e225 to
f98d44c
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 2, 2026 21:58
afb48ef to
b966843
Compare
simonx1
force-pushed
the
mcp-2026/tasks-extension
branch
from
September 2, 2026 22:00
f98d44c to
3be0550
Compare
simonx1
force-pushed
the
mcp-2026/cacheable-results
branch
from
September 2, 2026 23:00
b966843 to
044d42d
Compare
…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
force-pushed
the
mcp-2026/tasks-extension
branch
from
September 5, 2026 14:09
b67de03 to
d377326
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
MCPClient::Client.new(extensions: ['io.modelcontextprotocol/tasks'])(identifiers, oridentifier => settings) declares extensions in every request'sclientCapabilities;Client#tasks_extension?reports it.resultType "task"only once the extension is declared, only from a 2026-07-28 server, and only fortools/call; aCreateTaskResulton any other request (includingtasks/get) is anInvalidResultError.call_tool. ACreateTaskResultis driven to its final result:tasks/getpolling at the server'spollIntervalMs(capped at 60 s),tasks/updateforinput_requiredstates through the registered elicitation / sampling / roots handlers (eachinputRequestskey answered once across polls), thecreatedAt + ttlMsbackstop, and the failed task's JSON-RPC error surfaced as aServerErrorwith its code (TaskErrorfor a cancelled task).MCPClient::Client::TaskSupport).call_tool_as_taskreturns theMCPClient::Taskhandle (a locally completed task when the server answered synchronously);get_taskreturns the DetailedTask (input_requests,result,error);wait_for_task(task, timeout:);update_task(task, input_responses);cancel_tasksendstasks/cancel(acknowledgement only, eventually consistent);get_task_resultwaits and returns the result;list_tasksraises on 2026-07-28 servers (tasks/listwas removed).MCPClient::TaskreadsttlMs/pollIntervalMsand gainedcompleted?/failed?/cancelled?/remote?/ttl_elapsed?.capabilities.extensionsentry (CapabilityError);-32602ontasks/getmaps toTaskNotFound;-32021propagates asMissingRequiredClientCapabilityError.notifications/tasksreach the client's notification listeners;listen(notifications: { task_ids: [...] })without the extension is aCapabilityError. On Streamable HTTP,tasks/get/tasks/update/tasks/cancelcarryMcp-Name: <taskId>.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