Skip to content

McpWorkbench.call_tool passes a raw coroutine to CancellationToken.link_future: cancelling raises AttributeError and the tool call is never cancelled #8265

Description

@BlueX888

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions