"""
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
