Skip to content

feat(mcp): support the MCP Tasks extension (SEP-2663) in McpToolset - #7119

Open
zeishr wants to merge 2 commits into
google:mainfrom
FuzzingLabs:feat/mcp-tasks-extension
Open

zeishr wants to merge 2 commits into
google:mainfrom
FuzzingLabs:feat/mcp-tasks-extension

Conversation

@zeishr

@zeishr zeishr commented Sep 14, 2026

Copy link
Copy Markdown

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 McpToolset owns the ClientSession and 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 ClientSession construction sites, the same path sampling_callback and elicitation_callback already take. Two things make the pass-through necessary but not sufficient, and both are in the commit message. ClientSession parses a claimed result but never resolves it: the high-level Client does that, and ADK uses the low-level session. And claims are inert unless the connection negotiated 2026-07-28, which initialize() never does. So the toolset resolves claims itself, and prefers server/discover with an initialize() fallback, but only when an extension was actually configured, so the default path stays byte-identical.

Commit 2 adds enable_tasks=True: the server answers tools/call with a durable task handle, the toolset polls tasks/get to 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 856acf21 the pin admits both majors, and 1.x has no extension parameters on ClientSession at 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:641 conditions 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 ClientSession at all, which is the call 1.x has always received.

2. Should the Tasks path carry a FeatureName flag? ADK already gates recent MCP behaviour behind FeatureName._MCP_GRACEFUL_ERROR_HANDLING (session_context.py:203). I did not add one, because a constructor argument defaulting to False is 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

  • The wire models are ours, and private. There is no Python implementation of SEP-2663 to depend on: ext-tasks ships TypeScript only, python-sdk#3005 is unmerged and #3226 is open, and the task types mcp-types does carry are the older in-core design the SEP replaced (nested task, ttl rather than ttlMs, 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.
  • No raw JSON-RPC and no monkeypatching. send_request takes an arbitrary request plus a result model, and the Mcp-Name header the specification requires on tasks/* comes from the request's name_param.
  • The extension names go through dependencies/_mcp, like every other MCP import, bound to Any on 1.x so the modules annotating them stay importable there. _tasks.py cannot be imported on 1.x at all, so mcp_toolset imports it after the refusal rather than at module scope.
  • _run_guarded gained an opt-in propagate_cancel. It 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 detached. That is survivable for a single request the transport will eventually fail, but not for a resolver that owes the server a tasks/cancel. The existing call path keeps its semantics.

Testing Plan

Unit Tests:

  • I have added or updated unit tests for my change.
  • All unit tests pass locally.

I ran the MCP tests against both majors, since the pin admits both.

# MCP SDK 2.x (mcp 2.2.0, mcp-types 2.2.0, httpx2 2.12.0)
$ pytest tests/unittests/tools/mcp_tool tests/unittests/tools/test_load_mcp_resource_tool.py tests/unittests/telemetry/test_spans.py
552 passed, 2 skipped

# MCP SDK 1.x (mcp 1.30.0)
$ pytest tests/unittests/tools/mcp_tool
377 passed, 17 skipped

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.py is 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:

# MCP SDK 2.x
$ pytest tests/unittests
14670 passed, 87 skipped, 27 xfailed, 2 xpassed

# MCP SDK 1.x
$ pytest tests/unittests
14637 passed, 103 skipped, 27 xfailed, 2 xpassed

The extra skip on 1.x is contributing/samples/mcp/mcp_tasks_agent, whose smoke
load in test_samples.py builds the toolset at import and so cannot run where
the opt-in is refused. It is skipped through SKIP_LOAD, conditionally, so the
sample still has to load on 2.x.

Manual End-to-End (E2E) Tests:

The sample server in contributing/samples/mcp/mcp_tasks_agent/ serves slow_operation as a task when the client supports the extension, and inline when it does not, so the same tool exercises both paths.

$ python contributing/samples/mcp/mcp_tasks_agent/task_server.py
# and, in another terminal
$ adk run contributing/samples/mcp/mcp_tasks_agent

Driving the toolset directly with enable_tasks on and off gives identical results:

with tasks    : {"content": [{"text": "Finished the job after 20 seconds.", "type": "text"}],
                 "isError": false,
                 "structuredContent": {"result": "Finished the job after 20 seconds."}}   20.2s
without tasks : {"content": [{"text": "Finished the job after 20 seconds.", "type": "text"}],
                 "isError": false,
                 "structuredContent": {"result": "Finished the job after 20 seconds."}}   20.0s
identical     : True

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

  • I have read the CONTRIBUTING.md document.
  • I have performed a self-review of my own code.
  • I have commented my code, particularly in hard-to-understand areas.
  • I have added tests that prove my fix is effective or that my feature works.
  • New and existing unit tests pass locally with my changes.
  • I have manually tested my changes end-to-end.
  • Any dependent changes have been merged and published in downstream modules.

Additional context

The dual-major support from 856acf21 is what this builds on, so the extension path is reachable only on an mcp>=2,<3 install. That is a commit on main rather than a released constraint: v2.9.0 still publishes mcp>=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.md documents the feature and its SDK requirement.

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
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.

Support the MCP Tasks extension (SEP-2663) in McpToolset: expose the mcp 2.x client extension seam

2 participants