698 lines
27 KiB
Python
698 lines
27 KiB
Python
"""
|
|
LiteLLM Proxy uses this MCP Client to connnect to other MCP servers.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
from typing import (
|
|
Any,
|
|
Awaitable,
|
|
Callable,
|
|
Dict,
|
|
Generator,
|
|
List,
|
|
Optional,
|
|
Tuple,
|
|
TypeVar,
|
|
Union,
|
|
)
|
|
|
|
import httpx
|
|
from mcp import ClientSession, ReadResourceResult, Resource, StdioServerParameters
|
|
from mcp.client.sse import sse_client
|
|
from mcp.client.stdio import stdio_client
|
|
|
|
streamable_http_client: Optional[Any] = None
|
|
try:
|
|
import mcp.client.streamable_http as streamable_http_module # type: ignore
|
|
|
|
streamable_http_client = getattr(
|
|
streamable_http_module, "streamable_http_client", None
|
|
)
|
|
except ImportError:
|
|
pass
|
|
from mcp.types import CallToolRequestParams as MCPCallToolRequestParams
|
|
from mcp.types import CallToolResult as MCPCallToolResult
|
|
from mcp.types import (
|
|
GetPromptRequestParams,
|
|
GetPromptResult,
|
|
Prompt,
|
|
ResourceTemplate,
|
|
TextContent,
|
|
)
|
|
from mcp.types import Tool as MCPTool
|
|
from pydantic import AnyUrl
|
|
|
|
from litellm._logging import verbose_logger
|
|
from litellm.constants import MCP_CLIENT_TIMEOUT
|
|
from litellm.llms.custom_httpx.http_handler import get_ssl_configuration
|
|
from litellm.types.llms.custom_http import VerifyTypes
|
|
from litellm.types.mcp import (
|
|
MCPAuth,
|
|
MCPAuthType,
|
|
MCPStdioConfig,
|
|
MCPTransport,
|
|
MCPTransportType,
|
|
)
|
|
|
|
|
|
def to_basic_auth(auth_value: str) -> str:
|
|
"""Convert auth value to Basic Auth format."""
|
|
return base64.b64encode(auth_value.encode("utf-8")).decode()
|
|
|
|
|
|
TSessionResult = TypeVar("TSessionResult")
|
|
|
|
|
|
class MCPSigV4Auth(httpx.Auth):
|
|
"""
|
|
httpx Auth class that signs each request with AWS SigV4.
|
|
|
|
This is used for MCP servers that require AWS SigV4 authentication,
|
|
such as AWS Bedrock AgentCore MCP servers. httpx calls auth_flow()
|
|
for every outgoing request, enabling per-request signature computation.
|
|
"""
|
|
|
|
requires_request_body = True
|
|
|
|
def __init__(
|
|
self,
|
|
aws_access_key_id: Optional[str] = None,
|
|
aws_secret_access_key: Optional[str] = None,
|
|
aws_session_token: Optional[str] = None,
|
|
aws_region_name: Optional[str] = None,
|
|
aws_service_name: Optional[str] = None,
|
|
):
|
|
try:
|
|
from botocore.credentials import Credentials
|
|
except ImportError:
|
|
raise ImportError(
|
|
"Missing botocore to use AWS SigV4 authentication. "
|
|
"Run 'pip install boto3'."
|
|
)
|
|
|
|
self.service_name = aws_service_name or "bedrock-agentcore"
|
|
self.region_name = aws_region_name or "us-east-1"
|
|
|
|
# Note: os.environ/ prefixed values are already resolved by
|
|
# ProxyConfig._check_for_os_environ_vars() at config load time.
|
|
# Values arrive here as plain strings.
|
|
if aws_access_key_id and aws_secret_access_key:
|
|
self.credentials = Credentials(
|
|
access_key=aws_access_key_id,
|
|
secret_key=aws_secret_access_key,
|
|
token=aws_session_token,
|
|
)
|
|
else:
|
|
# Fall back to default boto3 credential chain
|
|
import botocore.session
|
|
|
|
session = botocore.session.get_session()
|
|
self.credentials = session.get_credentials()
|
|
if self.credentials is None:
|
|
raise ValueError(
|
|
"No AWS credentials found. Provide aws_access_key_id and "
|
|
"aws_secret_access_key, or configure default credentials "
|
|
"(env vars, ~/.aws/credentials, instance profile)."
|
|
)
|
|
|
|
def auth_flow(
|
|
self, request: httpx.Request
|
|
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
from botocore.auth import SigV4Auth
|
|
from botocore.awsrequest import AWSRequest
|
|
|
|
# Build AWSRequest from the httpx Request.
|
|
# Pass all request headers so the canonical SigV4 signature covers them.
|
|
aws_request = AWSRequest(
|
|
method=request.method,
|
|
url=str(request.url),
|
|
data=request.content,
|
|
headers=dict(request.headers),
|
|
)
|
|
|
|
# Sign the request — SigV4Auth.add_auth() adds Authorization,
|
|
# X-Amz-Date, and X-Amz-Security-Token (if session token present).
|
|
# Host header is derived automatically from the URL.
|
|
sigv4 = SigV4Auth(self.credentials, self.service_name, self.region_name)
|
|
sigv4.add_auth(aws_request)
|
|
|
|
# Copy SigV4 headers back to the httpx request
|
|
for header_name, header_value in aws_request.headers.items():
|
|
request.headers[header_name] = header_value
|
|
|
|
yield request
|
|
|
|
|
|
class MCPClient:
|
|
"""
|
|
MCP Client supporting:
|
|
SSE and HTTP transports
|
|
Authentication via Bearer token, Basic Auth, or API Key
|
|
Tool calling with error handling and result parsing
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
server_url: str = "",
|
|
transport_type: MCPTransportType = MCPTransport.http,
|
|
auth_type: MCPAuthType = None,
|
|
auth_value: Optional[Union[str, Dict[str, str]]] = None,
|
|
timeout: Optional[float] = None,
|
|
stdio_config: Optional[MCPStdioConfig] = None,
|
|
extra_headers: Optional[Dict[str, str]] = None,
|
|
ssl_verify: Optional[VerifyTypes] = None,
|
|
aws_auth: Optional[httpx.Auth] = None,
|
|
):
|
|
self.server_url: str = server_url
|
|
self.transport_type: MCPTransport = transport_type
|
|
self.auth_type: MCPAuthType = auth_type
|
|
self.timeout: float = timeout if timeout is not None else MCP_CLIENT_TIMEOUT
|
|
self._mcp_auth_value: Optional[Union[str, Dict[str, str]]] = None
|
|
self.stdio_config: Optional[MCPStdioConfig] = stdio_config
|
|
self.extra_headers: Optional[Dict[str, str]] = extra_headers
|
|
self.ssl_verify: Optional[VerifyTypes] = ssl_verify
|
|
self._aws_auth: Optional[httpx.Auth] = aws_auth
|
|
# handle the basic auth value if provided
|
|
if auth_value:
|
|
self.update_auth_value(auth_value)
|
|
|
|
def _create_transport_context(
|
|
self,
|
|
) -> Tuple[Any, Optional[httpx.AsyncClient]]:
|
|
"""
|
|
Create the appropriate transport context based on transport type.
|
|
|
|
Returns:
|
|
Tuple of (transport_context, http_client).
|
|
http_client is only set for HTTP transport and needs cleanup.
|
|
"""
|
|
http_client: Optional[httpx.AsyncClient] = None
|
|
|
|
if self.transport_type == MCPTransport.stdio:
|
|
if not self.stdio_config:
|
|
raise ValueError("stdio_config is required for stdio transport")
|
|
server_params = StdioServerParameters(
|
|
command=self.stdio_config.get("command", ""),
|
|
args=self.stdio_config.get("args", []),
|
|
env=self.stdio_config.get("env", {}),
|
|
)
|
|
return stdio_client(server_params), None
|
|
|
|
if self.transport_type == MCPTransport.sse:
|
|
headers = self._get_auth_headers()
|
|
httpx_client_factory = self._create_httpx_client_factory()
|
|
return (
|
|
sse_client(
|
|
url=self.server_url,
|
|
timeout=self.timeout,
|
|
headers=headers,
|
|
httpx_client_factory=httpx_client_factory,
|
|
),
|
|
None,
|
|
)
|
|
|
|
# HTTP transport (default)
|
|
if streamable_http_client is None:
|
|
raise ImportError(
|
|
"streamable_http_client is not available. "
|
|
"Please install mcp with HTTP support."
|
|
)
|
|
|
|
headers = self._get_auth_headers()
|
|
httpx_client_factory = self._create_httpx_client_factory()
|
|
verbose_logger.debug("litellm headers for streamable_http_client: %s", headers)
|
|
http_client = httpx_client_factory(
|
|
headers=headers,
|
|
timeout=httpx.Timeout(self.timeout),
|
|
)
|
|
transport_ctx = streamable_http_client(
|
|
url=self.server_url,
|
|
http_client=http_client,
|
|
)
|
|
return transport_ctx, http_client
|
|
|
|
async def _execute_session_operation(
|
|
self,
|
|
transport_ctx: Any,
|
|
operation: Callable[[ClientSession], Awaitable[TSessionResult]],
|
|
) -> TSessionResult:
|
|
"""
|
|
Execute an operation within a transport and session context.
|
|
|
|
Handles entering/exiting contexts and running the operation.
|
|
"""
|
|
transport = await transport_ctx.__aenter__()
|
|
try:
|
|
read_stream, write_stream = transport[0], transport[1]
|
|
session_ctx = ClientSession(read_stream, write_stream)
|
|
session = await session_ctx.__aenter__()
|
|
try:
|
|
await session.initialize()
|
|
return await operation(session)
|
|
finally:
|
|
try:
|
|
await session_ctx.__aexit__(None, None, None)
|
|
except BaseException as e:
|
|
verbose_logger.debug(f"Error during session context exit: {e}")
|
|
finally:
|
|
try:
|
|
await transport_ctx.__aexit__(None, None, None)
|
|
except BaseException as e:
|
|
verbose_logger.debug(f"Error during transport context exit: {e}")
|
|
|
|
async def run_with_session(
|
|
self, operation: Callable[[ClientSession], Awaitable[TSessionResult]]
|
|
) -> TSessionResult:
|
|
"""Open a session, run the provided coroutine, and clean up."""
|
|
http_client: Optional[httpx.AsyncClient] = None
|
|
try:
|
|
transport_ctx, http_client = self._create_transport_context()
|
|
return await self._execute_session_operation(transport_ctx, operation)
|
|
except Exception:
|
|
verbose_logger.warning(
|
|
"MCP client run_with_session failed for %s", self.server_url or "stdio"
|
|
)
|
|
raise
|
|
finally:
|
|
if http_client is not None:
|
|
try:
|
|
await http_client.aclose()
|
|
except BaseException as e:
|
|
verbose_logger.debug(f"Error during http_client cleanup: {e}")
|
|
|
|
def update_auth_value(self, mcp_auth_value: Union[str, Dict[str, str]]):
|
|
"""
|
|
Set the authentication header for the MCP client.
|
|
"""
|
|
if isinstance(mcp_auth_value, dict):
|
|
self._mcp_auth_value = mcp_auth_value
|
|
else:
|
|
if self.auth_type == MCPAuth.basic:
|
|
# Assuming mcp_auth_value is in format "username:password", convert it when updating
|
|
mcp_auth_value = to_basic_auth(mcp_auth_value)
|
|
self._mcp_auth_value = mcp_auth_value
|
|
|
|
def _get_auth_headers(self) -> dict:
|
|
"""Generate authentication headers based on auth type."""
|
|
headers = {}
|
|
|
|
if self._mcp_auth_value:
|
|
if isinstance(self._mcp_auth_value, str):
|
|
if self.auth_type == MCPAuth.bearer_token:
|
|
headers["Authorization"] = f"Bearer {self._mcp_auth_value}"
|
|
elif self.auth_type == MCPAuth.basic:
|
|
headers["Authorization"] = f"Basic {self._mcp_auth_value}"
|
|
elif self.auth_type == MCPAuth.api_key:
|
|
headers["X-API-Key"] = self._mcp_auth_value
|
|
elif self.auth_type == MCPAuth.authorization:
|
|
headers["Authorization"] = self._mcp_auth_value
|
|
elif self.auth_type == MCPAuth.oauth2:
|
|
headers["Authorization"] = f"Bearer {self._mcp_auth_value}"
|
|
elif self.auth_type == MCPAuth.token:
|
|
headers["Authorization"] = f"token {self._mcp_auth_value}"
|
|
elif isinstance(self._mcp_auth_value, dict):
|
|
headers.update(self._mcp_auth_value)
|
|
# Note: aws_sigv4 auth is not handled here — SigV4 requires per-request
|
|
# signing (including the body hash), so it uses httpx.Auth flow instead
|
|
# of static headers. See MCPSigV4Auth and _create_httpx_client_factory().
|
|
|
|
# update the headers with the extra headers
|
|
if self.extra_headers:
|
|
headers.update(self.extra_headers)
|
|
|
|
return headers
|
|
|
|
def _create_httpx_client_factory(self) -> Callable[..., httpx.AsyncClient]:
|
|
"""
|
|
Create a custom httpx client factory that uses LiteLLM's SSL configuration.
|
|
|
|
This factory follows the same CA bundle path logic as http_handler.py:
|
|
1. Check ssl_verify parameter (can be SSLContext, bool, or path to CA bundle)
|
|
2. Check SSL_VERIFY environment variable
|
|
3. Check SSL_CERT_FILE environment variable
|
|
4. Fall back to certifi CA bundle
|
|
"""
|
|
|
|
def factory(
|
|
*,
|
|
headers: Optional[Dict[str, str]] = None,
|
|
timeout: Optional[httpx.Timeout] = None,
|
|
auth: Optional[httpx.Auth] = None,
|
|
) -> httpx.AsyncClient:
|
|
"""Create an httpx.AsyncClient with LiteLLM's SSL configuration."""
|
|
# Get unified SSL configuration using the same logic as http_handler.py
|
|
ssl_config = get_ssl_configuration(self.ssl_verify)
|
|
|
|
verbose_logger.debug(
|
|
f"MCP client using SSL configuration: {type(ssl_config).__name__}"
|
|
)
|
|
|
|
# Use SigV4 auth if configured and no explicit auth provided.
|
|
# The MCP SDK's sse_client and streamable_http_client call this
|
|
# factory without passing auth=, so self._aws_auth is used.
|
|
# For non-SigV4 clients, self._aws_auth is None — no behavior change.
|
|
effective_auth = auth if auth is not None else self._aws_auth
|
|
|
|
return httpx.AsyncClient(
|
|
headers=headers,
|
|
timeout=timeout,
|
|
auth=effective_auth,
|
|
verify=ssl_config,
|
|
follow_redirects=True,
|
|
)
|
|
|
|
return factory
|
|
|
|
async def list_tools(self) -> List[MCPTool]:
|
|
"""List available tools from the server."""
|
|
verbose_logger.debug(
|
|
f"MCP client listing tools from {self.server_url or 'stdio'}"
|
|
)
|
|
|
|
async def _list_tools_operation(session: ClientSession):
|
|
return await session.list_tools()
|
|
|
|
try:
|
|
result = await self.run_with_session(_list_tools_operation)
|
|
tool_count = len(result.tools)
|
|
tool_names = [tool.name for tool in result.tools]
|
|
verbose_logger.info(
|
|
f"MCP client listed {tool_count} tools from {self.server_url or 'stdio'}: {tool_names}"
|
|
)
|
|
return result.tools
|
|
except asyncio.CancelledError:
|
|
verbose_logger.warning("MCP client list_tools was cancelled")
|
|
raise
|
|
except Exception as e:
|
|
error_type = type(e).__name__
|
|
verbose_logger.exception(
|
|
f"MCP client list_tools failed - "
|
|
f"Error Type: {error_type}, "
|
|
f"Error: {str(e)}, "
|
|
f"Server: {self.server_url or 'stdio'}, "
|
|
f"Transport: {self.transport_type}"
|
|
)
|
|
|
|
# Check if it's a stream/connection error
|
|
if "BrokenResourceError" in error_type or "Broken" in error_type:
|
|
verbose_logger.error(
|
|
"MCP client detected broken connection/stream during list_tools - "
|
|
"the MCP server may have crashed, disconnected, or timed out"
|
|
)
|
|
|
|
# Return empty list instead of raising to allow graceful degradation
|
|
return []
|
|
|
|
async def call_tool(
|
|
self,
|
|
call_tool_request_params: MCPCallToolRequestParams,
|
|
host_progress_callback: Optional[Callable] = None,
|
|
) -> MCPCallToolResult:
|
|
"""
|
|
Call an MCP Tool.
|
|
"""
|
|
verbose_logger.info(
|
|
f"MCP client calling tool '{call_tool_request_params.name}' with arguments: {call_tool_request_params.arguments}"
|
|
)
|
|
|
|
async def on_progress(
|
|
progress: float, total: float | None, message: str | None
|
|
):
|
|
percentage = (progress / total * 100) if total else 0
|
|
verbose_logger.info(
|
|
f"MCP Tool '{call_tool_request_params.name}' progress: "
|
|
f"{progress}/{total} ({percentage:.0f}%) - {message or ''}"
|
|
)
|
|
|
|
# Forward to Host if callback provided
|
|
if host_progress_callback:
|
|
try:
|
|
await host_progress_callback(progress, total)
|
|
except Exception as e:
|
|
verbose_logger.warning(f"Failed to forward to Host: {e}")
|
|
|
|
async def _call_tool_operation(session: ClientSession):
|
|
verbose_logger.debug("MCP client sending tool call to session")
|
|
return await session.call_tool(
|
|
name=call_tool_request_params.name,
|
|
arguments=call_tool_request_params.arguments,
|
|
progress_callback=on_progress,
|
|
)
|
|
|
|
try:
|
|
tool_result = await self.run_with_session(_call_tool_operation)
|
|
verbose_logger.info(
|
|
f"MCP client tool call '{call_tool_request_params.name}' completed successfully"
|
|
)
|
|
return tool_result
|
|
except asyncio.CancelledError:
|
|
verbose_logger.warning("MCP client tool call was cancelled")
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
error_trace = traceback.format_exc()
|
|
verbose_logger.debug(f"MCP client tool call traceback:\n{error_trace}")
|
|
|
|
# Log detailed error information
|
|
error_type = type(e).__name__
|
|
verbose_logger.error(
|
|
f"MCP client call_tool failed - "
|
|
f"Error Type: {error_type}, "
|
|
f"Error: {str(e)}, "
|
|
f"Tool: {call_tool_request_params.name}, "
|
|
f"Server: {self.server_url or 'stdio'}, "
|
|
f"Transport: {self.transport_type}"
|
|
)
|
|
|
|
# Check if it's a stream/connection error
|
|
if "BrokenResourceError" in error_type or "Broken" in error_type:
|
|
verbose_logger.error(
|
|
"MCP client detected broken connection/stream - "
|
|
"the MCP server may have crashed, disconnected, or timed out."
|
|
)
|
|
|
|
# Return a default error result instead of raising
|
|
return MCPCallToolResult(
|
|
content=[
|
|
TextContent(type="text", text=f"{error_type}: {str(e)}")
|
|
], # Empty content for error case
|
|
isError=True,
|
|
)
|
|
|
|
async def list_prompts(self) -> List[Prompt]:
|
|
"""List available prompts from the server."""
|
|
verbose_logger.debug(
|
|
f"MCP client listing tools from {self.server_url or 'stdio'}"
|
|
)
|
|
|
|
async def _list_prompts_operation(session: ClientSession):
|
|
return await session.list_prompts()
|
|
|
|
try:
|
|
result = await self.run_with_session(_list_prompts_operation)
|
|
prompt_count = len(result.prompts)
|
|
prompt_names = [prompt.name for prompt in result.prompts]
|
|
verbose_logger.info(
|
|
f"MCP client listed {prompt_count} tools from {self.server_url or 'stdio'}: {prompt_names}"
|
|
)
|
|
return result.prompts
|
|
except asyncio.CancelledError:
|
|
verbose_logger.warning("MCP client list_prompts was cancelled")
|
|
raise
|
|
except Exception as e:
|
|
error_type = type(e).__name__
|
|
verbose_logger.error(
|
|
f"MCP client list_prompts failed - "
|
|
f"Error Type: {error_type}, "
|
|
f"Error: {str(e)}, "
|
|
f"Server: {self.server_url or 'stdio'}, "
|
|
f"Transport: {self.transport_type}"
|
|
)
|
|
|
|
# Check if it's a stream/connection error
|
|
if "BrokenResourceError" in error_type or "Broken" in error_type:
|
|
verbose_logger.error(
|
|
"MCP client detected broken connection/stream during list_tools - "
|
|
"the MCP server may have crashed, disconnected, or timed out"
|
|
)
|
|
|
|
# Return empty list instead of raising to allow graceful degradation
|
|
return []
|
|
|
|
async def get_prompt(
|
|
self, get_prompt_request_params: GetPromptRequestParams
|
|
) -> GetPromptResult:
|
|
"""Fetch a prompt definition from the MCP server."""
|
|
verbose_logger.info(
|
|
f"MCP client fetching prompt '{get_prompt_request_params.name}' with arguments: {get_prompt_request_params.arguments}"
|
|
)
|
|
|
|
async def _get_prompt_operation(session: ClientSession):
|
|
verbose_logger.debug("MCP client sending get_prompt request to session")
|
|
return await session.get_prompt(
|
|
name=get_prompt_request_params.name,
|
|
arguments=get_prompt_request_params.arguments,
|
|
)
|
|
|
|
try:
|
|
get_prompt_result = await self.run_with_session(_get_prompt_operation)
|
|
verbose_logger.info(
|
|
f"MCP client get_prompt '{get_prompt_request_params.name}' completed successfully"
|
|
)
|
|
return get_prompt_result
|
|
except asyncio.CancelledError:
|
|
verbose_logger.warning("MCP client get_prompt was cancelled")
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
error_trace = traceback.format_exc()
|
|
verbose_logger.debug(f"MCP client get_prompt traceback:\n{error_trace}")
|
|
|
|
# Log detailed error information
|
|
error_type = type(e).__name__
|
|
verbose_logger.error(
|
|
f"MCP client get_prompt failed - "
|
|
f"Error Type: {error_type}, "
|
|
f"Error: {str(e)}, "
|
|
f"Prompt: {get_prompt_request_params.name}, "
|
|
f"Server: {self.server_url or 'stdio'}, "
|
|
f"Transport: {self.transport_type}"
|
|
)
|
|
|
|
# Check if it's a stream/connection error
|
|
if "BrokenResourceError" in error_type or "Broken" in error_type:
|
|
verbose_logger.error(
|
|
"MCP client detected broken connection/stream during get_prompt - "
|
|
"the MCP server may have crashed, disconnected, or timed out."
|
|
)
|
|
|
|
raise
|
|
|
|
async def list_resources(self) -> list[Resource]:
|
|
"""List available resources from the server."""
|
|
verbose_logger.debug(
|
|
f"MCP client listing resources from {self.server_url or 'stdio'}"
|
|
)
|
|
|
|
async def _list_resources_operation(session: ClientSession):
|
|
return await session.list_resources()
|
|
|
|
try:
|
|
result = await self.run_with_session(_list_resources_operation)
|
|
resource_count = len(result.resources)
|
|
resource_names = [resource.name for resource in result.resources]
|
|
verbose_logger.info(
|
|
f"MCP client listed {resource_count} resources from {self.server_url or 'stdio'}: {resource_names}"
|
|
)
|
|
return result.resources
|
|
except asyncio.CancelledError:
|
|
verbose_logger.warning("MCP client list_resources was cancelled")
|
|
raise
|
|
except Exception as e:
|
|
error_type = type(e).__name__
|
|
verbose_logger.error(
|
|
f"MCP client list_resources failed - "
|
|
f"Error Type: {error_type}, "
|
|
f"Error: {str(e)}, "
|
|
f"Server: {self.server_url or 'stdio'}, "
|
|
f"Transport: {self.transport_type}"
|
|
)
|
|
|
|
# Check if it's a stream/connection error
|
|
if "BrokenResourceError" in error_type or "Broken" in error_type:
|
|
verbose_logger.error(
|
|
"MCP client detected broken connection/stream during list_resources - "
|
|
"the MCP server may have crashed, disconnected, or timed out"
|
|
)
|
|
|
|
# Return empty list instead of raising to allow graceful degradation
|
|
return []
|
|
|
|
async def list_resource_templates(self) -> list[ResourceTemplate]:
|
|
"""List available resource templates from the server."""
|
|
verbose_logger.debug(
|
|
f"MCP client listing resource templates from {self.server_url or 'stdio'}"
|
|
)
|
|
|
|
async def _list_resource_templates_operation(session: ClientSession):
|
|
return await session.list_resource_templates()
|
|
|
|
try:
|
|
result = await self.run_with_session(_list_resource_templates_operation)
|
|
resource_template_count = len(result.resourceTemplates)
|
|
resource_template_names = [
|
|
resourceTemplate.name for resourceTemplate in result.resourceTemplates
|
|
]
|
|
verbose_logger.info(
|
|
f"MCP client listed {resource_template_count} resource templates from {self.server_url or 'stdio'}: {resource_template_names}"
|
|
)
|
|
return result.resourceTemplates
|
|
except asyncio.CancelledError:
|
|
verbose_logger.warning("MCP client list_resource_templates was cancelled")
|
|
raise
|
|
except Exception as e:
|
|
error_type = type(e).__name__
|
|
verbose_logger.error(
|
|
f"MCP client list_resource_templates failed - "
|
|
f"Error Type: {error_type}, "
|
|
f"Error: {str(e)}, "
|
|
f"Server: {self.server_url or 'stdio'}, "
|
|
f"Transport: {self.transport_type}"
|
|
)
|
|
|
|
# Check if it's a stream/connection error
|
|
if "BrokenResourceError" in error_type or "Broken" in error_type:
|
|
verbose_logger.error(
|
|
"MCP client detected broken connection/stream during list_resource_templates - "
|
|
"the MCP server may have crashed, disconnected, or timed out"
|
|
)
|
|
|
|
# Return empty list instead of raising to allow graceful degradation
|
|
return []
|
|
|
|
async def read_resource(self, url: AnyUrl) -> ReadResourceResult:
|
|
"""Fetch resource contents from the MCP server."""
|
|
verbose_logger.info(f"MCP client fetching resource '{url}'")
|
|
|
|
async def _read_resource_operation(session: ClientSession):
|
|
verbose_logger.debug("MCP client sending read_resource request to session")
|
|
return await session.read_resource(url)
|
|
|
|
try:
|
|
read_resource_result = await self.run_with_session(_read_resource_operation)
|
|
verbose_logger.info(
|
|
f"MCP client read_resource '{url}' completed successfully"
|
|
)
|
|
return read_resource_result
|
|
except asyncio.CancelledError:
|
|
verbose_logger.warning("MCP client read_resource was cancelled")
|
|
raise
|
|
except Exception as e:
|
|
import traceback
|
|
|
|
error_trace = traceback.format_exc()
|
|
verbose_logger.debug(f"MCP client read_resource traceback:\n{error_trace}")
|
|
|
|
# Log detailed error information
|
|
error_type = type(e).__name__
|
|
verbose_logger.error(
|
|
f"MCP client read_resource failed - "
|
|
f"Error Type: {error_type}, "
|
|
f"Error: {str(e)}, "
|
|
f"Url: {url}, "
|
|
f"Server: {self.server_url or 'stdio'}, "
|
|
f"Transport: {self.transport_type}"
|
|
)
|
|
|
|
# Check if it's a stream/connection error
|
|
if "BrokenResourceError" in error_type or "Broken" in error_type:
|
|
verbose_logger.error(
|
|
"MCP client detected broken connection/stream during read_resource - "
|
|
"the MCP server may have crashed, disconnected, or timed out."
|
|
)
|
|
|
|
raise
|