Coverage for merco/mcp/manager.py: 68%
122 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-07 14:04 +0800
1"""MCP Server lifecycle manager — connect, discover tools, register."""
2import asyncio
3import logging
4import time
5from .config import MCPServerConfig
6from .tool import MCPServerTool
8logger = logging.getLogger("merco.mcp")
10# Optional mcp imports
11_MCP_AVAILABLE = False
12try:
13 from mcp import ClientSession, StdioServerParameters
14 from mcp.client.stdio import stdio_client
15 _MCP_AVAILABLE = True
16except ImportError:
17 pass
20class MCPServerManager:
21 def __init__(self, tool_registry, hooks=None):
22 self._registry = tool_registry
23 self._hooks = hooks
24 self._servers: dict[str, dict] = {} # name → {config, tools: [MCPServerTool]}
25 self._original_config: dict = {}
27 async def load_config(self, servers_config: dict) -> None:
28 """Load config from merco.json. Connect enabled, skip if already connected."""
29 self._original_config = servers_config
30 if not _MCP_AVAILABLE:
31 logger.warning("mcp package not installed — skipping MCP")
32 return
33 for name, data in servers_config.items():
34 if name in self._servers:
35 continue
36 cfg = MCPServerConfig.from_dict(name, data)
37 if not cfg.enabled:
38 continue
39 await self.connect(name, cfg)
41 async def connect(self, name: str, config: MCPServerConfig) -> bool:
42 """Connect to MCP server + discover tools + register them."""
43 if not _MCP_AVAILABLE:
44 return False
45 try:
46 if config.command:
47 tools = await self._connect_stdio(config)
48 elif config.url:
49 tools = await self._connect_http(config)
50 else:
51 logger.warning("MCP '%s': no command or url", name)
52 return False
54 # Unregister old tools if reconnecting
55 await self._unregister_tools(name)
57 # Register each tool
58 server_tools = []
59 for spec in tools:
60 tool = MCPServerTool(spec, name, self._call_tool)
61 self._registry.register(tool)
62 server_tools.append(tool)
64 self._servers[name] = {"config": config, "tools": server_tools}
65 if self._hooks:
66 await self._hooks.emit("mcp.connect", server=name, tools=len(tools))
67 logger.info("MCP '%s': %d tools registered", name, len(tools))
68 return True
69 except Exception as e:
70 logger.warning("MCP '%s' connection failed: %s", name, e)
71 return False
73 async def _connect_stdio(self, config: MCPServerConfig) -> list[dict]:
74 params = StdioServerParameters(
75 command=config.command, args=config.args, env=config.env
76 )
77 async with stdio_client(params) as (read, write):
78 async with ClientSession(read, write) as session:
79 await session.initialize()
80 result = await session.list_tools()
81 return [t.model_dump() for t in result.tools]
83 async def _connect_http(self, config: MCPServerConfig) -> list[dict]:
84 # StreamableHTTP support — use mcp.client.streamable_http if available
85 try:
86 from mcp.client.streamable_http import streamablehttp_client
87 except ImportError:
88 raise ImportError("mcp HTTP transport requires mcp>=1.0 with streamable_http")
89 async with streamablehttp_client(config.url, headers=config.headers) as (read, write):
90 async with ClientSession(read, write) as session:
91 await session.initialize()
92 result = await session.list_tools()
93 return [t.model_dump() for t in result.tools]
95 async def _unregister_tools(self, name: str) -> None:
96 if name in self._servers:
97 for tool in self._servers[name]["tools"]:
98 self._registry.unregister(tool.name)
100 async def disconnect(self, name: str) -> None:
101 await self._unregister_tools(name)
102 self._servers.pop(name, None)
104 async def shutdown(self):
105 """关闭所有 MCP 连接。"""
106 for name in list(self._servers.keys()):
107 await self.disconnect(name)
109 async def reload(self) -> None:
110 for name in list(self._servers.keys()):
111 await self.disconnect(name)
112 await self.load_config(self._original_config)
114 async def _call_tool(self, tool_name: str, arguments: dict) -> dict:
115 # Find which server owns this tool, call via MCP session
116 for name, state in self._servers.items():
117 for tool in state["tools"]:
118 if tool.name == tool_name:
119 t0 = time.monotonic()
120 try:
121 if state["config"].command:
122 result = await self._call_stdio_tool(state["config"], tool_name, arguments)
123 else:
124 result = await self._call_http_tool(state["config"], tool_name, arguments)
125 if self._hooks:
126 await self._hooks.emit("mcp.tool_call", server=name, tool=tool_name,
127 duration=time.monotonic()-t0)
128 return result
129 except Exception as e:
130 if self._hooks:
131 await self._hooks.emit("mcp.error", server=name, tool=tool_name, error=str(e))
132 raise
133 return {"error": f"Tool '{tool_name}' not found in any MCP server", "isError": True}
135 async def _call_stdio_tool(self, config: MCPServerConfig, tool_name: str, arguments: dict) -> dict:
136 params = StdioServerParameters(
137 command=config.command, args=config.args, env=config.env
138 )
139 async with stdio_client(params) as (read, write):
140 async with ClientSession(read, write) as session:
141 await session.initialize()
142 result = await session.call_tool(tool_name, arguments)
143 return result.model_dump()
145 async def _call_http_tool(self, config: MCPServerConfig, tool_name: str, arguments: dict) -> dict:
146 try:
147 from mcp.client.streamable_http import streamablehttp_client
148 except ImportError:
149 raise ImportError("mcp HTTP transport requires mcp>=1.0 with streamable_http")
150 async with streamablehttp_client(config.url, headers=config.headers) as (read, write):
151 async with ClientSession(read, write) as session:
152 await session.initialize()
153 result = await session.call_tool(tool_name, arguments)
154 return result.model_dump()
156 def status(self) -> dict:
157 return {
158 name: {
159 "connected": True,
160 "tools_count": len(state["tools"]),
161 "enabled": state["config"].enabled,
162 }
163 for name, state in self._servers.items()
164 }