"""MCP client adapters. The Agent only needs a synchronous `call_tool(name, arguments)` surface. This module adapts simple callables or SDK-like sessions to that surface without forcing the rest of the codebase to import a concrete MCP SDK. """ from __future__ import annotations import json from collections.abc import Callable from dataclasses import dataclass, field from pathlib import Path from typing import Any @dataclass(frozen=True) class McpClientConfig: """Configuration needed after a real MCP session has been created.""" server_name: str = "pam-node" tool_names: dict[str, str] = field(default_factory=dict) @classmethod def from_mapping(cls, payload: dict[str, Any]) -> "McpClientConfig": tool_names = payload.get("tool_names") or payload.get("tools") or {} if not isinstance(tool_names, dict): raise ValueError("MCP tool_names must be an object") return cls( server_name=str(payload.get("server_name", "pam-node")), tool_names={str(key): str(value) for key, value in tool_names.items()}, ) def load_mcp_client_config(path: str | Path) -> McpClientConfig: payload = json.loads(Path(path).read_text(encoding="utf-8")) if not isinstance(payload, dict): raise ValueError("MCP client config must be a JSON object") return McpClientConfig.from_mapping(payload) class FunctionMcpToolClient: """Wrap a plain Python callable as an MCP tool client.""" def __init__(self, caller: Callable[[str, dict[str, Any]], Any]) -> None: self.caller = caller def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: return self.caller(tool_name, arguments) class SessionMcpToolClient: """Adapt SDK-like sessions exposing `call_tool`. The adapter accepts common result shapes: - raw dict/list/string - object with `structuredContent` - object with `content`, where text content may contain JSON """ def __init__(self, session: Any) -> None: if not hasattr(session, "call_tool"): raise TypeError("MCP session must expose call_tool") self.session = session def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: result = self.session.call_tool(tool_name, arguments) return normalize_mcp_sdk_result(result) def normalize_mcp_sdk_result(result: Any) -> Any: if hasattr(result, "structuredContent"): structured = getattr(result, "structuredContent") if structured is not None: return structured if hasattr(result, "content"): content = getattr(result, "content") text_parts: list[str] = [] for item in content or []: text = getattr(item, "text", None) if text is not None: text_parts.append(text) if text_parts: joined = "\n".join(text_parts) try: return json.loads(joined) except json.JSONDecodeError: return joined return result