From 8fd29c5932263a00461d18ed45fabc6f9f7772ad Mon Sep 17 00:00:00 2001 From: Stepan Filonov Date: Mon, 2 Feb 2026 03:52:02 +0800 Subject: [PATCH 1/8] works, but bad OOP, bad code --- mcphero/adapters/base_adapter.py | 386 ++++++++++++++++++++++--------- mcphero/adapters/openai.py | 221 ++++++------------ 2 files changed, 342 insertions(+), 265 deletions(-) diff --git a/mcphero/adapters/base_adapter.py b/mcphero/adapters/base_adapter.py index ec2009d..c9fb4e3 100644 --- a/mcphero/adapters/base_adapter.py +++ b/mcphero/adapters/base_adapter.py @@ -1,8 +1,16 @@ +# mcphero/adapters/base_adapter.py +""" +Base MCP adapter supporting 1-N servers. +""" + from __future__ import annotations +import asyncio import json import uuid +from dataclasses import dataclass, field from enum import Enum +from typing import Any, TypedDict import httpx @@ -11,82 +19,128 @@ PROTOCOL_VERSION = "2025-06-18" +class JsonRpcRequest(TypedDict): + id: str + jsonrpc: str + method: str + params: dict[str, Any] + + +class JsonRpcNotification(TypedDict): + jsonrpc: str + method: str + + +class _JsonRpcResponseRequired(TypedDict): + jsonrpc: str + id: str + + +class JsonRpcResponse(_JsonRpcResponseRequired, total=False): + result: dict[str, Any] + error: dict[str, Any] + + +class _RawMCPToolRequired(TypedDict): + name: str + + +class RawMCPTool(_RawMCPToolRequired, total=False): + description: str + inputSchema: dict[str, Any] + + class InitMode(Enum): auto = "auto" on_fail = "on_fail" none = "none" -class BaseAdapter: - """Async HTTP adapter for communicating with an MCP server over Streamable HTTP. +@dataclass +class MCPServerConfig: + """ + Configuration for a single MCP server. + It's important to specify AUTH headers as most MCP servers force auth nowadays. + """ - Handles the MCP session lifecycle (initialize + notifications/initialized) - and provides methods for listing and calling tools via JSON-RPC. + url: str + name: str | None = None + timeout: float = 30.0 + headers: dict[str, str] | None = None + init_mode: InitMode | str = InitMode.auto + tool_prefix: str | None = None - Args: - base_url: Root URL of the MCP server endpoint. - timeout: HTTP request timeout in seconds. - headers: Extra headers merged into every request. - init_mode: Controls when the MCP session is initialized. - - "auto" -- initialize before the first request (default). - - "on_fail" -- skip init upfront; if a request fails with an - HTTP error and the session hasn't been initialized yet, - initialize and retry the request once. - - "none" -- never auto-initialize; errors propagate as-is. - Accepts an :class:`InitMode` enum member or its string value. + def __post_init__(self): + if self.name is None: + self.name = self.url.rstrip("/").split("/")[-1] or "server" + if isinstance(self.init_mode, str): + self.init_mode = InitMode(self.init_mode) + + +@dataclass +class MCPToolDefinition: + """A discovered MCP tool with routing information.""" + + name: str # The name exposed to the LLM (possibly prefixed) + original_name: str # The original name on the MCP server + server_name: str # Which server this tool belongs to + description: str + input_schema: dict[str, Any] + + # Keep reference to raw tool if needed + raw: RawMCPTool = field(repr=False) + + +@dataclass +class ToolMapping: + """Internal mapping for routing tool calls.""" + + server_name: str + original_name: str + prefixed_name: str + connection: MCPConnection + + +class MCPConnection: + """ + Single MCP server connection handler. + Handles the entire lifecycle from initialization to independent requests. """ - def __init__( - self, - base_url: str, - *, - timeout: float = 30.0, - headers: dict | None = None, - init_mode: InitMode | str = InitMode.auto, - ): - self.base_url = base_url.rstrip("/") - self.timeout = timeout - self.headers = headers or {} - self.init_mode = ( - InitMode(init_mode) if isinstance(init_mode, str) else init_mode - ) + def __init__(self, config: MCPServerConfig): + self.config = config self._session_id: str | None = None - self._initialize_result: dict | None = None + self._initialize_result: JsonRpcResponse | None = None self._protocol_version: str | None = None @staticmethod - def _parse_response(response: httpx.Response) -> dict: - """Parse an HTTP response as JSON or SSE based on Content-Type.""" + def _parse_response(response: httpx.Response) -> JsonRpcResponse: content_type = response.headers.get("content-type", "") if "text/event-stream" in content_type: - return BaseAdapter._parse_sse_response(response.text) + return MCPConnection._parse_sse_response(response.text) return response.json() @staticmethod - def _parse_sse_response(text: str) -> dict: - """Extract the last JSON-RPC message from an SSE stream.""" + def _parse_sse_response(text: str) -> JsonRpcResponse: data_lines: list[str] = [] result = None for line in text.splitlines(): if line.startswith("data:"): data_lines.append(line[5:].lstrip(" ")) elif line == "": - # blank line = end of event if data_lines: result = json.loads("\n".join(data_lines)) data_lines = [] - # handle stream that doesn't end with a trailing blank line if data_lines: result = json.loads("\n".join(data_lines)) if result is None: raise ValueError("No data field found in SSE response") return result - async def initialize(self) -> dict: + async def initialize(self) -> JsonRpcResponse: if self._initialize_result is not None: return self._initialize_result - # Step 1: Send initialize JSON-RPC request init_payload = { "id": str(uuid.uuid4()), "jsonrpc": "2.0", @@ -94,110 +148,224 @@ async def initialize(self) -> dict: "params": { "protocolVersion": PROTOCOL_VERSION, "capabilities": {}, - "clientInfo": { - "name": "mcphero", - "version": __version__, - }, + "clientInfo": {"name": "mcphero", "version": __version__}, }, } - headers = { - "Accept": "application/json, text/event-stream", - **self.headers, - } + headers = {"Accept": "application/json, text/event-stream", **(self.config.headers or {})} - async with httpx.AsyncClient( - timeout=self.timeout, - headers=headers, - follow_redirects=True, - ) as client: - response = await client.post(self.base_url, json=init_payload) + async with httpx.AsyncClient(timeout=self.config.timeout, headers=headers, follow_redirects=True) as client: + response = await client.post(self.config.url, json=init_payload) response.raise_for_status() - session_id = response.headers.get("Mcp-Session-Id") - if session_id: + if session_id := response.headers.get("Mcp-Session-Id"): self._session_id = session_id result = self._parse_response(response) - self._protocol_version = result.get("result", {}).get( - "protocolVersion", PROTOCOL_VERSION - ) + self._protocol_version = result.get("result", {}).get("protocolVersion", PROTOCOL_VERSION) self._initialize_result = result - # Step 2: Send notifications/initialized notification - notification = { - "jsonrpc": "2.0", - "method": "notifications/initialized", - } - + notification: JsonRpcNotification = {"jsonrpc": "2.0", "method": "notifications/initialized"} notify_headers = dict(headers) if self._session_id: notify_headers["Mcp-Session-Id"] = self._session_id - - await client.post(self.base_url, json=notification, headers=notify_headers) + await client.post(self.config.url, json=notification, headers=notify_headers) return result async def _ensure_initialized(self) -> None: - if self.init_mode == InitMode.auto: + if self.config.init_mode == InitMode.auto: await self.initialize() - async def _make_request( - self, - data: dict, - *, - _retry: bool = True, - ) -> dict: - headers = { - "Accept": "application/json, text/event-stream", - **self.headers, - } + async def _make_request(self, data: JsonRpcRequest, *, _retry: bool = True) -> JsonRpcResponse: + headers = {"Accept": "application/json, text/event-stream", **(self.config.headers or {})} if self._session_id: headers["Mcp-Session-Id"] = self._session_id if self._protocol_version: headers["MCP-Protocol-Version"] = self._protocol_version - async with httpx.AsyncClient( - timeout=self.timeout, - headers=headers, - follow_redirects=True, - ) as client: - response = await client.post(self.base_url, json=data) - + async with httpx.AsyncClient(timeout=self.config.timeout, headers=headers, follow_redirects=True) as client: + response = await client.post(self.config.url, json=data) try: response.raise_for_status() except httpx.HTTPStatusError: - if ( - _retry - and self.init_mode == InitMode.on_fail - and self._initialize_result is None - ): + if _retry and self.config.init_mode == InitMode.on_fail and self._initialize_result is None: await self.initialize() return await self._make_request(data, _retry=False) raise return self._parse_response(response) - async def get_mcp_tools(self) -> dict: + async def get_tools(self) -> list[RawMCPTool]: await self._ensure_initialized() - return await self._make_request( - { - "id": str(uuid.uuid4()), - "jsonrpc": "2.0", - "method": "tools/list", - "params": {}, - } - ) + result = await self._make_request({ + "id": str(uuid.uuid4()), + "jsonrpc": "2.0", + "method": "tools/list", + "params": {}, + }) + return result.get("result", {}).get("tools", []) - async def call_mcp_tool(self, tool_name: str, arguments: dict) -> dict: + async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> JsonRpcResponse: await self._ensure_initialized() - return await self._make_request( - { - "id": str(uuid.uuid4()), - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": tool_name, - "arguments": arguments, - }, + return await self._make_request({ + "id": str(uuid.uuid4()), + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + }) + + +class BaseAdapter: + """ + Base MCP adapter supporting one or many servers. + + Args: + servers: Single URL/config or list of URLs/configs. + auto_prefix_on_collision: Auto-prefix tools when names collide across servers. + prefix_separator: Separator for prefixed names (default: "__"). + """ + + def __init__( + self, + servers: str | MCPServerConfig | list[str | MCPServerConfig], + *, + auto_prefix_on_collision: bool = True, + prefix_separator: str = "__", + ): + if isinstance(servers, (str, MCPServerConfig)): + servers = [servers] + + self._configs: list[MCPServerConfig] = [ + MCPServerConfig(url=s) if isinstance(s, str) else s + for s in servers + ] + self.auto_prefix_on_collision = auto_prefix_on_collision + self.prefix_separator = prefix_separator + + self._connections: dict[str, MCPConnection] = { + cfg.name: MCPConnection(cfg) for cfg in self._configs + } + self._tool_map: dict[str, ToolMapping] = {} + self._tools: list[MCPToolDefinition] = [] + self._discovered = False + + @property + def is_multi_server(self) -> bool: + return len(self._configs) > 1 + + @property + def tools(self) -> list[MCPToolDefinition]: + """Discovered tools. Call discover_tools() first.""" + if not self._discovered: + raise RuntimeError("Must call discover_tools() first") + return self._tools + + def _make_prefixed_name(self, prefix: str | None, tool_name: str) -> str: + if prefix: + return f"{prefix}{self.prefix_separator}{tool_name}" + return tool_name + + def _resolve_tools(self, tools_by_server: dict[str, list[RawMCPTool]]) -> list[MCPToolDefinition]: + """Resolve naming collisions and build typed tool definitions.""" + # Find collisions + tool_sources: dict[str, list[str]] = {} + for server_name, tools in tools_by_server.items(): + config = next(c for c in self._configs if c.name == server_name) + for tool in tools: + name = self._make_prefixed_name(config.tool_prefix, tool["name"]) + tool_sources.setdefault(name, []).append(server_name) + + collisions = {name for name, sources in tool_sources.items() if len(sources) > 1} + + # Build definitions + definitions: list[MCPToolDefinition] = [] + self._tool_map.clear() + + for server_name, tools in tools_by_server.items(): + config = next(c for c in self._configs if c.name == server_name) + conn = self._connections[server_name] + + for tool in tools: + base_name = self._make_prefixed_name(config.tool_prefix, tool["name"]) + + if base_name in collisions and self.auto_prefix_on_collision: + final_name = f"{server_name}{self.prefix_separator}{tool['name']}" + else: + final_name = base_name + + definition = MCPToolDefinition( + name=final_name, + original_name=tool["name"], + server_name=server_name, + description=tool.get("description", ""), + input_schema=tool.get("inputSchema", {"type": "object", "properties": {}}), + raw=tool, + ) + definitions.append(definition) + + self._tool_map[final_name] = ToolMapping( + server_name=server_name, + original_name=tool["name"], + prefixed_name=final_name, + connection=conn, + ) + + return definitions + + async def discover_tools(self, *, parallel: bool = True) -> list[MCPToolDefinition]: + """ + Discover tools from all servers. + + Returns: + List of typed tool definitions. + """ + if parallel and self.is_multi_server: + async def fetch_one(name: str) -> tuple[str, list[RawMCPTool]]: + return name, await self._connections[name].get_tools() + + results = await asyncio.gather( + *[fetch_one(name) for name in self._connections], + return_exceptions=True, + ) + tools_by_server = { + name: tools for result in results + if not isinstance(result, Exception) + for name, tools in [result] } - ) + else: + tools_by_server = {} + for name, conn in self._connections.items(): + try: + tools_by_server[name] = await conn.get_tools() + except Exception: + continue + + self._tools = self._resolve_tools(tools_by_server) + self._discovered = True + return self._tools + + async def call_tool(self, name: str, arguments: dict[str, Any]) -> JsonRpcResponse: + """Call a tool by its name (as exposed to the LLM).""" + if not self._discovered: + await self.discover_tools() + + mapping = self._tool_map.get(name) + if mapping is None: + raise KeyError(f"Unknown tool: {name}") + + return await mapping.connection.call_tool(mapping.original_name, arguments) + + async def initialize_all(self, *, parallel: bool = True) -> dict[str, JsonRpcResponse | Exception]: + """Pre-initialize all connections.""" + + async def init_one(name: str) -> tuple[str, JsonRpcResponse | Exception]: + try: + return name, await self._connections[name].initialize() + except Exception as e: + return name, e + + if parallel: + results = await asyncio.gather(*[init_one(n) for n in self._connections]) + return dict(results) + else: + return {name: (await init_one(name))[1] for name in self._connections} diff --git a/mcphero/adapters/openai.py b/mcphero/adapters/openai.py index b3f3720..4088588 100644 --- a/mcphero/adapters/openai.py +++ b/mcphero/adapters/openai.py @@ -1,12 +1,9 @@ -""" -MCP Tool Adapter for OpenAI. - -This adapter converts remote MCP server tools to OpenAI-compatible tool definitions -and processes OpenAI's tool calls to HTTP requests to the MCP server. -""" +# mcphero/adapters/openai.py +"""MCP Tool Adapter for OpenAI.""" from __future__ import annotations +import asyncio import json import httpx @@ -16,180 +13,92 @@ ChatCompletionToolParam, ) -from mcphero.adapters.base_adapter import BaseAdapter +from mcphero.adapters.base_adapter import BaseAdapter, MCPToolDefinition class MCPToolAdapterOpenAI(BaseAdapter): """ - Adapter that converts remote MCP server tools to OpenAI-compatible tool definitions. - Also converts OpenAI's tool_calls to HTTP requests to the MCP server. - + Adapter for OpenAI. Supports single or multiple MCP servers. + Usage: - - from openai import OpenAI - from mcphero import MCPToolAdapterOpenAI - - adapter = MCPToolAdapterOpenAI("https://api.mcphero.app/mcp/your-server-id") - client = OpenAI() - - # Get tool definitions + adapter = MCPToolAdapterOpenAI("https://mcp.example.com/server") + # or + adapter = MCPToolAdapterOpenAI([ + MCPServerConfig(url="https://mcp.example.com/weather", name="weather"), + MCPServerConfig(url="https://mcp.example.com/calendar", name="calendar"), + ]) + tools = await adapter.get_tool_definitions() - - # Make request with tools - response = client.chat.completions.create( - model="gpt-4o", - messages=[{"role": "user", "content": "What's the weather?"}], - tools=tools, - ) - - # Process tool calls if present + response = client.chat.completions.create(model="gpt-4o", messages=messages, tools=tools) + if response.choices[0].message.tool_calls: - tool_results = await adapter.process_tool_calls( - response.choices[0].message.tool_calls - ) - - # Continue conversation with results - messages = [ - {"role": "user", "content": "What's the weather?"}, - response.choices[0].message, - *tool_results, - ] - final_response = client.chat.completions.create( - model="gpt-4o", - messages=messages, - tools=tools, - ) + results = await adapter.process_tool_calls(response.choices[0].message.tool_calls) """ - async def get_tool_definitions(self) -> list[ChatCompletionToolParam]: - """ - Fetch tools from MCP server and convert them to OpenAI tool schemas. - - Returns: - List of tool definitions compatible with OpenAI's `tools` parameter. - - Example: - tools = await adapter.get_tool_definitions() - - response = client.chat.completions.create( - model="gpt-4o", - messages=messages, - tools=tools, - ) - """ - mcp_tools = await self.get_mcp_tools() - - openai_tools: list[ChatCompletionToolParam] = [] - for tool in mcp_tools["result"]["tools"]: - openai_tools.append( - { - "type": "function", - "function": { - "name": tool["name"], - "description": tool.get("description", ""), - "parameters": tool.get( - "inputSchema", - { - "type": "object", - "properties": {}, - }, - ), - }, - } - ) - - return openai_tools + @staticmethod + def _to_openai_tool(tool: MCPToolDefinition) -> ChatCompletionToolParam: + """Convert MCPToolDefinition to OpenAI format.""" + return { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.input_schema, + }, + } + + async def get_tool_definitions(self, *, parallel: bool = True) -> list[ChatCompletionToolParam]: + """Fetch tools and return OpenAI-compatible definitions.""" + tools = await self.discover_tools(parallel=parallel) + return [self._to_openai_tool(t) for t in tools] async def process_tool_calls( self, tool_calls: list[ChatCompletionMessageToolCall], + *, return_errors: bool = True, + parallel: bool = True, ) -> list[ChatCompletionToolMessageParam]: """ - Process OpenAI's tool_calls by invoking the tools via HTTP. - - Args: - tool_calls: List of tool calls from `response.choices[0].message.tool_calls`. - return_errors: If True, include error messages for failed calls. - If False, failed calls are omitted from results. - - Returns: - List of tool message dicts compatible with OpenAI's messages format. - Each result has `role="tool"`, `tool_call_id`, and `content`. - - Example: - response = client.chat.completions.create( - model="gpt-4o", - messages=messages, - tools=tools, - ) - - if response.choices[0].message.tool_calls: - tool_results = await adapter.process_tool_calls( - response.choices[0].message.tool_calls - ) - - # Add to conversation - messages.append(response.choices[0].message) - messages.extend(tool_results) + Process OpenAI tool calls, routing to appropriate servers. """ if not tool_calls: return [] - results: list[ChatCompletionToolMessageParam] = [] - - for tool_call in tool_calls: - tool_name = tool_call.function.name + async def execute_one(tc: ChatCompletionMessageToolCall) -> ChatCompletionToolMessageParam | None: try: - arguments = json.loads(tool_call.function.arguments) + arguments = json.loads(tc.function.arguments) except json.JSONDecodeError: if return_errors: - results.append( - { - "tool_call_id": tool_call.id, - "role": "tool", - "content": json.dumps( - {"error": "Failed to parse tool arguments"} - ), - } - ) - continue - - try: - result = await self.call_mcp_tool(tool_name, arguments) - - results.append( - { - "tool_call_id": tool_call.id, + result: ChatCompletionToolMessageParam = { + "tool_call_id": tc.id, "role": "tool", - "content": json.dumps(result) - if not isinstance(result, str) - else result, + "content": json.dumps({"error": "Failed to parse arguments"}), } - ) - - except httpx.HTTPError as e: - if return_errors: - results.append( - { - "tool_call_id": tool_call.id, - "role": "tool", - "content": json.dumps( - {"error": f"HTTP error calling tool: {str(e)}"} - ), - } - ) + return result + return None - except Exception as e: + try: + response = await self.call_tool(tc.function.name, arguments) + content = json.dumps(response) if not isinstance(response, str) else response + result: ChatCompletionToolMessageParam = { + "tool_call_id": tc.id, + "role": "tool", + "content": content, + } + return result + except (KeyError, httpx.HTTPError, Exception) as e: if return_errors: - results.append( - { - "tool_call_id": tool_call.id, - "role": "tool", - "content": json.dumps( - {"error": f"Unexpected error: {str(e)}"} - ), - } - ) - - return results + result: ChatCompletionToolMessageParam = { + "tool_call_id": tc.id, + "role": "tool", + "content": json.dumps({"error": str(e)}), + } + return result + return None + + if parallel: + results = await asyncio.gather(*[execute_one(tc) for tc in tool_calls], return_exceptions=True) + return [r for r in results if r is not None and not isinstance(r, BaseException)] + else: + return [r for tc in tool_calls if (r := await execute_one(tc)) is not None] From c8507e27787cc82288be8b3ef7e8a161d7cf4a31 Mon Sep 17 00:00:00 2001 From: Stepan Filonov Date: Mon, 2 Feb 2026 04:03:00 +0800 Subject: [PATCH 2/8] use generics --- mcphero/adapters/base_adapter.py | 166 +++++++++++++++++++++------- mcphero/adapters/gemini.py | 181 +++++++++++++++---------------- mcphero/adapters/openai.py | 90 +++++++-------- 3 files changed, 253 insertions(+), 184 deletions(-) diff --git a/mcphero/adapters/base_adapter.py b/mcphero/adapters/base_adapter.py index c9fb4e3..86bca1c 100644 --- a/mcphero/adapters/base_adapter.py +++ b/mcphero/adapters/base_adapter.py @@ -10,7 +10,7 @@ import uuid from dataclasses import dataclass, field from enum import Enum -from typing import Any, TypedDict +from typing import Any, Generic, TypedDict, TypeVar import httpx @@ -152,9 +152,14 @@ async def initialize(self) -> JsonRpcResponse: }, } - headers = {"Accept": "application/json, text/event-stream", **(self.config.headers or {})} + headers = { + "Accept": "application/json, text/event-stream", + **(self.config.headers or {}), + } - async with httpx.AsyncClient(timeout=self.config.timeout, headers=headers, follow_redirects=True) as client: + async with httpx.AsyncClient( + timeout=self.config.timeout, headers=headers, follow_redirects=True + ) as client: response = await client.post(self.config.url, json=init_payload) response.raise_for_status() @@ -162,14 +167,21 @@ async def initialize(self) -> JsonRpcResponse: self._session_id = session_id result = self._parse_response(response) - self._protocol_version = result.get("result", {}).get("protocolVersion", PROTOCOL_VERSION) + self._protocol_version = result.get("result", {}).get( + "protocolVersion", PROTOCOL_VERSION + ) self._initialize_result = result - notification: JsonRpcNotification = {"jsonrpc": "2.0", "method": "notifications/initialized"} + notification: JsonRpcNotification = { + "jsonrpc": "2.0", + "method": "notifications/initialized", + } notify_headers = dict(headers) if self._session_id: notify_headers["Mcp-Session-Id"] = self._session_id - await client.post(self.config.url, json=notification, headers=notify_headers) + await client.post( + self.config.url, json=notification, headers=notify_headers + ) return result @@ -177,19 +189,30 @@ async def _ensure_initialized(self) -> None: if self.config.init_mode == InitMode.auto: await self.initialize() - async def _make_request(self, data: JsonRpcRequest, *, _retry: bool = True) -> JsonRpcResponse: - headers = {"Accept": "application/json, text/event-stream", **(self.config.headers or {})} + async def _make_request( + self, data: JsonRpcRequest, *, _retry: bool = True + ) -> JsonRpcResponse: + headers = { + "Accept": "application/json, text/event-stream", + **(self.config.headers or {}), + } if self._session_id: headers["Mcp-Session-Id"] = self._session_id if self._protocol_version: headers["MCP-Protocol-Version"] = self._protocol_version - async with httpx.AsyncClient(timeout=self.config.timeout, headers=headers, follow_redirects=True) as client: + async with httpx.AsyncClient( + timeout=self.config.timeout, headers=headers, follow_redirects=True + ) as client: response = await client.post(self.config.url, json=data) try: response.raise_for_status() except httpx.HTTPStatusError: - if _retry and self.config.init_mode == InitMode.on_fail and self._initialize_result is None: + if ( + _retry + and self.config.init_mode == InitMode.on_fail + and self._initialize_result is None + ): await self.initialize() return await self._make_request(data, _retry=False) raise @@ -197,25 +220,35 @@ async def _make_request(self, data: JsonRpcRequest, *, _retry: bool = True) -> J async def get_tools(self) -> list[RawMCPTool]: await self._ensure_initialized() - result = await self._make_request({ - "id": str(uuid.uuid4()), - "jsonrpc": "2.0", - "method": "tools/list", - "params": {}, - }) + result = await self._make_request( + { + "id": str(uuid.uuid4()), + "jsonrpc": "2.0", + "method": "tools/list", + "params": {}, + } + ) return result.get("result", {}).get("tools", []) - async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> JsonRpcResponse: + async def call_tool( + self, tool_name: str, arguments: dict[str, Any] + ) -> JsonRpcResponse: await self._ensure_initialized() - return await self._make_request({ - "id": str(uuid.uuid4()), - "jsonrpc": "2.0", - "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments}, - }) + return await self._make_request( + { + "id": str(uuid.uuid4()), + "jsonrpc": "2.0", + "method": "tools/call", + "params": {"name": tool_name, "arguments": arguments}, + } + ) + +ToolCallT = TypeVar("ToolCallT") +ResultT = TypeVar("ResultT") -class BaseAdapter: + +class BaseAdapter(Generic[ToolCallT, ResultT]): """ Base MCP adapter supporting one or many servers. @@ -236,15 +269,15 @@ def __init__( servers = [servers] self._configs: list[MCPServerConfig] = [ - MCPServerConfig(url=s) if isinstance(s, str) else s - for s in servers + MCPServerConfig(url=s) if isinstance(s, str) else s for s in servers ] self.auto_prefix_on_collision = auto_prefix_on_collision self.prefix_separator = prefix_separator - self._connections: dict[str, MCPConnection] = { - cfg.name: MCPConnection(cfg) for cfg in self._configs - } + self._connections: dict[str, MCPConnection] = {} + for cfg in self._configs: + assert cfg.name is not None + self._connections[cfg.name] = MCPConnection(cfg) self._tool_map: dict[str, ToolMapping] = {} self._tools: list[MCPToolDefinition] = [] self._discovered = False @@ -265,7 +298,9 @@ def _make_prefixed_name(self, prefix: str | None, tool_name: str) -> str: return f"{prefix}{self.prefix_separator}{tool_name}" return tool_name - def _resolve_tools(self, tools_by_server: dict[str, list[RawMCPTool]]) -> list[MCPToolDefinition]: + def _resolve_tools( + self, tools_by_server: dict[str, list[RawMCPTool]] + ) -> list[MCPToolDefinition]: """Resolve naming collisions and build typed tool definitions.""" # Find collisions tool_sources: dict[str, list[str]] = {} @@ -275,7 +310,9 @@ def _resolve_tools(self, tools_by_server: dict[str, list[RawMCPTool]]) -> list[M name = self._make_prefixed_name(config.tool_prefix, tool["name"]) tool_sources.setdefault(name, []).append(server_name) - collisions = {name for name, sources in tool_sources.items() if len(sources) > 1} + collisions = { + name for name, sources in tool_sources.items() if len(sources) > 1 + } # Build definitions definitions: list[MCPToolDefinition] = [] @@ -298,7 +335,9 @@ def _resolve_tools(self, tools_by_server: dict[str, list[RawMCPTool]]) -> list[M original_name=tool["name"], server_name=server_name, description=tool.get("description", ""), - input_schema=tool.get("inputSchema", {"type": "object", "properties": {}}), + input_schema=tool.get( + "inputSchema", {"type": "object", "properties": {}} + ), raw=tool, ) definitions.append(definition) @@ -320,6 +359,7 @@ async def discover_tools(self, *, parallel: bool = True) -> list[MCPToolDefiniti List of typed tool definitions. """ if parallel and self.is_multi_server: + async def fetch_one(name: str) -> tuple[str, list[RawMCPTool]]: return name, await self._connections[name].get_tools() @@ -327,11 +367,11 @@ async def fetch_one(name: str) -> tuple[str, list[RawMCPTool]]: *[fetch_one(name) for name in self._connections], return_exceptions=True, ) - tools_by_server = { - name: tools for result in results - if not isinstance(result, Exception) - for name, tools in [result] - } + tools_by_server: dict[str, list[RawMCPTool]] = {} + for result in results: + if not isinstance(result, BaseException): + name, tools = result + tools_by_server[name] = tools else: tools_by_server = {} for name, conn in self._connections.items(): @@ -355,7 +395,57 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> JsonRpcRespon return await mapping.connection.call_tool(mapping.original_name, arguments) - async def initialize_all(self, *, parallel: bool = True) -> dict[str, JsonRpcResponse | Exception]: + async def _execute_single_tool_call( + self, tool_call: ToolCallT, *, return_errors: bool + ) -> ResultT | None: + """Execute a single provider-specific tool call. + + Each child adapter must implement this. It receives one provider-specific + tool-call object and returns one provider-specific result (or ``None`` to skip). + """ + raise NotImplementedError + + async def process_tool_calls( + self, + tool_calls: list[ToolCallT], + *, + return_errors: bool = True, + parallel: bool = True, + ) -> list[ResultT]: + """Generic loop: run ``_execute_single_tool_call`` for every item. + + Handles early-return on empty list, parallel/sequential execution, + and filters out ``None`` results. + """ + if not tool_calls: + return [] + + if parallel: + results = await asyncio.gather( + *[ + self._execute_single_tool_call(tc, return_errors=return_errors) + for tc in tool_calls + ], + return_exceptions=True, + ) + return [ + r for r in results if r is not None and not isinstance(r, BaseException) + ] + else: + return [ + r + for tc in tool_calls + if ( + r := await self._execute_single_tool_call( + tc, return_errors=return_errors + ) + ) + is not None + ] + + async def initialize_all( + self, *, parallel: bool = True + ) -> dict[str, JsonRpcResponse | Exception]: """Pre-initialize all connections.""" async def init_one(name: str) -> tuple[str, JsonRpcResponse | Exception]: diff --git a/mcphero/adapters/gemini.py b/mcphero/adapters/gemini.py index 920abbf..7241e76 100644 --- a/mcphero/adapters/gemini.py +++ b/mcphero/adapters/gemini.py @@ -19,10 +19,10 @@ import httpx -from mcphero.adapters.base_adapter import BaseAdapter +from mcphero.adapters.base_adapter import BaseAdapter, MCPToolDefinition -class MCPToolAdapterGemini(BaseAdapter): +class MCPToolAdapterGemini(BaseAdapter[types.FunctionCall, types.Content]): """ Adapter that converts remote MCP server tools to Gemini-compatible tool definitions. Also converts Gemini's function calls to HTTP requests to the MCP server. @@ -59,44 +59,102 @@ class MCPToolAdapterGemini(BaseAdapter): # Add results to conversation and continue """ - async def get_function_declarations(self) -> list[types.FunctionDeclaration]: + @staticmethod + def _to_gemini_declaration(tool: MCPToolDefinition) -> types.FunctionDeclaration: + """Convert MCPToolDefinition to Gemini FunctionDeclaration.""" + return types.FunctionDeclaration( + name=tool.name, + description=tool.description, + parameters=tool.input_schema, + ) + + async def get_function_declarations( + self, *, parallel: bool = True + ) -> list[types.FunctionDeclaration]: """ - Fetch tools from MCP server and convert them to Gemini FunctionDeclaration objects. + Fetch tools from MCP servers and convert them to Gemini FunctionDeclaration objects. Returns: List of FunctionDeclaration objects. """ - mcp_tools = await self.get_mcp_tools() - - declarations: list[types.FunctionDeclaration] = [] - for tool in mcp_tools["result"]["tools"]: - declaration = types.FunctionDeclaration( - name=tool["name"], - description=tool.get("description", ""), - parameters=tool.get( - "inputSchema", - { - "type": "object", - "properties": {}, - }, - ), - ) - declarations.append(declaration) + tools = await self.discover_tools(parallel=parallel) + return [self._to_gemini_declaration(t) for t in tools] - return declarations - - async def get_tool(self) -> types.Tool: + async def get_tool(self, *, parallel: bool = True) -> types.Tool: """ - Fetch tools from MCP server and return as a Gemini Tool object. + Fetch tools from MCP servers and return as a Gemini Tool object. This returns a Tool that can be passed directly to GenerateContentConfig. Returns: A Tool object containing all function declarations. """ - declarations = await self.get_function_declarations() + declarations = await self.get_function_declarations(parallel=parallel) return types.Tool(function_declarations=declarations) + async def _execute_single_tool_call( + self, tool_call: types.FunctionCall, *, return_errors: bool + ) -> types.Content | None: + fc = tool_call + tool_name = fc.name + arguments = fc.args if fc.args else {} + call_id = getattr(fc, "id", None) + + try: + result = await self.call_tool(tool_name, arguments) + + function_response = types.FunctionResponse( + name=tool_name, + response={"result": result} if not isinstance(result, dict) else result, + id=call_id, + ) + + return types.Content( + role="user", + parts=[ + types.Part.from_function_response( + name=function_response.name, + response=function_response.response, + ) + ], + ) + + except httpx.HTTPError as e: + if return_errors: + function_response = types.FunctionResponse( + name=tool_name, + response={"error": f"HTTP error calling tool: {str(e)}"}, + id=call_id, + ) + return types.Content( + role="user", + parts=[ + types.Part.from_function_response( + name=function_response.name, + response=function_response.response, + ) + ], + ) + return None + + except Exception as e: + if return_errors: + function_response = types.FunctionResponse( + name=tool_name, + response={"error": f"Unexpected error: {str(e)}"}, + id=call_id, + ) + return types.Content( + role="user", + parts=[ + types.Part.from_function_response( + name=function_response.name, + response=function_response.response, + ) + ], + ) + return None + async def process_function_calls( self, function_calls: list[types.FunctionCall], @@ -121,74 +179,9 @@ async def process_function_calls( contents.append(response.candidates[0].content) # Model's function call contents.extend(results) # Function responses """ - if not function_calls: - return [] - results: list[types.Content] = [] - - for fc in function_calls: - tool_name = fc.name - arguments = fc.args if fc.args else {} - call_id = getattr(fc, "id", None) - - try: - result = await self.call_mcp_tool(tool_name, arguments) - - function_response = types.FunctionResponse( - name=tool_name, - response={"result": result} - if not isinstance(result, dict) - else result, - id=call_id, - ) - - content = types.Content( - role="user", - parts=[ - types.Part.from_function_response( - name=function_response.name, - response=function_response.response, - ) - ], - ) - results.append(content) - - except httpx.HTTPError as e: - if return_errors: - function_response = types.FunctionResponse( - name=tool_name, - response={"error": f"HTTP error calling tool: {str(e)}"}, - id=call_id, - ) - content = types.Content( - role="user", - parts=[ - types.Part.from_function_response( - name=function_response.name, - response=function_response.response, - ) - ], - ) - results.append(content) - - except Exception as e: - if return_errors: - function_response = types.FunctionResponse( - name=tool_name, - response={"error": f"Unexpected error: {str(e)}"}, - id=call_id, - ) - content = types.Content( - role="user", - parts=[ - types.Part.from_function_response( - name=function_response.name, - response=function_response.response, - ) - ], - ) - results.append(content) - - return results + return await self.process_tool_calls( + function_calls, return_errors=return_errors, parallel=False + ) async def process_function_calls_as_parts( self, @@ -223,7 +216,7 @@ async def process_function_calls_as_parts( arguments = fc.args if fc.args else {} try: - result = await self.call_mcp_tool(tool_name, arguments) + result = await self.call_tool(tool_name, arguments) part = types.Part.from_function_response( name=tool_name, diff --git a/mcphero/adapters/openai.py b/mcphero/adapters/openai.py index 4088588..f96fdab 100644 --- a/mcphero/adapters/openai.py +++ b/mcphero/adapters/openai.py @@ -3,7 +3,6 @@ from __future__ import annotations -import asyncio import json import httpx @@ -16,10 +15,12 @@ from mcphero.adapters.base_adapter import BaseAdapter, MCPToolDefinition -class MCPToolAdapterOpenAI(BaseAdapter): +class MCPToolAdapterOpenAI( + BaseAdapter[ChatCompletionMessageToolCall, ChatCompletionToolMessageParam] +): """ Adapter for OpenAI. Supports single or multiple MCP servers. - + Usage: adapter = MCPToolAdapterOpenAI("https://mcp.example.com/server") # or @@ -27,10 +28,10 @@ class MCPToolAdapterOpenAI(BaseAdapter): MCPServerConfig(url="https://mcp.example.com/weather", name="weather"), MCPServerConfig(url="https://mcp.example.com/calendar", name="calendar"), ]) - + tools = await adapter.get_tool_definitions() response = client.chat.completions.create(model="gpt-4o", messages=messages, tools=tools) - + if response.choices[0].message.tool_calls: results = await adapter.process_tool_calls(response.choices[0].message.tool_calls) """ @@ -47,58 +48,43 @@ def _to_openai_tool(tool: MCPToolDefinition) -> ChatCompletionToolParam: }, } - async def get_tool_definitions(self, *, parallel: bool = True) -> list[ChatCompletionToolParam]: + async def get_tool_definitions( + self, *, parallel: bool = True + ) -> list[ChatCompletionToolParam]: """Fetch tools and return OpenAI-compatible definitions.""" tools = await self.discover_tools(parallel=parallel) return [self._to_openai_tool(t) for t in tools] - async def process_tool_calls( - self, - tool_calls: list[ChatCompletionMessageToolCall], - *, - return_errors: bool = True, - parallel: bool = True, - ) -> list[ChatCompletionToolMessageParam]: - """ - Process OpenAI tool calls, routing to appropriate servers. - """ - if not tool_calls: - return [] - - async def execute_one(tc: ChatCompletionMessageToolCall) -> ChatCompletionToolMessageParam | None: - try: - arguments = json.loads(tc.function.arguments) - except json.JSONDecodeError: - if return_errors: - result: ChatCompletionToolMessageParam = { - "tool_call_id": tc.id, - "role": "tool", - "content": json.dumps({"error": "Failed to parse arguments"}), - } - return result - return None - - try: - response = await self.call_tool(tc.function.name, arguments) - content = json.dumps(response) if not isinstance(response, str) else response - result: ChatCompletionToolMessageParam = { + async def _execute_single_tool_call( + self, tool_call: ChatCompletionMessageToolCall, *, return_errors: bool + ) -> ChatCompletionToolMessageParam | None: + tc = tool_call + try: + arguments = json.loads(tc.function.arguments) + except json.JSONDecodeError: + if return_errors: + return { "tool_call_id": tc.id, "role": "tool", - "content": content, + "content": json.dumps({"error": "Failed to parse arguments"}), } - return result - except (KeyError, httpx.HTTPError, Exception) as e: - if return_errors: - result: ChatCompletionToolMessageParam = { - "tool_call_id": tc.id, - "role": "tool", - "content": json.dumps({"error": str(e)}), - } - return result - return None + return None - if parallel: - results = await asyncio.gather(*[execute_one(tc) for tc in tool_calls], return_exceptions=True) - return [r for r in results if r is not None and not isinstance(r, BaseException)] - else: - return [r for tc in tool_calls if (r := await execute_one(tc)) is not None] + try: + response = await self.call_tool(tc.function.name, arguments) + content = ( + json.dumps(response) if not isinstance(response, str) else response + ) + return { + "tool_call_id": tc.id, + "role": "tool", + "content": content, + } + except (KeyError, httpx.HTTPError, Exception) as e: + if return_errors: + return { + "tool_call_id": tc.id, + "role": "tool", + "content": json.dumps({"error": str(e)}), + } + return None From f67c5bd6003977a9b8f8d0e920f15e78de9b5a41 Mon Sep 17 00:00:00 2001 From: Stepan Filonov Date: Mon, 2 Feb 2026 04:38:24 +0800 Subject: [PATCH 3/8] Update tests, changelog and CI --- .github/workflows/ci.yaml | 74 +++ CHANGELOG.md | 7 + mcphero/__init__.py | 2 + pyproject.toml | 2 +- tests/test_base_adapter.py | 956 +++++++++++++++++++++++------------ tests/test_gemini_adapter.py | 86 +++- tests/test_openai_adapter.py | 113 ++++- 7 files changed, 868 insertions(+), 372 deletions(-) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..8052f1f --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,74 @@ +name: CI + +on: + pull_request: + branches: ["*"] + push: + branches: [main, master] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pre-commit: + name: Pre-commit hooks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: pre-commit/action@v3.0.1 + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: uv sync --group lint + - name: Ruff check (mcphero) + run: uv run ruff check mcphero + - name: Ruff format check (mcphero) + run: uv run ruff format mcphero --check + - name: Ruff check (tests) + run: uv run ruff check tests + - name: Ruff format check (tests) + run: uv run ruff format tests --check + + typecheck: + name: Type check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: uv sync --group lint + - name: Pyright + run: uv run pyright mcphero + + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: uv sync --group testing --all-extras + - name: Run tests + run: uv run pytest tests diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e55a03..5e439dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [1.0.0] - 2026-02-02 +### Added +- Support for multiple MCPServers per adapter - with name collision handling and parallel invocation +- Github CI +### Changed +- Fully changed the API. There weren't any production users that we know of, so this breaking change is fine, no LTS for pre-1.0.0 version. +### Fixed ## [0.2.1] - 2026-02-01 diff --git a/mcphero/__init__.py b/mcphero/__init__.py index 313b819..732f8fb 100644 --- a/mcphero/__init__.py +++ b/mcphero/__init__.py @@ -1,7 +1,9 @@ from mcphero.adapters.openai import MCPToolAdapterOpenAI +from mcphero.adapters.base_adapter import MCPServerConfig __all__ = [ "MCPToolAdapterOpenAI", + "MCPServerConfig", "MCPToolAdapterGemini", # pyright: ignore[reportUnsupportedDunderAll] ] diff --git a/pyproject.toml b/pyproject.toml index 206bfce..5ba26d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mcphero" -version = "0.2.1" +version = "1.0.0" description = "Library to use MCP servers natively with AI clients as tools." readme = "README.md" requires-python = ">=3.9" diff --git a/tests/test_base_adapter.py b/tests/test_base_adapter.py index af3794f..da7f570 100644 --- a/tests/test_base_adapter.py +++ b/tests/test_base_adapter.py @@ -5,377 +5,403 @@ import respx from mcphero.__about__ import __version__ -from mcphero.adapters.base_adapter import PROTOCOL_VERSION, BaseAdapter, InitMode +from mcphero.adapters.base_adapter import ( + PROTOCOL_VERSION, + BaseAdapter, + InitMode, + MCPConnection, + MCPServerConfig, +) def _sse_body(payload: dict) -> str: - """Format a dict as an SSE event body.""" return f"event: message\ndata: {json.dumps(payload)}\n\n" -class TestBaseAdapterInit: - def test_stores_base_url(self, base_url): - adapter = BaseAdapter(base_url) - assert adapter.base_url == base_url +def _init_response(version=PROTOCOL_VERSION): + return {"jsonrpc": "2.0", "id": "1", "result": {"protocolVersion": version}} - def test_strips_trailing_slash(self): - adapter = BaseAdapter("https://example.com/") - assert adapter.base_url == "https://example.com" - def test_default_timeout(self, base_url): - adapter = BaseAdapter(base_url) - assert adapter.timeout == 30.0 +def _tools_response(tools): + return {"jsonrpc": "2.0", "id": "1", "result": {"tools": tools}} - def test_custom_timeout(self, base_url): - adapter = BaseAdapter(base_url, timeout=60.0) - assert adapter.timeout == 60.0 - def test_default_headers(self, base_url): - adapter = BaseAdapter(base_url) - assert adapter.headers == {} +# ── MCPServerConfig ────────────────────────────────────────────── - def test_custom_headers(self, base_url): - headers = {"Authorization": "Bearer token"} - adapter = BaseAdapter(base_url, headers=headers) - assert adapter.headers == headers - def test_initial_session_state(self, base_url): - adapter = BaseAdapter(base_url) - assert adapter._session_id is None - assert adapter._initialize_result is None - assert adapter._protocol_version is None +class TestMCPServerConfig: + def test_auto_name_from_url(self): + cfg = MCPServerConfig(url="https://example.com/mcp/weather") + assert cfg.name == "weather" - def test_default_init_mode_is_auto(self, base_url): - adapter = BaseAdapter(base_url) - assert adapter.init_mode == InitMode.auto + def test_auto_name_strips_trailing_slash(self): + cfg = MCPServerConfig(url="https://example.com/mcp/weather/") + assert cfg.name == "weather" + + def test_explicit_name(self): + cfg = MCPServerConfig(url="https://example.com/x", name="my-srv") + assert cfg.name == "my-srv" + + def test_default_timeout(self): + cfg = MCPServerConfig(url="https://example.com/x") + assert cfg.timeout == 30.0 + + def test_custom_timeout(self): + cfg = MCPServerConfig(url="https://example.com/x", timeout=60.0) + assert cfg.timeout == 60.0 + + def test_default_headers(self): + cfg = MCPServerConfig(url="https://example.com/x") + assert cfg.headers is None + + def test_custom_headers(self): + h = {"Authorization": "Bearer token"} + cfg = MCPServerConfig(url="https://example.com/x", headers=h) + assert cfg.headers == h + + def test_default_init_mode(self): + cfg = MCPServerConfig(url="https://example.com/x") + assert cfg.init_mode == InitMode.auto - def test_accepts_string_init_mode(self, base_url): - adapter = BaseAdapter(base_url, init_mode="on_fail") - assert adapter.init_mode == InitMode.on_fail + def test_string_init_mode(self): + cfg = MCPServerConfig(url="https://example.com/x", init_mode="on_fail") + assert cfg.init_mode == InitMode.on_fail - def test_accepts_enum_init_mode(self, base_url): - adapter = BaseAdapter(base_url, init_mode=InitMode.none) - assert adapter.init_mode == InitMode.none + def test_enum_init_mode(self): + cfg = MCPServerConfig(url="https://example.com/x", init_mode=InitMode.none) + assert cfg.init_mode == InitMode.none + def test_default_tool_prefix(self): + cfg = MCPServerConfig(url="https://example.com/x") + assert cfg.tool_prefix is None -class TestInitialize: + def test_custom_tool_prefix(self): + cfg = MCPServerConfig(url="https://example.com/x", tool_prefix="wx") + assert cfg.tool_prefix == "wx" + + +# ── MCPConnection: initialize ──────────────────────────────────── + + +class TestMCPConnectionInitialize: @respx.mock - async def test_sends_initialize_request(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": { - "protocolVersion": PROTOCOL_VERSION, - "capabilities": {}, - "serverInfo": {"name": "test-server", "version": "1.0"}, - }, - } - init_route = respx.post(base_url).mock( + async def test_sends_correct_payload(self, base_url): + route = respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "srv-session-123"}), + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "s1"}, + ), httpx.Response(202), ] ) - adapter = BaseAdapter(base_url) - result = await adapter.initialize() - - assert result == init_result - assert init_route.call_count == 2 + conn = MCPConnection(MCPServerConfig(url=base_url)) + result = await conn.initialize() - # Verify the initialize request payload - first_call = init_route.calls[0] - body = first_call.request.content - import json + assert result == _init_response() + assert route.call_count == 2 - payload = json.loads(body) - assert payload["jsonrpc"] == "2.0" + payload = json.loads(route.calls[0].request.content) assert payload["method"] == "initialize" assert payload["params"]["protocolVersion"] == PROTOCOL_VERSION assert payload["params"]["clientInfo"]["name"] == "mcphero" assert payload["params"]["clientInfo"]["version"] == __version__ - assert payload["params"]["capabilities"] == {} @respx.mock - async def test_extracts_session_id_from_response(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } + async def test_extracts_session_id(self, base_url): respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "my-session-42"}), + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "sess-42"}, + ), httpx.Response(202), ] ) - adapter = BaseAdapter(base_url) - await adapter.initialize() + conn = MCPConnection(MCPServerConfig(url=base_url)) + await conn.initialize() - assert adapter._session_id == "my-session-42" + assert conn._session_id == "sess-42" @respx.mock async def test_sends_initialized_notification(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } route = respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "sess-1"}), + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "s1"}, + ), httpx.Response(202), ] ) - adapter = BaseAdapter(base_url) - await adapter.initialize() - - # Second call should be the notification - import json + conn = MCPConnection(MCPServerConfig(url=base_url)) + await conn.initialize() - second_call = route.calls[1] - payload = json.loads(second_call.request.content) - assert payload["jsonrpc"] == "2.0" - assert payload["method"] == "notifications/initialized" - assert "id" not in payload + notification = json.loads(route.calls[1].request.content) + assert notification["method"] == "notifications/initialized" + assert "id" not in notification @respx.mock - async def test_notification_includes_session_id_header(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } + async def test_notification_includes_session_id(self, base_url): route = respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "sess-abc"}), + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "abc"}, + ), httpx.Response(202), ] ) - adapter = BaseAdapter(base_url) - await adapter.initialize() + conn = MCPConnection(MCPServerConfig(url=base_url)) + await conn.initialize() - second_call = route.calls[1] - assert second_call.request.headers.get("Mcp-Session-Id") == "sess-abc" + assert route.calls[1].request.headers.get("Mcp-Session-Id") == "abc" @respx.mock - async def test_idempotent_returns_cached_result(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } + async def test_idempotent(self, base_url): route = respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "sess-1"}), + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "s1"}, + ), httpx.Response(202), ] ) - adapter = BaseAdapter(base_url) - result1 = await adapter.initialize() - result2 = await adapter.initialize() + conn = MCPConnection(MCPServerConfig(url=base_url)) + r1 = await conn.initialize() + r2 = await conn.initialize() - assert result1 == result2 - # Only 2 calls total (init + notification), not 4 + assert r1 == r2 assert route.call_count == 2 @respx.mock async def test_works_without_session_id(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result), + httpx.Response(200, json=_init_response()), httpx.Response(202), ] ) - adapter = BaseAdapter(base_url) - result = await adapter.initialize() + conn = MCPConnection(MCPServerConfig(url=base_url)) + await conn.initialize() - assert result == init_result - assert adapter._session_id is None + assert conn._session_id is None @respx.mock async def test_stores_negotiated_protocol_version(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": "2024-11-05"}, - } respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result), + httpx.Response(200, json=_init_response("2024-11-05")), httpx.Response(202), ] ) - adapter = BaseAdapter(base_url) - await adapter.initialize() + conn = MCPConnection(MCPServerConfig(url=base_url)) + await conn.initialize() + + assert conn._protocol_version == "2024-11-05" + + @respx.mock + async def test_parses_sse_response(self, base_url): + init_result = _init_response() + respx.post(base_url).mock( + side_effect=[ + httpx.Response( + 200, + text=_sse_body(init_result), + headers={ + "content-type": "text/event-stream", + "Mcp-Session-Id": "sse-1", + }, + ), + httpx.Response(202), + ] + ) - assert adapter._protocol_version == "2024-11-05" + conn = MCPConnection(MCPServerConfig(url=base_url)) + result = await conn.initialize() + assert result == init_result + assert conn._session_id == "sse-1" + + +# ── MCPConnection: _make_request ───────────────────────────────── -class TestMakeRequest: + +class TestMCPConnectionMakeRequest: @respx.mock - async def test_post_returns_parsed_json(self, base_url): + async def test_returns_parsed_json(self, base_url): expected = {"jsonrpc": "2.0", "id": "1", "result": {"tools": []}} respx.post(base_url).mock( return_value=httpx.Response(200, json=expected) ) - adapter = BaseAdapter(base_url) - result = await adapter._make_request( + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="none")) + result = await conn._make_request( {"id": "1", "jsonrpc": "2.0", "method": "tools/list", "params": {}} ) + assert result == expected @respx.mock async def test_http_error_raises(self, base_url): respx.post(base_url).mock( - return_value=httpx.Response(500, text="Internal Server Error") + return_value=httpx.Response(500, text="Error") ) - adapter = BaseAdapter(base_url) + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="none")) with pytest.raises(httpx.HTTPStatusError): - await adapter._make_request( + await conn._make_request( {"id": "1", "jsonrpc": "2.0", "method": "tools/list", "params": {}} ) @respx.mock - async def test_no_session_id_header_before_init(self, base_url): + async def test_no_session_header_before_init(self, base_url): route = respx.post(base_url).mock( - return_value=httpx.Response(200, json={"jsonrpc": "2.0", "id": "1", "result": {}}) + return_value=httpx.Response( + 200, json={"jsonrpc": "2.0", "id": "1", "result": {}} + ) ) - adapter = BaseAdapter(base_url) - await adapter._make_request( + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="none")) + await conn._make_request( {"id": "1", "jsonrpc": "2.0", "method": "tools/list", "params": {}} ) - request = route.calls[0].request - assert "Mcp-Session-Id" not in request.headers + assert "Mcp-Session-Id" not in route.calls[0].request.headers @respx.mock - async def test_includes_session_id_after_init(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } + async def test_includes_session_after_init(self, base_url): respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "sess-xyz"}), + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "sess-xyz"}, + ), httpx.Response(202), ] ) - adapter = BaseAdapter(base_url) - await adapter.initialize() + conn = MCPConnection(MCPServerConfig(url=base_url)) + await conn.initialize() - # Now mock a subsequent request respx.reset() route = respx.post(base_url).mock( - return_value=httpx.Response(200, json={"jsonrpc": "2.0", "id": "2", "result": {}}) + return_value=httpx.Response( + 200, json={"jsonrpc": "2.0", "id": "2", "result": {}} + ) ) - await adapter._make_request( + await conn._make_request( {"id": "2", "jsonrpc": "2.0", "method": "tools/list", "params": {}} ) - request = route.calls[0].request - assert request.headers.get("Mcp-Session-Id") == "sess-xyz" + assert route.calls[0].request.headers.get("Mcp-Session-Id") == "sess-xyz" @respx.mock async def test_includes_protocol_version_after_init(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": "2024-11-05"}, - } respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result), + httpx.Response(200, json=_init_response("2024-11-05")), httpx.Response(202), ] ) - adapter = BaseAdapter(base_url) - await adapter.initialize() + conn = MCPConnection(MCPServerConfig(url=base_url)) + await conn.initialize() respx.reset() route = respx.post(base_url).mock( - return_value=httpx.Response(200, json={"jsonrpc": "2.0", "id": "2", "result": {}}) + return_value=httpx.Response( + 200, json={"jsonrpc": "2.0", "id": "2", "result": {}} + ) ) - await adapter._make_request( + await conn._make_request( {"id": "2", "jsonrpc": "2.0", "method": "tools/list", "params": {}} ) - request = route.calls[0].request - assert request.headers.get("MCP-Protocol-Version") == "2024-11-05" + assert ( + route.calls[0].request.headers.get("MCP-Protocol-Version") == "2024-11-05" + ) + + @respx.mock + async def test_parses_sse_response(self, base_url): + result = {"jsonrpc": "2.0", "id": "1", "result": {"tools": [{"name": "t"}]}} + respx.post(base_url).mock( + return_value=httpx.Response( + 200, + text=_sse_body(result), + headers={"content-type": "text/event-stream"}, + ) + ) + + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="none")) + r = await conn._make_request( + {"id": "1", "jsonrpc": "2.0", "method": "tools/list", "params": {}} + ) + + assert r == result + + +# ── Init modes (on MCPConnection) ──────────────────────────────── class TestInitModeAuto: @respx.mock - async def test_get_mcp_tools_calls_initialize_before_request(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } - tools_result = { - "jsonrpc": "2.0", - "id": "2", - "result": {"tools": []}, - } + async def test_initializes_before_get_tools(self, base_url): route = respx.post(base_url).mock( side_effect=[ - # initialize request - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "s1"}), - # notifications/initialized + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "s1"}, + ), httpx.Response(202), - # tools/list request - httpx.Response(200, json=tools_result), + httpx.Response(200, json=_tools_response([])), ] ) - adapter = BaseAdapter(base_url, init_mode=InitMode.auto) - result = await adapter.get_mcp_tools() + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="auto")) + tools = await conn.get_tools() - assert result == tools_result - # 3 calls: initialize, notification, tools/list + assert tools == [] assert route.call_count == 3 @respx.mock - async def test_call_mcp_tool_calls_initialize_before_request(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } + async def test_initializes_before_call_tool(self, base_url): call_result = { "jsonrpc": "2.0", "id": "2", - "result": {"content": [{"type": "text", "text": "hello"}]}, + "result": {"content": [{"type": "text", "text": "hi"}]}, } route = respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "s1"}), + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "s1"}, + ), httpx.Response(202), httpx.Response(200, json=call_result), ] ) - adapter = BaseAdapter(base_url, init_mode=InitMode.auto) - result = await adapter.call_mcp_tool("test_tool", {"arg": "val"}) + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="auto")) + result = await conn.call_tool("test_tool", {"arg": "val"}) assert result == call_result assert route.call_count == 3 @@ -383,106 +409,81 @@ async def test_call_mcp_tool_calls_initialize_before_request(self, base_url): class TestInitModeOnFail: @respx.mock - async def test_does_not_call_initialize_upfront(self, base_url): - tools_result = { - "jsonrpc": "2.0", - "id": "2", - "result": {"tools": []}, - } + async def test_skips_init_on_success(self, base_url): route = respx.post(base_url).mock( - return_value=httpx.Response(200, json=tools_result), + return_value=httpx.Response(200, json=_tools_response([])) ) - adapter = BaseAdapter(base_url, init_mode=InitMode.on_fail) - result = await adapter.get_mcp_tools() + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="on_fail")) + tools = await conn.get_tools() - assert result == tools_result - # Only 1 call: the tools/list request, no init + assert tools == [] assert route.call_count == 1 @respx.mock async def test_initializes_and_retries_on_failure(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } - tools_result = { - "jsonrpc": "2.0", - "id": "2", - "result": {"tools": []}, - } route = respx.post(base_url).mock( side_effect=[ - # First tools/list fails httpx.Response(400, text="Bad Request"), - # initialize request - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "s1"}), - # notifications/initialized + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "s1"}, + ), httpx.Response(202), - # Retry tools/list succeeds - httpx.Response(200, json=tools_result), + httpx.Response(200, json=_tools_response([])), ] ) - adapter = BaseAdapter(base_url, init_mode=InitMode.on_fail) - result = await adapter.get_mcp_tools() + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="on_fail")) + tools = await conn.get_tools() - assert result == tools_result - # 4 calls: failed tools/list, init, notification, retry tools/list + assert tools == [] assert route.call_count == 4 @respx.mock - async def test_no_retry_if_already_initialized(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } - # Pre-initialize the adapter + async def test_no_retry_after_init(self, base_url): respx.post(base_url).mock( side_effect=[ - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "s1"}), + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "s1"}, + ), httpx.Response(202), ] ) - adapter = BaseAdapter(base_url, init_mode=InitMode.on_fail) - await adapter.initialize() + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="on_fail")) + await conn.initialize() respx.reset() - # Now a request fails — should NOT retry since already initialized respx.post(base_url).mock( - return_value=httpx.Response(400, text="Bad Request"), + return_value=httpx.Response(400, text="Bad Request") ) with pytest.raises(httpx.HTTPStatusError): - await adapter.get_mcp_tools() + await conn.get_tools() @respx.mock - async def test_error_propagates_if_retry_also_fails(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": {"protocolVersion": PROTOCOL_VERSION}, - } + async def test_error_propagates_on_retry_failure(self, base_url): route = respx.post(base_url).mock( side_effect=[ - # First tools/list fails httpx.Response(400, text="Bad Request"), - # initialize succeeds - httpx.Response(200, json=init_result, headers={"Mcp-Session-Id": "s1"}), - # notifications/initialized + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "s1"}, + ), httpx.Response(202), - # Retry tools/list also fails httpx.Response(500, text="Internal Server Error"), ] ) - adapter = BaseAdapter(base_url, init_mode=InitMode.on_fail) + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="on_fail")) with pytest.raises(httpx.HTTPStatusError) as exc_info: - await adapter.get_mcp_tools() + await conn.get_tools() assert exc_info.value.response.status_code == 500 assert route.call_count == 4 @@ -490,152 +491,447 @@ async def test_error_propagates_if_retry_also_fails(self, base_url): class TestInitModeNone: @respx.mock - async def test_does_not_call_initialize(self, base_url): - tools_result = { - "jsonrpc": "2.0", - "id": "2", - "result": {"tools": []}, - } + async def test_no_init(self, base_url): route = respx.post(base_url).mock( - return_value=httpx.Response(200, json=tools_result), + return_value=httpx.Response(200, json=_tools_response([])) ) - adapter = BaseAdapter(base_url, init_mode=InitMode.none) - result = await adapter.get_mcp_tools() + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="none")) + tools = await conn.get_tools() - assert result == tools_result - # Only 1 call: tools/list, no init + assert tools == [] assert route.call_count == 1 @respx.mock async def test_error_propagates_directly(self, base_url): route = respx.post(base_url).mock( - return_value=httpx.Response(400, text="Bad Request"), + return_value=httpx.Response(400, text="Bad Request") ) - adapter = BaseAdapter(base_url, init_mode=InitMode.none) + conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="none")) with pytest.raises(httpx.HTTPStatusError): - await adapter.get_mcp_tools() + await conn.get_tools() - # Only 1 call, no retry, no init assert route.call_count == 1 +# ── SSE parsing ────────────────────────────────────────────────── + + class TestParseSSE: def test_parses_json_content_type(self): payload = {"jsonrpc": "2.0", "id": "1", "result": {}} response = httpx.Response( - 200, - json=payload, - headers={"content-type": "application/json"}, + 200, json=payload, headers={"content-type": "application/json"} ) - assert BaseAdapter._parse_response(response) == payload + assert MCPConnection._parse_response(response) == payload def test_parses_sse_content_type(self): payload = {"jsonrpc": "2.0", "id": "1", "result": {"tools": []}} - sse_body = _sse_body(payload) response = httpx.Response( 200, - text=sse_body, + text=_sse_body(payload), headers={"content-type": "text/event-stream"}, ) - assert BaseAdapter._parse_response(response) == payload + assert MCPConnection._parse_response(response) == payload - def test_parses_sse_without_trailing_blank_line(self): + def test_parses_sse_without_trailing_blank(self): payload = {"jsonrpc": "2.0", "id": "1", "result": {}} - sse_body = f"event: message\ndata: {json.dumps(payload)}" response = httpx.Response( 200, - text=sse_body, + text=f"event: message\ndata: {json.dumps(payload)}", headers={"content-type": "text/event-stream"}, ) - assert BaseAdapter._parse_response(response) == payload + assert MCPConnection._parse_response(response) == payload def test_parses_sse_with_charset(self): payload = {"jsonrpc": "2.0", "id": "1", "result": {}} - sse_body = _sse_body(payload) response = httpx.Response( 200, - text=sse_body, + text=_sse_body(payload), headers={"content-type": "text/event-stream; charset=utf-8"}, ) - assert BaseAdapter._parse_response(response) == payload + assert MCPConnection._parse_response(response) == payload - def test_sse_returns_last_event(self): + def test_returns_last_sse_event(self): first = {"jsonrpc": "2.0", "method": "notifications/progress"} second = {"jsonrpc": "2.0", "id": "1", "result": {"done": True}} - sse_body = _sse_body(first) + _sse_body(second) response = httpx.Response( 200, - text=sse_body, + text=_sse_body(first) + _sse_body(second), headers={"content-type": "text/event-stream"}, ) - assert BaseAdapter._parse_response(response) == second + assert MCPConnection._parse_response(response) == second - def test_sse_raises_on_empty_body(self): + def test_raises_on_empty_body(self): response = httpx.Response( - 200, - text="", - headers={"content-type": "text/event-stream"}, + 200, text="", headers={"content-type": "text/event-stream"} ) with pytest.raises(ValueError, match="No data field found"): - BaseAdapter._parse_response(response) + MCPConnection._parse_response(response) + +# ── BaseAdapter: construction ──────────────────────────────────── + + +class TestBaseAdapterConstruction: + def test_single_url_string(self, base_url): + adapter = BaseAdapter(base_url) + assert len(adapter._configs) == 1 + assert adapter._configs[0].url == base_url + + def test_single_config(self, base_url): + cfg = MCPServerConfig(url=base_url, name="my-server") + adapter = BaseAdapter(cfg) + assert len(adapter._configs) == 1 + assert adapter._configs[0].name == "my-server" + + def test_multiple_configs(self): + adapter = BaseAdapter( + [ + MCPServerConfig(url="https://a.com/weather", name="weather"), + MCPServerConfig(url="https://b.com/calendar", name="calendar"), + ] + ) + assert len(adapter._configs) == 2 + assert adapter.is_multi_server + + def test_single_is_not_multi_server(self, base_url): + adapter = BaseAdapter(base_url) + assert not adapter.is_multi_server + + def test_connections_created_per_server(self): + adapter = BaseAdapter( + [ + MCPServerConfig(url="https://a.com/weather", name="weather"), + MCPServerConfig(url="https://b.com/calendar", name="calendar"), + ] + ) + assert "weather" in adapter._connections + assert "calendar" in adapter._connections -class TestInitializeSSE: + def test_tools_raises_before_discovery(self, base_url): + adapter = BaseAdapter(base_url) + with pytest.raises(RuntimeError, match="Must call discover_tools"): + _ = adapter.tools + + +# ── BaseAdapter: discover_tools ────────────────────────────────── + + +class TestDiscoverTools: @respx.mock - async def test_initialize_parses_sse_response(self, base_url): - init_result = { - "jsonrpc": "2.0", - "id": "1", - "result": { - "protocolVersion": PROTOCOL_VERSION, - "capabilities": {}, - "serverInfo": {"name": "test-server", "version": "1.0"}, - }, - } - sse_body = _sse_body(init_result) + async def test_single_server(self, base_url, sample_mcp_tools): respx.post(base_url).mock( + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) + ) + + adapter = BaseAdapter(MCPServerConfig(url=base_url, init_mode="none")) + tools = await adapter.discover_tools() + + assert len(tools) == 2 + assert tools[0].name == "get_weather" + assert tools[0].original_name == "get_weather" + assert tools[1].name == "search" + + @respx.mock + async def test_multi_server_unique_tools(self): + url_a = "https://a.com/mcp/weather" + url_b = "https://b.com/mcp/calendar" + + respx.post(url_a).mock( + return_value=httpx.Response( + 200, + json=_tools_response( + [{"name": "get_weather", "description": "Get weather"}] + ), + ) + ) + respx.post(url_b).mock( + return_value=httpx.Response( + 200, + json=_tools_response( + [{"name": "get_events", "description": "Get events"}] + ), + ) + ) + + adapter = BaseAdapter( + [ + MCPServerConfig(url=url_a, name="weather", init_mode="none"), + MCPServerConfig(url=url_b, name="calendar", init_mode="none"), + ] + ) + tools = await adapter.discover_tools() + + assert len(tools) == 2 + names = {t.name for t in tools} + assert names == {"get_weather", "get_events"} + + @respx.mock + async def test_multi_server_collision_auto_prefix(self): + url_a = "https://a.com/mcp/weather" + url_b = "https://b.com/mcp/calendar" + + respx.post(url_a).mock( + return_value=httpx.Response( + 200, + json=_tools_response( + [{"name": "search", "description": "Search weather"}] + ), + ) + ) + respx.post(url_b).mock( + return_value=httpx.Response( + 200, + json=_tools_response( + [{"name": "search", "description": "Search events"}] + ), + ) + ) + + adapter = BaseAdapter( + [ + MCPServerConfig(url=url_a, name="weather", init_mode="none"), + MCPServerConfig(url=url_b, name="calendar", init_mode="none"), + ] + ) + tools = await adapter.discover_tools() + + assert len(tools) == 2 + names = {t.name for t in tools} + assert names == {"weather__search", "calendar__search"} + + @respx.mock + async def test_multi_server_collision_disabled(self): + url_a = "https://a.com/mcp/weather" + url_b = "https://b.com/mcp/calendar" + + respx.post(url_a).mock( + return_value=httpx.Response( + 200, + json=_tools_response( + [{"name": "search", "description": "Search weather"}] + ), + ) + ) + respx.post(url_b).mock( + return_value=httpx.Response( + 200, + json=_tools_response( + [{"name": "search", "description": "Search events"}] + ), + ) + ) + + adapter = BaseAdapter( + [ + MCPServerConfig(url=url_a, name="weather", init_mode="none"), + MCPServerConfig(url=url_b, name="calendar", init_mode="none"), + ], + auto_prefix_on_collision=False, + ) + tools = await adapter.discover_tools() + + names = [t.name for t in tools] + assert names.count("search") == 2 + + @respx.mock + async def test_tool_prefix(self): + url = "https://a.com/mcp/weather" + respx.post(url).mock( + return_value=httpx.Response( + 200, + json=_tools_response([{"name": "search", "description": "Search"}]), + ) + ) + + adapter = BaseAdapter( + MCPServerConfig( + url=url, name="weather", init_mode="none", tool_prefix="wx" + ) + ) + tools = await adapter.discover_tools() + + assert tools[0].name == "wx__search" + assert tools[0].original_name == "search" + + @respx.mock + async def test_custom_prefix_separator(self): + url = "https://a.com/mcp/weather" + respx.post(url).mock( + return_value=httpx.Response( + 200, + json=_tools_response([{"name": "search", "description": "Search"}]), + ) + ) + + adapter = BaseAdapter( + MCPServerConfig( + url=url, name="weather", init_mode="none", tool_prefix="wx" + ), + prefix_separator=".", + ) + tools = await adapter.discover_tools() + + assert tools[0].name == "wx.search" + + +# ── BaseAdapter: call_tool ─────────────────────────────────────── + + +class TestCallTool: + @respx.mock + async def test_routes_to_correct_server(self): + url_a = "https://a.com/mcp/weather" + url_b = "https://b.com/mcp/calendar" + + weather_route = respx.post(url_a).mock( side_effect=[ httpx.Response( 200, - text=sse_body, - headers={ - "content-type": "text/event-stream", - "Mcp-Session-Id": "sess-sse-1", - }, + json=_tools_response( + [{"name": "get_weather", "description": "Weather"}] + ), + ), + httpx.Response( + 200, json={"jsonrpc": "2.0", "id": "2", "result": {"temp": 72}} ), - httpx.Response(202), ] ) + calendar_route = respx.post(url_b).mock( + return_value=httpx.Response( + 200, + json=_tools_response( + [{"name": "get_events", "description": "Events"}] + ), + ) + ) - adapter = BaseAdapter(base_url) - result = await adapter.initialize() + adapter = BaseAdapter( + [ + MCPServerConfig(url=url_a, name="weather", init_mode="none"), + MCPServerConfig(url=url_b, name="calendar", init_mode="none"), + ] + ) + await adapter.discover_tools() - assert result == init_result - assert adapter._session_id == "sess-sse-1" - assert adapter._protocol_version == PROTOCOL_VERSION + result = await adapter.call_tool("get_weather", {"location": "NYC"}) + + assert result["result"]["temp"] == 72 + assert weather_route.call_count == 2 # discovery + call + assert calendar_route.call_count == 1 # discovery only @respx.mock - async def test_make_request_parses_sse_response(self, base_url): - tools_result = { - "jsonrpc": "2.0", - "id": "2", - "result": {"tools": [{"name": "my_tool"}]}, - } - sse_body = _sse_body(tools_result) + async def test_unknown_tool_raises(self, base_url, sample_mcp_tools): + respx.post(base_url).mock( + return_value=httpx.Response( + 200, json=_tools_response(sample_mcp_tools) + ) + ) + + adapter = BaseAdapter(MCPServerConfig(url=base_url, init_mode="none")) + await adapter.discover_tools() + + with pytest.raises(KeyError, match="Unknown tool"): + await adapter.call_tool("nonexistent", {}) + + @respx.mock + async def test_auto_discovers_if_needed(self, base_url, sample_mcp_tools): + call_response = {"jsonrpc": "2.0", "id": "2", "result": {"temp": 72}} respx.post(base_url).mock( + side_effect=[ + httpx.Response(200, json=_tools_response(sample_mcp_tools)), + httpx.Response(200, json=call_response), + ] + ) + + adapter = BaseAdapter(MCPServerConfig(url=base_url, init_mode="none")) + result = await adapter.call_tool("get_weather", {"location": "NYC"}) + + assert result == call_response + + @respx.mock + async def test_routes_prefixed_tool_to_original_name(self): + url_a = "https://a.com/mcp/weather" + url_b = "https://b.com/mcp/calendar" + + respx.post(url_a).mock( + side_effect=[ + httpx.Response( + 200, + json=_tools_response( + [{"name": "search", "description": "Search weather"}] + ), + ), + httpx.Response( + 200, + json={"jsonrpc": "2.0", "id": "2", "result": {"data": "wx"}}, + ), + ] + ) + respx.post(url_b).mock( return_value=httpx.Response( 200, - text=sse_body, - headers={"content-type": "text/event-stream"}, - ), + json=_tools_response( + [{"name": "search", "description": "Search events"}] + ), + ) ) - adapter = BaseAdapter(base_url) - result = await adapter._make_request( - {"id": "2", "jsonrpc": "2.0", "method": "tools/list", "params": {}} + adapter = BaseAdapter( + [ + MCPServerConfig(url=url_a, name="weather", init_mode="none"), + MCPServerConfig(url=url_b, name="calendar", init_mode="none"), + ] + ) + await adapter.discover_tools() + + # Call using the prefixed name + result = await adapter.call_tool("weather__search", {"q": "rain"}) + assert result["result"]["data"] == "wx" + + +# ── BaseAdapter: initialize_all ────────────────────────────────── + + +class TestInitializeAll: + @respx.mock + async def test_initializes_all_connections(self): + url_a = "https://a.com/mcp/weather" + url_b = "https://b.com/mcp/calendar" + + respx.post(url_a).mock( + side_effect=[ + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "sa"}, + ), + httpx.Response(202), + ] + ) + respx.post(url_b).mock( + side_effect=[ + httpx.Response( + 200, + json=_init_response(), + headers={"Mcp-Session-Id": "sb"}, + ), + httpx.Response(202), + ] + ) + + adapter = BaseAdapter( + [ + MCPServerConfig(url=url_a, name="weather"), + MCPServerConfig(url=url_b, name="calendar"), + ] ) + results = await adapter.initialize_all() - assert result == tools_result + assert "weather" in results + assert "calendar" in results + assert not isinstance(results["weather"], Exception) + assert not isinstance(results["calendar"], Exception) diff --git a/tests/test_gemini_adapter.py b/tests/test_gemini_adapter.py index 23ca25b..0dfa099 100644 --- a/tests/test_gemini_adapter.py +++ b/tests/test_gemini_adapter.py @@ -9,16 +9,22 @@ except ImportError: HAS_GOOGLE_GENAI = False +from mcphero.adapters.base_adapter import MCPServerConfig + pytestmark = pytest.mark.skipif( not HAS_GOOGLE_GENAI, reason="google-genai not installed" ) +def _tools_response(tools): + return {"jsonrpc": "2.0", "id": "1", "result": {"tools": tools}} + + @pytest.fixture def adapter(base_url): from mcphero.adapters.gemini import MCPToolAdapterGemini - return MCPToolAdapterGemini(base_url) + return MCPToolAdapterGemini(MCPServerConfig(url=base_url, init_mode="none")) class TestGetFunctionDeclarations: @@ -26,7 +32,7 @@ class TestGetFunctionDeclarations: async def test_converts_mcp_tools(self, base_url, adapter, sample_mcp_tools): respx.post(base_url).mock( return_value=httpx.Response( - 200, json={"result": {"tools": sample_mcp_tools}} + 200, json=_tools_response(sample_mcp_tools) ) ) @@ -42,7 +48,7 @@ async def test_handles_missing_schema( ): respx.post(base_url).mock( return_value=httpx.Response( - 200, json={"result": {"tools": sample_mcp_tool_no_schema}} + 200, json=_tools_response(sample_mcp_tool_no_schema) ) ) @@ -54,7 +60,7 @@ async def test_handles_missing_schema( @respx.mock async def test_empty_list(self, base_url, adapter): respx.post(base_url).mock( - return_value=httpx.Response(200, json={"result": {"tools": []}}) + return_value=httpx.Response(200, json=_tools_response([])) ) declarations = await adapter.get_function_declarations() @@ -68,7 +74,7 @@ async def test_returns_tool_wrapping_declarations( ): respx.post(base_url).mock( return_value=httpx.Response( - 200, json={"result": {"tools": sample_mcp_tools}} + 200, json=_tools_response(sample_mcp_tools) ) ) @@ -80,9 +86,12 @@ async def test_returns_tool_wrapping_declarations( class TestProcessFunctionCalls: @respx.mock - async def test_success(self, base_url, adapter, sample_tool_result): + async def test_success(self, base_url, adapter, sample_mcp_tools, sample_tool_result): respx.post(base_url).mock( - return_value=httpx.Response(200, json=sample_tool_result) + side_effect=[ + httpx.Response(200, json=_tools_response(sample_mcp_tools)), + httpx.Response(200, json=sample_tool_result), + ] ) fc = types.FunctionCall(name="get_weather", args={"location": "London"}) @@ -94,10 +103,12 @@ async def test_success(self, base_url, adapter, sample_tool_result): assert len(results[0].parts) == 1 @respx.mock - async def test_non_dict_result_wrapped(self, base_url, adapter): - # When result is not a dict, it gets wrapped as {"result": ...} + async def test_non_dict_result_wrapped(self, base_url, adapter, sample_mcp_tools): respx.post(base_url).mock( - return_value=httpx.Response(200, json="string result") + side_effect=[ + httpx.Response(200, json=_tools_response(sample_mcp_tools)), + httpx.Response(200, json="string result"), + ] ) fc = types.FunctionCall(name="get_weather", args={"location": "London"}) @@ -106,10 +117,12 @@ async def test_non_dict_result_wrapped(self, base_url, adapter): assert len(results) == 1 @respx.mock - async def test_dict_result_passed_directly(self, base_url, adapter): - data = {"temp": 72} + async def test_dict_result_passed_directly(self, base_url, adapter, sample_mcp_tools): respx.post(base_url).mock( - return_value=httpx.Response(200, json=data) + side_effect=[ + httpx.Response(200, json=_tools_response(sample_mcp_tools)), + httpx.Response(200, json={"temp": 72}), + ] ) fc = types.FunctionCall(name="get_weather", args={"location": "London"}) @@ -118,7 +131,15 @@ async def test_dict_result_passed_directly(self, base_url, adapter): assert len(results) == 1 @respx.mock - async def test_http_error(self, base_url, adapter): + async def test_http_error(self, base_url, adapter, sample_mcp_tools): + respx.post(base_url).mock( + return_value=httpx.Response( + 200, json=_tools_response(sample_mcp_tools) + ) + ) + await adapter.discover_tools() + + respx.reset() respx.post(base_url).mock( return_value=httpx.Response(500, text="Server Error") ) @@ -129,7 +150,15 @@ async def test_http_error(self, base_url, adapter): assert len(results) == 1 @respx.mock - async def test_return_errors_false(self, base_url, adapter): + async def test_return_errors_false(self, base_url, adapter, sample_mcp_tools): + respx.post(base_url).mock( + return_value=httpx.Response( + 200, json=_tools_response(sample_mcp_tools) + ) + ) + await adapter.discover_tools() + + respx.reset() respx.post(base_url).mock( return_value=httpx.Response(500, text="Server Error") ) @@ -140,9 +169,12 @@ async def test_return_errors_false(self, base_url, adapter): assert len(results) == 0 @respx.mock - async def test_empty_args(self, base_url, adapter): + async def test_empty_args(self, base_url, adapter, sample_mcp_tool_no_schema): respx.post(base_url).mock( - return_value=httpx.Response(200, json={"status": "ok"}) + side_effect=[ + httpx.Response(200, json=_tools_response(sample_mcp_tool_no_schema)), + httpx.Response(200, json={"status": "ok"}), + ] ) fc = types.FunctionCall(name="ping", args=None) @@ -153,7 +185,15 @@ async def test_empty_args(self, base_url, adapter): class TestProcessFunctionCallsAsParts: @respx.mock - async def test_returns_parts(self, base_url, adapter, sample_tool_result): + async def test_returns_parts(self, base_url, adapter, sample_mcp_tools, sample_tool_result): + respx.post(base_url).mock( + return_value=httpx.Response( + 200, json=_tools_response(sample_mcp_tools) + ) + ) + await adapter.discover_tools() + + respx.reset() respx.post(base_url).mock( return_value=httpx.Response(200, json=sample_tool_result) ) @@ -165,7 +205,15 @@ async def test_returns_parts(self, base_url, adapter, sample_tool_result): assert isinstance(parts[0], types.Part) @respx.mock - async def test_error_returns_part(self, base_url, adapter): + async def test_error_returns_part(self, base_url, adapter, sample_mcp_tools): + respx.post(base_url).mock( + return_value=httpx.Response( + 200, json=_tools_response(sample_mcp_tools) + ) + ) + await adapter.discover_tools() + + respx.reset() respx.post(base_url).mock( return_value=httpx.Response(500, text="Server Error") ) diff --git a/tests/test_openai_adapter.py b/tests/test_openai_adapter.py index d392bf9..f707e01 100644 --- a/tests/test_openai_adapter.py +++ b/tests/test_openai_adapter.py @@ -6,9 +6,14 @@ from openai.types.chat import ChatCompletionMessageToolCall from openai.types.chat.chat_completion_message_tool_call import Function +from mcphero.adapters.base_adapter import MCPServerConfig from mcphero.adapters.openai import MCPToolAdapterOpenAI +def _tools_response(tools): + return {"jsonrpc": "2.0", "id": "1", "result": {"tools": tools}} + + class TestGetToolDefinitions: @respx.mock async def test_converts_mcp_tools_to_openai_format( @@ -16,11 +21,13 @@ async def test_converts_mcp_tools_to_openai_format( ): respx.post(base_url).mock( return_value=httpx.Response( - 200, json={"result": {"tools": sample_mcp_tools}} + 200, json=_tools_response(sample_mcp_tools) ) ) - adapter = MCPToolAdapterOpenAI(base_url) + adapter = MCPToolAdapterOpenAI( + MCPServerConfig(url=base_url, init_mode="none") + ) tools = await adapter.get_tool_definitions() assert len(tools) == 2 @@ -35,11 +42,13 @@ async def test_handles_missing_input_schema( ): respx.post(base_url).mock( return_value=httpx.Response( - 200, json={"result": {"tools": sample_mcp_tool_no_schema}} + 200, json=_tools_response(sample_mcp_tool_no_schema) ) ) - adapter = MCPToolAdapterOpenAI(base_url) + adapter = MCPToolAdapterOpenAI( + MCPServerConfig(url=base_url, init_mode="none") + ) tools = await adapter.get_tool_definitions() assert len(tools) == 1 @@ -51,22 +60,29 @@ async def test_handles_missing_input_schema( @respx.mock async def test_handles_empty_tools(self, base_url): respx.post(base_url).mock( - return_value=httpx.Response(200, json={"result": {"tools": []}}) + return_value=httpx.Response(200, json=_tools_response([])) ) - adapter = MCPToolAdapterOpenAI(base_url) + adapter = MCPToolAdapterOpenAI( + MCPServerConfig(url=base_url, init_mode="none") + ) tools = await adapter.get_tool_definitions() assert tools == [] class TestProcessToolCalls: @respx.mock - async def test_success(self, base_url, sample_tool_result): + async def test_success(self, base_url, sample_mcp_tools, sample_tool_result): respx.post(base_url).mock( - return_value=httpx.Response(200, json=sample_tool_result) + side_effect=[ + httpx.Response(200, json=_tools_response(sample_mcp_tools)), + httpx.Response(200, json=sample_tool_result), + ] ) - adapter = MCPToolAdapterOpenAI(base_url) + adapter = MCPToolAdapterOpenAI( + MCPServerConfig(url=base_url, init_mode="none") + ) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", @@ -86,7 +102,20 @@ async def test_success(self, base_url, sample_tool_result): assert json.loads(results[0]["content"]) == sample_tool_result @respx.mock - async def test_multiple_calls(self, base_url): + async def test_multiple_calls(self, base_url, sample_mcp_tools): + # Pre-discover tools, then mock the tool call responses + respx.post(base_url).mock( + return_value=httpx.Response( + 200, json=_tools_response(sample_mcp_tools) + ) + ) + + adapter = MCPToolAdapterOpenAI( + MCPServerConfig(url=base_url, init_mode="none") + ) + await adapter.discover_tools() + + respx.reset() respx.post(base_url).mock( side_effect=[ httpx.Response(200, json={"temp": 72}), @@ -94,7 +123,6 @@ async def test_multiple_calls(self, base_url): ] ) - adapter = MCPToolAdapterOpenAI(base_url) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", @@ -121,7 +149,9 @@ async def test_multiple_calls(self, base_url): @respx.mock async def test_invalid_json_arguments(self, base_url): - adapter = MCPToolAdapterOpenAI(base_url) + adapter = MCPToolAdapterOpenAI( + MCPServerConfig(url=base_url, init_mode="none") + ) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", @@ -140,12 +170,24 @@ async def test_invalid_json_arguments(self, base_url): assert "parse" in content["error"].lower() @respx.mock - async def test_http_error(self, base_url): + async def test_http_error(self, base_url, sample_mcp_tools): + # Discover tools first, then fail on tool call + respx.post(base_url).mock( + return_value=httpx.Response( + 200, json=_tools_response(sample_mcp_tools) + ) + ) + + adapter = MCPToolAdapterOpenAI( + MCPServerConfig(url=base_url, init_mode="none") + ) + await adapter.discover_tools() + + respx.reset() respx.post(base_url).mock( return_value=httpx.Response(500, text="Server Error") ) - adapter = MCPToolAdapterOpenAI(base_url) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", @@ -163,12 +205,25 @@ async def test_http_error(self, base_url): assert "error" in content @respx.mock - async def test_return_errors_false_omits_failures(self, base_url): + async def test_return_errors_false_omits_failures( + self, base_url, sample_mcp_tools + ): + respx.post(base_url).mock( + return_value=httpx.Response( + 200, json=_tools_response(sample_mcp_tools) + ) + ) + + adapter = MCPToolAdapterOpenAI( + MCPServerConfig(url=base_url, init_mode="none") + ) + await adapter.discover_tools() + + respx.reset() respx.post(base_url).mock( return_value=httpx.Response(500, text="Server Error") ) - adapter = MCPToolAdapterOpenAI(base_url) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", @@ -184,12 +239,19 @@ async def test_return_errors_false_omits_failures(self, base_url): assert len(results) == 0 @respx.mock - async def test_string_result_not_double_encoded(self, base_url): + async def test_string_result_not_double_encoded( + self, base_url, sample_mcp_tools + ): respx.post(base_url).mock( - return_value=httpx.Response(200, json="plain string result") + side_effect=[ + httpx.Response(200, json=_tools_response(sample_mcp_tools)), + httpx.Response(200, json="plain string result"), + ] ) - adapter = MCPToolAdapterOpenAI(base_url) + adapter = MCPToolAdapterOpenAI( + MCPServerConfig(url=base_url, init_mode="none") + ) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", @@ -207,12 +269,19 @@ async def test_string_result_not_double_encoded(self, base_url): assert results[0]["content"] == "plain string result" @respx.mock - async def test_dict_result_is_json_encoded(self, base_url, sample_tool_result): + async def test_dict_result_is_json_encoded( + self, base_url, sample_mcp_tools, sample_tool_result + ): respx.post(base_url).mock( - return_value=httpx.Response(200, json=sample_tool_result) + side_effect=[ + httpx.Response(200, json=_tools_response(sample_mcp_tools)), + httpx.Response(200, json=sample_tool_result), + ] ) - adapter = MCPToolAdapterOpenAI(base_url) + adapter = MCPToolAdapterOpenAI( + MCPServerConfig(url=base_url, init_mode="none") + ) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", From bdf6ad3a6574d063ca86f9e3c014e51fe89f2df2 Mon Sep 17 00:00:00 2001 From: Stepan Filonov Date: Mon, 2 Feb 2026 04:42:30 +0800 Subject: [PATCH 4/8] Update docs --- README.md | 155 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 142 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 71f2ccb..8efdc36 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ pip install "mcphero[google-genai]" ```python import asyncio from openai import OpenAI -from mcphero.adapters.openai import MCPToolAdapterOpenAI +from mcphero import MCPToolAdapterOpenAI async def main(): adapter = MCPToolAdapterOpenAI("https://api.mcphero.app/mcp/your-server-id") @@ -75,7 +75,7 @@ asyncio.run(main()) import asyncio from google import genai from google.genai import types -from mcphero.adapters.gemini import MCPToolAdapterGemini +from mcphero import MCPToolAdapterGemini async def main(): adapter = MCPToolAdapterGemini("https://api.mcphero.app/mcp/your-server-id") @@ -117,37 +117,164 @@ async def main(): asyncio.run(main()) ``` +## Multiple MCP Servers + +Adapters natively support connecting to multiple MCP servers at once. Use `MCPServerConfig` to configure each server, then pass them as a list. + +### MCPServerConfig + +```python +from mcphero import MCPServerConfig + +config = MCPServerConfig( + url="https://api.mcphero.app/mcp/your-server-id", # required + name="weather", # optional, auto-derived from URL if omitted + timeout=30.0, # optional, default 30s + headers={ # optional, auth headers for the server + "Authorization": "Bearer your-token", + }, + init_mode="auto", # "auto" | "on_fail" | "none" + tool_prefix="wx", # optional, prefix for tool names from this server +) +``` + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `url` | `str` | *required* | HTTP endpoint of the MCP server | +| `name` | `str \| None` | derived from URL | Identifier for the server (e.g. last path segment) | +| `timeout` | `float` | `30.0` | Request timeout in seconds | +| `headers` | `dict[str, str] \| None` | `None` | Headers sent with every request (useful for auth) | +| `init_mode` | `"auto" \| "on_fail" \| "none"` | `"auto"` | When to run MCP initialization handshake | +| `tool_prefix` | `str \| None` | `None` | Prefix applied to all tool names from this server | + +**`init_mode` options:** +- `"auto"` - initialize the connection before every request (default, safest) +- `"on_fail"` - skip initialization, but retry with initialization if a request fails +- `"none"` - never initialize (for servers that don't require it) + +### Multi-Server Example + +```python +import asyncio +from openai import OpenAI +from mcphero import MCPToolAdapterOpenAI, MCPServerConfig + +async def main(): + adapter = MCPToolAdapterOpenAI([ + MCPServerConfig( + url="https://api.mcphero.app/mcp/weather", + name="weather", + headers={"Authorization": "Bearer weather-token"}, + ), + MCPServerConfig( + url="https://api.mcphero.app/mcp/calendar", + name="calendar", + headers={"Authorization": "Bearer calendar-token"}, + ), + ]) + + client = OpenAI() + + # Tools from ALL servers are fetched in parallel and merged + tools = await adapter.get_tool_definitions() + + messages = [{"role": "user", "content": "What's the weather today and what's on my calendar?"}] + response = client.chat.completions.create( + model="gpt-4o", + messages=messages, + tools=tools, + ) + + # Tool calls are automatically routed to the correct server + if response.choices[0].message.tool_calls: + results = await adapter.process_tool_calls( + response.choices[0].message.tool_calls + ) + messages.append(response.choices[0].message) + messages.extend(results) + + final_response = client.chat.completions.create( + model="gpt-4o", + messages=messages, + tools=tools, + ) + print(final_response.choices[0].message.content) + +asyncio.run(main()) +``` + +### Tool Name Collisions + +When multiple servers expose tools with the same name, the adapter auto-prefixes them with the server name to avoid collisions: + +```python +# Both servers have a "search" tool +adapter = MCPToolAdapterOpenAI([ + MCPServerConfig(url="https://example.com/mcp/weather", name="weather"), + MCPServerConfig(url="https://example.com/mcp/calendar", name="calendar"), +]) + +tools = await adapter.get_tool_definitions() +# "search" becomes "weather__search" and "calendar__search" +``` + +You can control this behavior: + +```python +# Custom separator +adapter = MCPToolAdapterOpenAI(configs, prefix_separator="-") +# "weather-search", "calendar-search" + +# Disable auto-prefixing (will raise on collision) +adapter = MCPToolAdapterOpenAI(configs, auto_prefix_on_collision=False) + +# Manual prefix via config (always applied, regardless of collisions) +MCPServerConfig(url="...", tool_prefix="wx") +# "wx__search" +``` + ## API Reference ### MCPToolAdapterOpenAI ```python -from mcphero.adapters.openai import MCPToolAdapterOpenAI +from mcphero import MCPToolAdapterOpenAI, MCPServerConfig + +# Single server (URL string) +adapter = MCPToolAdapterOpenAI("https://api.mcphero.app/mcp/your-server-id") +# Single server (config) adapter = MCPToolAdapterOpenAI( - base_url="https://api.mcphero.app/mcp/your-server-id", - timeout=30.0, # optional - headers={"Authorization": "Bearer ..."}, # optional + MCPServerConfig( + url="https://api.mcphero.app/mcp/your-server-id", + headers={"Authorization": "Bearer ..."}, + ) ) + +# Multiple servers +adapter = MCPToolAdapterOpenAI([ + MCPServerConfig(url="https://server-a.com/mcp", name="a"), + MCPServerConfig(url="https://server-b.com/mcp", name="b"), +]) ``` #### Methods | Method | Returns | Description | |--------|---------|-------------| -| `get_tool_definitions()` | `list[ChatCompletionToolParam]` | Fetch tools from MCP server as OpenAI tool schemas | +| `get_tool_definitions()` | `list[ChatCompletionToolParam]` | Fetch tools from MCP server(s) as OpenAI tool schemas | | `process_tool_calls(tool_calls, return_errors=True)` | `list[ChatCompletionToolMessageParam]` | Execute tool calls and return results for the conversation | +| `discover_tools()` | `list[MCPToolDefinition]` | Low-level: discover tools with routing metadata | +| `call_tool(name, arguments)` | `JsonRpcResponse` | Low-level: call a single tool by name | +| `initialize_all()` | `dict[str, JsonRpcResponse \| Exception]` | Pre-initialize all server connections | ### MCPToolAdapterGemini ```python -from mcphero.adapters.gemini import MCPToolAdapterGemini +from mcphero import MCPToolAdapterGemini, MCPServerConfig -adapter = MCPToolAdapterGemini( - base_url="https://api.mcphero.app/mcp/your-server-id", - timeout=30.0, # optional - headers={"Authorization": "Bearer ..."}, # optional -) +# Same constructor options as OpenAI adapter +adapter = MCPToolAdapterGemini("https://api.mcphero.app/mcp/your-server-id") ``` #### Methods @@ -158,6 +285,8 @@ adapter = MCPToolAdapterGemini( | `get_tool()` | `types.Tool` | Fetch tools as a Gemini Tool object | | `process_function_calls(function_calls, return_errors=True)` | `list[types.Content]` | Execute function calls and return Content objects | | `process_function_calls_as_parts(function_calls, return_errors=True)` | `list[types.Part]` | Execute function calls and return Part objects | +| `discover_tools()` | `list[MCPToolDefinition]` | Low-level: discover tools with routing metadata | +| `call_tool(name, arguments)` | `JsonRpcResponse` | Low-level: call a single tool by name | ## Error Handling From d9fec4ceb166f81c3b4868906084a4212192442f Mon Sep 17 00:00:00 2001 From: Stepan Filonov Date: Mon, 2 Feb 2026 04:49:30 +0800 Subject: [PATCH 5/8] Adjust CI, move python to 3.11+ --- .github/workflows/ci.yaml | 12 +----------- pyproject.toml | 6 +++--- tests/test_openai_adapter.py | 1 - uv.lock | 2 +- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8052f1f..0f6270b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,16 +11,6 @@ concurrency: cancel-in-progress: true jobs: - pre-commit: - name: Pre-commit hooks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - uses: pre-commit/action@v3.0.1 - lint: name: Lint runs-on: ubuntu-latest @@ -61,7 +51,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 diff --git a/pyproject.toml b/pyproject.toml index 5ba26d5..58011ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,15 +7,15 @@ name = "mcphero" version = "1.0.0" description = "Library to use MCP servers natively with AI clients as tools." readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" classifiers = [ "Development Status :: 3 - Alpha", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", diff --git a/tests/test_openai_adapter.py b/tests/test_openai_adapter.py index f707e01..c970cec 100644 --- a/tests/test_openai_adapter.py +++ b/tests/test_openai_adapter.py @@ -1,7 +1,6 @@ import json import httpx -import pytest import respx from openai.types.chat import ChatCompletionMessageToolCall from openai.types.chat.chat_completion_message_tool_call import Function diff --git a/uv.lock b/uv.lock index be02ad8..05de8f3 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.9" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.10'", "python_full_version < '3.10'", From 1ce299a9e3684ab8dc7dd80f388a41a189044159 Mon Sep 17 00:00:00 2001 From: Stepan Filonov Date: Mon, 2 Feb 2026 04:53:15 +0800 Subject: [PATCH 6/8] adjust pre-commit to reflect CI --- .pre-commit-config.yaml | 8 ++++ tests/test_base_adapter.py | 24 +++-------- tests/test_gemini_adapter.py | 48 +++++++++------------- tests/test_openai_adapter.py | 77 +++++++++++------------------------- 4 files changed, 54 insertions(+), 103 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d19dc60..eb7559a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -75,3 +75,11 @@ repos: require_serial: true verbose: true pass_filenames: false + - id: ruff (tests) + name: ruff (tests) + entry: make lint-tests + language: system + types: [ python ] + require_serial: true + verbose: true + pass_filenames: false diff --git a/tests/test_base_adapter.py b/tests/test_base_adapter.py index da7f570..b01cd2b 100644 --- a/tests/test_base_adapter.py +++ b/tests/test_base_adapter.py @@ -244,9 +244,7 @@ class TestMCPConnectionMakeRequest: @respx.mock async def test_returns_parsed_json(self, base_url): expected = {"jsonrpc": "2.0", "id": "1", "result": {"tools": []}} - respx.post(base_url).mock( - return_value=httpx.Response(200, json=expected) - ) + respx.post(base_url).mock(return_value=httpx.Response(200, json=expected)) conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="none")) result = await conn._make_request( @@ -257,9 +255,7 @@ async def test_returns_parsed_json(self, base_url): @respx.mock async def test_http_error_raises(self, base_url): - respx.post(base_url).mock( - return_value=httpx.Response(500, text="Error") - ) + respx.post(base_url).mock(return_value=httpx.Response(500, text="Error")) conn = MCPConnection(MCPServerConfig(url=base_url, init_mode="none")) with pytest.raises(httpx.HTTPStatusError): @@ -458,9 +454,7 @@ async def test_no_retry_after_init(self, base_url): await conn.initialize() respx.reset() - respx.post(base_url).mock( - return_value=httpx.Response(400, text="Bad Request") - ) + respx.post(base_url).mock(return_value=httpx.Response(400, text="Bad Request")) with pytest.raises(httpx.HTTPStatusError): await conn.get_tools() @@ -748,9 +742,7 @@ async def test_tool_prefix(self): ) adapter = BaseAdapter( - MCPServerConfig( - url=url, name="weather", init_mode="none", tool_prefix="wx" - ) + MCPServerConfig(url=url, name="weather", init_mode="none", tool_prefix="wx") ) tools = await adapter.discover_tools() @@ -803,9 +795,7 @@ async def test_routes_to_correct_server(self): calendar_route = respx.post(url_b).mock( return_value=httpx.Response( 200, - json=_tools_response( - [{"name": "get_events", "description": "Events"}] - ), + json=_tools_response([{"name": "get_events", "description": "Events"}]), ) ) @@ -826,9 +816,7 @@ async def test_routes_to_correct_server(self): @respx.mock async def test_unknown_tool_raises(self, base_url, sample_mcp_tools): respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) adapter = BaseAdapter(MCPServerConfig(url=base_url, init_mode="none")) diff --git a/tests/test_gemini_adapter.py b/tests/test_gemini_adapter.py index 0dfa099..e1d54c3 100644 --- a/tests/test_gemini_adapter.py +++ b/tests/test_gemini_adapter.py @@ -31,9 +31,7 @@ class TestGetFunctionDeclarations: @respx.mock async def test_converts_mcp_tools(self, base_url, adapter, sample_mcp_tools): respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) declarations = await adapter.get_function_declarations() @@ -73,9 +71,7 @@ async def test_returns_tool_wrapping_declarations( self, base_url, adapter, sample_mcp_tools ): respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) tool = await adapter.get_tool() @@ -86,7 +82,9 @@ async def test_returns_tool_wrapping_declarations( class TestProcessFunctionCalls: @respx.mock - async def test_success(self, base_url, adapter, sample_mcp_tools, sample_tool_result): + async def test_success( + self, base_url, adapter, sample_mcp_tools, sample_tool_result + ): respx.post(base_url).mock( side_effect=[ httpx.Response(200, json=_tools_response(sample_mcp_tools)), @@ -117,7 +115,9 @@ async def test_non_dict_result_wrapped(self, base_url, adapter, sample_mcp_tools assert len(results) == 1 @respx.mock - async def test_dict_result_passed_directly(self, base_url, adapter, sample_mcp_tools): + async def test_dict_result_passed_directly( + self, base_url, adapter, sample_mcp_tools + ): respx.post(base_url).mock( side_effect=[ httpx.Response(200, json=_tools_response(sample_mcp_tools)), @@ -133,16 +133,12 @@ async def test_dict_result_passed_directly(self, base_url, adapter, sample_mcp_t @respx.mock async def test_http_error(self, base_url, adapter, sample_mcp_tools): respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) await adapter.discover_tools() respx.reset() - respx.post(base_url).mock( - return_value=httpx.Response(500, text="Server Error") - ) + respx.post(base_url).mock(return_value=httpx.Response(500, text="Server Error")) fc = types.FunctionCall(name="get_weather", args={"location": "London"}) results = await adapter.process_function_calls([fc]) @@ -152,16 +148,12 @@ async def test_http_error(self, base_url, adapter, sample_mcp_tools): @respx.mock async def test_return_errors_false(self, base_url, adapter, sample_mcp_tools): respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) await adapter.discover_tools() respx.reset() - respx.post(base_url).mock( - return_value=httpx.Response(500, text="Server Error") - ) + respx.post(base_url).mock(return_value=httpx.Response(500, text="Server Error")) fc = types.FunctionCall(name="get_weather", args={"location": "London"}) results = await adapter.process_function_calls([fc], return_errors=False) @@ -185,11 +177,11 @@ async def test_empty_args(self, base_url, adapter, sample_mcp_tool_no_schema): class TestProcessFunctionCallsAsParts: @respx.mock - async def test_returns_parts(self, base_url, adapter, sample_mcp_tools, sample_tool_result): + async def test_returns_parts( + self, base_url, adapter, sample_mcp_tools, sample_tool_result + ): respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) await adapter.discover_tools() @@ -207,16 +199,12 @@ async def test_returns_parts(self, base_url, adapter, sample_mcp_tools, sample_t @respx.mock async def test_error_returns_part(self, base_url, adapter, sample_mcp_tools): respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) await adapter.discover_tools() respx.reset() - respx.post(base_url).mock( - return_value=httpx.Response(500, text="Server Error") - ) + respx.post(base_url).mock(return_value=httpx.Response(500, text="Server Error")) fc = types.FunctionCall(name="get_weather", args={"location": "London"}) parts = await adapter.process_function_calls_as_parts([fc]) diff --git a/tests/test_openai_adapter.py b/tests/test_openai_adapter.py index c970cec..636d647 100644 --- a/tests/test_openai_adapter.py +++ b/tests/test_openai_adapter.py @@ -19,20 +19,19 @@ async def test_converts_mcp_tools_to_openai_format( self, base_url, sample_mcp_tools ): respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) - adapter = MCPToolAdapterOpenAI( - MCPServerConfig(url=base_url, init_mode="none") - ) + adapter = MCPToolAdapterOpenAI(MCPServerConfig(url=base_url, init_mode="none")) tools = await adapter.get_tool_definitions() assert len(tools) == 2 assert tools[0]["type"] == "function" assert tools[0]["function"]["name"] == "get_weather" - assert tools[0]["function"]["description"] == "Get the current weather for a location" + assert ( + tools[0]["function"]["description"] + == "Get the current weather for a location" + ) assert tools[0]["function"]["parameters"] == sample_mcp_tools[0]["inputSchema"] @respx.mock @@ -45,9 +44,7 @@ async def test_handles_missing_input_schema( ) ) - adapter = MCPToolAdapterOpenAI( - MCPServerConfig(url=base_url, init_mode="none") - ) + adapter = MCPToolAdapterOpenAI(MCPServerConfig(url=base_url, init_mode="none")) tools = await adapter.get_tool_definitions() assert len(tools) == 1 @@ -62,9 +59,7 @@ async def test_handles_empty_tools(self, base_url): return_value=httpx.Response(200, json=_tools_response([])) ) - adapter = MCPToolAdapterOpenAI( - MCPServerConfig(url=base_url, init_mode="none") - ) + adapter = MCPToolAdapterOpenAI(MCPServerConfig(url=base_url, init_mode="none")) tools = await adapter.get_tool_definitions() assert tools == [] @@ -79,9 +74,7 @@ async def test_success(self, base_url, sample_mcp_tools, sample_tool_result): ] ) - adapter = MCPToolAdapterOpenAI( - MCPServerConfig(url=base_url, init_mode="none") - ) + adapter = MCPToolAdapterOpenAI(MCPServerConfig(url=base_url, init_mode="none")) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", @@ -104,14 +97,10 @@ async def test_success(self, base_url, sample_mcp_tools, sample_tool_result): async def test_multiple_calls(self, base_url, sample_mcp_tools): # Pre-discover tools, then mock the tool call responses respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) - adapter = MCPToolAdapterOpenAI( - MCPServerConfig(url=base_url, init_mode="none") - ) + adapter = MCPToolAdapterOpenAI(MCPServerConfig(url=base_url, init_mode="none")) await adapter.discover_tools() respx.reset() @@ -148,9 +137,7 @@ async def test_multiple_calls(self, base_url, sample_mcp_tools): @respx.mock async def test_invalid_json_arguments(self, base_url): - adapter = MCPToolAdapterOpenAI( - MCPServerConfig(url=base_url, init_mode="none") - ) + adapter = MCPToolAdapterOpenAI(MCPServerConfig(url=base_url, init_mode="none")) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", @@ -172,20 +159,14 @@ async def test_invalid_json_arguments(self, base_url): async def test_http_error(self, base_url, sample_mcp_tools): # Discover tools first, then fail on tool call respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) - adapter = MCPToolAdapterOpenAI( - MCPServerConfig(url=base_url, init_mode="none") - ) + adapter = MCPToolAdapterOpenAI(MCPServerConfig(url=base_url, init_mode="none")) await adapter.discover_tools() respx.reset() - respx.post(base_url).mock( - return_value=httpx.Response(500, text="Server Error") - ) + respx.post(base_url).mock(return_value=httpx.Response(500, text="Server Error")) tool_calls = [ ChatCompletionMessageToolCall( @@ -204,24 +185,16 @@ async def test_http_error(self, base_url, sample_mcp_tools): assert "error" in content @respx.mock - async def test_return_errors_false_omits_failures( - self, base_url, sample_mcp_tools - ): + async def test_return_errors_false_omits_failures(self, base_url, sample_mcp_tools): respx.post(base_url).mock( - return_value=httpx.Response( - 200, json=_tools_response(sample_mcp_tools) - ) + return_value=httpx.Response(200, json=_tools_response(sample_mcp_tools)) ) - adapter = MCPToolAdapterOpenAI( - MCPServerConfig(url=base_url, init_mode="none") - ) + adapter = MCPToolAdapterOpenAI(MCPServerConfig(url=base_url, init_mode="none")) await adapter.discover_tools() respx.reset() - respx.post(base_url).mock( - return_value=httpx.Response(500, text="Server Error") - ) + respx.post(base_url).mock(return_value=httpx.Response(500, text="Server Error")) tool_calls = [ ChatCompletionMessageToolCall( @@ -238,9 +211,7 @@ async def test_return_errors_false_omits_failures( assert len(results) == 0 @respx.mock - async def test_string_result_not_double_encoded( - self, base_url, sample_mcp_tools - ): + async def test_string_result_not_double_encoded(self, base_url, sample_mcp_tools): respx.post(base_url).mock( side_effect=[ httpx.Response(200, json=_tools_response(sample_mcp_tools)), @@ -248,9 +219,7 @@ async def test_string_result_not_double_encoded( ] ) - adapter = MCPToolAdapterOpenAI( - MCPServerConfig(url=base_url, init_mode="none") - ) + adapter = MCPToolAdapterOpenAI(MCPServerConfig(url=base_url, init_mode="none")) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", @@ -278,9 +247,7 @@ async def test_dict_result_is_json_encoded( ] ) - adapter = MCPToolAdapterOpenAI( - MCPServerConfig(url=base_url, init_mode="none") - ) + adapter = MCPToolAdapterOpenAI(MCPServerConfig(url=base_url, init_mode="none")) tool_calls = [ ChatCompletionMessageToolCall( id="call_1", From 85bb1d7167d8dd3d957d2534304db6c59fbf6b2a Mon Sep 17 00:00:00 2001 From: Stepan Filonov Date: Mon, 2 Feb 2026 05:25:23 +0800 Subject: [PATCH 7/8] handle collisions and server names better, add logging --- mcphero/adapters/base_adapter.py | 37 ++++++++++++++++++++++++++++---- mcphero/logging.py | 21 ++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 mcphero/logging.py diff --git a/mcphero/adapters/base_adapter.py b/mcphero/adapters/base_adapter.py index 86bca1c..6a11a18 100644 --- a/mcphero/adapters/base_adapter.py +++ b/mcphero/adapters/base_adapter.py @@ -11,6 +11,7 @@ from dataclasses import dataclass, field from enum import Enum from typing import Any, Generic, TypedDict, TypeVar +from urllib.parse import urlparse import httpx @@ -72,7 +73,14 @@ class MCPServerConfig: def __post_init__(self): if self.name is None: - self.name = self.url.rstrip("/").split("/")[-1] or "server" + parsed = urlparse(self.url) + hostname = parsed.hostname or "unknown" + parts = hostname.split(".") + # Take the second-level domain part (e.g. "context7" from mcp.context7.com, + # "mcphero" from api.mcphero.app) + domain = parts[-2] if len(parts) >= 2 else hostname + path_part = parsed.path.rstrip("/").split("/")[-1] or "server" + self.name = f"{domain}_{path_part}" if isinstance(self.init_mode, str): self.init_mode = InitMode(self.init_mode) @@ -275,13 +283,34 @@ def __init__( self.prefix_separator = prefix_separator self._connections: dict[str, MCPConnection] = {} - for cfg in self._configs: - assert cfg.name is not None - self._connections[cfg.name] = MCPConnection(cfg) + self._prepare_connections() self._tool_map: dict[str, ToolMapping] = {} self._tools: list[MCPToolDefinition] = [] self._discovered = False + def _prepare_connections(self): + seen_names: set[str] = set() + for cfg in self._configs: + assert cfg.name is not None + base_name = cfg.name + unique_name = base_name + counter = 1 + + while unique_name in seen_names: + if not self.auto_prefix_on_collision: + raise ValueError( + f"Duplicate server name '{base_name}'. " + "Provide unique names explicitly or enable auto_prefix_on_collision." + ) + counter += 1 + unique_name = f"{base_name}{self.prefix_separator}{counter}" + + seen_names.add(unique_name) + if unique_name != base_name: + cfg.name = unique_name # Keep config in sync for later look-ups + + self._connections[unique_name] = MCPConnection(cfg) + @property def is_multi_server(self) -> bool: return len(self._configs) > 1 diff --git a/mcphero/logging.py b/mcphero/logging.py new file mode 100644 index 0000000..9ce54fa --- /dev/null +++ b/mcphero/logging.py @@ -0,0 +1,21 @@ +import logging +import sys +from typing import TextIO + + +def get_logger(name: str, log_level: int, stream: "TextIO", fmt: str) -> logging.Logger: + logger = logging.getLogger(name) + logger.setLevel(log_level) + logger.propagate = False + + handler = logging.StreamHandler(stream=stream) + logger.addHandler(handler) + return logger + + +logger = get_logger( + name="faststream", + log_level=logging.INFO, + stream=sys.stderr, + fmt="%(asctime)s %(levelname)8s - %(message)s", +) From 73734325b2ca11a80edca0117d933ce9f254b153 Mon Sep 17 00:00:00 2001 From: Stepan Filonov Date: Mon, 2 Feb 2026 05:29:15 +0800 Subject: [PATCH 8/8] Fix tests, adjust pytest config --- pyproject.toml | 1 - tests/test_base_adapter.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 58011ea..12dfbd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,6 @@ Website = "https://mcphero.app" [tool.pytest.ini_options] minversion = "7.0" asyncio_mode = "auto" -timeout = 30 addopts = "--strict-markers --strict-config -q -m 'not slow'" testpaths = ["tests"] markers = ["slow", "all", "benchmark"] diff --git a/tests/test_base_adapter.py b/tests/test_base_adapter.py index b01cd2b..b30d179 100644 --- a/tests/test_base_adapter.py +++ b/tests/test_base_adapter.py @@ -32,11 +32,11 @@ def _tools_response(tools): class TestMCPServerConfig: def test_auto_name_from_url(self): cfg = MCPServerConfig(url="https://example.com/mcp/weather") - assert cfg.name == "weather" + assert cfg.name == "example_weather" def test_auto_name_strips_trailing_slash(self): cfg = MCPServerConfig(url="https://example.com/mcp/weather/") - assert cfg.name == "weather" + assert cfg.name == "example_weather" def test_explicit_name(self): cfg = MCPServerConfig(url="https://example.com/x", name="my-srv")