from __future__ import annotations
import asyncio
import contextlib
import logging
import time
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any
import httpx
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.client.streamable_http import streamable_http_client
from mcp.types import Tool
from .config import McpServerConfig, resolve_paths
from .secrets import resolve_secrets
logger = logging.getLogger(__name__)
# ── Budgets ──────────────────────────────────────────────────────────────────
# Every wait on this path is bounded, because a server that accepted a request
# and went silent is indistinguishable from a slow one — and nothing above this
# layer could tell them apart. Before these existed the wait was *infinite*:
# with ``read_timeout_seconds=None`` the SDK waits inside
# ``anyio.fail_after(None)``, the caller's future is never resolved, and the
# runner task stays wedged for the life of the process. Every later reload then
# blocked on that same client (``disconnect_all`` is sequential), so one silent
# server made ``reload_tools`` look permanently broken.
#
# Two layers, deliberately ordered: the SDK-side read timeout fires first and
# cleans up *inside* the runner, so the client stays usable; the caller-side
# bound is the backstop, covering also a caller queued behind a stuck operation
# (and a server that stops reading its requests, where the SDK never gets to its
# read wait). It aborts the runner, which is rebuilt on the next command.
_SESSION_READ_TIMEOUT_S = 30.0
"""Per-request read timeout handed to ``ClientSession``: initialize + tools/list."""
_CALL_TOOL_READ_TIMEOUT_S = 240.0
"""Per-request override for tools/call — a remote tool may legitimately take minutes."""
_CONNECT_TIMEOUT_S = 60.0
"""Caller-side. Twice the SDK bound, so a silent handshake fails SDK-side first."""
_LIST_TOOLS_TIMEOUT_S = 45.0
"""Caller-side, likewise above the SDK bound (see the retry in the runner)."""
_CALL_TOOL_TIMEOUT_S = 300.0
_QUICK_OP_TIMEOUT_S = 15.0
"""Ops that do no I/O — only the wait for their turn behind another op."""
_DISCONNECT_TIMEOUT_S = 15.0
"""Transport teardown. One that will not finish is abandoned (see disconnect())."""
# httpx bounds for the HTTP transports. These are the SDK's own recommended
# values (``mcp.shared._httpx_utils``), which we were not getting: we hand the
# transport our own AsyncClient for its headers, and a bare ``httpx.AsyncClient``
# defaults to 5s for *everything* — so the streamable-http GET stream died every
# time its server went quiet for five seconds. ``read`` must stay generous: a
# streamable-http server holds the response stream open.
_HTTP_TIMEOUT_S = 30.0
_HTTP_STREAM_READ_TIMEOUT_S = 300.0
class McpOperationTimeout(RuntimeError):
"""An MCP operation did not answer within its budget.
Carries the server and op so the caller can name the culprit — on prod the
log line ``mcp.op_timeout`` is the only thing that says *which* server was
silent, because a server that never answers writes nothing itself.
"""
def __init__(self, server: str, op: str, timeout: float) -> None:
super().__init__(
f"MCP server {server!r} did not answer '{op}' within {timeout:.0f}s"
)
self.server = server
self.op = op
self.timeout = timeout
@dataclass
class _Cmd:
"""A command enqueued to the client's runner task."""
op: str
fut: asyncio.Future | None
payload: dict[str, Any] = field(default_factory=dict)
class McpClient:
"""Lightweight wrapper around the official Python MCP client SDK.
Manages a single server connection (stdio, SSE, or streamable-http),
exposes ``list_tools()`` and ``call_tool()``, and handles lifecycle
(connect / disconnect).
**Threading model.** The MCP client SDK (``stdio_client`` /
``sse_client`` / ``streamable_http_client`` and ``ClientSession``) is
built on anyio task groups, whose cancel scopes require that
``__aenter__`` and ``__aexit__`` run in the *same* asyncio task. To
guarantee that, every client owns a single long-running **runner task**
that holds the ``AsyncExitStack`` and performs all transport enter/exit
(connect, disconnect, reconnect) as well as every ``list_tools`` /
``call_tool``. The public methods just enqueue a command onto a queue and
await its result Future, so callers can invoke them from any task
(lifespan, health-check, request handler) without crossing cancel-scope
task boundaries.
Reconnect uses exponential backoff (base 1s, max 30s, ±20% jitter)
so that a flapping server does not hammer the transport.
"""
def __init__(self, name: str, config: McpServerConfig) -> None:
self.name = name
self.config = config
# Mirrored snapshot of the runner's state, updated from inside the
# runner task. asyncio is single-threaded, so these are safe to read
# synchronously from any task via the properties below.
self._connected: bool = False
self._instructions: str | None = None
# Runner task + command queue. Lazily created on first use.
self._runner_task: asyncio.Task | None = None
self._queue: asyncio.Queue[_Cmd] | None = None
# ── Public, sync state ────────────────────────────────────────────────
@property
def connected(self) -> bool:
return self._connected
@property
def instructions(self) -> str | None:
"""Server-provided instructions from MCP initialize handshake."""
return self._instructions
# ── Public, async API (enqueue + await) ───────────────────────────────
async def connect(self) -> None:
"""Open transport, initialise session. Idempotent when already connected."""
await self._send("connect", _CONNECT_TIMEOUT_S)
async def disconnect(self) -> None:
"""Close transport, stop the runner task, and reset state.
Bounded, because ``McpManager.disconnect_all`` walks the whole pool and
this is where a hang used to become *every* reload's problem: the
teardown runs the MCP SDK's exit stack, which can wait on a server that
stopped answering. A teardown that will not finish in
:data:`_DISCONNECT_TIMEOUT_S` is abandoned — the runner is cancelled and
its transport dies with the task.
"""
runner = self._runner_task
queue = self._queue
# Ask the runner to stop. It closes the transport *inside its own
# task* — the anyio cancel scope is exited where it was entered.
if queue is not None and runner is not None and not runner.done():
with contextlib.suppress(Exception):
queue.put_nowait(_Cmd("disconnect", None, {}))
if runner is not None and not runner.done():
# Shield so a lifespan cancellation does not interrupt the
# runner's own transport teardown; the wait around it is bounded.
try:
await asyncio.wait_for(asyncio.shield(runner), _DISCONNECT_TIMEOUT_S)
except TimeoutError:
logger.warning(
"mcp.disconnect_timeout server=%s after=%.0fs — abandoning the runner",
self.name,
_DISCONNECT_TIMEOUT_S,
)
self._abort_runner()
except asyncio.CancelledError:
# The caller is being cancelled (shutdown, or an outer deadline
# such as the budget on the MCP reload phase). Re-raising is
# what lets that deadline actually fire — and the runner, still
# shielded and possibly stuck in this same teardown, is
# cancelled on the way out.
self._abort_runner()
raise
except BaseException:
pass
self._runner_task = None
self._queue = None
self._connected = False
self._instructions = None
async def list_tools(self) -> list[Tool]:
"""Return the tools exposed by the remote MCP server."""
return await self._send( # type: ignore[return-value]
"list_tools", _LIST_TOOLS_TIMEOUT_S
)
async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None) -> tuple[str, bool]:
"""Execute a remote tool and return its output as a string.
Returns (output_text, is_error). ``is_error`` comes from the MCP
``CallToolResult.isError`` field so the caller can set ``success=False``.
Text content is concatenated; images are reported as a placeholder.
"""
return await self._send( # type: ignore[return-value]
"call_tool",
_CALL_TOOL_TIMEOUT_S,
tool_name=tool_name,
arguments=arguments,
)
async def mark_disconnected(self) -> None:
"""Force the client into a disconnected state without closing transport.
Used by the health-check loop when a server drops silently: the next
operation will reconnect (with backoff).
"""
await self._send("mark_disconnected", _QUICK_OP_TIMEOUT_S)
# ── Runner plumbing ───────────────────────────────────────────────────
def _ensure_runner(self) -> None:
"""Lazily start the runner task + queue if not already running."""
if self._runner_task is not None and not self._runner_task.done():
return
loop = asyncio.get_running_loop()
self._queue = asyncio.Queue()
self._runner_task = loop.create_task(self._runner())
def _abort_runner(self) -> None:
"""Cancel the runner task and drop our handles to it.
The runner closes the transport in its own ``finally``, so cancelling it
is what actually frees the stream. Clearing the handles here — rather
than letting the dying task do it — is what keeps the next command from
queueing behind a runner that is already unwinding, and the mirrored
state has to be cleared here too: the dying runner's own teardown is
ignored (it is no longer the current one), so nothing else would.
"""
runner = self._runner_task
self._runner_task = None
self._queue = None
self._connected = False
self._instructions = None
if runner is not None and not runner.done():
runner.cancel()
async def _send(self, op: str, timeout: float, **payload: Any) -> Any:
"""Enqueue a command and await its result, for at most *timeout* seconds.
*timeout* is required so no future op can quietly reintroduce an
unbounded wait; the module constants above say what each op is worth.
"""
self._ensure_runner()
loop = asyncio.get_running_loop()
fut: asyncio.Future = loop.create_future()
assert self._queue is not None
await self._queue.put(_Cmd(op, fut, payload))
try:
return await asyncio.wait_for(fut, timeout)
except TimeoutError:
# The runner is somewhere inside this op and will not come back:
# abort it, or every later command queues behind the same silence.
logger.warning(
"mcp.op_timeout server=%s op=%s after=%.0fs", self.name, op, timeout
)
self._abort_runner()
raise McpOperationTimeout(self.name, op, timeout) from None
except asyncio.CancelledError:
# The caller gave up (outer deadline, stop, shutdown). Nothing will
# consume this result, and a runner still waiting on a silent server
# would stay wedged for the life of the process.
fut.cancel()
self._abort_runner()
raise
async def _runner(self) -> None:
"""Owns the AsyncExitStack; processes commands until ``disconnect``.
All transport ``enter_async_context`` / ``aclose`` calls happen here,
in this single task, so anyio cancel scopes are always entered and
exited by the same task.
"""
queue = self._queue
assert queue is not None
me = asyncio.current_task()
exit_stack = AsyncExitStack()
session: ClientSession | None = None
connected: bool = False
instructions: str | None = None
backoff: float = 1.0
last_attempt: float | None = None
def publish_state() -> None:
"""Mirror this runner's state — unless this runner was replaced.
A runner that a timed-out operation aborted keeps unwinding its own
teardown for a moment, and its dying state must not be written over
the fresh runner a later command has already started.
"""
if self._runner_task is me:
self._connected = connected
self._instructions = instructions
async def close_all() -> None:
nonlocal session, connected, instructions
try:
await exit_stack.aclose()
except SystemExit:
raise
except BaseException:
# anyio raises RuntimeError / BaseExceptionGroup when an
# async generator is closed; we are tearing down anyway.
pass
finally:
session = None
connected = False
instructions = None
publish_state()
async def open_transport() -> None:
nonlocal session, instructions
# Config values are made concrete here and nowhere else: project-
# relative paths become absolute, and `${VAR}` credentials are read
# from the environment. Keeping both at transport-open means the
# stored config — and anything that serialises it back to disk —
# never holds this machine's paths or a live secret.
cfg = resolve_paths(resolve_secrets(self.config, server=self.name))
try:
if cfg.is_stdio:
if not cfg.command:
raise ValueError("stdio transport requires 'command'")
params = StdioServerParameters(
command=cfg.command,
args=cfg.args or [],
env=cfg.env,
cwd=cfg.cwd,
)
transport = await exit_stack.enter_async_context(stdio_client(params))
elif cfg.is_sse:
if not cfg.url:
raise ValueError("sse transport requires 'url'")
transport = await exit_stack.enter_async_context(
sse_client(
cfg.url,
headers=cfg.headers,
timeout=_HTTP_TIMEOUT_S,
sse_read_timeout=_HTTP_STREAM_READ_TIMEOUT_S,
)
)
elif cfg.is_streamable_http:
if not cfg.url:
raise ValueError("streamable_http transport requires 'url'")
http_client = await exit_stack.enter_async_context(
httpx.AsyncClient(
headers=cfg.headers or {},
# Never the httpx default: 5s on the GET stream read
# tears the transport down on an idle server.
timeout=httpx.Timeout(
_HTTP_TIMEOUT_S, read=_HTTP_STREAM_READ_TIMEOUT_S
),
)
)
transport = await exit_stack.enter_async_context(
streamable_http_client(cfg.url, http_client=http_client)
)
# streamable_http_client returns (read, write, get_session_id).
transport = transport[:2]
else:
raise ValueError(f"unknown transport: {cfg.transport}")
read_stream, write_stream = transport
session = await exit_stack.enter_async_context(
ClientSession(
read_stream,
write_stream,
# Without this the SDK waits forever on every request.
read_timeout_seconds=timedelta(seconds=_SESSION_READ_TIMEOUT_S),
)
)
init_result = await session.initialize()
instructions = getattr(init_result, "instructions", None)
except BaseException:
# Roll back any half-entered contexts before propagating.
await close_all()
raise
def check_backoff() -> bool:
nonlocal last_attempt
if last_attempt is None:
return True
elapsed = time.monotonic() - last_attempt
jitter = backoff * 0.2 * (2 * (time.monotonic() % 1) - 1)
return elapsed >= (backoff + jitter)
async def ensure_connected() -> None:
nonlocal connected, backoff, last_attempt, instructions
if connected and session is not None:
return
if not check_backoff():
remaining = backoff - (time.monotonic() - (last_attempt or 0))
logger.warning(
"MCP server %r reconnect blocked by backoff (%.1fs remaining)",
self.name,
max(0, remaining),
)
raise RuntimeError(
f"MCP server {self.name!r} is disconnected and reconnect is throttled"
)
last_attempt = time.monotonic()
logger.warning("MCP server %r disconnected, reconnecting...", self.name)
await close_all()
await open_transport() # raises on failure -> caller retries later
connected = True
backoff = 1.0
last_attempt = None
publish_state()
logger.info("MCP server %r connected (%s)", self.name, self.config.transport)
try:
while True:
cmd = await queue.get()
try:
if cmd.op == "connect":
await ensure_connected()
self._resolve(cmd.fut, None)
elif cmd.op == "disconnect":
break # finally -> close_all
elif cmd.op == "mark_disconnected":
connected = False
publish_state()
self._resolve(cmd.fut, None)
elif cmd.op == "list_tools":
await ensure_connected()
try:
result = await session.list_tools() # type: ignore[union-attr]
except Exception:
await close_all()
await ensure_connected()
result = await session.list_tools() # type: ignore[union-attr]
self._resolve(cmd.fut, list(result.tools))
elif cmd.op == "call_tool":
tool_name = cmd.payload["tool_name"]
arguments = cmd.payload.get("arguments")
await ensure_connected()
try:
result = await session.call_tool( # type: ignore[union-attr]
tool_name,
arguments or {},
read_timeout_seconds=timedelta(
seconds=_CALL_TOOL_READ_TIMEOUT_S
),
)
except Exception:
await close_all()
await ensure_connected()
result = await session.call_tool( # type: ignore[union-attr]
tool_name,
arguments or {},
read_timeout_seconds=timedelta(
seconds=_CALL_TOOL_READ_TIMEOUT_S
),
)
parts: list[str] = []
for item in result.content:
if item.type == "text":
parts.append(item.text)
elif item.type == "image":
parts.append(f"[image: {item.mimeType} ({len(item.data)} bytes)]")
else:
parts.append(f"[{item.type}]")
is_error = getattr(result, "isError", False)
self._resolve(cmd.fut, ("\n".join(parts), is_error))
else:
self._fail(cmd.fut, ValueError(f"unknown op: {cmd.op}"))
except asyncio.CancelledError:
task = asyncio.current_task()
if task is None or task.cancelling():
# Runner is really being cancelled (app teardown).
# Release the in-flight caller, then let close_all run
# in finally.
self._cancel(cmd.fut)
raise
# A failed transport connect inside the MCP SDK's anyio
# cancel scopes can surface as CancelledError without an
# actual task.cancel() (seen with streamable_http against a
# dead endpoint). Treat it as an ordinary connect failure:
# one unreachable MCP server must not take down the whole
# startup (McpManager.load_all only catches Exception).
publish_state()
self._fail(cmd.fut, RuntimeError(
f"MCP server {self.name!r} connect failed (anyio CancelledError)"
))
except Exception as exc:
# On a connect/ensure failure, mirror the disconnected
# state before surfacing the error to the caller.
publish_state()
self._fail(cmd.fut, exc)
finally:
await close_all()
# Runner is gone — fail anything still queued so callers waiting
# in _send don't hang forever.
while not queue.empty():
try:
cmd = queue.get_nowait()
except Exception:
break
self._fail(cmd.fut, RuntimeError(f"MCP runner for {self.name!r} exited"))
@staticmethod
def _resolve(fut: asyncio.Future | None, value: Any) -> None:
if fut is not None and not fut.done():
fut.set_result(value)
@staticmethod
def _fail(fut: asyncio.Future | None, exc: BaseException) -> None:
if fut is not None and not fut.done():
fut.set_exception(exc)
@staticmethod
def _cancel(fut: asyncio.Future | None) -> None:
if fut is not None and not fut.done():
fut.cancel()