Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions raven/agent/tools/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ def httpx_client_factory(
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)

logger.info("MCP server '{}': connected, {} tools registered", name, len(tools.tools))
except asyncio.CancelledError:
# MCP SDK cancel scopes can leak CancelledError when a transport fails.
# Preserve cancellation only when Raven's own task was cancelled.
task = asyncio.current_task()
if task is not None and task.cancelling() > 0:
raise
logger.error("MCP server '{}': connection cancelled by server/SDK", name)
except (Exception, BaseExceptionGroup) as e:
# BaseExceptionGroup is raised by anyio task groups (e.g. streamableHttp cancel
# scope failures) and is not a subclass of Exception in Python 3.11+.
Expand Down
56 changes: 56 additions & 0 deletions tests/test_sandbox_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,62 @@ def fake_stdio_client(params):

assert reached == ["mcp-server"]

async def test_stdio_sdk_cancellation_does_not_escape(self, monkeypatch):
"""A bare transport cancellation is isolated to the failing MCP server."""
from contextlib import AsyncExitStack

import mcp.client.stdio

from raven.agent.tools.mcp import connect_mcp_servers
from raven.agent.tools.registry import ToolRegistry

def fake_stdio_client(params):
raise asyncio.CancelledError()

monkeypatch.setattr(mcp.client.stdio, "stdio_client", fake_stdio_client)

cfg = MagicMock()
cfg.type = "stdio"
cfg.command = "mcp-server"
cfg.args = []
cfg.env = None
cfg.tool_timeout = 30

await connect_mcp_servers({"svc": cfg}, ToolRegistry(), AsyncExitStack(), executor=None)

async def test_stdio_external_cancellation_propagates(self, monkeypatch):
"""Cancellation of Raven's connection task is never treated as an MCP failure."""
from contextlib import AsyncExitStack, asynccontextmanager

import mcp.client.stdio

from raven.agent.tools.mcp import connect_mcp_servers
from raven.agent.tools.registry import ToolRegistry

entered = asyncio.Event()

@asynccontextmanager
async def fake_stdio_client(params):
entered.set()
await asyncio.Event().wait()
yield

monkeypatch.setattr(mcp.client.stdio, "stdio_client", fake_stdio_client)

cfg = MagicMock()
cfg.type = "stdio"
cfg.command = "mcp-server"
cfg.args = []
cfg.env = None
cfg.tool_timeout = 30

task = asyncio.create_task(connect_mcp_servers({"svc": cfg}, ToolRegistry(), AsyncExitStack(), executor=None))
await entered.wait()
task.cancel()

with pytest.raises(asyncio.CancelledError):
await task

async def test_stdio_sandboxed_with_spawning_does_not_raise(self):
"""Sandboxed executor that supports spawning does not trigger the guard."""
from contextlib import AsyncExitStack
Expand Down