bizyengine 1.2.50__py3-none-any.whl → 1.2.52__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bizyengine/bizy_server/errno.py +21 -0
- bizyengine/bizy_server/server.py +119 -159
- bizyengine/bizyair_extras/__init__.py +1 -0
- bizyengine/bizyair_extras/nodes_seedream.py +195 -0
- bizyengine/bizybot/__init__.py +12 -0
- bizyengine/bizybot/client.py +774 -0
- bizyengine/bizybot/config.py +129 -0
- bizyengine/bizybot/coordinator.py +556 -0
- bizyengine/bizybot/exceptions.py +186 -0
- bizyengine/bizybot/mcp/__init__.py +3 -0
- bizyengine/bizybot/mcp/manager.py +520 -0
- bizyengine/bizybot/mcp/models.py +46 -0
- bizyengine/bizybot/mcp/registry.py +129 -0
- bizyengine/bizybot/mcp/routing.py +378 -0
- bizyengine/bizybot/models.py +344 -0
- bizyengine/core/common/client.py +0 -1
- bizyengine/version.txt +1 -1
- {bizyengine-1.2.50.dist-info → bizyengine-1.2.52.dist-info}/METADATA +2 -1
- {bizyengine-1.2.50.dist-info → bizyengine-1.2.52.dist-info}/RECORD +21 -9
- {bizyengine-1.2.50.dist-info → bizyengine-1.2.52.dist-info}/WHEEL +0 -0
- {bizyengine-1.2.50.dist-info → bizyengine-1.2.52.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
统一异常处理模块
|
|
3
|
+
|
|
4
|
+
定义了协调器系统中使用的所有自定义异常类型,
|
|
5
|
+
提供了错误处理的基础架构和优雅降级机制。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CoordinatorError(Exception):
|
|
12
|
+
"""基础协调器异常
|
|
13
|
+
|
|
14
|
+
所有协调器相关异常的基类,提供统一的错误处理接口。
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
message: str,
|
|
20
|
+
error_code: Optional[str] = None,
|
|
21
|
+
details: Optional[Dict[str, Any]] = None,
|
|
22
|
+
cause: Optional[Exception] = None,
|
|
23
|
+
):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.error_code = error_code or self.__class__.__name__
|
|
27
|
+
self.details = details or {}
|
|
28
|
+
self.cause = cause
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
31
|
+
"""将异常转换为字典格式,用于API响应"""
|
|
32
|
+
result = {
|
|
33
|
+
"error": self.error_code,
|
|
34
|
+
"message": self.message,
|
|
35
|
+
"details": self.details,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if self.cause:
|
|
39
|
+
result["cause"] = str(self.cause)
|
|
40
|
+
|
|
41
|
+
return result
|
|
42
|
+
|
|
43
|
+
def __str__(self) -> str:
|
|
44
|
+
if self.details:
|
|
45
|
+
return f"{self.message} (Details: {self.details})"
|
|
46
|
+
return self.message
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LLMError(CoordinatorError):
|
|
50
|
+
"""LLM相关错误
|
|
51
|
+
|
|
52
|
+
包括API调用失败、响应解析错误、模型不可用等。
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class LLMAPIError(LLMError):
|
|
59
|
+
"""LLM API调用错误"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
message: str,
|
|
64
|
+
status_code: Optional[int] = None,
|
|
65
|
+
response_body: Optional[str] = None,
|
|
66
|
+
**kwargs,
|
|
67
|
+
):
|
|
68
|
+
super().__init__(message, **kwargs)
|
|
69
|
+
self.status_code = status_code
|
|
70
|
+
self.response_body = response_body
|
|
71
|
+
|
|
72
|
+
if status_code:
|
|
73
|
+
self.details["status_code"] = status_code
|
|
74
|
+
if response_body:
|
|
75
|
+
self.details["response_body"] = response_body
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LLMResponseError(LLMError):
|
|
79
|
+
"""LLM响应解析错误"""
|
|
80
|
+
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class LLMTimeoutError(LLMError):
|
|
85
|
+
"""LLM请求超时错误"""
|
|
86
|
+
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class MCPError(CoordinatorError):
|
|
91
|
+
"""MCP相关错误
|
|
92
|
+
|
|
93
|
+
包括连接失败、协议错误、工具调用失败等。
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MCPConnectionError(MCPError):
|
|
100
|
+
"""MCP连接错误"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, message: str, server_name: Optional[str] = None, **kwargs):
|
|
103
|
+
super().__init__(message, **kwargs)
|
|
104
|
+
self.server_name = server_name
|
|
105
|
+
|
|
106
|
+
if server_name:
|
|
107
|
+
self.details["server_name"] = server_name
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class MCPToolError(MCPError):
|
|
111
|
+
"""MCP工具调用错误"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
message: str,
|
|
116
|
+
tool_name: Optional[str] = None,
|
|
117
|
+
server_name: Optional[str] = None,
|
|
118
|
+
**kwargs,
|
|
119
|
+
):
|
|
120
|
+
super().__init__(message, **kwargs)
|
|
121
|
+
self.tool_name = tool_name
|
|
122
|
+
self.server_name = server_name
|
|
123
|
+
|
|
124
|
+
if tool_name:
|
|
125
|
+
self.details["tool_name"] = tool_name
|
|
126
|
+
if server_name:
|
|
127
|
+
self.details["server_name"] = server_name
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ToolNotFoundError(MCPToolError):
|
|
131
|
+
"""工具未找到错误"""
|
|
132
|
+
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ToolExecutionError(MCPToolError):
|
|
137
|
+
"""工具执行错误"""
|
|
138
|
+
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ToolValidationError(MCPToolError):
|
|
143
|
+
"""工具参数验证错误"""
|
|
144
|
+
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ConfigurationError(CoordinatorError):
|
|
149
|
+
"""配置相关错误
|
|
150
|
+
|
|
151
|
+
包括配置文件格式错误、必需参数缺失、环境变量错误等。
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ConfigFileError(ConfigurationError):
|
|
158
|
+
"""配置文件错误"""
|
|
159
|
+
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ConfigValidationError(ConfigurationError):
|
|
164
|
+
"""配置验证错误"""
|
|
165
|
+
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ValidationError(CoordinatorError):
|
|
170
|
+
"""数据验证错误"""
|
|
171
|
+
|
|
172
|
+
def __init__(
|
|
173
|
+
self,
|
|
174
|
+
message: str,
|
|
175
|
+
field: Optional[str] = None,
|
|
176
|
+
value: Optional[Any] = None,
|
|
177
|
+
**kwargs,
|
|
178
|
+
):
|
|
179
|
+
super().__init__(message, **kwargs)
|
|
180
|
+
self.field = field
|
|
181
|
+
self.value = value
|
|
182
|
+
|
|
183
|
+
if field:
|
|
184
|
+
self.details["field"] = field
|
|
185
|
+
if value is not None:
|
|
186
|
+
self.details["value"] = str(value)
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP client manager for handling multiple MCP server connections using MCP Python SDK
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from contextlib import AsyncExitStack
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from mcp import ClientSession, StdioServerParameters
|
|
12
|
+
from mcp.client.stdio import stdio_client
|
|
13
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
14
|
+
|
|
15
|
+
from bizyengine.bizybot.config import MCPServerConfig
|
|
16
|
+
from bizyengine.bizybot.exceptions import (
|
|
17
|
+
MCPConnectionError,
|
|
18
|
+
MCPError,
|
|
19
|
+
ToolExecutionError,
|
|
20
|
+
ToolNotFoundError,
|
|
21
|
+
ToolValidationError,
|
|
22
|
+
)
|
|
23
|
+
from bizyengine.bizybot.mcp.models import ServerStatus, Tool
|
|
24
|
+
from bizyengine.bizybot.mcp.registry import MCPToolRegistry
|
|
25
|
+
from bizyengine.bizybot.mcp.routing import MCPToolRouter, ToolCallBatch
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MCPServerConnection:
|
|
29
|
+
"""
|
|
30
|
+
Individual MCP server connection using MCP Python SDK
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, server_name: str, config: MCPServerConfig):
|
|
34
|
+
self.server_name = server_name
|
|
35
|
+
self.config = config
|
|
36
|
+
self.session: Optional[ClientSession] = None
|
|
37
|
+
self.exit_stack = AsyncExitStack()
|
|
38
|
+
self.tools: List[Tool] = []
|
|
39
|
+
self._connected = False
|
|
40
|
+
self._cleanup_lock = asyncio.Lock()
|
|
41
|
+
# Owner-task based lifecycle management for HTTP transport
|
|
42
|
+
self._owner_task: Optional[asyncio.Task] = None
|
|
43
|
+
self._stop_event = asyncio.Event()
|
|
44
|
+
self._ready_event = asyncio.Event()
|
|
45
|
+
|
|
46
|
+
async def initialize(self) -> None:
|
|
47
|
+
"""Initialize MCP connection using appropriate transport"""
|
|
48
|
+
try:
|
|
49
|
+
transport_type = self.config.transport or "stdio"
|
|
50
|
+
|
|
51
|
+
if transport_type == "stdio":
|
|
52
|
+
await self._initialize_stdio()
|
|
53
|
+
elif transport_type == "streamable_http":
|
|
54
|
+
await self._initialize_http()
|
|
55
|
+
else:
|
|
56
|
+
raise MCPConnectionError(
|
|
57
|
+
f"Unsupported transport type: {transport_type}",
|
|
58
|
+
server_name=self.server_name,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
except MCPConnectionError:
|
|
62
|
+
raise
|
|
63
|
+
except Exception as e:
|
|
64
|
+
await self.cleanup()
|
|
65
|
+
raise MCPConnectionError(
|
|
66
|
+
f"Initialization failed: {e}", server_name=self.server_name
|
|
67
|
+
) from e
|
|
68
|
+
|
|
69
|
+
async def _initialize_stdio(self) -> None:
|
|
70
|
+
"""Initialize stdio transport for local MCP servers"""
|
|
71
|
+
command = self.config.command
|
|
72
|
+
if not command:
|
|
73
|
+
raise MCPConnectionError(
|
|
74
|
+
"No command specified for stdio transport", server_name=self.server_name
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Handle command resolution (e.g., uvx, npx)
|
|
78
|
+
if command in ["uvx", "npx"]:
|
|
79
|
+
import shutil
|
|
80
|
+
|
|
81
|
+
resolved_command = shutil.which(command)
|
|
82
|
+
if not resolved_command:
|
|
83
|
+
raise MCPConnectionError(
|
|
84
|
+
f"Command '{command}' not found in PATH",
|
|
85
|
+
server_name=self.server_name,
|
|
86
|
+
)
|
|
87
|
+
command = resolved_command
|
|
88
|
+
|
|
89
|
+
server_params = StdioServerParameters(
|
|
90
|
+
command=command,
|
|
91
|
+
args=self.config.args or [],
|
|
92
|
+
env={**os.environ, **(self.config.env or {})},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
# Use MCP SDK's stdio client
|
|
97
|
+
stdio_transport = await self.exit_stack.enter_async_context(
|
|
98
|
+
stdio_client(server_params)
|
|
99
|
+
)
|
|
100
|
+
read, write = stdio_transport
|
|
101
|
+
|
|
102
|
+
# Create client session
|
|
103
|
+
session = await self.exit_stack.enter_async_context(
|
|
104
|
+
ClientSession(read, write)
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Initialize MCP protocol
|
|
108
|
+
await session.initialize()
|
|
109
|
+
self.session = session
|
|
110
|
+
self._connected = True
|
|
111
|
+
|
|
112
|
+
# Discover tools
|
|
113
|
+
await self._discover_tools()
|
|
114
|
+
|
|
115
|
+
except Exception:
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
async def _http_owner(self) -> None:
|
|
119
|
+
"""Owner task that manages HTTP transport lifecycle in a single task.
|
|
120
|
+
Ensures enter/exit of streamablehttp_client and ClientSession happen in the same task
|
|
121
|
+
to satisfy AnyIO cancel scope requirements.
|
|
122
|
+
"""
|
|
123
|
+
url = self.config.url
|
|
124
|
+
timeout_seconds = (
|
|
125
|
+
int(self.config.timeout) if self.config.timeout is not None else 30
|
|
126
|
+
)
|
|
127
|
+
timeout = timedelta(seconds=timeout_seconds)
|
|
128
|
+
headers = self.config.headers
|
|
129
|
+
try:
|
|
130
|
+
async with streamablehttp_client(
|
|
131
|
+
url=url, timeout=timeout, headers=headers
|
|
132
|
+
) as transport:
|
|
133
|
+
read, write, get_session_id = transport
|
|
134
|
+
async with ClientSession(read, write) as session:
|
|
135
|
+
await session.initialize()
|
|
136
|
+
self.session = session
|
|
137
|
+
self._connected = True
|
|
138
|
+
if get_session_id:
|
|
139
|
+
try:
|
|
140
|
+
# Session id is optional; just trigger retrieval if available
|
|
141
|
+
get_session_id()
|
|
142
|
+
except Exception:
|
|
143
|
+
# Session id is optional; ignore retrieval errors
|
|
144
|
+
pass
|
|
145
|
+
# Discover tools
|
|
146
|
+
try:
|
|
147
|
+
await self._discover_tools()
|
|
148
|
+
finally:
|
|
149
|
+
# Signal ready regardless of discover success to unblock initializer
|
|
150
|
+
if not self._ready_event.is_set():
|
|
151
|
+
self._ready_event.set()
|
|
152
|
+
# Wait for stop signal
|
|
153
|
+
await self._stop_event.wait()
|
|
154
|
+
except Exception:
|
|
155
|
+
# Ensure initializer doesn't hang if failure happens before ready
|
|
156
|
+
if not self._ready_event.is_set():
|
|
157
|
+
self._ready_event.set()
|
|
158
|
+
finally:
|
|
159
|
+
# Reset connection state on exit
|
|
160
|
+
self.session = None
|
|
161
|
+
self._connected = False
|
|
162
|
+
|
|
163
|
+
async def _initialize_http(self) -> None:
|
|
164
|
+
"""Initialize HTTP transport for remote MCP servers"""
|
|
165
|
+
url = self.config.url
|
|
166
|
+
if not url:
|
|
167
|
+
raise MCPConnectionError(
|
|
168
|
+
"No URL specified for HTTP transport", server_name=self.server_name
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
# Start owner task managing HTTP transport
|
|
173
|
+
# Reset control events
|
|
174
|
+
self._stop_event.clear()
|
|
175
|
+
self._ready_event.clear()
|
|
176
|
+
# Launch owner task
|
|
177
|
+
self._owner_task = asyncio.create_task(self._http_owner())
|
|
178
|
+
# Wait until the owner has initialized or errored
|
|
179
|
+
await self._ready_event.wait()
|
|
180
|
+
if not self._connected or not self.session:
|
|
181
|
+
raise MCPConnectionError(
|
|
182
|
+
f"HTTP initialization failed for {self.server_name}",
|
|
183
|
+
server_name=self.server_name,
|
|
184
|
+
)
|
|
185
|
+
except Exception:
|
|
186
|
+
# Ensure owner task is stopped if started
|
|
187
|
+
if self._owner_task and not self._owner_task.done():
|
|
188
|
+
self._stop_event.set()
|
|
189
|
+
try:
|
|
190
|
+
await self._owner_task
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
finally:
|
|
194
|
+
self._owner_task = None
|
|
195
|
+
raise
|
|
196
|
+
|
|
197
|
+
async def _discover_tools(self) -> None:
|
|
198
|
+
"""Discover available tools from the MCP server"""
|
|
199
|
+
if not self.session:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
# Use SDK's list_tools method
|
|
204
|
+
tools_response = await self.session.list_tools()
|
|
205
|
+
|
|
206
|
+
self.tools = []
|
|
207
|
+
for tool_data in tools_response.tools:
|
|
208
|
+
tool = Tool(
|
|
209
|
+
name=tool_data.name,
|
|
210
|
+
description=tool_data.description,
|
|
211
|
+
input_schema=tool_data.inputSchema,
|
|
212
|
+
server_name=self.server_name,
|
|
213
|
+
title=getattr(tool_data, "title", None),
|
|
214
|
+
)
|
|
215
|
+
self.tools.append(tool)
|
|
216
|
+
|
|
217
|
+
except Exception:
|
|
218
|
+
self.tools = []
|
|
219
|
+
|
|
220
|
+
def list_tools(self) -> List[Dict[str, Any]]:
|
|
221
|
+
"""Get list of available tools"""
|
|
222
|
+
if not self._connected:
|
|
223
|
+
raise MCPConnectionError(f"Server {self.server_name} not connected")
|
|
224
|
+
|
|
225
|
+
return [
|
|
226
|
+
{
|
|
227
|
+
"name": tool.name,
|
|
228
|
+
"description": tool.description,
|
|
229
|
+
"inputSchema": tool.input_schema,
|
|
230
|
+
"title": tool.title,
|
|
231
|
+
}
|
|
232
|
+
for tool in self.tools
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
async def call_tool(
|
|
236
|
+
self, tool_name: str, arguments: Dict[str, Any]
|
|
237
|
+
) -> Dict[str, Any]:
|
|
238
|
+
"""Call a specific tool using MCP SDK with retry mechanism"""
|
|
239
|
+
if not self.session or not self._connected:
|
|
240
|
+
raise MCPConnectionError(
|
|
241
|
+
f"Server {self.server_name} not connected", server_name=self.server_name
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# Verify tool exists
|
|
245
|
+
tool = next((t for t in self.tools if t.name == tool_name), None)
|
|
246
|
+
if not tool:
|
|
247
|
+
raise ToolNotFoundError(
|
|
248
|
+
f"Tool '{tool_name}' not found on server {self.server_name}",
|
|
249
|
+
tool_name=tool_name,
|
|
250
|
+
server_name=self.server_name,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Validate arguments against tool schema (basic validation)
|
|
254
|
+
try:
|
|
255
|
+
self._validate_tool_arguments(arguments, tool.input_schema)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
raise ToolValidationError(
|
|
258
|
+
f"Invalid arguments for tool '{tool_name}': {e}",
|
|
259
|
+
tool_name=tool_name,
|
|
260
|
+
server_name=self.server_name,
|
|
261
|
+
) from e
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
# Use SDK's call_tool method
|
|
265
|
+
result = await self.session.call_tool(tool_name, arguments)
|
|
266
|
+
|
|
267
|
+
# Process result
|
|
268
|
+
tool_result = {
|
|
269
|
+
"content": result.content,
|
|
270
|
+
"isError": getattr(result, "isError", False),
|
|
271
|
+
"server_name": self.server_name,
|
|
272
|
+
"tool_name": tool_name,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if tool_result["isError"]:
|
|
276
|
+
raise ToolExecutionError(
|
|
277
|
+
f"Tool returned error: {result.content}",
|
|
278
|
+
tool_name=tool_name,
|
|
279
|
+
server_name=self.server_name,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return tool_result
|
|
283
|
+
|
|
284
|
+
except ToolExecutionError:
|
|
285
|
+
raise
|
|
286
|
+
except Exception as e:
|
|
287
|
+
raise ToolExecutionError(
|
|
288
|
+
f"Tool execution failed: {e}",
|
|
289
|
+
tool_name=tool_name,
|
|
290
|
+
server_name=self.server_name,
|
|
291
|
+
) from e
|
|
292
|
+
|
|
293
|
+
def _validate_tool_arguments(
|
|
294
|
+
self, arguments: Dict[str, Any], schema: Dict[str, Any]
|
|
295
|
+
) -> None:
|
|
296
|
+
"""Basic validation of tool arguments against schema"""
|
|
297
|
+
if not isinstance(arguments, dict):
|
|
298
|
+
raise ToolValidationError("Arguments must be a dictionary")
|
|
299
|
+
|
|
300
|
+
# Check required fields
|
|
301
|
+
required_fields = schema.get("required", [])
|
|
302
|
+
for field in required_fields:
|
|
303
|
+
if field not in arguments:
|
|
304
|
+
raise ToolValidationError(
|
|
305
|
+
f"Required field '{field}' missing from arguments"
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Basic type checking for properties
|
|
309
|
+
properties = schema.get("properties", {})
|
|
310
|
+
for field_name, field_value in arguments.items():
|
|
311
|
+
if field_name in properties:
|
|
312
|
+
expected_type = properties[field_name].get("type")
|
|
313
|
+
if expected_type and not self._check_json_type(
|
|
314
|
+
field_value, expected_type
|
|
315
|
+
):
|
|
316
|
+
raise ToolValidationError(
|
|
317
|
+
f"Field '{field_name}' has incorrect type. Expected {expected_type}"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _check_json_type(self, value: Any, expected_type: str) -> bool:
|
|
321
|
+
"""Check if value matches JSON schema type"""
|
|
322
|
+
type_mapping = {
|
|
323
|
+
"string": str,
|
|
324
|
+
"number": (int, float),
|
|
325
|
+
"integer": int,
|
|
326
|
+
"boolean": bool,
|
|
327
|
+
"array": list,
|
|
328
|
+
"object": dict,
|
|
329
|
+
"null": type(None),
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
expected_python_type = type_mapping.get(expected_type)
|
|
333
|
+
if expected_python_type is None:
|
|
334
|
+
return True # Unknown type, skip validation
|
|
335
|
+
|
|
336
|
+
return isinstance(value, expected_python_type)
|
|
337
|
+
|
|
338
|
+
async def cleanup(self) -> None:
|
|
339
|
+
"""Clean up server connection resources"""
|
|
340
|
+
async with self._cleanup_lock:
|
|
341
|
+
try:
|
|
342
|
+
# Stop owner task (HTTP transport) if running
|
|
343
|
+
if self._owner_task and not self._owner_task.done():
|
|
344
|
+
self._stop_event.set()
|
|
345
|
+
try:
|
|
346
|
+
await self._owner_task
|
|
347
|
+
finally:
|
|
348
|
+
self._owner_task = None
|
|
349
|
+
# Close any stdio resources managed by exit stack
|
|
350
|
+
await self.exit_stack.aclose()
|
|
351
|
+
self.session = None
|
|
352
|
+
self._connected = False
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
def is_connected(self) -> bool:
|
|
357
|
+
"""Check if server connection is active"""
|
|
358
|
+
return self._connected and self.session is not None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class MCPClientManager:
|
|
362
|
+
"""
|
|
363
|
+
Manager for multiple MCP server connections using MCP Python SDK
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
def __init__(self):
|
|
367
|
+
self.connections: Dict[str, MCPServerConnection] = {}
|
|
368
|
+
self.tool_registry = MCPToolRegistry()
|
|
369
|
+
self.server_status: Dict[str, ServerStatus] = {}
|
|
370
|
+
|
|
371
|
+
# Tool routing
|
|
372
|
+
self.router = MCPToolRouter(self)
|
|
373
|
+
|
|
374
|
+
async def initialize_servers(self, config: Dict[str, MCPServerConfig]) -> None:
|
|
375
|
+
"""Initialize connections to all configured MCP servers
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
config: Mapping of server name to global MCPServerConfig dataclass
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
for server_name, server_cfg in config.items():
|
|
382
|
+
# Re-validate using dataclass validation method if available
|
|
383
|
+
if hasattr(server_cfg, "_validate"):
|
|
384
|
+
server_cfg._validate()
|
|
385
|
+
|
|
386
|
+
# Validate transport
|
|
387
|
+
if server_cfg.transport not in ("stdio", "streamable_http"):
|
|
388
|
+
raise MCPConnectionError(
|
|
389
|
+
f"Unsupported transport type: {server_cfg.transport}",
|
|
390
|
+
server_name=server_name,
|
|
391
|
+
)
|
|
392
|
+
try:
|
|
393
|
+
# Create and initialize server connection with dataclass directly
|
|
394
|
+
connection = MCPServerConnection(server_name, server_cfg)
|
|
395
|
+
await connection.initialize()
|
|
396
|
+
|
|
397
|
+
self.connections[server_name] = connection
|
|
398
|
+
|
|
399
|
+
# Register tools in the registry
|
|
400
|
+
self.tool_registry.register_server_tools(server_name, connection.tools)
|
|
401
|
+
|
|
402
|
+
# Update server status
|
|
403
|
+
self.server_status[server_name] = ServerStatus(
|
|
404
|
+
name=server_name,
|
|
405
|
+
connected=True,
|
|
406
|
+
session_id=None, # SDK handles session management internally
|
|
407
|
+
last_error=None,
|
|
408
|
+
capabilities={}, # Could be populated from session info
|
|
409
|
+
tools_count=len(connection.tools),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
except Exception as e:
|
|
413
|
+
self.server_status[server_name] = ServerStatus(
|
|
414
|
+
name=server_name,
|
|
415
|
+
connected=False,
|
|
416
|
+
session_id=None,
|
|
417
|
+
last_error=str(e),
|
|
418
|
+
capabilities={},
|
|
419
|
+
tools_count=0,
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def list_all_tools(self) -> List[Tool]:
|
|
423
|
+
"""Get all available tools from all connected servers"""
|
|
424
|
+
return self.tool_registry.get_all_tools()
|
|
425
|
+
|
|
426
|
+
def find_tool_server(self, tool_name: str) -> Tuple[str, Tool]:
|
|
427
|
+
"""Find which server provides a specific tool"""
|
|
428
|
+
try:
|
|
429
|
+
return self.tool_registry.find_tool_server(tool_name)
|
|
430
|
+
except ValueError as e:
|
|
431
|
+
raise MCPError(str(e))
|
|
432
|
+
|
|
433
|
+
async def call_tool(
|
|
434
|
+
self, tool_name: str, arguments: Dict[str, Any]
|
|
435
|
+
) -> Dict[str, Any]:
|
|
436
|
+
"""Call a tool by name (automatically finds the correct server)"""
|
|
437
|
+
try:
|
|
438
|
+
server_name, tool = self.find_tool_server(tool_name)
|
|
439
|
+
return await self.call_tool_on_server(server_name, tool_name, arguments)
|
|
440
|
+
except Exception:
|
|
441
|
+
raise
|
|
442
|
+
|
|
443
|
+
async def call_tool_on_server(
|
|
444
|
+
self, server_name: str, tool_name: str, arguments: Dict[str, Any]
|
|
445
|
+
) -> Dict[str, Any]:
|
|
446
|
+
"""Call a tool on a specific MCP server"""
|
|
447
|
+
if server_name not in self.connections:
|
|
448
|
+
raise MCPConnectionError(f"MCP server '{server_name}' not found")
|
|
449
|
+
|
|
450
|
+
connection = self.connections[server_name]
|
|
451
|
+
if not connection.is_connected():
|
|
452
|
+
raise MCPConnectionError(f"MCP server '{server_name}' not connected")
|
|
453
|
+
|
|
454
|
+
return await connection.call_tool(tool_name, arguments)
|
|
455
|
+
|
|
456
|
+
def get_connection(self, server_name: str) -> MCPServerConnection:
|
|
457
|
+
"""Get MCP server connection"""
|
|
458
|
+
if server_name not in self.connections:
|
|
459
|
+
raise MCPConnectionError(f"MCP server '{server_name}' not found")
|
|
460
|
+
|
|
461
|
+
connection = self.connections[server_name]
|
|
462
|
+
if not connection.is_connected():
|
|
463
|
+
raise MCPConnectionError(f"MCP server '{server_name}' not connected")
|
|
464
|
+
|
|
465
|
+
return connection
|
|
466
|
+
|
|
467
|
+
def get_server_status(self) -> Dict[str, ServerStatus]:
|
|
468
|
+
"""Get status of all MCP servers"""
|
|
469
|
+
# Update connection status
|
|
470
|
+
for server_name, connection in self.connections.items():
|
|
471
|
+
if server_name in self.server_status:
|
|
472
|
+
self.server_status[server_name].connected = connection.is_connected()
|
|
473
|
+
|
|
474
|
+
return self.server_status.copy()
|
|
475
|
+
|
|
476
|
+
def get_tools_for_llm(self) -> List[Dict[str, Any]]:
|
|
477
|
+
"""Get all tools in OpenAI function calling format for LLM"""
|
|
478
|
+
return self.tool_registry.get_tools_for_llm()
|
|
479
|
+
|
|
480
|
+
def get_registry_stats(self) -> Dict[str, Any]:
|
|
481
|
+
"""Get statistics about the tool registry"""
|
|
482
|
+
return self.tool_registry.get_registry_stats()
|
|
483
|
+
|
|
484
|
+
def search_tools(self, query: str) -> List[Tuple[str, Tool]]:
|
|
485
|
+
"""Search for tools by name or description"""
|
|
486
|
+
return self.tool_registry.search_tools(query)
|
|
487
|
+
|
|
488
|
+
def get_tool_conflicts(self) -> Dict[str, List[str]]:
|
|
489
|
+
"""Get information about tool name conflicts"""
|
|
490
|
+
return self.tool_registry.get_tool_conflicts()
|
|
491
|
+
|
|
492
|
+
async def execute_tool_calls(
|
|
493
|
+
self, tool_calls: List[Dict[str, Any]]
|
|
494
|
+
) -> List[Dict[str, Any]]:
|
|
495
|
+
"""Execute multiple tool calls using the router"""
|
|
496
|
+
if not tool_calls:
|
|
497
|
+
return []
|
|
498
|
+
|
|
499
|
+
batch = ToolCallBatch(tool_calls)
|
|
500
|
+
return await batch.execute(self.router)
|
|
501
|
+
|
|
502
|
+
async def execute_single_tool_call(
|
|
503
|
+
self, tool_call: Dict[str, Any]
|
|
504
|
+
) -> Dict[str, Any]:
|
|
505
|
+
"""Execute a single tool call using the router"""
|
|
506
|
+
return await self.router.route_tool_call(tool_call)
|
|
507
|
+
|
|
508
|
+
async def cleanup(self) -> None:
|
|
509
|
+
"""Cleanup all MCP connections"""
|
|
510
|
+
|
|
511
|
+
# Cleanup all connections
|
|
512
|
+
for connection in self.connections.values():
|
|
513
|
+
try:
|
|
514
|
+
await connection.cleanup()
|
|
515
|
+
except Exception:
|
|
516
|
+
pass
|
|
517
|
+
|
|
518
|
+
self.connections.clear()
|
|
519
|
+
self.tool_registry.clear()
|
|
520
|
+
self.server_status.clear()
|