fa45d8aa5f
- health_checklist.json: 192.168.1.122→node122
- ocr_client.py: docstring IP→node122
- docs/market-data-requirements.md: IP→node122
- 所有API调用通过ProxyHandler({})绕过系统代理
Privoxy对node122:18003返回500,直连正常
826 lines
34 KiB
Python
826 lines
34 KiB
Python
"""
|
|
LiteLLM Proxy uses this MCP Client to connnect to other MCP servers.
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import os
|
|
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, MCP_NPM_CACHE_DIR
|
|
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()
|
|
|
|
|
|
def _strip_header_whitespace(headers: Dict[str, str]) -> Dict[str, str]:
|
|
return {
|
|
(key.strip() if isinstance(key, str) else key): (
|
|
value.strip() if isinstance(value, str) else value
|
|
)
|
|
for key, value in headers.items()
|
|
}
|
|
|
|
|
|
def _first_non_cancelled_cause(exc: BaseException) -> Optional[BaseException]:
|
|
queue: List[BaseException] = [exc]
|
|
while queue:
|
|
current = queue.pop(0)
|
|
nested = getattr(current, "exceptions", None)
|
|
if nested:
|
|
queue.extend(nested)
|
|
elif not isinstance(current, asyncio.CancelledError):
|
|
return current
|
|
return None
|
|
|
|
|
|
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,
|
|
aws_role_name: Optional[str] = None,
|
|
aws_session_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_role_name:
|
|
self.credentials = self._assume_role(
|
|
aws_role_name=aws_role_name,
|
|
aws_session_name=aws_session_name,
|
|
aws_access_key_id=aws_access_key_id,
|
|
aws_secret_access_key=aws_secret_access_key,
|
|
aws_session_token=aws_session_token,
|
|
aws_region_name=self.region_name,
|
|
)
|
|
elif 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)."
|
|
)
|
|
|
|
@staticmethod
|
|
def _assume_role(
|
|
aws_role_name: str,
|
|
aws_session_name: Optional[str],
|
|
aws_access_key_id: Optional[str],
|
|
aws_secret_access_key: Optional[str],
|
|
aws_session_token: Optional[str],
|
|
aws_region_name: str,
|
|
):
|
|
"""Call STS AssumeRole and return temporary credentials."""
|
|
import boto3
|
|
from botocore.credentials import Credentials
|
|
|
|
session_name = (
|
|
aws_session_name or f"litellm-mcp-{int(__import__('time').time())}"
|
|
)
|
|
sts_kwargs: dict = {"region_name": aws_region_name}
|
|
if aws_access_key_id and aws_secret_access_key:
|
|
sts_kwargs["aws_access_key_id"] = aws_access_key_id
|
|
sts_kwargs["aws_secret_access_key"] = aws_secret_access_key
|
|
if aws_session_token:
|
|
sts_kwargs["aws_session_token"] = aws_session_token
|
|
sts_client = boto3.client("sts", **sts_kwargs)
|
|
sts_response = sts_client.assume_role(
|
|
RoleArn=aws_role_name,
|
|
RoleSessionName=session_name,
|
|
)
|
|
sts_creds = sts_response["Credentials"]
|
|
return Credentials(
|
|
access_key=sts_creds["AccessKeyId"],
|
|
secret_key=sts_creds["SecretAccessKey"],
|
|
token=sts_creds["SessionToken"],
|
|
)
|
|
|
|
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
|
|
Sampling callbacks for upstream server LLM requests
|
|
Elicitation callbacks for upstream server user-input requests
|
|
"""
|
|
|
|
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,
|
|
sampling_callback: Optional[Callable] = None,
|
|
elicitation_callback: Optional[Callable] = None,
|
|
logging_callback: Optional[Callable] = 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
|
|
self._last_initialize_instructions: Optional[str] = None
|
|
self._sampling_callback: Optional[Callable] = sampling_callback
|
|
self._elicitation_callback: Optional[Callable] = elicitation_callback
|
|
self._logging_callback: Optional[Callable] = logging_callback
|
|
# 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._get_safe_stdio_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
|
|
|
|
def _get_safe_stdio_env(
|
|
self, provided_env: Optional[Dict[str, str]]
|
|
) -> Optional[Dict[str, str]]:
|
|
"""
|
|
Return a safe environment for the stdio subprocess.
|
|
|
|
If provided_env is set, we use it as-is.
|
|
If provided_env is None, we return a minimal allowlist from the parent environment
|
|
to avoid leaking sensitive LiteLLM keys (OPENAI_API_KEY, etc.) to sub-processes.
|
|
"""
|
|
if provided_env is not None:
|
|
return provided_env
|
|
|
|
# Minimal allowlist of safe/standard environment variables
|
|
safe_keys = {
|
|
"PATH",
|
|
"HOME",
|
|
"USER",
|
|
"LOGNAME",
|
|
"TMPDIR",
|
|
"TMP",
|
|
"TEMP",
|
|
"SHELL",
|
|
"LANG",
|
|
"LC_ALL",
|
|
# Node/Package manager caches
|
|
"NPM_CONFIG_CACHE",
|
|
"PNPM_HOME",
|
|
"XDG_CACHE_HOME",
|
|
"XDG_CONFIG_HOME",
|
|
"XDG_DATA_HOME",
|
|
# System info
|
|
"SYSTEMROOT",
|
|
"COMSPEC",
|
|
"PATHEXT",
|
|
"WINDIR",
|
|
}
|
|
|
|
safe_env = {}
|
|
for key in safe_keys:
|
|
if key in os.environ:
|
|
safe_env[key] = os.environ[key]
|
|
|
|
if "NPM_CONFIG_CACHE" not in safe_env:
|
|
safe_env["NPM_CONFIG_CACHE"] = MCP_NPM_CACHE_DIR
|
|
|
|
return safe_env
|
|
|
|
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.
|
|
Passes sampling/elicitation/logging callbacks to the ClientSession
|
|
so that upstream MCP servers can request LLM inference (sampling),
|
|
user input (elicitation), or send log messages.
|
|
"""
|
|
transport = await transport_ctx.__aenter__()
|
|
in_flight_error: Optional[BaseException] = None
|
|
try:
|
|
read_stream, write_stream = transport[0], transport[1]
|
|
# Build session kwargs with optional callbacks
|
|
session_kwargs: Dict[str, Any] = {}
|
|
if self._sampling_callback is not None:
|
|
session_kwargs["sampling_callback"] = self._sampling_callback
|
|
if self._elicitation_callback is not None:
|
|
session_kwargs["elicitation_callback"] = self._elicitation_callback
|
|
if self._logging_callback is not None:
|
|
session_kwargs["logging_callback"] = self._logging_callback
|
|
session_ctx = ClientSession(read_stream, write_stream, **session_kwargs)
|
|
session = await session_ctx.__aenter__()
|
|
try:
|
|
init_result = await session.initialize()
|
|
self._last_initialize_instructions = None
|
|
if init_result is not None:
|
|
ins = getattr(init_result, "instructions", None)
|
|
if isinstance(ins, str) and ins.strip():
|
|
self._last_initialize_instructions = ins.strip()
|
|
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}")
|
|
except BaseException as e:
|
|
in_flight_error = e
|
|
raise
|
|
finally:
|
|
try:
|
|
await transport_ctx.__aexit__(None, None, None)
|
|
except BaseException as exit_error:
|
|
verbose_logger.debug(
|
|
f"Error during transport context exit: {exit_error}"
|
|
)
|
|
root_cause = _first_non_cancelled_cause(exit_error)
|
|
if root_cause is not None and isinstance(
|
|
in_flight_error, asyncio.CancelledError
|
|
):
|
|
raise root_cause from in_flight_error
|
|
|
|
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:
|
|
self._last_initialize_instructions = None
|
|
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 self.auth_type == MCPAuth.oauth2_token_exchange:
|
|
headers["Authorization"] = f"Bearer {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 _strip_header_whitespace(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, raise_on_error: bool = False) -> List[MCPTool]:
|
|
"""List available tools from the server.
|
|
|
|
Args:
|
|
raise_on_error: When True, re-raise exceptions instead of returning
|
|
an empty list. Used by the proxy's pass-through MCP flow so it
|
|
can surface upstream HTTP 401 responses as a proper 401 to the
|
|
MCP client (triggering the upstream OAuth flow) rather than
|
|
masking them as "connected, no tools".
|
|
"""
|
|
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"
|
|
)
|
|
|
|
if raise_on_error:
|
|
raise
|
|
# 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(
|
|
f"MCP client tool call timed out after {self.timeout}s for {self.server_url}"
|
|
)
|
|
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
|