Conversation
An MCP extension needs three things from the client session: its identifier advertised in per-request capabilities, a claim that folds its non-core result shape into tools/call parsing, and bindings for its notifications. MCP SDK 2.x exposes all three on ClientSession. McpToolset owns that session and did not expose them, so an extension could not be carried without forking the toolset. They now follow the same path sampling and elicitation already take, down to SessionContext and both ClientSession construction sites, with the three names resolved in dependencies/_mcp like every other MCP import. Passing them through is necessary but not sufficient, for two reasons that are easy to miss. ClientSession parses a claimed result and stops there. It raises UnexpectedClaimedResult unless the call opted in with allow_claimed, and it never calls claim.resolve -- the high-level mcp Client does that. ADK uses the low-level session, so McpTool now opts in when a claim is registered, looks the claim up by the model its result parsed into, resolves it, and holds the outcome to the same output schema a direct result gets. The agent sees an ordinary tool result either way. Claims are also inert unless the connection negotiated 2026-07-28, and initialize() always performs the pre-2026 handshake, which is what SessionContext called. An extension registered on such a session could never fire. Bring-up now prefers server/discover, but only when an extension was actually configured: the default path keeps calling initialize() with no new wire traffic and no new failure modes. The probe is bounded to half the bring-up budget so that a server which never answers it still leaves room for the initialize() fallback, and a server too old for server/discover logs why extensions are inactive rather than failing the session. Since 856acf2 the pin admits both majors, and 1.x has no extension parameters on ClientSession at all -- not a rename, an absence. An opt-in there is refused at construction rather than accepted and ignored. A silent no-op would surface as a tool call that never returns, on the install least able to explain why, so the message names the SDK and the fix. Nothing else changes on 1.x: with no extension configured the three arguments are not passed to ClientSession, which is the call 1.x has always received. One fix comes along because the tasks extension needs it. _run_guarded races a coroutine against the session task with asyncio.wait, which does not cancel what it waits on, so a cancelled caller leaves the coroutine running detached. That is survivable for a single request the transport will eventually fail, but not for a resolver that owes the server a cancellation: it would never learn it should send one. _run_guarded takes an opt-in propagate_cancel for that, and the existing call path keeps its semantics. Bug: google#6826
A tool call normally answers on the connection that made it, which stops working once the work takes minutes: an intermediary caps how long a request may stay open, and a dropped connection loses the operation with no handle to resume from. Servers work around it by splitting one operation into start, status and result tools and letting the model drive the polling loop -- a model turn per poll, and only if the prompt says so. The Tasks extension is the protocol's answer. The server decides per request that the work is long-running and replies with a durable task handle; the client polls tasks/get until a terminal state and reads the result from there. With enable_tasks=True the toolset does that itself, so the agent declares one tool, calls it once, and gets an ordinary tool result. Off by default, and a server without the extension is unaffected. The wire models are ours because there is nothing to depend on: ext-tasks publishes TypeScript only, the SDK's client-side implementation is still an open issue, and the task types the SDK does carry are the older in-core design that SEP-2663 replaced -- nested task object, ttl rather than ttlMs, a different notification method. They stay private in _tasks.py so an upstream implementation can replace them without touching ADK's surface. No raw JSON-RPC is involved: send_request takes an arbitrary request, and the Mcp-Name header the specification requires on tasks/* comes from the request's name_param. _tasks.py is MCP SDK 2.x only and cannot be imported on 1.x, so the toolset imports it after refusing enable_tasks there, not at module scope. That refusal is the same one the extension arguments get, for the same reason: an opt-in that quietly did nothing on a call this feature exists to make long-running is indistinguishable from a hang. The resolver reports rather than raises. A task that fails or that the server cancels comes back as a result with isError set, because that is what a tool failing inline produces and going through a task should not change it. A task that reaches input_required is cancelled and reported as unsupported, rather than left for the server to hold until its ttl expires; bridging tasks/update to an interaction channel is a follow-up, and upstream has not settled that design either. Poll intervals follow the server, clamped to [100ms, 30s] so that a zero cannot spin and an absurd value cannot hide a finished task. Verified end to end against the sample server: with enable_tasks=True the call is served as a task and polled to completion over a dozen-odd requests -- the count follows the server's stated interval -- and the result dict is byte-identical to the one the same tool returns with the option off. Closes: google#6826
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.
Please ensure you have read the contribution guide before creating a pull request.
Link to Issue or Description of Change
Two commits, meant to be read in order. The first exposes the MCP SDK 2.x client extension seam through
McpToolset; the second adds the Tasks extension (SEP-2663) on top of it. Splitting them into two PRs is a one-command operation if you would rather review them apart, but a seam with no in-tree consumer is hard to judge on its merit, and Tasks is the consumer that justifies its shape.Problem:
A tool call answers on the connection that made it. Once the work takes minutes that stops holding: an intermediary caps how long a request may stay open, and a dropped connection loses the operation with no handle to resume from. Servers work around it by splitting one operation into start, status and result tools and letting the model drive the polling loop, which costs a model turn per poll and only happens if the prompt says so.
The protocol's answer is the Tasks extension. ADK cannot carry it, or any other extension, because
McpToolsetowns theClientSessionand does not expose the three arguments 2.x added for this:extensions,result_claims,notification_bindings.Solution:
Commit 1 plumbs those three down to both
ClientSessionconstruction sites, the same pathsampling_callbackandelicitation_callbackalready take. Two things make the pass-through necessary but not sufficient, and both are in the commit message.ClientSessionparses a claimed result but never resolves it: the high-levelClientdoes that, and ADK uses the low-level session. And claims are inert unless the connection negotiated2026-07-28, whichinitialize()never does. So the toolset resolves claims itself, and prefersserver/discoverwith aninitialize()fallback, but only when an extension was actually configured, so the default path stays byte-identical.Commit 2 adds
enable_tasks=True: the server answerstools/callwith a durable task handle, the toolset pollstasks/getto a terminal state and returns the result. The agent declares one tool, calls it once, gets an ordinary tool result. Off by default, and a server without the extension is unaffected.Two questions for maintainers
I asked both on #6826 on 9 September and went ahead rather than block the PR on them. Either is a one-line change if you disagree.
1. What should an extension opt-in do on MCP SDK 1.x? Since
856acf21the pin admits both majors, and 1.x has no extension parameters onClientSessionat all, so there is nothing to rename around. The opt-in can either no-op or raise. This PR raises, at toolset construction. The reason to want Tasks is a tool call that runs for minutes, and an opt-in that quietly does nothing there presents as a call that never returns, on the install least equipped to explain why. The opposite argument has a precedent in the tree:mcp_tool.py:641conditions a 2.x-only field silently. But that is implicit behaviour, not something a caller asked for by name. Failing hard on an install that used to work is your decision, not mine.Nothing else changes on 1.x. With no extension configured the three arguments are not passed to
ClientSessionat all, which is the call 1.x has always received.2. Should the Tasks path carry a
FeatureNameflag? ADK already gates recent MCP behaviour behindFeatureName._MCP_GRACEFUL_ERROR_HANDLING(session_context.py:203). I did not add one, because a constructor argument defaulting toFalseis already opt-in and a second switch would mean two ways to turn the same thing off. Easy to add if you would rather have it.Notes on the shape
ext-tasksships TypeScript only, python-sdk#3005 is unmerged and #3226 is open, and the task typesmcp-typesdoes carry are the older in-core design the SEP replaced (nestedtask,ttlrather thanttlMs, a different notification method), which is wire-incompatible. They live in_tasks.py, all private, so an upstream implementation can replace them without touching ADK's public surface.send_requesttakes an arbitrary request plus a result model, and theMcp-Nameheader the specification requires ontasks/*comes from the request'sname_param.dependencies/_mcp, like every other MCP import, bound toAnyon 1.x so the modules annotating them stay importable there._tasks.pycannot be imported on 1.x at all, somcp_toolsetimports it after the refusal rather than at module scope._run_guardedgained an opt-inpropagate_cancel. It races a coroutine against the session task withasyncio.wait, which does not cancel what it waits on, so a cancelled caller leaves the coroutine detached. That is survivable for a single request the transport will eventually fail, but not for a resolver that owes the server atasks/cancel. The existing call path keeps its semantics.Testing Plan
Unit Tests:
I ran the MCP tests against both majors, since the pin admits both.
The 17 skips on 1.x are the extension tests, which have nothing to assert where the seam does not exist. The refusal that replaces them has its own tests (
test_extension_arguments_are_refused_on_sdk_v1,test_enable_tasks_is_refused_on_sdk_v1), and those two are the ones skipped under 2.x.test_mcp_tasks.pyis skipped at collection on 1.x, since the module under test cannot be imported there.The full unit suite passes on both majors as well:
The extra skip on 1.x is
contributing/samples/mcp/mcp_tasks_agent, whose smokeload in
test_samples.pybuilds the toolset at import and so cannot run wherethe opt-in is refused. It is skipped through
SKIP_LOAD, conditionally, so thesample still has to load on 2.x.
Manual End-to-End (E2E) Tests:
The sample server in
contributing/samples/mcp/mcp_tasks_agent/servesslow_operationas a task when the client supports the extension, and inline when it does not, so the same tool exercises both paths.Driving the toolset directly with
enable_taskson and off gives identical results:The tasks run took 13 requests against the server in one measurement, the count following the server's stated poll interval. The inline run holds one request open for the full twenty seconds.
Checklist
Additional context
The dual-major support from
856acf21is what this builds on, so the extension path is reachable only on anmcp>=2,<3install. That is a commit onmainrather than a released constraint:v2.9.0still publishesmcp>=1.24,<2. Which is why the refusal on 1.x matters more now than it will once the widened pin ships.docs/guides/tools/mcp_tool/tasks/index.mddocuments the feature and its SDK requirement.