camel-ai 0.2.61__py3-none-any.whl → 0.2.62__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.

Potentially problematic release.


This version of camel-ai might be problematic. Click here for more details.

Files changed (32) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/chat_agent.py +1 -1
  3. camel/agents/mcp_agent.py +5 -5
  4. camel/{data_collector → data_collectors}/alpaca_collector.py +1 -1
  5. camel/{data_collector → data_collectors}/sharegpt_collector.py +1 -1
  6. camel/retrievers/auto_retriever.py +20 -1
  7. camel/{runtime → runtimes}/daytona_runtime.py +1 -1
  8. camel/{runtime → runtimes}/docker_runtime.py +1 -1
  9. camel/{runtime → runtimes}/llm_guard_runtime.py +2 -2
  10. camel/{runtime → runtimes}/remote_http_runtime.py +1 -1
  11. camel/{runtime → runtimes}/ubuntu_docker_runtime.py +1 -1
  12. camel/societies/workforce/base.py +7 -3
  13. camel/societies/workforce/single_agent_worker.py +2 -1
  14. camel/societies/workforce/worker.py +5 -3
  15. camel/toolkits/__init__.py +2 -0
  16. camel/toolkits/file_write_toolkit.py +4 -2
  17. camel/toolkits/mcp_toolkit.py +469 -733
  18. camel/toolkits/pptx_toolkit.py +777 -0
  19. camel/utils/mcp_client.py +979 -0
  20. {camel_ai-0.2.61.dist-info → camel_ai-0.2.62.dist-info}/METADATA +4 -1
  21. {camel_ai-0.2.61.dist-info → camel_ai-0.2.62.dist-info}/RECORD +32 -30
  22. /camel/{data_collector → data_collectors}/__init__.py +0 -0
  23. /camel/{data_collector → data_collectors}/base.py +0 -0
  24. /camel/{runtime → runtimes}/__init__.py +0 -0
  25. /camel/{runtime → runtimes}/api.py +0 -0
  26. /camel/{runtime → runtimes}/base.py +0 -0
  27. /camel/{runtime → runtimes}/configs.py +0 -0
  28. /camel/{runtime → runtimes}/utils/__init__.py +0 -0
  29. /camel/{runtime → runtimes}/utils/function_risk_toolkit.py +0 -0
  30. /camel/{runtime → runtimes}/utils/ignore_risk_toolkit.py +0 -0
  31. {camel_ai-0.2.61.dist-info → camel_ai-0.2.62.dist-info}/WHEEL +0 -0
  32. {camel_ai-0.2.61.dist-info → camel_ai-0.2.62.dist-info}/licenses/LICENSE +0 -0
@@ -11,868 +11,604 @@
11
11
  # See the License for the specific language governing permissions and
12
12
  # limitations under the License.
13
13
  # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
- import inspect
14
+
15
15
  import json
16
16
  import os
17
- import shlex
18
- from contextlib import AsyncExitStack, asynccontextmanager
19
- from datetime import timedelta
20
- from typing import (
21
- TYPE_CHECKING,
22
- Any,
23
- AsyncGenerator,
24
- Callable,
25
- Dict,
26
- List,
27
- Optional,
28
- Set,
29
- Union,
30
- cast,
31
- )
32
- from urllib.parse import urlparse
33
-
34
- if TYPE_CHECKING:
35
- from mcp import ClientSession, ListToolsResult, Tool
36
-
17
+ from contextlib import AsyncExitStack
18
+ from typing import Any, Dict, List, Optional
37
19
 
38
20
  from camel.logger import get_logger
39
21
  from camel.toolkits import BaseToolkit, FunctionTool
40
22
  from camel.utils.commons import run_async
23
+ from camel.utils.mcp_client import MCPClient, create_mcp_client
41
24
 
42
25
  logger = get_logger(__name__)
43
26
 
44
27
 
45
- class MCPClient(BaseToolkit):
46
- r"""Internal class that provides an abstraction layer to interact with
47
- external tools using the Model Context Protocol (MCP). It supports three
48
- modes of connection:
28
+ class MCPConnectionError(Exception):
29
+ r"""Raised when MCP connection fails."""
30
+
31
+ pass
49
32
 
50
- 1. stdio mode: Connects via standard input/output streams for local
51
- command-line interactions.
52
33
 
53
- 2. SSE mode (HTTP Server-Sent Events): Connects via HTTP for persistent,
54
- event-based interactions.
34
+ class MCPToolError(Exception):
35
+ r"""Raised when MCP tool execution fails."""
55
36
 
56
- 3. streamable-http mode: Connects via HTTP for persistent, streamable
57
- interactions.
37
+ pass
38
+
39
+
40
+ class MCPToolkit(BaseToolkit):
41
+ r"""MCPToolkit provides a unified interface for managing multiple
42
+ MCP server connections and their tools.
43
+
44
+ This class handles the lifecycle of multiple MCP server connections and
45
+ offers a centralized configuration mechanism for both local and remote
46
+ MCP services. The toolkit manages multiple :obj:`MCPClient` instances and
47
+ aggregates their tools into a unified interface compatible with the CAMEL
48
+ framework.
58
49
 
59
50
  Connection Lifecycle:
60
51
  There are three ways to manage the connection lifecycle:
61
52
 
62
- 1. Using the async context manager:
63
- ```python
64
- async with MCPClient(command_or_url="...") as client:
65
- # Client is connected here
66
- result = await client.some_tool()
67
- # Client is automatically disconnected here
68
- ```
53
+ 1. Using the async context manager (recommended):
54
+
55
+ .. code-block:: python
56
+
57
+ async with MCPToolkit(config_path="config.json") as toolkit:
58
+ # Toolkit is connected here
59
+ tools = toolkit.get_tools()
60
+ # Toolkit is automatically disconnected here
69
61
 
70
62
  2. Using the factory method:
71
- ```python
72
- client = await MCPClient.create(command_or_url="...")
73
- # Client is connected here
74
- result = await client.some_tool()
75
- # Don't forget to disconnect when done!
76
- await client.disconnect()
77
- ```
63
+
64
+ .. code-block:: python
65
+
66
+ toolkit = await MCPToolkit.create(config_path="config.json")
67
+ # Toolkit is connected here
68
+ tools = toolkit.get_tools()
69
+ # Don't forget to disconnect when done!
70
+ await toolkit.disconnect()
78
71
 
79
72
  3. Using explicit connect/disconnect:
80
- ```python
81
- client = MCPClient(command_or_url="...")
82
- await client.connect()
83
- # Client is connected here
84
- result = await client.some_tool()
85
- # Don't forget to disconnect when done!
86
- await client.disconnect()
87
- ```
88
73
 
