open-swarm 0.1.1748636259__py3-none-any.whl → 0.1.1748636455__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,233 @@
1
+ """
2
+ MCP Client Module
3
+
4
+ Manages connections and interactions with MCP servers using the MCP Python SDK.
5
+ Redirects MCP server stderr to log files unless debug mode is enabled.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ from typing import Any, Dict, List, Callable
12
+ from contextlib import contextmanager
13
+ import sys
14
+
15
+ from mcp import ClientSession, StdioServerParameters # type: ignore
16
+ from mcp.client.stdio import stdio_client # type: ignore
17
+ from swarm.types import Tool
18
+ from .cache_utils import get_cache
19
+
20
+ logger = logging.getLogger(__name__)
21
+ logger.setLevel(logging.DEBUG)
22
+
23
+ class MCPClient:
24
+ """
25
+ Manages connections and interactions with MCP servers using the MCP Python SDK.
26
+ """
27
+
28
+ def __init__(self, server_config: Dict[str, Any], timeout: int = 15, debug: bool = False):
29
+ """
30
+ Initialize the MCPClient with server configuration.
31
+
32
+ Args:
33
+ server_config (dict): Configuration dictionary for the MCP server.
34
+ timeout (int): Timeout for operations in seconds.
35
+ debug (bool): If True, MCP server stderr goes to console; otherwise, to log file.
36
+ """
37
+ self.command = server_config.get("command", "npx")
38
+ self.args = server_config.get("args", [])
39
+ self.env = {**os.environ.copy(), **server_config.get("env", {})}
40
+ self.timeout = timeout
41
+ self.debug = debug
42
+ self._tool_cache: Dict[str, Tool] = {}
43
+
44
+ # Initialize cache using the helper
45
+ self.cache = get_cache()
46
+
47
+ logger.info(f"Initialized MCPClient with command={self.command}, args={self.args}, debug={self.debug}")
48
+
49
+ @contextmanager
50
+ def _redirect_stderr(self):
51
+ import sys, os
52
+ if not self.debug:
53
+ old_stderr = sys.stderr
54
+ sys.stderr = open(os.devnull, "w")
55
+ try:
56
+ yield
57
+ finally:
58
+ sys.stderr.close()
59
+ sys.stderr = old_stderr
60
+ else:
61
+ yield
62
+
63
+ async def list_tools(self) -> List[Tool]:
64
+ """
65
+ Discover tools from the MCP server and cache their schemas.
66
+
67
+ Returns:
68
+ List[Tool]: A list of discovered tools with schemas.
69
+ """
70
+ logger.debug(f"Entering list_tools for command={self.command}, args={self.args}")
71
+
72
+ # Attempt to retrieve tools from cache
73
+ args_string = "_".join(self.args)
74
+ cache_key = f"mcp_tools_{self.command}_{args_string}"
75
+ cached_tools = self.cache.get(cache_key)
76
+
77
+ if cached_tools:
78
+ logger.debug("Retrieved tools from cache")
79
+ tools = []
80
+ for tool_data in cached_tools:
81
+ tool_name = tool_data["name"]
82
+ tool = Tool(
83
+ name=tool_name,
84
+ description=tool_data["description"],
85
+ input_schema=tool_data.get("input_schema", {}),
86
+ func=self._create_tool_callable(tool_name),
87
+ )
88
+ tools.append(tool)
89
+ logger.debug(f"Returning {len(tools)} cached tools")
90
+ return tools
91
+
92
+ server_params = StdioServerParameters(command=self.command, args=self.args, env=self.env)
93
+ logger.debug("Opening stdio_client connection")
94
+ async with stdio_client(server_params) as (read, write):
95
+ logger.debug("Opening ClientSession")
96
+ async with ClientSession(read, write) as session:
97
+ try:
98
+ logger.info("Initializing session for tool discovery")
99
+ await asyncio.wait_for(session.initialize(), timeout=self.timeout)
100
+ logger.info("Initializing session for tool discovery")
101
+ await asyncio.wait_for(session.initialize(), timeout=self.timeout)
102
+ logger.info("Capabilities initialized. Entering tool discovery.")
103
+ logger.info("Requesting tool list from MCP server...")
104
+ tools_response = await asyncio.wait_for(session.list_tools(), timeout=self.timeout)
105
+ logger.debug("Tool list received from MCP server")
106
+
107
+ serialized_tools = [
108
+ {
109
+ 'name': tool.name,
110
+ 'description': tool.description,
111
+ 'input_schema': tool.inputSchema,
112
+ }
113
+ for tool in tools_response.tools
114
+ ]
115
+
116
+ self.cache.set(cache_key, serialized_tools, 3600)
117
+ logger.debug(f"Cached {len(serialized_tools)} tools.")
118
+
119
+ tools = []
120
+ for tool in tools_response.tools:
121
+ input_schema = tool.inputSchema or {}
122
+ cached_tool = Tool(
123
+ name=tool.name,
124
+ description=tool.description,
125
+ input_schema=input_schema,
126
+ func=self._create_tool_callable(tool.name),
127
+ )
128
+ self._tool_cache[tool.name] = cached_tool
129
+ tools.append(cached_tool)
130
+ logger.debug(f"Discovered tool: {tool.name} with schema: {input_schema}")
131
+
132
+ logger.debug(f"Returning {len(tools)} tools from MCP server")
133
+ return tools
134
+
135
+ except asyncio.TimeoutError:
136
+ logger.error(f"Timeout after {self.timeout}s waiting for tool list")
137
+ raise RuntimeError("Tool list request timed out")
138
+ except Exception as e:
139
+ logger.error(f"Error listing tools: {e}")
140
+ raise RuntimeError("Failed to list tools") from e
141
+
142
+ async def _do_list_resources(self) -> Any:
143
+ server_params = StdioServerParameters(command=self.command, args=self.args, env=self.env)
144
+ logger.debug("Opening stdio_client connection for resources")
145
+ async with stdio_client(server_params) as (read, write):
146
+ logger.debug("Opening ClientSession for resources")
147
+ async with ClientSession(read, write) as session:
148
+ logger.info("Requesting resource list from MCP server...")
149
+ with self._redirect_stderr():
150
+ # Ensure we initialize the session before listing resources
151
+ logger.debug("Initializing session before listing resources")
152
+ await asyncio.wait_for(session.initialize(), timeout=self.timeout)
153
+ resources_response = await asyncio.wait_for(session.list_resources(), timeout=self.timeout)
154
+ logger.debug("Resource list received from MCP server")
155
+ return resources_response
156
+
157
+ def _create_tool_callable(self, tool_name: str) -> Callable[..., Any]:
158
+ """
159
+ Dynamically create a callable function for the specified tool.
160
+ """
161
+ async def dynamic_tool_func(**kwargs) -> Any:
162
+ logger.debug(f"Creating tool callable for '{tool_name}'")
163
+ server_params = StdioServerParameters(command=self.command, args=self.args, env=self.env)
164
+ async with stdio_client(server_params) as (read, write):
165
+ async with ClientSession(read, write) as session:
166
+ try:
167
+ logger.debug(f"Initializing session for tool '{tool_name}'")
168
+ await asyncio.wait_for(session.initialize(), timeout=self.timeout)
169
+ if tool_name in self._tool_cache:
170
+ tool = self._tool_cache[tool_name]
171
+ self._validate_input_schema(tool.input_schema, kwargs)
172
+ logger.info(f"Calling tool '{tool_name}' with arguments: {kwargs}")
173
+ result = await asyncio.wait_for(session.call_tool(tool_name, kwargs), timeout=self.timeout)
174
+ logger.info(f"Tool '{tool_name}' executed successfully: {result}")
175
+ return result
176
+ except asyncio.TimeoutError:
177
+ logger.error(f"Timeout after {self.timeout}s executing tool '{tool_name}'")
178
+ raise RuntimeError(f"Tool '{tool_name}' execution timed out")
179
+ except Exception as e:
180
+ logger.error(f"Failed to execute tool '{tool_name}': {e}")
181
+ raise RuntimeError(f"Tool execution failed: {e}") from e
182
+
183
+ return dynamic_tool_func
184
+
185
+ def _validate_input_schema(self, schema: Dict[str, Any], kwargs: Dict[str, Any]):
186
+ """
187
+ Validate the provided arguments against the input schema.
188
+ """
189
+ if not schema:
190
+ logger.debug("No input schema available for validation. Skipping.")
191
+ return
192
+
193
+ required_params = schema.get("required", [])
194
+ for param in required_params:
195
+ if param not in kwargs:
196
+ raise ValueError(f"Missing required parameter: '{param}'")
197
+
198
+ logger.debug(f"Validated input against schema: {schema} with arguments: {kwargs}")
199
+
200
+ async def list_resources(self) -> Any:
201
+ """
202
+ Discover resources from the MCP server using the internal method with enforced timeout.
203
+ """
204
+ return await asyncio.wait_for(self._do_list_resources(), timeout=self.timeout)
205
+
206
+ async def get_resource(self, resource_uri: str) -> Any:
207
+ """
208
+ Retrieve a specific resource from the MCP server.
209
+
210
+ Args:
211
+ resource_uri (str): The URI of the resource to retrieve.
212
+
213
+ Returns:
214
+ Any: The resource retrieval response.
215
+ """
216
+ server_params = StdioServerParameters(command=self.command, args=self.args, env=self.env)
217
+ logger.debug("Opening stdio_client connection for resource retrieval")
218
+ async with stdio_client(server_params) as (read, write):
219
+ logger.debug("Opening ClientSession for resource retrieval")
220
+ async with ClientSession(read, write) as session:
221
+ try:
222
+ logger.debug(f"Initializing session for resource retrieval of {resource_uri}")
223
+ await asyncio.wait_for(session.initialize(), timeout=self.timeout)
224
+ logger.info(f"Retrieving resource '{resource_uri}' from MCP server")
225
+ response = await asyncio.wait_for(session.read_resource(resource_uri), timeout=self.timeout)
226
+ logger.info(f"Resource '{resource_uri}' retrieved successfully")
227
+ return response
228
+ except asyncio.TimeoutError:
229
+ logger.error(f"Timeout retrieving resource '{resource_uri}' after {self.timeout}s")
230
+ raise RuntimeError(f"Resource '{resource_uri}' retrieval timed out")
231
+ except Exception as e:
232
+ logger.error(f"Failed to retrieve resource '{resource_uri}': {e}")
233
+ raise RuntimeError(f"Resource retrieval failed: {e}") from e
@@ -0,0 +1,135 @@
1
+ """
2
+ MCPToolProvider Module for Open-Swarm
3
+
4
+ This module is responsible for discovering tools from MCP (Model Context Protocol) servers
5
+ and integrating them into the Open-Swarm framework as `Tool` instances. It handles
6
+ communication with MCP servers, constructs callable functions for dynamic tools, and
7
+ ensures that these tools are properly validated and integrated into the agent's function list.
8
+ """
9
+
10
+ import logging
11
+ from typing import List, Dict, Any
12
+
13
+ from swarm.settings import DEBUG
14
+ from swarm.types import Tool, Agent
15
+ from swarm.extensions.mcp.mcp_client import MCPClient
16
+
17
+ from .cache_utils import get_cache
18
+
19
+ logger = logging.getLogger(__name__)
20
+ logger.setLevel(logging.DEBUG if DEBUG else logging.INFO)
21
+ stream_handler = logging.StreamHandler()
22
+ formatter = logging.Formatter("[%(levelname)s] %(asctime)s - %(name)s - %(message)s")
23
+ stream_handler.setFormatter(formatter)
24
+ if not logger.handlers:
25
+ logger.addHandler(stream_handler)
26
+
27
+
28
+ class MCPToolProvider:
29
+ """
30
+ Singleton MCPToolProvider to discover tools from an MCP server and convert them into `Tool` instances.
31
+ """
32
+ _instances: Dict[str, "MCPToolProvider"] = {}
33
+
34
+ @classmethod
35
+ def get_instance(cls, server_name: str, server_config: Dict[str, Any], timeout: int = 15, debug: bool = False) -> "MCPToolProvider":
36
+ """Get or create a singleton instance for the given server name."""
37
+ if server_name not in cls._instances:
38
+ cls._instances[server_name] = cls(server_name, server_config, timeout, debug)
39
+ return cls._instances[server_name]
40
+
41
+ def __init__(self, server_name: str, server_config: Dict[str, Any], timeout: int = 15, debug: bool = False):
42
+ """
43
+ Initialize an MCPToolProvider instance with a configurable timeout.
44
+
45
+ Args:
46
+ server_name (str): The name of the MCP server.
47
+ server_config (dict): Configuration dictionary for the specific server.
48
+ timeout (int): Timeout in seconds for MCP operations (default 15, overridden by caller if provided).
49
+ debug (bool): If True, MCP server stderr goes to stderr; otherwise, to log file.
50
+ """
51
+ if server_name in self._instances:
52
+ raise ValueError(f"MCPToolProvider for '{server_name}' already initialized. Use get_instance().")
53
+ self.server_name = server_name
54
+ self.client = MCPClient(server_config=server_config, timeout=timeout, debug=debug)
55
+ self.cache = get_cache()
56
+ logger.debug(f"Initialized MCPToolProvider for server '{self.server_name}' with timeout {timeout}s.")
57
+
58
+ async def discover_tools(self, agent: Agent) -> List[Tool]:
59
+ """
60
+ Discover tools from the MCP server and return them as a list of `Tool` instances.
61
+ Utilizes Django cache to persist tool metadata if available.
62
+
63
+ Args:
64
+ agent (Agent): The agent for which tools are being discovered.
65
+
66
+ Returns:
67
+ List[Tool]: A list of discovered `Tool` instances.
68
+
69
+ Raises:
70
+ RuntimeError: If tool discovery from the MCP server fails.
71
+ """
72
+ cache_key = f"mcp_tools_{self.server_name}"
73
+ cached_tools = self.cache.get(cache_key)
74
+
75
+ if cached_tools:
76
+ logger.debug(f"Retrieved tools for server '{self.server_name}' from cache.")
77
+ tools = []
78
+ for tool_data in cached_tools:
79
+ tool_name = tool_data["name"]
80
+ tool = Tool(
81
+ name=tool_name,
82
+ description=tool_data["description"],
83
+ input_schema=tool_data.get("input_schema", {}),
84
+ func=self._create_tool_callable(tool_name),
85
+ )
86
+ tools.append(tool)
87
+ return tools
88
+
89
+ logger.debug(f"Starting tool discovery from MCP server '{self.server_name}' for agent '{agent.name}'.")
90
+ try:
91
+ tools = await self.client.list_tools()
92
+ logger.debug(f"Discovered tools from MCP server '{self.server_name}': {[tool.name for tool in tools]}")
93
+
94
+ # Serialize tools for caching
95
+ serialized_tools = [
96
+ {
97
+ 'name': tool.name,
98
+ 'description': tool.description,
99
+ 'input_schema': tool.input_schema,
100
+ }
101
+ for tool in tools
102
+ ]
103
+
104
+ # Cache the tools for 1 hour (3600 seconds)
105
+ self.cache.set(cache_key, serialized_tools, 3600)
106
+ logger.debug(f"Cached tools for MCP server '{self.server_name}'.")
107
+
108
+ return tools
109
+
110
+ except Exception as e:
111
+ logger.error(f"Failed to discover tools from MCP server '{self.server_name}': {e}", exc_info=True)
112
+ raise RuntimeError(f"Tool discovery failed for MCP server '{self.server_name}': {e}") from e
113
+
114
+ def _create_tool_callable(self, tool_name: str):
115
+ """
116
+ Create a callable function for a dynamically discovered tool.
117
+
118
+ Args:
119
+ tool_name (str): The name of the tool.
120
+
121
+ Returns:
122
+ Callable: An async callable function for the tool.
123
+ """
124
+ async def dynamic_tool_func(**kwargs) -> Any:
125
+ try:
126
+ logger.info(f"Executing tool '{tool_name}' with arguments: {kwargs}")
127
+ tool_callable = self.client._create_tool_callable(tool_name)
128
+ result = await tool_callable(**kwargs)
129
+ logger.info(f"Tool '{tool_name}' executed successfully: {result}")
130
+ return result
131
+ except Exception as e:
132
+ logger.error(f"Error executing tool '{tool_name}': {e}")
133
+ raise RuntimeError(f"Tool execution failed: {e}") from e
134
+
135
+ return dynamic_tool_func
@@ -0,0 +1,260 @@
1
+ """
2
+ Utilities for MCP server interactions in the Swarm framework.
3
+ Handles discovery and merging of tools and resources from MCP servers.
4
+ """
5
+
6
+ import logging
7
+ from typing import List, Dict, Any, Optional, cast
8
+ import asyncio # Needed for async operations
9
+
10
+ # Import necessary types from the core swarm types
11
+ from swarm.types import Agent, AgentFunction
12
+ # Import the MCPToolProvider which handles communication with MCP servers
13
+ from .mcp_tool_provider import MCPToolProvider
14
+
15
+ # Configure module-level logging
16
+ logger = logging.getLogger(__name__)
17
+ # Ensure logger level is set appropriately (e.g., DEBUG for development)
18
+ # logger.setLevel(logging.DEBUG) # Uncomment for verbose logging
19
+ # Add handler if not already configured by root logger setup
20
+ if not logger.handlers:
21
+ stream_handler = logging.StreamHandler()
22
+ formatter = logging.Formatter("[%(levelname)s] %(asctime)s - %(name)s:%(lineno)d - %(message)s")
23
+ stream_handler.setFormatter(formatter)
24
+ logger.addHandler(stream_handler)
25
+
26
+ # Dictionary to manage locks for concurrent discovery per agent (optional)
27
+ # _discovery_locks: Dict[str, asyncio.Lock] = {}
28
+
29
+ async def discover_and_merge_agent_tools(agent: Agent, config: Dict[str, Any], debug: bool = False) -> List[AgentFunction]:
30
+ """
31
+ Discover tools from MCP servers listed in the agent's config and merge
32
+ them with the agent's statically defined functions.
33
+
34
+ Handles deduplication of discovered tools based on name.
35
+
36
+ Args:
37
+ agent: The agent instance for which to discover tools.
38
+ config: The main Swarm configuration dictionary containing MCP server details.
39
+ debug: If True, enable detailed debugging logs.
40
+
41
+ Returns:
42
+ List[AgentFunction]: A combined list containing the agent's static functions
43
+ and unique tools discovered from its associated MCP servers.
44
+ Returns the agent's static functions if no MCP servers are defined.
45
+ Returns an empty list if the agent is None.
46
+ """
47
+ if not agent:
48
+ logger.error("Cannot discover tools: Agent object is None.")
49
+ return []
50
+ # Use agent's name for logging clarity
51
+ agent_name = getattr(agent, "name", "UnnamedAgent")
52
+
53
+ logger.debug(f"Starting tool discovery for agent '{agent_name}'.")
54
+ # Get the list of MCP servers associated with the agent
55
+ mcp_server_names = getattr(agent, "mcp_servers", [])
56
+
57
+ # Retrieve the agent's statically defined functions
58
+ static_functions = getattr(agent, "functions", []) or []
59
+ if not isinstance(static_functions, list):
60
+ logger.warning(f"Agent '{agent_name}' functions attribute is not a list ({type(static_functions)}). Treating as empty.")
61
+ static_functions = []
62
+
63
+ # If no MCP servers are listed for the agent, return only static functions
64
+ if not mcp_server_names:
65
+ func_names = [getattr(f, 'name', getattr(f, '__name__', '<unknown>')) for f in static_functions]
66
+ logger.debug(f"Agent '{agent_name}' has no MCP servers listed. Returning {len(static_functions)} static functions: {func_names}")
67
+ return static_functions
68
+
69
+ # List to hold tools discovered from all MCP servers
70
+ all_discovered_tools: List[AgentFunction] = []
71
+ # Set to keep track of discovered tool names for deduplication
72
+ discovered_tool_names = set()
73
+
74
+ # Iterate through each MCP server listed for the agent
75
+ for server_name in mcp_server_names:
76
+ if not isinstance(server_name, str):
77
+ logger.warning(f"Invalid MCP server name type for agent '{agent_name}': {type(server_name)}. Skipping.")
78
+ continue
79
+
80
+ logger.debug(f"Discovering tools from MCP server '{server_name}' for agent '{agent_name}'.")
81
+ # Get the configuration for the specific MCP server from the main config
82
+ server_config = config.get("mcpServers", {}).get(server_name)
83
+ if not server_config:
84
+ logger.warning(f"MCP server '{server_name}' configuration not found in main config for agent '{agent_name}'. Skipping.")
85
+ continue
86
+
87
+ try:
88
+ # Get an instance of the MCPToolProvider for this server
89
+ # Timeout can be adjusted based on expected MCP response time
90
+ provider = MCPToolProvider.get_instance(server_name, server_config, timeout=15, debug=debug)
91
+ # Call the provider to discover tools (this interacts with the MCP server)
92
+ discovered_tools_from_server = await provider.discover_tools(agent)
93
+
94
+ # Validate the response from the provider
95
+ if not isinstance(discovered_tools_from_server, list):
96
+ logger.warning(f"Invalid tools format received from MCP server '{server_name}' for agent '{agent_name}': Expected list, got {type(discovered_tools_from_server)}. Skipping.")
97
+ continue
98
+
99
+ server_tool_count = 0
100
+ for tool in discovered_tools_from_server:
101
+ # Attempt to get tool name for deduplication and logging
102
+ tool_name = getattr(tool, 'name', None) # Assuming tool objects have a 'name' attribute
103
+ if not tool_name:
104
+ logger.warning(f"Discovered tool from '{server_name}' is missing a 'name'. Skipping.")
105
+ continue
106
+
107
+ # Deduplication: Add tool only if its name hasn't been seen before
108
+ if tool_name not in discovered_tool_names:
109
+ # Ensure 'requires_approval' attribute exists (defaulting to True if missing)
110
+ if not hasattr(tool, "requires_approval"):
111
+ logger.debug(f"Tool '{tool_name}' from '{server_name}' missing 'requires_approval', defaulting to True.")
112
+ try:
113
+ setattr(tool, "requires_approval", True)
114
+ except AttributeError:
115
+ logger.warning(f"Could not set 'requires_approval' on tool '{tool_name}'.")
116
+
117
+ all_discovered_tools.append(tool)
118
+ discovered_tool_names.add(tool_name)
119
+ server_tool_count += 1
120
+ else:
121
+ logger.debug(f"Tool '{tool_name}' from '{server_name}' is a duplicate. Skipping.")
122
+
123
+ tool_names_log = [getattr(t, 'name', '<noname>') for t in discovered_tools_from_server]
124
+ logger.debug(f"Discovered {server_tool_count} unique tools from '{server_name}': {tool_names_log}")
125
+
126
+ except Exception as e:
127
+ # Log errors during discovery for a specific server but continue with others
128
+ logger.error(f"Failed to discover tools from MCP server '{server_name}' for agent '{agent_name}': {e}", exc_info=debug) # Show traceback if debug
129
+
130
+ # Combine static functions with the unique discovered tools
131
+ # Static functions take precedence if names conflict (though deduplication above is based on discovered names)
132
+ final_functions = static_functions + all_discovered_tools
133
+
134
+ # Log final combined list details if debugging
135
+ if debug:
136
+ static_names = [getattr(f, 'name', getattr(f, '__name__', '<unknown>')) for f in static_functions]
137
+ discovered_names = list(discovered_tool_names) # Names of unique discovered tools
138
+ combined_names = [getattr(f, 'name', getattr(f, '__name__', '<unknown>')) for f in final_functions]
139
+ logger.debug(f"[DEBUG] Agent '{agent_name}' - Static functions: {static_names}")
140
+ logger.debug(f"[DEBUG] Agent '{agent_name}' - Unique discovered tools: {discovered_names}")
141
+ logger.debug(f"[DEBUG] Agent '{agent_name}' - Final combined functions: {combined_names}")
142
+
143
+ logger.debug(f"Agent '{agent_name}' total functions/tools after merge: {len(final_functions)} (Static: {len(static_functions)}, Discovered: {len(all_discovered_tools)})")
144
+ return final_functions
145
+
146
+
147
+ async def discover_and_merge_agent_resources(agent: Agent, config: Dict[str, Any], debug: bool = False) -> List[Dict[str, Any]]:
148
+ """
149
+ Discover resources from MCP servers listed in the agent's config and merge
150
+ them with the agent's statically defined resources.
151
+
152
+ Handles deduplication of discovered resources based on their 'uri'.
153
+
154
+ Args:
155
+ agent: The agent instance for which to discover resources.
156
+ config: The main Swarm configuration dictionary containing MCP server details.
157
+ debug: If True, enable detailed debugging logs.
158
+
159
+ Returns:
160
+ List[Dict[str, Any]]: A combined list containing the agent's static resources
161
+ and unique resources discovered from its associated MCP servers.
162
+ Returns the agent's static resources if no MCP servers are defined.
163
+ Returns an empty list if the agent is None.
164
+ """
165
+ if not agent:
166
+ logger.error("Cannot discover resources: Agent object is None.")
167
+ return []
168
+ agent_name = getattr(agent, "name", "UnnamedAgent")
169
+
170
+ logger.debug(f"Starting resource discovery for agent '{agent_name}'.")
171
+ mcp_server_names = getattr(agent, "mcp_servers", [])
172
+
173
+ # Get static resources, ensure it's a list
174
+ static_resources = getattr(agent, "resources", []) or []
175
+ if not isinstance(static_resources, list):
176
+ logger.warning(f"Agent '{agent_name}' resources attribute is not a list ({type(static_resources)}). Treating as empty.")
177
+ static_resources = []
178
+ # Ensure static resources are dicts (basic check)
179
+ static_resources = [r for r in static_resources if isinstance(r, dict)]
180
+
181
+ if not mcp_server_names:
182
+ res_names = [r.get('name', '<unnamed>') for r in static_resources]
183
+ logger.debug(f"Agent '{agent_name}' has no MCP servers listed. Returning {len(static_resources)} static resources: {res_names}")
184
+ return static_resources
185
+
186
+ # List to hold resources discovered from all MCP servers
187
+ all_discovered_resources: List[Dict[str, Any]] = []
188
+
189
+ # Iterate through each MCP server listed for the agent
190
+ for server_name in mcp_server_names:
191
+ if not isinstance(server_name, str):
192
+ logger.warning(f"Invalid MCP server name type for agent '{agent_name}': {type(server_name)}. Skipping.")
193
+ continue
194
+
195
+ logger.debug(f"Discovering resources from MCP server '{server_name}' for agent '{agent_name}'.")
196
+ server_config = config.get("mcpServers", {}).get(server_name)
197
+ if not server_config:
198
+ logger.warning(f"MCP server '{server_name}' configuration not found for agent '{agent_name}'. Skipping.")
199
+ continue
200
+
201
+ try:
202
+ provider = MCPToolProvider.get_instance(server_name, server_config, timeout=15, debug=debug)
203
+ # Fetch resources using the provider's client
204
+ # Assuming provider.client has a method like list_resources() that returns {'resources': [...]}
205
+ resources_response = await provider.client.list_resources()
206
+
207
+ # Validate the structure of the response
208
+ if not isinstance(resources_response, dict) or "resources" not in resources_response:
209
+ logger.warning(f"Invalid resources response format from MCP server '{server_name}' for agent '{agent_name}'. Expected dict with 'resources' key, got: {type(resources_response)}")
210
+ continue
211
+
212
+ resources_from_server = resources_response["resources"]
213
+ if not isinstance(resources_from_server, list):
214
+ logger.warning(f"Invalid 'resources' format in response from '{server_name}': Expected list, got {type(resources_from_server)}.")
215
+ continue
216
+
217
+ # Filter for valid resource dictionaries (must be dict and have 'uri')
218
+ valid_resources = [res for res in resources_from_server if isinstance(res, dict) and 'uri' in res]
219
+ invalid_count = len(resources_from_server) - len(valid_resources)
220
+ if invalid_count > 0:
221
+ logger.warning(f"Filtered out {invalid_count} invalid resource entries from '{server_name}'.")
222
+
223
+ all_discovered_resources.extend(valid_resources)
224
+ res_names_log = [r.get('name', '<unnamed>') for r in valid_resources]
225
+ logger.debug(f"Discovered {len(valid_resources)} valid resources from '{server_name}': {res_names_log}")
226
+
227
+ except AttributeError:
228
+ logger.error(f"MCPToolProvider client for '{server_name}' does not have a 'list_resources' method.", exc_info=debug)
229
+ except Exception as e:
230
+ logger.error(f"Failed to discover resources from MCP server '{server_name}' for agent '{agent_name}': {e}", exc_info=debug)
231
+
232
+ # Deduplicate discovered resources based on 'uri'
233
+ # Use a dictionary to keep only the first occurrence of each URI
234
+ unique_discovered_resources_map: Dict[str, Dict[str, Any]] = {}
235
+ for resource in all_discovered_resources:
236
+ uri = resource.get('uri') # URI is expected from validation above
237
+ if uri not in unique_discovered_resources_map:
238
+ unique_discovered_resources_map[uri] = resource
239
+
240
+ unique_discovered_resources_list = list(unique_discovered_resources_map.values())
241
+
242
+ # Combine static resources with unique discovered resources
243
+ # Create a map of static resource URIs to prevent duplicates if they also exist in discovered
244
+ static_resource_uris = {res.get('uri') for res in static_resources if res.get('uri')}
245
+ final_resources = static_resources + [
246
+ res for res in unique_discovered_resources_list if res.get('uri') not in static_resource_uris
247
+ ]
248
+
249
+ if debug:
250
+ static_names = [r.get('name', '<unnamed>') for r in static_resources]
251
+ discovered_names = [r.get('name', '<unnamed>') for r in all_discovered_resources] # Before dedupe
252
+ unique_discovered_names = [r.get('name', '<unnamed>') for r in unique_discovered_resources_list] # After dedupe
253
+ combined_names = [r.get('name', '<unnamed>') for r in final_resources]
254
+ logger.debug(f"[DEBUG] Agent '{agent_name}' - Static resources: {static_names}")
255
+ logger.debug(f"[DEBUG] Agent '{agent_name}' - Discovered resources (before URI dedupe): {discovered_names}")
256
+ logger.debug(f"[DEBUG] Agent '{agent_name}' - Unique discovered resources (after URI dedupe): {unique_discovered_names}")
257
+ logger.debug(f"[DEBUG] Agent '{agent_name}' - Final combined resources: {combined_names}")
258
+
259
+ logger.debug(f"Agent '{agent_name}' total resources after merge: {len(final_resources)} (Static: {len(static_resources)}, Unique Discovered: {len(unique_discovered_resources_list)})")
260
+ return final_resources