bizyengine 1.2.49__py3-none-any.whl → 1.2.51__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.
@@ -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,3 @@
1
+ """
2
+ MCP client modules
3
+ """
@@ -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()