89
- Attributes:
90
- command_or_url (str): URL for SSE mode or command executable for stdio
91
- mode. (default: :obj:`None`)
92
- args (List[str]): List of command-line arguments if stdio mode is used.
93
- (default: :obj:`None`)
94
- env (Dict[str, str]): Environment variables for the stdio mode command.
95
- (default: :obj:`None`)
96
- timeout (Optional[float]): Connection timeout.
97
- (default: :obj:`None`)
98
- headers (Dict[str, str]): Headers for the HTTP request.
74
+ .. code-block:: python
75
+
76
+ toolkit = MCPToolkit(config_path="config.json")
77
+ await toolkit.connect()
78
+ # Toolkit is connected here
79
+ tools = toolkit.get_tools()
80
+ # Don't forget to disconnect when done!
81
+ await toolkit.disconnect()
82
+
83
+ Note:
84
+ Both MCPClient and MCPToolkit now use the same async context manager
85
+ pattern for consistent connection management. MCPToolkit automatically
86
+ manages multiple MCPClient instances using AsyncExitStack.
87
+
88
+ Args:
89
+ clients (Optional[List[MCPClient]], optional): List of :obj:`MCPClient`
90
+ instances to manage. (default: :obj:`None`)
91
+ config_path (Optional[str], optional): Path to a JSON configuration
92
+ file defining MCP servers. The file should contain server
93
+ configurations in the standard MCP format. (default: :obj:`None`)
94
+ config_dict (Optional[Dict[str, Any]], optional): Dictionary containing
95
+ MCP server configurations in the same format as the config file.
96
+ This allows for programmatic configuration without file I/O.
99
97
  (default: :obj:`None`)
100
- mode (Optional[str]): Connection mode. Can be "sse" for Server-Sent
101
- Events, "streamable-http" for streaming HTTP,
102
- or None for stdio mode.
98
+ timeout (Optional[float], optional): Timeout for connection attempts
99
+ in seconds. This timeout applies to individual client connections.
103
100
  (default: :obj:`None`)
