What happened?
Describe the bug
McpWorkbench.call_tool passes a raw coroutine object to CancellationToken.link_future, so cancelling the token while a tool call is in flight raises AttributeError: 'coroutine' object has no attribute 'cancel' out of CancellationToken.cancel() and the in-flight MCP tool call is not cancelled — it keeps running to completion. If the token is already cancelled before the call, link_future raises the same AttributeError inside call_tool's try block and the caller gets a confusing is_error=True result whose content is just the exception string.
To Reproduce
A minimal MCP stdio server whose sleep tool sleeps N seconds (slow_server.py, the exact server used to produce the outputs below):
import asyncio
import sys
from typing import Any, Optional
from mcp.server import Server
from mcp.server.models import InitializationOptions
from mcp.server.stdio import stdio_server
from mcp.types import ServerCapabilities, TextContent, Tool
class SlowServer:
def __init__(self) -> None:
self.server: Server[object] = Server("slow-mcp-server")
@self.server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="sleep",
description="Sleep for N seconds then return",
inputSchema={
"type": "object",
"properties": {"seconds": {"type": "number"}},
"required": ["seconds"],
},
)
]
@self.server.call_tool()
async def call_tool(name: str, arguments: Optional[dict[str, Any]] = None) -> list[TextContent]:
if name == "sleep":
secs = float((arguments or {}).get("seconds", 1))
await asyncio.sleep(secs)
return [TextContent(type="text", text=f"slept {secs}s")]
raise ValueError(f"Unknown tool: {name}")
async def run(self) -> None:
options = InitializationOptions(
server_name="slow-mcp-server",
server_version="1.0.0",
capabilities=ServerCapabilities(),
)
async with stdio_server() as (read_stream, write_stream):
await self.server.run(read_stream, write_stream, options)
if __name__ == "__main__":
asyncio.run(SlowServer().run())
Client (repro_cancel.py) — starts a 5s tool call, cancels the token at 1s:
import asyncio
import sys
import time
from autogen_core import CancellationToken
from autogen_ext.tools.mcp import McpWorkbench, StdioServerParams
SERVER = "slow_server.py"
PY = sys.executable
async def main() -> None:
params = StdioServerParams(command=PY, args=[SERVER], read_timeout_seconds=60)
async with McpWorkbench(params) as wb:
token = CancellationToken()
start = time.monotonic()
task = asyncio.create_task(wb.call_tool("sleep", {"seconds": 5}, cancellation_token=token))
await asyncio.sleep(1.0)
try:
token.cancel()
print("token.cancel() returned normally")
except Exception as e:
print(f"token.cancel() raised: {type(e).__name__}: {e}")
result = await task
elapsed = time.monotonic() - start
print(f"call_tool returned after {elapsed:.1f}s (requested sleep=5s, cancelled at 1s)")
print(f"is_error={result.is_error} content={[p.content for p in result.result]}")
asyncio.run(main())
Actual output — observed independently by two verifiers at commit 027ecf0a379bcc1d09956d46d12d44a3ad9cee14 (verbatim):
Verifier 1:
token.cancel() raised: AttributeError: 'coroutine' object has no attribute 'cancel'
call_tool returned after 5.2s (sleep=5s, cancelled at 1s)
is_error=False content=['slept 5.0s']
Verifier 2:
token.cancel() raised: AttributeError: 'coroutine' object has no attribute 'cancel'
call_tool returned after 5.2s (requested sleep=5s, cancelled at 1s)
is_error=False content=['slept 5.0s']
Pre-cancelled-token variant (call token.cancel() before call_tool):
(pre-cancelled token path) is_error=True content=["'coroutine' object has no attribute 'cancel'\n"]
RuntimeWarning: coroutine 'ClientSession.call_tool' was never awaited
is_error=True content=["'coroutine' object has no attribute 'cancel'\n"]
I.e. token.cancel() itself explodes with AttributeError, the 5-second tool call still runs to completion (5.2s wall time) and returns is_error=False — cancellation is silently lost.
Expected behavior
Cancelling the token should cancel the in-flight tool call: token.cancel() returns normally, and call_tool ends early (cancelled) instead of running the full 5 seconds and returning a successful result. With a pre-cancelled token, call_tool should report cancellation, not an is_error=True result containing 'coroutine' object has no attribute 'cancel'.
Basis: CancellationToken.link_future is annotated def link_future(self, future: Future[Any]) -> Future[Any] (python/packages/autogen-core/src/autogen_core/_cancellation_token.py:35) and only works on futures/tasks — the callback it registers calls future.cancel(), and a coroutine object has no .cancel() (verified: hasattr(c, "cancel") == False). The sibling implementation in the same package does it correctly — McpToolAdapter._run (python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py:116-117):
result_future = asyncio.ensure_future(session.call_tool(name=self._tool.name, arguments=args))
cancellation_token.link_future(result_future)
Additional context
Root cause: python/packages/autogen-ext/src/autogen_ext/tools/mcp/_workbench.py:341 links the result of await self._actor.call("call_tool", ...) (line 340) to the token, but McpSessionActor._run_actor (_actor.py:203-204) does result = session.call_tool(...) without await and then cmd["future"].set_result(result), so what call hands back is the raw ClientSession.call_tool coroutine, not a future/task — despite McpSessionActor.call being annotated -> McpFuture (_actor.py:29,98). That coroutine goes straight into link_future.
Reachability: any agent using McpWorkbench (e.g. AssistantAgent(workbench=mcp)) that passes a CancellationToken to call_tool and cancels it mid-run; the docstring examples in _workbench.py use exactly this path.
Happy to open a PR wrapping the coroutine the way _base.py does (task = asyncio.ensure_future(result_future); cancellation_token.link_future(task); result = await task, ideally also making McpSessionActor.call return a task/future instead of a bare coroutine) — happy to be assigned.
Related: I searched open issues/PRs and found none covering this. The closest is closed #7851 ("MCP tool error isolation"), a different topic; no open PRs touch _workbench.py.
Which packages was the bug in?
Python Extensions (autogen-ext)
AutoGen library version.
Python dev (main branch)
Other library version.
autogen-ext 0.7.5 installed from main at commit 027ecf0a379bcc1d09956d46d12d44a3ad9cee14; mcp==1.12.1
Model used
None — reproduced with a local MCP stdio server only, no model involved.
Model provider
Other (please specify below)
Other model provider
Not applicable — no model was used.
Python version
3.12
Operating system
MacOS
What happened?
Describe the bug
McpWorkbench.call_toolpasses a raw coroutine object toCancellationToken.link_future, so cancelling the token while a tool call is in flight raisesAttributeError: 'coroutine' object has no attribute 'cancel'out ofCancellationToken.cancel()and the in-flight MCP tool call is not cancelled — it keeps running to completion. If the token is already cancelled before the call,link_futureraises the sameAttributeErrorinsidecall_tool'stryblock and the caller gets a confusingis_error=Trueresult whose content is just the exception string.To Reproduce
A minimal MCP stdio server whose
sleeptool sleeps N seconds (slow_server.py, the exact server used to produce the outputs below):Client (
repro_cancel.py) — starts a 5s tool call, cancels the token at 1s:Actual output — observed independently by two verifiers at commit
027ecf0a379bcc1d09956d46d12d44a3ad9cee14(verbatim):Verifier 1:
Verifier 2:
Pre-cancelled-token variant (call
token.cancel()beforecall_tool):I.e.
token.cancel()itself explodes withAttributeError, the 5-second tool call still runs to completion (5.2s wall time) and returnsis_error=False— cancellation is silently lost.Expected behavior
Cancelling the token should cancel the in-flight tool call:
token.cancel()returns normally, andcall_toolends early (cancelled) instead of running the full 5 seconds and returning a successful result. With a pre-cancelled token,call_toolshould report cancellation, not anis_error=Trueresult containing'coroutine' object has no attribute 'cancel'.Basis:
CancellationToken.link_futureis annotateddef link_future(self, future: Future[Any]) -> Future[Any](python/packages/autogen-core/src/autogen_core/_cancellation_token.py:35) and only works on futures/tasks — the callback it registers callsfuture.cancel(), and a coroutine object has no.cancel()(verified:hasattr(c, "cancel") == False). The sibling implementation in the same package does it correctly —McpToolAdapter._run(python/packages/autogen-ext/src/autogen_ext/tools/mcp/_base.py:116-117):Additional context
Root cause:
python/packages/autogen-ext/src/autogen_ext/tools/mcp/_workbench.py:341links the result ofawait self._actor.call("call_tool", ...)(line 340) to the token, butMcpSessionActor._run_actor(_actor.py:203-204) doesresult = session.call_tool(...)withoutawaitand thencmd["future"].set_result(result), so whatcallhands back is the rawClientSession.call_toolcoroutine, not a future/task — despiteMcpSessionActor.callbeing annotated-> McpFuture(_actor.py:29,98). That coroutine goes straight intolink_future.Reachability: any agent using
McpWorkbench(e.g.AssistantAgent(workbench=mcp)) that passes aCancellationTokentocall_tooland cancels it mid-run; the docstring examples in_workbench.pyuse exactly this path.Happy to open a PR wrapping the coroutine the way
_base.pydoes (task = asyncio.ensure_future(result_future); cancellation_token.link_future(task); result = await task, ideally also makingMcpSessionActor.callreturn a task/future instead of a bare coroutine) — happy to be assigned.Related: I searched open issues/PRs and found none covering this. The closest is closed #7851 ("MCP tool error isolation"), a different topic; no open PRs touch
_workbench.py.Which packages was the bug in?
Python Extensions (autogen-ext)
AutoGen library version.
Python dev (main branch)
Other library version.
autogen-ext 0.7.5 installed from main at commit
027ecf0a379bcc1d09956d46d12d44a3ad9cee14;mcp==1.12.1Model used
None — reproduced with a local MCP stdio server only, no model involved.
Model provider
Other (please specify below)
Other model provider
Not applicable — no model was used.
Python version
3.12
Operating system
MacOS