Files
strix/strix/tools/mcp/session.py
2026-09-01 08:11:43 -07:00

807 lines
36 KiB
Python

"""Own each MCP connection's live session on its own supervising task.
The bug this fixes: the streamable-HTTP transport (the ``mcp`` SDK) opens an
internal anyio task group when ``server.connect()`` runs, and that task group's
cancel scope is entered on whatever task called ``connect()`` and stays open for
the session's whole life. In the old code that task was the run's main task, the
one the agent loop runs on. So when a provider returned an HTTP error on one of
the transport's background tasks (for example a ``403`` on a background POST),
the task group cancelled its scope, the cancellation
propagated to the main task, and the whole scan died with a bare
``CancelledError`` (mislabeled as a user interrupt). Teardown then raised
"Attempted to exit cancel scope in a different task than it was entered in"
because cleanup ran on a different task than connect.
The fix, mirroring how child agents run on their own ``asyncio.create_task``
(see :func:`strix.core.execution.spawn_child_agent`): give each connection its
own dedicated supervising task that owns ``connect()``, the session's held-open
lifetime, and ``cleanup()``. Three consequences:
- **Containment.** The transport's cancel scope is now entered on the supervising
task, so a background failure cancels only that task. The run and every other
connection keep going.
- **Co-located teardown.** ``connect()`` and ``cleanup()`` run on the same task,
so the "exit cancel scope in a different task" error cannot happen.
- **A value, not a cancellation, reaches the caller.** The agent never touches the
live session directly. It hands a call to the supervising task over a queue and
awaits the result as a value; if the session task dies, the caller gets a
"connection unavailable" value instead of a cancellation propagating into the
agent loop.
Failure handling follows connection-pool discipline: discard on error, rebuild on
next use. A failure while connecting or rebuilding describes the session. A
non-2xx response from a tool call describes that request, not the session. Permission
and protocol failures from a call return a failed tool output while the connection
stays usable. Other classified failures are retried on the rebuilt session and then,
if they keep failing, temporarily quarantine the connection. Authentication failures
and repeated transient exhaustion permanently retire a connection.
Security: the connection's :class:`~strix.tools.mcp.config.McpConnectionConfig`
holds a live bearer credential and is kept here in memory only, on the same
in-process object that already holds the live session. It is never logged,
serialized into the run's event stream, or written to disk; :meth:`__repr__`
omits it and the token field's own ``repr`` is already suppressed.
"""
from __future__ import annotations
import asyncio
import contextlib
import dataclasses
import logging
import secrets
import time
import weakref
from typing import TYPE_CHECKING, Any, Literal, cast
from strix.tools.mcp.config import DEFAULT_MAX_CONCURRENT_CALLS
from strix.tools.mcp.failures import FailureInfo, HttpStatusRecorder, classify
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from agents.mcp import MCPServer
from mcp.types import Tool as MCPTool
from strix.tools.mcp.client import ResultTransform
from strix.tools.mcp.config import McpConnectionConfig
# One operation to run against the live session, e.g. ``list_tools`` or a tool
# call. Runs on the supervising task (supervised sessions) or inline (adopted
# sessions), and its return value becomes the caller's result.
Job = Callable[[MCPServer], Awaitable[Any]]
_Phase = Literal["connect", "call"]
logger = logging.getLogger(__name__)
# How long a graceful (sentinel) shutdown waits for the serve loop to drain
# before the supervising task is cancelled instead. Bounds teardown so a slow or
# hung in-flight call cannot stall it forever.
_SHUTDOWN_TIMEOUT = 10.0
_MAX_ATTEMPTS = 3
_SETTLE_DELAY = 0.05
_SEMAPHORES: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Semaphore]] = (
weakref.WeakKeyDictionary()
)
_JITTER = secrets.SystemRandom()
# Everything the SDK can surface for a failed call: ordinary errors plus the
# transport's task-group ``BaseExceptionGroup``. Caught wholesale and handed to
# ``classify``; ``asyncio.CancelledError`` is always handled separately first,
# so shutdown and genuine cancellation still propagate.
_CLASSIFIABLE: tuple[type[BaseException], ...] = (BaseExceptionGroup, Exception)
def _retry_delay(attempt: int, retry_after: float | None) -> float:
if retry_after is not None:
return retry_after
base = min(8.0, 0.5 * (2 ** (attempt - 1)))
return base + _JITTER.uniform(0.0, base * 0.1) # type: ignore[no-any-return]
def _call_semaphore(name: str, limit: int) -> asyncio.Semaphore:
loop = asyncio.get_running_loop()
semaphores = _SEMAPHORES.setdefault(loop, {})
return semaphores.setdefault(name, asyncio.Semaphore(limit))
class McpConnectionUnavailableError(RuntimeError):
"""A dead MCP connection could not be reached and did not come back.
Raised by :meth:`SupervisedMcpSession.list_tools` when the connection is dead
so the read-only dispatch tools (``describe_mcp``) can report it cleanly.
:meth:`SupervisedMcpSession.dispatch` does not raise it: a call to a dead
connection returns the standard failed-tool output instead.
"""
@dataclasses.dataclass
class _Outcome:
"""What running one job resolved to: a value, a call failure, or a dead connection."""
value: Any = None
dead: bool = False
call_failure: FailureInfo | None = None
@dataclasses.dataclass
class _Request:
"""One job handed to the supervising task, with the future its result lands in."""
job: Job
future: asyncio.Future[_Outcome]
phase: _Phase
class SupervisedMcpSession:
"""One MCP connection whose live session is owned by a dedicated task.
Built two ways:
- :meth:`__init__` + :meth:`start` for a *supervised* session: the engine owns
connecting. ``start`` spawns the supervising task, which builds and connects
the server on itself and then serves calls handed to it over a queue. This is
the path that contains a background session failure to one task.
- :meth:`adopt` for an *adopted* session: the caller already holds a connected
server (strix-pro's cloud sessions, and the test fakes). There is no
supervising task; calls run inline against the given server. Reconnect works
only when a config was supplied.
Public async API used by the dispatch tools: :meth:`list_tools` and
:meth:`dispatch`. Lifecycle: :meth:`start`, :meth:`aclose`. Read-only:
:attr:`name`, :attr:`server`, :attr:`config`, :attr:`is_dead`.
"""
def __init__(self, config: McpConnectionConfig) -> None:
self._name = config.name
self._config: McpConnectionConfig | None = config
self._server: MCPServer | None = None
self._supervised = True
self._task: asyncio.Task[None] | None = None
self._queue: asyncio.Queue[_Request | None] | None = None
self._ready: asyncio.Future[bool] | None = None
self._pending: set[asyncio.Future[_Outcome]] = set()
self._dead = False
self._closing = False
self._on_dead: Callable[[], None] | None = None
self._recorder: HttpStatusRecorder | None = None
self._unavailable_until: float | None = None
self._quarantine_count = 0
self._last_failure = FailureInfo("unknown", reason="connection unavailable")
self._reconnect_lock = asyncio.Lock()
self._call_semaphore: asyncio.Semaphore | None = None
@classmethod
def adopt(
cls,
server: MCPServer,
*,
name: str,
config: McpConnectionConfig | None = None,
) -> SupervisedMcpSession:
"""Wrap an already-connected server without a supervising task.
Calls run inline against ``server`` on the caller's task, matching the old
direct-dispatch behavior. Reconnect is available only when ``config`` is
given; otherwise a failed call can be quarantined but cannot be revived.
"""
self = cls.__new__(cls)
self._name = name
self._config = config
self._server = server
self._supervised = False
self._task = None
self._queue = None
self._ready = None
self._pending = set()
self._dead = False
self._closing = False
self._on_dead = None
self._recorder = None
self._unavailable_until = None
self._quarantine_count = 0
self._last_failure = FailureInfo("unknown", reason="connection unavailable")
self._reconnect_lock = asyncio.Lock()
self._call_semaphore = None
return self
# -- read-only accessors --------------------------------------------------
@property
def name(self) -> str:
return self._name
@property
def server(self) -> MCPServer | None:
"""The current live server, or ``None`` once dead. Swapped on reconnect."""
return self._server
@property
def config(self) -> McpConnectionConfig | None:
"""The connection config kept for reconnect. Carries the bearer token, so
never log or serialize this."""
return self._config
@property
def is_dead(self) -> bool:
return self._dead
@property
def is_unavailable(self) -> bool:
"""Whether the connection is temporarily quarantined."""
return (
not self._dead
and self._unavailable_until is not None
and time.monotonic() < self._unavailable_until
)
def set_on_dead(self, callback: Callable[[], None] | None) -> None:
"""Register a one-shot callback fired when the connection transitions to dead.
The callback runs on whatever task marks the connection dead (the
supervising task for a supervised session, the caller's task for an
adopted one), so it must not block. It fires at most once, on the
healthy->dead edge, and never for a connection that only ever shut down
cleanly. The interfaces use it to push a live "offline" status without
polling. Exceptions from the callback are swallowed (logged) so a status
push can never take down the session task.
"""
self._on_dead = callback
def _mark_dead(self, failure: FailureInfo | None = None, *, attempt: int = 1) -> None:
"""Flip the connection to dead and fire ``on_dead`` once on the transition."""
if self._dead:
return
failure = failure or self._last_failure
self._dead = True
self._unavailable_until = None
logger.error(
"MCP connection %r permanently unavailable kind=%s status=%s reason=%s "
"attempt=%d delay=0",
self._name,
failure.kind,
failure.status,
failure.reason,
attempt,
)
callback = self._on_dead
if callback is None:
return
try:
callback()
except Exception:
logger.exception("MCP on_dead callback for %r failed", self._name)
def __repr__(self) -> str:
# Deliberately omits the config so the bearer token can never reach a log
# line through an accidental repr of this object.
return f"SupervisedMcpSession(name={self._name!r}, dead={self._dead})"
# -- lifecycle ------------------------------------------------------------
async def start(self) -> bool:
"""Spawn the supervising task, connect on it, and wait until it is ready.
Returns ``True`` when the session connected, ``False`` when the initial
connect failed (the caller then skips this connection, fail-open). Only
valid for a supervised session.
"""
loop = asyncio.get_running_loop()
self._queue = asyncio.Queue()
self._ready = loop.create_future()
self._task = asyncio.create_task(self._supervise(), name=f"mcp-session-{self._name}")
return await self._ready
async def aclose(self) -> None:
"""Shut the connection down and clean up its session on its owning task.
For a connected supervised session this signals the supervising task with a
sentinel so ``cleanup()`` runs on the same task that ran ``connect()``,
giving an orderly shutdown the supervisor tells apart from a session death.
Teardown is always bounded: if the serve loop cannot drain the sentinel in
time (a slow or hung in-flight call), or the session never finished
connecting (including a connect cancelled mid-await), the task is cancelled
instead. ``_closing`` is set first, so the supervisor treats that
cancellation as shutdown and still cleans up on its own task.
"""
self._closing = True
if self._supervised and self._task is not None:
if not self._task.done():
# A cancelled readiness future (the connect was cancelled mid-await)
# counts as "not connected": never call ``.result()`` on it, which
# would raise here and skip the cleanup below.
connected = (
self._ready is not None
and self._ready.done()
and not self._ready.cancelled()
and self._ready.result()
)
if connected and self._queue is not None:
# Reached the serve loop: a sentinel gives a clean, cancel-free
# teardown, with cleanup() running on the supervising task. Bound
# it, though: a hung in-flight call would otherwise leave the
# sentinel queued behind it forever, so cancel the task if the
# drain does not finish in time (wait_for cancels it on timeout).
with contextlib.suppress(Exception):
await self._queue.put(None)
with contextlib.suppress(
asyncio.TimeoutError, asyncio.CancelledError, Exception
):
await asyncio.wait_for(self._task, _SHUTDOWN_TIMEOUT)
else:
# Still stuck in connect(), never connected, or connect
# cancelled: cancel to unstick it.
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._task
else:
await self._safe_cleanup()
self._fail_pending()
# -- caller-facing operations --------------------------------------------
async def list_tools(self) -> list[MCPTool]:
"""List the connection's tools, retrying transient session failures.
Raises :class:`McpConnectionUnavailableError` when the connection is dead
and never returns a call failure.
"""
outcome = await self._run_job(lambda server: server.list_tools(), phase="connect")
if outcome.dead:
raise McpConnectionUnavailableError(self._unavailable_message())
if outcome.call_failure is not None:
raise RuntimeError("MCP list_tools returned a call failure")
return cast("list[MCPTool]", outcome.value)
async def dispatch(
self,
tool_name: str,
arguments: dict[str, Any],
*,
label: str,
result_transform: ResultTransform | None = None,
) -> Any:
"""Run one tool call with bounded retries for transient session failures.
Returns the tool output on success, or the standard failed-tool output
(``success: False``) when the provider rejects the call or the connection
is unavailable. A call rejection keeps the connection usable because the
provider rejected the request, not the session.
"""
from strix.tools.mcp.client import dispatch_mcp_call
async def job(server: MCPServer) -> Any:
return await dispatch_mcp_call(
server,
tool_name,
arguments,
label=label,
result_transform=result_transform,
)
outcome = await self._run_job(job, phase="call")
if outcome.call_failure is not None:
from strix.tools.mcp.client import _errored_tool_output
return _errored_tool_output(self._call_rejected_message(outcome.call_failure))
if outcome.dead:
from strix.tools.mcp.client import _errored_tool_output
return _errored_tool_output(self._unavailable_message())
return outcome.value
# -- job routing ----------------------------------------------------------
async def _run_job(self, job: Job, *, phase: _Phase) -> _Outcome:
"""Route one job to the owning task (supervised) or run it inline (adopted)."""
if self._supervised:
return await self._submit(job, phase)
return await self._execute(job, phase)
async def _submit(self, job: Job, phase: _Phase) -> _Outcome:
"""Hand a job to the supervising task and await its result as a value."""
if self._dead or self._closing or self._task is None or self._task.done():
return _Outcome(dead=True)
loop = asyncio.get_running_loop()
future: asyncio.Future[_Outcome] = loop.create_future()
self._pending.add(future)
if self._queue is None:
self._pending.discard(future)
return _Outcome(dead=True)
await self._queue.put(_Request(job=job, future=future, phase=phase))
# The task may have ended between the guard above and the put; ``_fail_pending``
# would then never see this future, so resolve it here.
if self._task.done() and not future.done():
self._pending.discard(future)
return _Outcome(dead=True)
return await future
# -- the supervising task -------------------------------------------------
async def _supervise(self) -> None:
"""Own the session for its whole life on one task: connect, serve, clean up."""
try:
self._server = await self._open()
except asyncio.CancelledError:
# The connect was cancelled (the run is going down, or the transport
# scope cancelled mid-connect). Report not-ready so the attach path
# treats it as a skipped connection; do not propagate.
self._report_ready(value=False)
await self._safe_cleanup()
self._fail_pending()
return
except _CLASSIFIABLE as exc:
failure = classify(exc)
logger.warning(
"Skipping MCP connection %r kind=%s status=%s attempt=1 delay=0",
self._name,
failure.kind,
failure.status,
exc_info=True,
)
self._report_ready(value=False)
await self._safe_cleanup()
self._fail_pending()
return
self._report_ready(value=True)
try:
await self._serve_loop()
finally:
await self._safe_cleanup()
self._fail_pending()
async def _serve_loop(self) -> None:
assert self._queue is not None
while True:
try:
request = await self._queue.get()
except asyncio.CancelledError:
# A cancellation while idle is the transport's task group cancelling
# this supervising task because a background session task failed.
# Contained here. If we are closing, this is an ordinary shutdown,
# so let it propagate. Otherwise quarantine the failed session and
# keep serving requests so a later call can revive it.
if self._closing:
raise
failure = self._recorder.take() if self._recorder is not None else None
failure = failure or FailureInfo("transport", reason="session cancelled")
self._last_failure = failure
if failure.kind in {"auth", "permission"}:
self._mark_dead(failure, attempt=1)
return
await self._quarantine(failure, attempt=1)
if self._dead:
return
continue
if request is None: # shutdown sentinel
return
outcome = await self._execute(request.job, request.phase)
if not request.future.done():
request.future.set_result(outcome)
self._pending.discard(request.future)
if self._dead:
return
# -- run one job with bounded classified retries --------------------------
async def _execute(self, job: Job, phase: _Phase) -> _Outcome: # noqa: PLR0912
"""Run one job on a healthy session, disposing it the instant it errors.
Discard-on-error, rebuild-on-next-use is the whole discipline here, and it
rests on one invariant: **a session object is only ever awaited while
healthy.** The moment a call fails, the very next thing this method does,
before any other ``await`` including the backoff sleep inside
:meth:`_handle_failure`, is dispose that session on this task
(:meth:`_safe_cleanup` runs the transport teardown and clears ``_server``).
Why the ordering is the crux, not a nicety: when a provider returns a non-2xx
status mid-call, the streamable-HTTP transport's task group cancels its scope,
which cancels this supervising task; the failure surfaces as a
``CancelledError`` and the scope keeps firing (re-raising on every subsequent
``await``) until the session is torn down. Disposing closes the transport's
AsyncExitStack, which exits that firing scope. If instead we slept for backoff
first, the sleep would re-raise the firing ``CancelledError``, escape this
method, and kill the supervising task, leaving the slot wedged with
``is_dead`` False forever. Disposing first is what turns a failure into a
returned value and keeps the task alive to rebuild on the next attempt.
The rebuild itself happens lazily at the top of the loop: once a failure has
set ``_server`` to None, the next iteration builds a fresh session (guarded by
:meth:`_reconnect`) and retries the operation on it. A permission or protocol
failure from a call returns immediately after disposal because it describes
that request, not the session. A genuine shutdown (``_closing``) and a real
external cancellation still propagate; only the transport's teardown
cancellation is contained.
"""
if self._dead:
return _Outcome(dead=True)
if self._unavailable_until is not None:
remaining = self._unavailable_until - time.monotonic()
if remaining > 0:
return _Outcome(dead=True)
self._unavailable_until = None
logger.info(
"MCP connection %r revive started kind=%s status=%s attempt=1",
self._name,
self._last_failure.kind,
self._last_failure.status,
)
if self._call_semaphore is None:
self._call_semaphore = _call_semaphore(
self._name,
(
self._config.max_concurrent_calls
if self._config is not None
else DEFAULT_MAX_CONCURRENT_CALLS
),
)
failure: FailureInfo | None = None
for attempt in range(1, _MAX_ATTEMPTS + 1):
# Lazy, atomic rebuild: a prior failure disposed the session, so build a
# fresh one here. The rebuild lock lets concurrent callers (adopted
# sessions dispatched from several agent tasks) share one rebuild rather
# than each building their own.
if self._server is None:
reconnected, reconnect_failure = await self._reconnect()
if not reconnected:
failure = reconnect_failure or FailureInfo(
"transport", reason="reconnect failed"
)
outcome = await self._handle_failure(failure, attempt, phase="connect")
if outcome is not None:
return outcome
continue
assert self._server is not None
call_semaphore = self._call_semaphore
assert call_semaphore is not None
try:
async with call_semaphore:
result = await job(self._server)
# A success clears the quarantine strikes. A connection that
# recovered and served a call is healthy again, so transient
# failure bursts separated by successful revivals must not
# accumulate toward permanent retirement; only sustained failure
# with no success in between should retire the connection.
self._quarantine_count = 0
return _Outcome(value=result)
except asyncio.CancelledError:
if not self._supervised or self._closing:
raise
failure = (
self._recorder.take() if self._recorder is not None else None
) or FailureInfo("transport", reason="session cancelled")
# Dispose BEFORE any other await. The transport's cancel scope may be
# firing right now; _safe_cleanup exits it so the backoff sleep below
# cannot re-raise the cancellation and kill this task. See the
# method docstring for why this ordering is load-bearing.
await self._safe_cleanup()
except _CLASSIFIABLE as exc:
failure = classify(exc)
if failure.kind == "unknown" and self._recorder is not None:
failure = self._recorder.take() or failure
# Dispose BEFORE any other await, same reason as the branch above:
# never await on a session that has already errored.
await self._safe_cleanup()
# Session is disposed and _server is None; _handle_failure may sleep for
# backoff safely, and the next loop iteration rebuilds and retries.
outcome = await self._handle_failure(failure, attempt, phase=phase)
if outcome is not None:
return outcome
return _Outcome(dead=True)
async def _handle_failure(
self, failure: FailureInfo, attempt: int, *, phase: _Phase
) -> _Outcome | None:
self._last_failure = failure
if failure.kind == "auth":
self._mark_dead(failure, attempt=attempt)
return _Outcome(dead=True)
if failure.kind == "permission":
if phase == "call":
return _Outcome(call_failure=failure)
self._mark_dead(failure, attempt=attempt)
return _Outcome(dead=True)
if (
phase == "call"
and failure.kind == "protocol"
and failure.status is not None
and 400 <= failure.status <= 499
):
return _Outcome(call_failure=failure)
if attempt == _MAX_ATTEMPTS:
await self._quarantine(failure, attempt=attempt)
return _Outcome(dead=True)
delay = _retry_delay(attempt, failure.retry_after)
self._log_retry(failure, attempt, delay)
await asyncio.sleep(delay)
return None
def _log_retry(self, failure: FailureInfo, attempt: int, delay: float) -> None:
logger.warning(
"MCP connection %r retryable failure kind=%s status=%s attempt=%d delay=%.2f",
self._name,
failure.kind,
failure.status,
attempt,
delay,
)
async def _quarantine(self, failure: FailureInfo, *, attempt: int) -> None:
await self._safe_cleanup()
self._quarantine_count += 1
if self._quarantine_count >= 3:
self._mark_dead(failure, attempt=attempt)
return
cooldown = 30.0 * (2 ** (self._quarantine_count - 1))
self._unavailable_until = time.monotonic() + cooldown
logger.warning(
"MCP connection %r quarantined kind=%s status=%s attempt=%d delay=%.2f",
self._name,
failure.kind,
failure.status,
attempt,
cooldown,
)
async def _reconnect(self) -> tuple[bool, FailureInfo | None]:
"""Build a fresh session under the rebuild lock, so concurrent callers share one.
Called only when ``_server`` is None (a prior failure already disposed the old
session). The lock serializes rebuilds; a caller that finds the session already
rebuilt by whoever held the lock first reuses it instead of building a second
one. There is deliberately no cleanup of an existing ``_server`` here: this
method never runs against a live session, because the failure path disposes
before it ever reaches a rebuild.
"""
async with self._reconnect_lock:
if self._server is not None:
# Another caller rebuilt while we waited for the lock; share it.
return True, None
if self._config is None:
return False, FailureInfo("transport", reason="no reconnect config")
try:
server = await self._open()
except asyncio.CancelledError:
if self._closing:
raise
self._server = None
return False, FailureInfo("transport", reason="reconnect cancelled")
except _CLASSIFIABLE as exc:
self._server = None
failure = classify(exc)
if failure.kind == "unknown" and self._recorder is not None:
failure = self._recorder.take() or failure
return False, failure
# connect() is the only readiness surface exposed by the SDK.
self._server = server
try:
await asyncio.sleep(_SETTLE_DELAY)
except asyncio.CancelledError:
# Dispose the just-built session before returning; _safe_cleanup
# re-raises when we are shutting down and absorbs otherwise.
await self._safe_cleanup()
if self._closing:
raise
return False, FailureInfo("transport", reason="reconnect cancelled")
return True, None
async def _open(self) -> MCPServer:
"""Build and connect the SDK server, reusing the existing setup steps.
If ``connect()`` fails, the just-built server is cleaned up here on this
same task before the error propagates, so a failed connect never orphans
an MCP subprocess or half-open HTTP session.
"""
from strix.tools.mcp.client import _build_server
if self._config is None:
raise RuntimeError(f"MCP connection {self._name!r} has no config to connect")
built = _build_server(self._config)
server = built.server
self._recorder = built.recorder
try:
await server.connect() # type: ignore[no-untyped-call]
except asyncio.CancelledError:
with contextlib.suppress(Exception):
await server.cleanup() # type: ignore[no-untyped-call]
raise
except _CLASSIFIABLE:
with contextlib.suppress(Exception):
await server.cleanup() # type: ignore[no-untyped-call]
raise
return server
# -- helpers --------------------------------------------------------------
def _call_rejected_message(self, failure: FailureInfo) -> str:
if failure.kind == "permission":
return (
f"MCP connection {self._name!r} rejected this call (status={failure.status}): "
"the provider denied this specific request, not the connection. The connection "
"is still available. Check the arguments — resource and project identifiers, "
"and required fields — and whether the configured credential is allowed to read "
"that resource, then retry."
)
if failure.kind == "protocol":
return (
f"MCP connection {self._name!r} rejected this call as invalid "
f"(status={failure.status}): the request itself was malformed, not the "
"connection. The connection is still available. Check the tool's required "
"arguments and value formats with describe_mcp, then retry."
)
raise AssertionError(f"Unexpected call failure kind: {failure.kind}")
async def _safe_cleanup(self) -> None:
"""Dispose the live session on this task, completing teardown even under a
firing cancel scope.
Why this is delicate: the streamable-HTTP transport holds an anyio task group
whose cancel scope was entered on this supervising task. When a background POST
got a non-2xx status the SDK cancelled that scope, and until the scope is
exited every ``await`` on this task re-raises ``CancelledError``.
``server.cleanup()`` closes the AsyncExitStack that runs the task group's
``__aexit__``, and that ``__aexit__`` is exactly what exits the scope and stops
the firing; it also absorbs the scope's own cancellation internally, so the
common case returns cleanly. A stray ``CancelledError`` can still surface,
though, and ``contextlib.suppress(Exception)`` would let it through because
``CancelledError`` is a ``BaseException``, not an ``Exception``.
So we catch ``CancelledError`` explicitly. During a real shutdown
(``_closing``) that cancellation is the run going down and must propagate, so
we re-raise it. Otherwise we absorb it and retry the close a bounded number of
times: if a cleanup was interrupted before the exit stack finished unwinding,
closing again continues from where it left off (the stack pops one callback at
a time), so the scope still ends up exited and this task stays runnable for the
next rebuild.
"""
server = self._server
self._server = None
if server is None:
return
for _ in range(_MAX_ATTEMPTS):
try:
# suppress(Exception) absorbs an ordinary cleanup error but lets a
# CancelledError through, because it is a BaseException; the outer
# handler below is what decides whether to propagate or retry it.
with contextlib.suppress(Exception):
await server.cleanup() # type: ignore[no-untyped-call]
except asyncio.CancelledError:
if self._closing:
raise
# Firing scope hit the cleanup await before the stack finished
# unwinding; swallow this cancellation and close again to complete
# the teardown. A fully-closed stack makes the retry a clean no-op.
continue
else:
return
def _report_ready(self, value: bool) -> None:
if self._ready is not None and not self._ready.done():
self._ready.set_result(value)
def _fail_pending(self) -> None:
for future in self._pending:
if not future.done():
future.set_result(_Outcome(dead=True))
self._pending.clear()
def _unavailable_message(self) -> str:
if self._unavailable_until is not None:
remaining = max(0.0, self._unavailable_until - time.monotonic())
return (
f"MCP connection {self._name!r} is temporarily unavailable "
f"(kind={self._last_failure.kind}, status={self._last_failure.status}); "
f"retrying in about {remaining:.0f} seconds."
)
return (
f"MCP connection {self._name!r} is unavailable "
f"(kind={self._last_failure.kind}, status={self._last_failure.status}); "
"it will not be retried."
)