104
- strict (Optional[bool]): Whether to enforce strict mode for the
105
- function call. (default: :obj:`False`)
101
+
102
+ Note:
103
+ At least one of :obj:`clients`, :obj:`config_path`, or
104
+ :obj:`config_dict` must be provided. If multiple sources are provided,
105
+ clients from all sources will be combined.
106
+
107
+ For web servers in the config, you can specify authorization headers
108
+ using the "headers" field to connect to protected MCP server endpoints.
109
+
110
+ Example configuration:
111
+
112
+ .. code-block:: json
113
+
114
+ {
115
+ "mcpServers": {
116
+ "filesystem": {
117
+ "command": "npx",
118
+ "args": ["-y", "@modelcontextprotocol/server-filesystem",
119
+ "/path"]
120
+ },
121
+ "protected-server": {
122
+ "url": "https://example.com/mcp",
123
+ "timeout": 30,
124
+ "headers": {
125
+ "Authorization": "Bearer YOUR_TOKEN",
126
+ "X-API-Key": "YOUR_API_KEY"
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ Attributes:
133
+ clients (List[MCPClient]): List of :obj:`MCPClient` instances being
134
+ managed by this toolkit.
135
+
136
+ Raises:
137
+ ValueError: If no configuration sources are provided or if the
138
+ configuration is invalid.
139
+ MCPConnectionError: If connection to any MCP server fails during
140
+ initialization.
106
141
  """
107
142
 
108
143
  def __init__(
109
144
  self,
110
- command_or_url: str,
111
- args: Optional[List[str]] = None,
112
- env: Optional[Dict[str, str]] = None,
145
+ clients: Optional[List[MCPClient]] = None,
146
+ config_path: Optional[str] = None,
147
+ config_dict: Optional[Dict[str, Any]] = None,
113
148
  timeout: Optional[float] = None,
114
- headers: Optional[Dict[str, str]] = None,
115
- mode: Optional[str] = None,
116
- strict: Optional[bool] = False,
117
149
  ):
118
- from mcp import Tool
119
-
150
+ # Call parent constructor first
120
151
  super().__init__(timeout=timeout)
121
152
 
122
- self.command_or_url = command_or_url
123
- self.args = args or []
124
- self.env = env or {}
125
- self.headers = headers or {}
126
- self.strict = strict
127
- self.mode = mode
153
+ # Validate input parameters
154
+ sources_provided = sum(
155
+ 1 for src in [clients, config_path, config_dict] if src is not None
156
+ )
157
+ if sources_provided == 0:
158
+ error_msg = (
159
+ "At least one of clients, config_path, or "
160
+ "config_dict must be provided"
161
+ )
162
+ raise ValueError(error_msg)
128
163
 
129
- self._mcp_tools: List[Tool] = []
130
- self._session: Optional['ClientSession'] = None
131
- self._exit_stack = AsyncExitStack()
164
+ self.clients: List[MCPClient] = clients or []
132
165
  self._is_connected = False
166
+ self._exit_stack: Optional[AsyncExitStack] = None
133
167
 
134
- async def connect(self):
135
- r"""Explicitly connect to the MCP server.
136
-
137
- Returns:
138
- MCPClient: The client used to connect to the server.
139
- """
140
- from mcp.client.session import ClientSession
141
- from mcp.client.sse import sse_client
142
- from mcp.client.stdio import StdioServerParameters, stdio_client
143
- from mcp.client.streamable_http import streamablehttp_client
168
+ # Load clients from config sources
169
+ if config_path:
170
+ self.clients.extend(self._load_clients_from_config(config_path))
144
171
 
145
- if self._is_connected:
146
- logger.warning("Server is already connected")
147
- return self
172
+ if config_dict:
173
+ self.clients.extend(self._load_clients_from_dict(config_dict))
148
174
 
149
- try:
150
- if urlparse(self.command_or_url).scheme in ("http", "https"):
151
- if self.mode == "sse" or self.mode is None:
152
- (
153
- read_stream,
154
- write_stream,
155
- ) = await self._exit_stack.enter_async_context(
156
- sse_client(
157
- self.command_or_url,
158
- headers=self.headers,
159
- timeout=self.timeout,
160
- )
161
- )
162
- elif self.mode == "streamable-http":
163
- try:
164
- (
165
- read_stream,
166
- write_stream,
167
- _,
168
- ) = await self._exit_stack.enter_async_context(
169
- streamablehttp_client(
170
- self.command_or_url,
171
- headers=self.headers,
172
- timeout=timedelta(seconds=self.timeout),
173
- )
174
- )
175
- except Exception as e:
176
- # Handle anyio task group errors
177
- logger.error(f"Streamable HTTP client error: {e}")
178
- else:
179
- raise ValueError(
180
- f"Invalid mode '{self.mode}' for HTTP URL"
181
- )
182
- else:
183
- command = self.command_or_url
184
- arguments = self.args
185
- if not self.args:
186
- argv = shlex.split(command)
187
- if not argv:
188
- raise ValueError("Command is empty")
189
-
190
- command = argv[0]
191
- arguments = argv[1:]
192
-
193
- if os.name == "nt" and command.lower() == "npx":
194
- command = "npx.cmd"
195
-
196
- server_parameters = StdioServerParameters(
197
- command=command, args=arguments, env=self.env
198
- )
199
- (
200
- read_stream,
201
- write_stream,
202
- ) = await self._exit_stack.enter_async_context(
203
- stdio_client(server_parameters)
204
- )
175
+ if not self.clients:
176
+ raise ValueError("No valid MCP clients could be created")
205
177
 
206
- self._session = await self._exit_stack.enter_async_context(
207
- ClientSession(
208
- read_stream,
209
- write_stream,
210
- timedelta(seconds=self.timeout) if self.timeout else None,
211
- )
212
- )
213
- await self._session.initialize()
214
- list_tools_result = await self.list_mcp_tools()
215
- self._mcp_tools = list_tools_result.tools
216
- self._is_connected = True
217
- return self
218
- except Exception as e:
219
- # Ensure resources are cleaned up on connection failure
220
- await self.disconnect()
221
- logger.error(f"Failed to connect to MCP server: {e}")
222
- raise e
178
+ async def connect(self) -> "MCPToolkit":
179
+ r"""Connect to all MCP servers using AsyncExitStack.
223
180
 
224
- def connect_sync(self):
225
- r"""Synchronously connect to the MCP server."""
226
- return run_async(self.connect)()
181
+ Establishes connections to all configured MCP servers sequentially.
182
+ Uses :obj:`AsyncExitStack` to manage the lifecycle of all connections,
183
+ ensuring proper cleanup on exit or error.
227
184
 
228
- async def disconnect(self):
229
- r"""Explicitly disconnect from the MCP server."""
230
- # If the server is not connected, do nothing
231
- if not self._is_connected:
232
- return
233
- self._is_connected = False
185
+ Returns:
186
+ MCPToolkit: Returns :obj:`self` for method chaining, allowing for
187
+ fluent interface usage.
234
188
 
235
- try:
236
- await self._exit_stack.aclose()
237
- except Exception as e:
238
- logger.warning(f"{e}")
239
- finally:
240
- self._exit_stack = AsyncExitStack()
241
- self._session = None
189
+ Raises:
190
+ MCPConnectionError: If connection to any MCP server fails. The
191
+ error message will include details about which client failed
192
+ to connect and the underlying error reason.
242
193
 
243
- def disconnect_sync(self):
244
- r"""Synchronously disconnect from the MCP server."""
245
- return run_async(self.disconnect)()
194
+ Warning:
195
+ If any client fails to connect, all previously established
196
+ connections will be automatically cleaned up before raising
197
+ the exception.
246
198
 
247
- @asynccontextmanager
248
- async def connection(self):
249
- r"""Async context manager for establishing and managing the connection
250
- with the MCP server. Automatically selects SSE or stdio mode based
251
- on the provided `command_or_url`.
199
+ Example:
200
+ .. code-block:: python
252
201
 
253
- Yields:
254
- MCPClient: Instance with active connection ready for tool
255
- interaction.
202
+ toolkit = MCPToolkit(config_dict=config)
203
+ try:
204
+ await toolkit.connect()
205
+ # Use the toolkit
206
+ tools = toolkit.get_tools()
207
+ finally:
208
+ await toolkit.disconnect()
256
209
  """
257
- try:
258
- await self.connect()
259
- yield self
260
- finally:
261
- try:
262
- await self.disconnect()
263
- except Exception as e:
264
- logger.warning(f"Error: {e}")
265
-
266
- def connection_sync(self):
267
- r"""Synchronously connect to the MCP server."""
268
- return run_async(self.connection)()
210
+ if self._is_connected:
211
+ logger.warning("MCPToolkit is already connected")
212
+ return self
269
213
 
270
- async def list_mcp_tools(self) -> Union[str, "ListToolsResult"]:
271
- r"""Retrieves the list of available tools from the connected MCP
272
- server.
214
+ self._exit_stack = AsyncExitStack()
273
215
 
274
- Returns:
275
- ListToolsResult: Result containing available MCP tools.
276
- """
277
- if not self._session:
278
- return "MCP Client is not connected. Call `connection()` first."
279
216
  try:
280
- return await self._session.list_tools()
281
- except Exception as e:
282
- logger.exception("Failed to list MCP tools")
283
- raise e
284
-
285
- def list_mcp_tools_sync(self) -> Union[str, "ListToolsResult"]:
286
- r"""Synchronously list the available tools from the connected MCP
287
- server."""
288
- return run_async(self.list_mcp_tools)()
289
-
290
- def generate_function_from_mcp_tool(self, mcp_tool: "Tool") -> Callable:
291
- r"""Dynamically generates a Python callable function corresponding to
292
- a given MCP tool.
217
+ # Connect to all clients using AsyncExitStack
218
+ for i, client in enumerate(self.clients):
219
+ try:
220
+ # Use MCPClient directly as async context manager
221
+ await self._exit_stack.enter_async_context(client)
222
+ msg = f"Connected to client {i+1}/{len(self.clients)}"
223
+ logger.debug(msg)
224
+ except Exception as e:
225
+ logger.error(f"Failed to connect to client {i+1}: {e}")
226
+ # AsyncExitStack will handle cleanup of already connected
227
+ await self._exit_stack.aclose()
228
+ self._exit_stack = None
229
+ error_msg = f"Failed to connect to client {i+1}: {e}"
230
+ raise MCPConnectionError(error_msg) from e
293
231
 
294
- Args:
295
- mcp_tool (Tool): The MCP tool definition received from the MCP
296
- server.
232
+ self._is_connected = True
233
+ msg = f"Successfully connected to {len(self.clients)} MCP servers"
234
+ logger.info(msg)
235
+ return self
297
236
 
298
- Returns:
299
- Callable: A dynamically created async Python function that wraps
300
- the MCP tool.
301
- """
302
- func_name = mcp_tool.name
303
- func_desc = mcp_tool.description or "No description provided."
304
- parameters_schema = mcp_tool.inputSchema.get("properties", {})
305
- required_params = mcp_tool.inputSchema.get("required", [])
306
-
307
- type_map = {
308
- "string": str,
309
- "integer": int,
310
- "number": float,
311
- "boolean": bool,
312
- "array": list,
313
- "object": dict,
314
- }
315
- annotations = {} # used to type hints
316
- defaults: Dict[str, Any] = {} # store default values
317
-
318
- func_params = []
319
- for param_name, param_schema in parameters_schema.items():
320
- param_type = param_schema.get("type", "Any")
321
- param_type = type_map.get(param_type, Any)
322
-
323
- annotations[param_name] = param_type
324
- if param_name not in required_params:
325
- defaults[param_name] = None
326
-
327
- func_params.append(param_name)
328
-
329
- async def dynamic_function(**kwargs) -> str:
330
- r"""Auto-generated function for MCP Tool interaction.
331
-
332
- Args:
333
- kwargs: Keyword arguments corresponding to MCP tool parameters.
334
-
335
- Returns:
336
- str: The textual result returned by the MCP tool.
337
- """
338
- from mcp.types import CallToolResult
339
-
340
- missing_params: Set[str] = set(required_params) - set(
341
- kwargs.keys()
342
- )
343
- if missing_params:
344
- logger.warning(
345
- f"Missing required parameters: {missing_params}"
346
- )
347
- return "Missing required parameters."
237
+ except Exception:
238
+ self._is_connected = False
239
+ if self._exit_stack:
240
+ await self._exit_stack.aclose()
241
+ self._exit_stack = None
242
+ raise
348
243
 
349
- if not self._session:
350
- logger.error(
351
- "MCP Client is not connected. Call `connection()` first."
352
- )
353
- raise RuntimeError(
354
- "MCP Client is not connected. Call `connection()` first."
355
- )
244
+ async def disconnect(self):
245
+ r"""Disconnect from all MCP servers."""
246
+ if not self._is_connected:
247
+ return
356
248
 
249
+ if self._exit_stack:
357
250
  try:
358
- result: CallToolResult = await self._session.call_tool(
359
- func_name, kwargs
360
- )
251
+ await self._exit_stack.aclose()
361
252
  except Exception as e:
362
- logger.error(f"Failed to call MCP tool '{func_name}': {e!s}")
363
- raise e
253
+ logger.warning(f"Error during disconnect: {e}")
254
+ finally:
255
+ self._exit_stack = None
364
256
 
365
- if not result.content or len(result.content) == 0:
366
- return "No data available for this request."
367
-
368
- # Handle different content types
369
- try:
370
- content = result.content[0]
371
- if content.type == "text":
372
- return content.text
373
- elif content.type == "image":
374
- # Return image URL or data URI if available
375
- if hasattr(content, "url") and content.url:
376
- return f"Image available at: {content.url}"
377
- return "Image content received (data URI not shown)"
378
- elif content.type == "embedded_resource":
379
- # Return resource information if available
380
- if hasattr(content, "name") and content.name:
381
- return f"Embedded resource: {content.name}"
382
- return "Embedded resource received"
383
- else:
384
- msg = f"Received content of type '{content.type}'"
385
- return f"{msg} which is not fully supported yet."
386
- except (IndexError, AttributeError) as e:
387
- logger.error(
388
- f"Error processing content from MCP tool response: {e!s}"
389
- )
390
- raise e
391
-
392
- dynamic_function.__name__ = func_name
393
- dynamic_function.__doc__ = func_desc
394
- dynamic_function.__annotations__ = annotations
395
-
396
- sig = inspect.Signature(
397
- parameters=[
398
- inspect.Parameter(
399
- name=param,
400
- kind=inspect.Parameter.KEYWORD_ONLY,
401
- default=defaults.get(param, inspect.Parameter.empty),
402
- annotation=annotations[param],
403
- )
404
- for param in func_params
405
- ]
406
- )
407
- dynamic_function.__signature__ = sig # type: ignore[attr-defined]
408
-
409
- return dynamic_function
410
-
411
- def generate_function_from_mcp_tool_sync(self, mcp_tool: "Tool") -> Any:
412
- r"""Synchronously generate a function from an MCP tool."""
413
- return run_async(self.generate_function_from_mcp_tool)(mcp_tool)
414
-
415
- def _build_tool_schema(self, mcp_tool: "Tool") -> Dict[str, Any]:
416
- input_schema = mcp_tool.inputSchema
417
- properties = input_schema.get("properties", {})
418
- required = input_schema.get("required", [])
419
-
420
- parameters = {
421
- "type": "object",
422
- "properties": properties,
423
- "required": required,
424
- "additionalProperties": False,
425
- }
426
-
427
- return {
428
- "type": "function",
429
- "function": {
430
- "name": mcp_tool.name,
431
- "description": mcp_tool.description
432
- or "No description provided.",
433
- "strict": self.strict,
434
- "parameters": parameters,
435
- },
436
- }
257
+ self._is_connected = False
258
+ logger.debug("Disconnected from all MCP servers")
437
259
 
438
- def get_tools(self) -> List[FunctionTool]:
439
- r"""Returns a list of FunctionTool objects representing the
440
- functions in the toolkit. Each function is dynamically generated
441
- based on the MCP tool definitions received from the server.
260
+ @property
261
+ def is_connected(self) -> bool:
262
+ r"""Check if toolkit is connected.
442
263
 
443
264
  Returns:
444
- List[FunctionTool]: A list of FunctionTool objects
445
- representing the functions in the toolkit.
265
+ bool: True if the toolkit is connected to all MCP servers,
266
+ False otherwise.
446
267
  """
447
- return [
448
- FunctionTool(
449
- self.generate_function_from_mcp_tool(mcp_tool),
450
- openai_tool_schema=self._build_tool_schema(mcp_tool),
451
- )
452
- for mcp_tool in self._mcp_tools
453
- ]
268
+ if not self._is_connected:
269
+ return False
454
270
 
455
- def get_text_tools(self) -> str:
456
- r"""Returns a string containing the descriptions of the tools
457
- in the toolkit.
271
+ # Check if all clients are connected
272
+ return all(client.is_connected() for client in self.clients)
458
273
 
459
- Returns:
460
- str: A string containing the descriptions of the tools
461
- in the toolkit.
462
- """
463
- return "\n".join(
464
- f"tool_name: {tool.name}\n"
465
- + f"description: {tool.description or 'No description'}\n"
466
- + f"input Schema: {tool.inputSchema}\n"
467
- for tool in self._mcp_tools
468
- )
274
+ def connect_sync(self):
275
+ r"""Synchronously connect to all MCP servers."""
276
+ return run_async(self.connect)()
469
277
 
470
- async def call_tool(
471
- self, tool_name: str, tool_args: Dict[str, Any]
472
- ) -> Any:
473
- r"""Calls the specified tool with the provided arguments.
278
+ def disconnect_sync(self):
279
+ r"""Synchronously disconnect from all MCP servers."""
280
+ return run_async(self.disconnect)()
474
281
 
475
- Args:
476
- tool_name (str): Name of the tool to call.
477
- tool_args (Dict[str, Any]): Arguments to pass to the tool
478
- (default: :obj:`{}`).
282
+ async def __aenter__(self) -> "MCPToolkit":
283
+ r"""Async context manager entry point.
479
284
 
480
- Returns:
481
- Any: The result of the tool call.
285
+ Usage:
286
+ async with MCPToolkit(config_dict=config) as toolkit:
287
+ tools = toolkit.get_tools()
482
288
  """
483
- if self._session is None:
484
- raise RuntimeError("Session is not initialized.")
289
+ await self.connect()
290
+ return self
485
291
 
486
- return await self._session.call_tool(tool_name, tool_args)
292
+ def __enter__(self) -> "MCPToolkit":
293
+ r"""Synchronously enter the async context manager."""
294
+ return run_async(self.__aenter__)()
487
295
 
488
- def call_tool_sync(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
489
- r"""Synchronously call a tool."""
490
- return run_async(self.call_tool)(tool_name, tool_args)
296
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
297
+ r"""Async context manager exit point."""
298
+ await self.disconnect()
299
+ return None
491
300
 
492
- @property
493
- def session(self) -> Optional["ClientSession"]:
494
- return self._session
301
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
302
+ r"""Synchronously exit the async context manager."""
303
+ return run_async(self.__aexit__)(exc_type, exc_val, exc_tb)
495
304
 
496
305
  @classmethod
497
306
  async def create(
498
307
  cls,
499
- command_or_url: str,
500
- args: Optional[List[str]] = None,
501
- env: Optional[Dict[str, str]] = None,
308
+ clients: Optional[List[MCPClient]] = None,
309
+ config_path: Optional[str] = None,
310
+ config_dict: Optional[Dict[str, Any]] = None,
502
311
  timeout: Optional[float] = None,
503
- headers: Optional[Dict[str, str]] = None,
504
- mode: Optional[str] = None,
505
- ) -> "MCPClient":
506
- r"""Factory method that creates and connects to the MCP server.
312
+ ) -> "MCPToolkit":
313
+ r"""Factory method that creates and connects to all MCP servers.
507
314
 
508
- This async factory method ensures the connection to the MCP server is
509
- established before the client object is fully constructed.
315
+ Creates a new :obj:`MCPToolkit` instance and automatically establishes
316
+ connections to all configured MCP servers. This is a convenience method
317
+ that combines instantiation and connection in a single call.
510
318
 
511
319
  Args:
512
- command_or_url (str): URL for SSE mode or command executable
513
- for stdio mode.
514
- args (Optional[List[str]]): List of command-line arguments if
515
- stdio mode is used. (default: :obj:`None`)
516
- env (Optional[Dict[str, str]]): Environment variables for
517
- the stdio mode command. (default: :obj:`None`)
518
- timeout (Optional[float]): Connection timeout.
519
- (default: :obj:`None`)
520
- headers (Optional[Dict[str, str]]): Headers for the HTTP request.
521
- (default: :obj:`None`)
522
- mode (Optional[str]): Connection mode. Can be "sse" for
523
- Server-Sent Events, "streamable-http" for
524
- streaming HTTP, or None for stdio mode.
525
- (default: :obj:`None`)
320
+ clients (Optional[List[MCPClient]], optional): List of
321
+ :obj:`MCPClient` instances to manage. (default: :obj:`None`)
322
+ config_path (Optional[str], optional): Path to a JSON configuration
323
+ file defining MCP servers. (default: :obj:`None`)
324
+ config_dict (Optional[Dict[str, Any]], optional): Dictionary
325
+ containing MCP server configurations in the same format as the
326
+ config file. (default: :obj:`None`)
327
+ timeout (Optional[float], optional): Timeout for connection
328
+ attempts in seconds. (default: :obj:`None`)
526
329
 
527
330
  Returns:
528
- MCPClient: A fully initialized and connected MCPClient instance.
331
+ MCPToolkit: A fully initialized and connected :obj:`MCPToolkit`
332
+ instance with all servers ready for use.
529
333
 
530
334
  Raises:
531
- RuntimeError: If connection to the MCP server fails.
335
+ MCPConnectionError: If connection to any MCP server fails during
336
+ initialization. All successfully connected servers will be
337
+ properly disconnected before raising the exception.
338
+ ValueError: If no configuration sources are provided or if the
339
+ configuration is invalid.
340
+
341
+ Example:
342
+ .. code-block:: python
343
+
344
+ # Create and connect in one step
345
+ toolkit = await MCPToolkit.create(config_path="servers.json")
346
+ try:
347
+ tools = toolkit.get_tools()
348
+ # Use the toolkit...
349
+ finally:
350
+ await toolkit.disconnect()
532
351
  """
533
- client = cls(
534
- command_or_url=command_or_url,
535
- args=args,
536
- env=env,
352
+ toolkit = cls(
353
+ clients=clients,
354
+ config_path=config_path,
355
+ config_dict=config_dict,
537
356
  timeout=timeout,
538
- headers=headers,
539
- mode=mode,
540
357
  )
541
358
  try:
542
- await client.connect()
543
- return client
359
+ await toolkit.connect()
360
+ return toolkit
544
361
  except Exception as e:
545
362
  # Ensure cleanup on initialization failure
546
- await client.disconnect()
547
- logger.error(f"Failed to initialize MCPClient: {e}")
548
- raise RuntimeError(f"Failed to initialize MCPClient: {e}") from e
363
+ await toolkit.disconnect()
364
+ logger.error(f"Failed to initialize MCPToolkit: {e}")
365
+ raise MCPConnectionError(
366
+ f"Failed to initialize MCPToolkit: {e}"
367
+ ) from e
549
368
 
550
369
  @classmethod
551
370
  def create_sync(
552
- self,
553
- command_or_url: str,
554
- args: Optional[List[str]] = None,
555
- env: Optional[Dict[str, str]] = None,
371
+ cls,
372
+ clients: Optional[List[MCPClient]] = None,
373
+ config_path: Optional[str] = None,
374
+ config_dict: Optional[Dict[str, Any]] = None,
556
375
  timeout: Optional[float] = None,
557
- headers: Optional[Dict[str, str]] = None,
558
- mode: Optional[str] = None,
559
- ) -> "MCPClient":
560
- r"""Synchronously create and connect to the MCP server."""
561
- return run_async(self.create)(
562
- command_or_url, args, env, timeout, headers, mode
376
+ ) -> "MCPToolkit":
377
+ r"""Synchronously create and connect to all MCP servers."""
378
+ return run_async(cls.create)(
379
+ clients, config_path, config_dict, timeout
563
380
  )
564
381
 
565
- async def __aenter__(self) -> "MCPClient":
566
- r"""Async context manager entry point. Automatically connects to the
567
- MCP server when used in an async with statement.
382
+ def _load_clients_from_config(self, config_path: str) -> List[MCPClient]:
383
+ r"""Load clients from configuration file."""
384
+ if not os.path.exists(config_path):
385
+ raise FileNotFoundError(f"Config file not found: '{config_path}'")
568
386
 
569
- Returns:
570
- MCPClient: Self with active connection.
571
- """
572
- await self.connect()
573
- return self
574
-
575
- def __enter__(self) -> "MCPClient":
576
- r"""Synchronously enter the async context manager."""
577
- return run_async(self.__aenter__)()
578
-
579
- async def __aexit__(self) -> None:
580
- r"""Async context manager exit point. Automatically disconnects from
581
- the MCP server when exiting an async with statement.
582
-
583
- Returns:
584
- None
585
- """
586
- await self.disconnect()
587
-
588
- def __exit__(self, exc_type, exc_val, exc_tb) -> None:
589
- r"""Synchronously exit the async context manager.
590
-
591
- Args:
592
- exc_type (Optional[Type[Exception]]): The type of exception that
593
- occurred during the execution of the with statement.
594
- exc_val (Optional[Exception]): The exception that occurred during
595
- the execution of the with statement.
596
- exc_tb (Optional[TracebackType]): The traceback of the exception
597
- that occurred during the execution of the with statement.
598
-
599
- Returns:
600
- None
601
- """
602
- return run_async(self.__aexit__)()
603
-
604
-
605
- class MCPToolkit(BaseToolkit):
606
- r"""MCPToolkit provides a unified interface for managing multiple
607
- MCP server connections and their tools.
608
-
609
- This class handles the lifecycle of multiple MCP server connections and
610
- offers a centralized configuration mechanism for both local and remote
611
- MCP services.
612
-
613
- Connection Lifecycle:
614
- There are three ways to manage the connection lifecycle:
615
-
616
- 1. Using the async context manager:
617
- ```python
618
- async with MCPToolkit(config_path="config.json") as toolkit:
619
- # Toolkit is connected here
620
- tools = toolkit.get_tools()
621
- # Toolkit is automatically disconnected here
622
- ```
623
-
624
- 2. Using the factory method:
625
- ```python
626
- toolkit = await MCPToolkit.create(config_path="config.json")
627
- # Toolkit is connected here
628
- tools = toolkit.get_tools()
629
- # Don't forget to disconnect when done!
630
- await toolkit.disconnect()
631
- ```
632
-
633
- 3. Using explicit connect/disconnect:
634
- ```python
635
- toolkit = MCPToolkit(config_path="config.json")
636
- await toolkit.connect()
637
- # Toolkit is connected here
638
- tools = toolkit.get_tools()
639
- # Don't forget to disconnect when done!
640
- await toolkit.disconnect()
641
- ```
642
-
643
- Args:
644
- servers (Optional[List[MCPClient]]): List of MCPClient
645
- instances to manage. (default: :obj:`None`)
646
- config_path (Optional[str]): Path to a JSON configuration file
647
- defining MCP servers. (default: :obj:`None`)
648
- config_dict (Optional[Dict[str, Any]]): Dictionary containing MCP
649
- server configurations in the same format as the config file.
650
- (default: :obj:`None`)
651
- strict (Optional[bool]): Whether to enforce strict mode for the
652
- function call. (default: :obj:`False`)
653
-
654
- Note:
655
- Either `servers`, `config_path`, or `config_dict` must be provided.
656
- If multiple are provided, servers from all sources will be combined.
657
-
658
- For web servers in the config, you can specify authorization
659
- headers using the "headers" field to connect to protected MCP server
660
- endpoints.
661
-
662
- Example configuration:
663
-
664
- .. code-block:: json
665
-
666
- {
667
- "mcpServers": {
668
- "protected-server": {
669
- "url": "https://example.com/mcp",
670
- "timeout": 30,
671
- "headers": {
672
- "Authorization": "Bearer YOUR_TOKEN",
673
- "X-API-Key": "YOUR_API_KEY"
674
- }
675
- }
676
- }
677
- }
678
-
679
- Attributes:
680
- servers (List[MCPClient]): List of MCPClient instances being managed.
681
- """
387
+ try:
388
+ with open(config_path, "r", encoding="utf-8") as f:
389
+ data = json.load(f)
390
+ except json.JSONDecodeError as e:
391
+ error_msg = f"Invalid JSON in config file '{config_path}': {e}"
392
+ raise ValueError(error_msg) from e
393
+ except Exception as e:
394
+ error_msg = f"Error reading config file '{config_path}': {e}"
395
+ raise IOError(error_msg) from e
682
396
 
683
- def __init__(
684
- self,
685
- servers: Optional[List[MCPClient]] = None,
686
- config_path: Optional[str] = None,
687
- config_dict: Optional[Dict[str, Any]] = None,
688
- strict: Optional[bool] = False,
689
- ):
690
- super().__init__()
397
+ return self._load_clients_from_dict(data)
691
398
 
692
- sources_provided = sum(
693
- 1 for src in [servers, config_path, config_dict] if src is not None
694
- )
695
- if sources_provided > 1:
696
- logger.warning(
697
- "Multiple configuration sources provided "
698
- f"({sources_provided}). Servers from all sources "
699
- "will be combined."
700
- )
399
+ def _load_clients_from_dict(
400
+ self, config: Dict[str, Any]
401
+ ) -> List[MCPClient]:
402
+ r"""Load clients from configuration dictionary."""
403
+ if not isinstance(config, dict):
404
+ raise ValueError("Config must be a dictionary")
701
405
 
702
- self.servers: List[MCPClient] = servers or []
406
+ mcp_servers = config.get("mcpServers", {})
407
+ if not isinstance(mcp_servers, dict):
408
+ raise ValueError("'mcpServers' must be a dictionary")
703
409
 
704
- if config_path:
705
- self.servers.extend(
706
- self._load_servers_from_config(config_path, strict)
707
- )
410
+ clients = []
708
411
 
709
- if config_dict:
710
- self.servers.extend(self._load_servers_from_dict(config_dict))
412
+ for name, cfg in mcp_servers.items():
413
+ try:
414
+ if "timeout" not in cfg and self.timeout is not None:
415
+ cfg["timeout"] = self.timeout
711
416
 
712
- self._connected = False
417
+ client = self._create_client_from_config(name, cfg)
418
+ clients.append(client)
419
+ except Exception as e:
420
+ logger.error(f"Failed to create client for '{name}': {e}")
421
+ error_msg = f"Invalid configuration for server '{name}': {e}"
422
+ raise ValueError(error_msg) from e
713
423
 
714
- def _load_servers_from_config(
715
- self, config_path: str, strict: Optional[bool] = False
716
- ) -> List[MCPClient]:
717
- r"""Loads MCP server configurations from a JSON file.
424
+ return clients
718
425
 
719
- Args:
720
- config_path (str): Path to the JSON configuration file.
721
- strict (bool): Whether to enforce strict mode for the
722
- function call. (default: :obj:`False`)
426
+ def _create_client_from_config(
427
+ self, name: str, cfg: Dict[str, Any]
428
+ ) -> MCPClient:
429
+ r"""Create a single MCP client from configuration."""
430
+ if not isinstance(cfg, dict):
431
+ error_msg = f"Configuration for server '{name}' must be a dict"
432
+ raise ValueError(error_msg)
723
433
 
724
- Returns:
725
- List[MCPClient]: List of configured MCPClient instances.
726
- """
727
434
  try:
728
- with open(config_path, "r", encoding="utf-8") as f:
729
- try:
730
- data = json.load(f)
731
- except json.JSONDecodeError as e:
732
- logger.warning(
733
- f"Invalid JSON in config file '{config_path}': {e!s}"
734
- )
735
- raise e
736
- except FileNotFoundError as e:
737
- logger.warning(f"Config file not found: '{config_path}'")
738
- raise e
435
+ # Use the new mcp_client factory function
436
+ # Pass timeout from toolkit if available
437
+ kwargs = {}
438
+ if hasattr(self, "timeout") and self.timeout is not None:
439
+ kwargs["timeout"] = self.timeout
739
440
 
740
- return self._load_servers_from_dict(config=data, strict=strict)
441
+ client = create_mcp_client(cfg, **kwargs)
442
+ return client
443
+ except Exception as e:
444
+ error_msg = f"Failed to create client for server '{name}': {e}"
445
+ raise ValueError(error_msg) from e
741
446
 
742
- def _load_servers_from_dict(
743
- self, config: Dict[str, Any], strict: Optional[bool] = False
744
- ) -> List[MCPClient]:
745
- r"""Loads MCP server configurations from a dictionary.
447
+ def get_tools(self) -> List[FunctionTool]:
448
+ r"""Aggregates all tools from the managed MCP client instances.
746
449
 
747
- Args:
748
- config (Dict[str, Any]): Dictionary containing server
749
- configurations.
750
- strict (bool): Whether to enforce strict mode for the
751
- function call. (default: :obj:`False`)
450
+ Collects and combines tools from all connected MCP clients into a
451
+ single unified list. Each tool is converted to a CAMEL-compatible
452
+ :obj:`FunctionTool` that can be used with CAMEL agents.
752
453
 
753
454
  Returns:
754
- List[MCPClient]: List of configured MCPClient instances.
455
+ List[FunctionTool]: Combined list of all available function tools
456
+ from all connected MCP servers. Returns an empty list if no
457
+ clients are connected or if no tools are available.
458
+
459
+ Note:
460
+ This method can be called even when the toolkit is not connected,
461
+ but it will log a warning and may return incomplete results.
462
+ For best results, ensure the toolkit is connected before calling
463
+ this method.
464
+
465
+ Example:
466
+ .. code-block:: python
467
+
468
+ async with MCPToolkit(config_dict=config) as toolkit:
469
+ tools = toolkit.get_tools()
470
+ print(f"Available tools: {len(tools)}")
471
+ for tool in tools:
472
+ print(f" - {tool.func.__name__}")
755
473
  """
756
- all_servers = []
757
-
758
- mcp_servers = config.get("mcpServers", {})
759
- if not isinstance(mcp_servers, dict):
760
- logger.warning("'mcpServers' is not a dictionary, skipping...")
761
- mcp_servers = {}
762
-
763
- for name, cfg in mcp_servers.items():
764
- if not isinstance(cfg, dict):
765
- logger.warning(
766
- f"Configuration for server '{name}' must be a dictionary"
767
- )
768
- continue
474
+ if not self.is_connected:
475
+ logger.warning(
476
+ "MCPToolkit is not connected. "
477
+ "Tools may not be available until connected."
478
+ )
769
479
 
770
- if "command" not in cfg and "url" not in cfg:
771
- logger.warning(
772
- f"Missing required 'command' or 'url' field for server "
773
- f"'{name}'"
480
+ all_tools = []
481
+ for i, client in enumerate(self.clients):
482
+ try:
483
+ client_tools = client.get_tools()
484
+ all_tools.extend(client_tools)
485
+ logger.debug(
486
+ f"Client {i+1} contributed {len(client_tools)} tools"
774
487
  )
775
- continue
776
-
777
- # Include headers if provided in the configuration
778
- headers = cfg.get("headers", {})
779
-
780
- cmd_or_url = cast(str, cfg.get("command") or cfg.get("url"))
781
- server = MCPClient(
782
- command_or_url=cmd_or_url,
783
- args=cfg.get("args", []),
784
- env={**os.environ, **cfg.get("env", {})},
785
- timeout=cfg.get("timeout", None),
786
- headers=headers,
787
- mode=cfg.get("mode", None),
788
- strict=strict,
789
- )
790
- all_servers.append(server)
488
+ except Exception as e:
489
+ logger.error(f"Failed to get tools from client {i+1}: {e}")
791
490
 
792
- return all_servers
491
+ logger.info(f"Total tools available: {len(all_tools)}")
492
+ return all_tools
793
493
 
794
- async def connect(self):
795
- r"""Explicitly connect to all MCP servers.
494
+ def get_text_tools(self) -> str:
495
+ r"""Returns a string containing the descriptions of the tools.
796
496
 
797
497
  Returns:
798
- MCPToolkit: The connected toolkit instance
498
+ str: A string containing the descriptions of all tools.
799
499
  """
800
- if self._connected:
801
- logger.warning("MCPToolkit is already connected")
802
- return self
500
+ if not self.is_connected:
501
+ logger.warning(
502
+ "MCPToolkit is not connected. "
503
+ "Tool descriptions may not be available until connected."
504
+ )
803
505
 
804
- try:
805
- # Sequentially connect to each server
806
- for server in self.servers:
807
- await server.connect()
808
- self._connected = True
809
- return self
810
- except Exception as e:
811
- # Ensure resources are cleaned up on connection failure
812
- await self.disconnect()
813
- logger.error(f"Failed to connect to one or more MCP servers: {e}")
814
- raise e
506
+ tool_descriptions = []
507
+ for i, client in enumerate(self.clients):
508
+ try:
509
+ client_tools_text = client.get_text_tools()
510
+ if client_tools_text:
511
+ tool_descriptions.append(
512
+ f"=== Client {i+1} Tools ===\n{client_tools_text}"
513
+ )
514
+ except Exception as e:
515
+ logger.error(
516
+ f"Failed to get tool descriptions from client {i+1}: {e}"
517
+ )
815
518
 
816
- def connect_sync(self):
817
- r"""Synchronously connect to all MCP servers."""
818
- return run_async(self.connect)()
519
+ return "\n\n".join(tool_descriptions)
819
520
 
820
- async def disconnect(self):
821
- r"""Explicitly disconnect from all MCP servers."""
822
- if not self._connected:
823
- return
521
+ async def call_tool(
522
+ self, tool_name: str, tool_args: Dict[str, Any]
523
+ ) -> Any:
524
+ r"""Call a tool by name across all managed clients.
824
525
 
825
- for server in self.servers:
826
- await server.disconnect()
827
- self._connected = False
526
+ Searches for and executes a tool with the specified name across all
527
+ connected MCP clients. The method will try each client in sequence
528
+ until the tool is found and successfully executed.
828
529
 
829
- def disconnect_sync(self):
830
- r"""Synchronously disconnect from all MCP servers."""
831
- return run_async(self.disconnect)()
530
+ Args:
531
+ tool_name (str): Name of the tool to call. Must match a tool name
532
+ available from one of the connected MCP servers.
533
+ tool_args (Dict[str, Any]): Arguments to pass to the tool. The
534
+ argument names and types must match the tool's expected
535
+ parameters.
832
536
 
833
- @asynccontextmanager
834
- async def connection(self) -> AsyncGenerator["MCPToolkit", None]:
835
- r"""Async context manager that simultaneously establishes connections
836
- to all managed MCP server instances.
537
+ Returns:
538
+ Any: The result of the tool call. The type and structure depend
539
+ on the specific tool being called.
837
540
 
838
- Yields:
839
- MCPToolkit: Self with all servers connected.
541
+ Raises:
542
+ MCPConnectionError: If the toolkit is not connected to any MCP
543
+ servers.
544
+ MCPToolError: If the tool is not found in any client, or if all
545
+ attempts to call the tool fail. The error message will include
546
+ details about the last failure encountered.
547
+
548
+ Example:
549
+ .. code-block:: python
550
+
551
+ async with MCPToolkit(config_dict=config) as toolkit:
552
+ # Call a file reading tool
553
+ result = await toolkit.call_tool(
554
+ "read_file",
555
+ {"path": "/tmp/example.txt"}
556
+ )
557
+ print(f"File contents: {result}")
840
558
  """
841
- try:
842
- await self.connect()
843
- yield self
844
- finally:
845
- await self.disconnect()
559
+ if not self.is_connected:
560
+ raise MCPConnectionError(
561
+ "MCPToolkit is not connected. Call connect() first."
562
+ )
846
563
 
847
- def connection_sync(self):
848
- r"""Synchronously connect to all MCP servers."""
849
- return run_async(self.connection)()
564
+ # Try to find and call the tool from any client
565
+ last_error = None
566
+ for i, client in enumerate(self.clients):
567
+ try:
568
+ # Check if this client has the tool
569
+ tools = client.get_tools()
570
+ tool_names = [tool.func.__name__ for tool in tools]
571
+
572
+ if tool_name in tool_names:
573
+ result = await client.call_tool(tool_name, tool_args)
574
+ logger.debug(
575
+ f"Tool '{tool_name}' called successfully "
576
+ f"on client {i+1}"
577
+ )
578
+ return result
579
+ except Exception as e:
580
+ last_error = e
581
+ logger.debug(f"Tool '{tool_name}' failed on client {i+1}: {e}")
582
+ continue
850
583
 
851
- def is_connected(self) -> bool:
852
- r"""Checks if all the managed servers are connected.
584
+ # If we get here, the tool wasn't found or all calls failed
585
+ if last_error:
586
+ raise MCPToolError(
587
+ f"Tool '{tool_name}' failed on all clients. "
588
+ f"Last error: {last_error}"
589
+ ) from last_error
590
+ else:
591
+ raise MCPToolError(f"Tool '{tool_name}' not found in any client")
853
592
 
854
- Returns:
855
- bool: True if connected, False otherwise.
856
- """
857
- return self._connected
593
+ def call_tool_sync(self, tool_name: str, tool_args: Dict[str, Any]) -> Any:
594
+ r"""Synchronously call a tool."""
595
+ return run_async(self.call_tool)(tool_name, tool_args)
858
596
 
859
- def get_tools(self) -> List[FunctionTool]:
860
- r"""Aggregates all tools from the managed MCP server instances.
597
+ def list_available_tools(self) -> Dict[str, List[str]]:
598
+ r"""List all available tools organized by client.
861
599
 
862
600
  Returns:
863
- List[FunctionTool]: Combined list of all available function tools.
601
+ Dict[str, List[str]]: Dictionary mapping client indices to tool
602
+ names.
864
603
  """
865
- all_tools = []
866
- for server in self.servers:
867
- all_tools.extend(server.get_tools())
868
- return all_tools
869
-
870
- def get_text_tools(self) -> str:
871
- r"""Returns a string containing the descriptions of the tools
872
- in the toolkit.
604
+ available_tools = {}
605
+ for i, client in enumerate(self.clients):
606
+ try:
607
+ tools = client.get_tools()
608
+ tool_names = [tool.func.__name__ for tool in tools]
609
+ available_tools[f"client_{i+1}"] = tool_names
610
+ except Exception as e:
611
+ logger.error(f"Failed to list tools from client {i+1}: {e}")
612
+ available_tools[f"client_{i+1}"] = []
873
613
 
874
- Returns:
875
- str: A string containing the descriptions of the tools
876
- in the toolkit.
877
- """
878
- return "\n".join(server.get_text_tools() for server in self.servers)
614
+ return available_tools