# Lightweight MCP HTTP client wrapper extracted from mcp_service
# to centralize transport/session-specific logic.
from dataclasses import dataclass, field
from typing import Dict, Optional
from contextlib import asynccontextmanager
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.session import ClientSession
import mcp.types as types
from datetime import timedelta
import logging
import asyncio
from typing import Tuple, Callable, Awaitable
from aidputils.agents.toolkit.metrics_util import MetricUtility
from aidputils.agents.tools.mcp.mcp_auth import build_headers_from_auth
# Gracefully handle ClosedResourceError without hard dependency if anyio is unavailable
try:
from anyio import ClosedResourceError as AnyioClosedResourceError
except Exception:
class AnyioClosedResourceError(Exception):
pass
Key = Tuple[str, str]
# Single registry holding both session and its per-key lock
_ENTRIES: Dict[Key, "CacheEntry"] = {}
_ENTRIES_GUARD: asyncio.Lock = asyncio.Lock()
logger = logging.getLogger(__name__)
_MCP_TOOL_METRICS = MetricUtility("MCP_TOOL")
_MCP_TOOL_SUCCESS_COUNTER = _MCP_TOOL_METRICS.create_counter("MCP_TOOL_SUCCESS_COUNTER")
_MCP_TOOL_FAILURE_COUNTER = _MCP_TOOL_METRICS.create_counter("MCP_TOOL_FAILURE_COUNTER")
[docs]
@dataclass
class CacheEntry:
session: Optional[ClientSession] = None
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
[docs]
class MCPSessionCache:
[docs]
def get(self, server_name: str, session_id: str) -> Optional[ClientSession]:
"""
Return a cached ClientSession for (server_name, session_id) if present.
"""
entry = _ENTRIES.get((server_name, session_id))
return entry.session if entry is not None else None
[docs]
def set(self, server_name: str, session_id: str, session: ClientSession) -> None:
"""
Store a ClientSession for (server_name, session_id).
"""
key = (server_name, session_id)
entry = _ENTRIES.get(key)
if entry is None:
entry = CacheEntry()
_ENTRIES[key] = entry
entry.session = session
[docs]
def delete(self, server_name: str, session_id: str) -> None:
"""
Remove a cached ClientSession for (server_name, session_id).
"""
_ENTRIES.pop((server_name, session_id), None)
async def _get_entry_for_key(self, key: Key) -> "CacheEntry":
"""
Get or create a CacheEntry (with its lock) for a given key in a thread-safe manner.
"""
async with _ENTRIES_GUARD:
entry = _ENTRIES.get(key)
if entry is None:
entry = CacheEntry()
_ENTRIES[key] = entry
return entry
[docs]
async def get_or_create(
self,
server_name: str,
session_id: str,
init_session: Callable[[str], Awaitable[ClientSession]],
) -> ClientSession:
"""
Return a session for (server_name, session_id) if cached, otherwise create one by
awaiting init_session(session_id), store it, and return it. Uses a per-key lock
to avoid duplicate concurrent initialization.
"""
key = (server_name, session_id)
entry = await self._get_entry_for_key(key)
if entry.session is not None:
return entry.session
async with entry.lock:
# Re-check within lock
if entry.session is not None:
return entry.session
# Create new session via provided initializer
session = await init_session(session_id)
entry.session = session
return session
[docs]
async def close(self, server_name: str, session_id: str) -> None:
"""
Best-effort closure of a single session; does not enforce underlying transport close semantics.
"""
key = (server_name, session_id)
entry = _ENTRIES.pop(key, None)
if entry is None:
return
session = entry.session
close = getattr(session, "close", None) if session is not None else None
if callable(close):
try:
await close()
except Exception:
# Ignore errors on best-effort close
pass
[docs]
async def close_all(self) -> None:
"""
Best-effort closure of all cached sessions.
"""
keys = list(_ENTRIES.keys())
for server_name, session_id in keys:
await self.close(server_name, session_id)
[docs]
@dataclass
class MCPHTTPClient:
def __init__(self,
server_name,
url,
headers,
auth: Optional[dict] = None,
transport: str = "streamable_http",
request_timeout_seconds: float = 30.0,
sse_read_timeout_seconds: float = 30.0):
self.url = url
self.server_name = server_name
self.headers = headers
self.auth = auth or {}
self.per_session_metadata = {}
self.transport = transport
self.request_timeout_seconds = request_timeout_seconds
self.sse_read_timeout_seconds = sse_read_timeout_seconds
self.mcp_session_cache = MCPSessionCache()
[docs]
async def init_session(self, session_id: str) -> ClientSession:
"""
Initialize and park a ClientSession for this server and session_id.
Persist it into the global mcp_session_cache and return the session once ready.
"""
ready_event = asyncio.Event()
stop_event = asyncio.Event()
entry = {"ready_event": ready_event, "stop_event": stop_event, "task": None}
task = asyncio.create_task(self._run(session_id))
entry["task"] = task
self.per_session_metadata[session_id] = entry
await ready_event.wait()
if task.done():
task.result()
# Return the cached session
sess =self.mcp_session_cache.get(self.server_name, session_id)
if sess is None:
raise RuntimeError("Failed to initialize MCP session")
return sess
async def _run(self, session_id):
try:
async with streamablehttp_client(
self.url,
headers=self.get_all_headers()) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session_local:
try:
try:
await session_local.initialize()
except Exception:
# Some client versions initialize lazily; ignore if not supported
pass
# Persist into cache and signal readiness
self.mcp_session_cache.set(self.server_name, session_id, session_local)
entry = self.per_session_metadata[session_id]
entry.get("ready_event").set()
# Park until asked to stop
await entry.get("stop_event").wait()
logger.info("Stop signal received, exiting scope.")
except Exception as e:
logger.error("Exception on server '%s' for session_id '%s': %s",
self.server_name, session_id, e, exc_info=True)
except Exception as e:
entry = self.per_session_metadata[session_id]
logger.error("MCPHTTPClient persistent scope crashed for server '%s', session_id '%s': %s", self.server_name, session_id, e, exc_info=True)
if not entry.get("ready_event").is_set():
entry.get("ready_event").set()
raise
finally:
self.mcp_session_cache.delete(self.server_name, session_id)
[docs]
async def stop_session(self, session_id: str) -> None:
"""
Stop a parked persistent session for the given session_id, if running,
and remove it from the cache.
"""
info = self.per_session_metadata.get(session_id)
if not info:
await self.mcp_session_cache.close(self.server_name, session_id)
return
task = info.get("task")
stop_event = info.get("stop_event")
if task and stop_event and not task.done():
stop_event.set()
try:
await task
except Exception:
# Already logged by the scope
pass
self.per_session_metadata.pop(session_id, None)
await self.mcp_session_cache.close(self.server_name, session_id)
[docs]
@asynccontextmanager
async def session(self, _server_name: str):
"""
Ephemeral session context manager for single-call operations.
"""
async with streamablehttp_client(
self.url,
headers=self.get_all_headers(),
timeout=timedelta(seconds=float(self.request_timeout_seconds)),
sse_read_timeout=timedelta(seconds=float(self.sse_read_timeout_seconds)),
terminate_on_close=True,
) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session:
try:
await session.initialize()
yield session
finally:
pass
[docs]
async def get_resources(self, server_name: str, uris=None, use_cached: bool = False, session_id: Optional[str] = None):
"""
List resources.
- If use_cached is False (default): use an on-demand ephemeral session.
- If use_cached is True: reuse/create a persistent session associated with session_id from the cache.
Returns a list of resource dicts when possible, otherwise raw objects.
"""
if not use_cached:
async with self.session(server_name) as session:
result = await session.list_resources()
else:
if not session_id:
raise ValueError("session_id is required when use_cached=True for get_resources")
session = await self.get_or_create_session(session_id)
result = await session.list_resources()
try:
return [r.model_dump(mode="python") for r in result.resources]
except Exception:
return result.resources
[docs]
async def get_prompt(self, server_name: str, prompt: str, use_cached: bool = False, session_id: Optional[str] = None):
"""
Get a prompt by name.
- If use_cached is False (default): use an on-demand ephemeral session.
- If use_cached is True: reuse/create a persistent session associated with session_id from the cache.
Returns a dict when possible, otherwise the raw result object.
"""
if not use_cached:
async with self.session(server_name) as session:
result = await session.get_prompt(prompt)
else:
if not session_id:
raise ValueError("session_id is required when use_cached=True for get_prompt")
session = await self.get_or_create_session(session_id)
result = await session.get_prompt(prompt)
try:
return result.model_dump(mode="python")
except Exception:
return result
[docs]
async def ping(self, server_name: str) -> bool:
"""
Send a lightweight ping request to validate connectivity/auth.
Returns True if completed within the configured timeout.
Note: Uses explicit request_read_timeout_seconds to avoid indefinite waits
if a server streams or stalls.
"""
async with self.session(server_name) as session:
await session.send_request(
types.ClientRequest(types.PingRequest()),
types.EmptyResult,
request_read_timeout_seconds=timedelta(seconds=float(self.request_timeout_seconds)),
)
return True
[docs]
async def test_connection(self, server_name: str) -> bool:
"""
Validate basic MCP connectivity/auth by establishing a session.
Some MCP servers do not implement ping, but a successful session
initialization is still enough to verify that the endpoint is reachable
and authentication is working.
"""
async with self.session(server_name):
return True
[docs]
async def get_or_create_session(self, session_id: str) -> ClientSession:
"""
Return an existing session for session_id or lazily initialize a new one via init_session.
"""
return await self.mcp_session_cache.get_or_create(self.server_name, session_id, self.init_session)