griptape-nodes 0.55.1__py3-none-any.whl → 0.56.1__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.
Files changed (60) hide show
  1. griptape_nodes/app/app.py +10 -15
  2. griptape_nodes/app/watch.py +35 -67
  3. griptape_nodes/bootstrap/utils/__init__.py +1 -0
  4. griptape_nodes/bootstrap/utils/python_subprocess_executor.py +122 -0
  5. griptape_nodes/bootstrap/workflow_executors/local_session_workflow_executor.py +418 -0
  6. griptape_nodes/bootstrap/workflow_executors/local_workflow_executor.py +37 -8
  7. griptape_nodes/bootstrap/workflow_executors/subprocess_workflow_executor.py +326 -0
  8. griptape_nodes/bootstrap/workflow_executors/utils/__init__.py +1 -0
  9. griptape_nodes/bootstrap/workflow_executors/utils/subprocess_script.py +51 -0
  10. griptape_nodes/bootstrap/workflow_publishers/__init__.py +1 -0
  11. griptape_nodes/bootstrap/workflow_publishers/local_workflow_publisher.py +43 -0
  12. griptape_nodes/bootstrap/workflow_publishers/subprocess_workflow_publisher.py +84 -0
  13. griptape_nodes/bootstrap/workflow_publishers/utils/__init__.py +1 -0
  14. griptape_nodes/bootstrap/workflow_publishers/utils/subprocess_script.py +54 -0
  15. griptape_nodes/cli/commands/engine.py +4 -15
  16. griptape_nodes/cli/commands/init.py +88 -0
  17. griptape_nodes/cli/commands/models.py +2 -0
  18. griptape_nodes/cli/main.py +6 -1
  19. griptape_nodes/cli/shared.py +1 -0
  20. griptape_nodes/exe_types/core_types.py +130 -0
  21. griptape_nodes/exe_types/node_types.py +125 -13
  22. griptape_nodes/machines/control_flow.py +10 -0
  23. griptape_nodes/machines/dag_builder.py +21 -2
  24. griptape_nodes/machines/parallel_resolution.py +25 -10
  25. griptape_nodes/node_library/workflow_registry.py +73 -3
  26. griptape_nodes/retained_mode/events/agent_events.py +2 -0
  27. griptape_nodes/retained_mode/events/base_events.py +18 -17
  28. griptape_nodes/retained_mode/events/execution_events.py +15 -3
  29. griptape_nodes/retained_mode/events/flow_events.py +63 -7
  30. griptape_nodes/retained_mode/events/mcp_events.py +363 -0
  31. griptape_nodes/retained_mode/events/node_events.py +3 -4
  32. griptape_nodes/retained_mode/events/resource_events.py +290 -0
  33. griptape_nodes/retained_mode/events/workflow_events.py +57 -2
  34. griptape_nodes/retained_mode/griptape_nodes.py +17 -1
  35. griptape_nodes/retained_mode/managers/agent_manager.py +67 -4
  36. griptape_nodes/retained_mode/managers/event_manager.py +31 -13
  37. griptape_nodes/retained_mode/managers/flow_manager.py +731 -33
  38. griptape_nodes/retained_mode/managers/library_manager.py +15 -23
  39. griptape_nodes/retained_mode/managers/mcp_manager.py +364 -0
  40. griptape_nodes/retained_mode/managers/model_manager.py +184 -83
  41. griptape_nodes/retained_mode/managers/node_manager.py +15 -4
  42. griptape_nodes/retained_mode/managers/os_manager.py +118 -1
  43. griptape_nodes/retained_mode/managers/resource_components/__init__.py +1 -0
  44. griptape_nodes/retained_mode/managers/resource_components/capability_field.py +41 -0
  45. griptape_nodes/retained_mode/managers/resource_components/comparator.py +18 -0
  46. griptape_nodes/retained_mode/managers/resource_components/resource_instance.py +236 -0
  47. griptape_nodes/retained_mode/managers/resource_components/resource_type.py +79 -0
  48. griptape_nodes/retained_mode/managers/resource_manager.py +306 -0
  49. griptape_nodes/retained_mode/managers/resource_types/__init__.py +1 -0
  50. griptape_nodes/retained_mode/managers/resource_types/cpu_resource.py +108 -0
  51. griptape_nodes/retained_mode/managers/resource_types/os_resource.py +87 -0
  52. griptape_nodes/retained_mode/managers/settings.py +45 -0
  53. griptape_nodes/retained_mode/managers/sync_manager.py +10 -3
  54. griptape_nodes/retained_mode/managers/workflow_manager.py +447 -263
  55. griptape_nodes/traits/multi_options.py +5 -1
  56. griptape_nodes/traits/options.py +10 -2
  57. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/METADATA +2 -2
  58. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/RECORD +60 -37
  59. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/WHEEL +1 -1
  60. {griptape_nodes-0.55.1.dist-info → griptape_nodes-0.56.1.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import importlib.util
4
5
  import json
5
6
  import logging
@@ -895,7 +896,8 @@ class LibraryManager:
895
896
  continue # SKIP IT
896
897
 
897
898
  # Attempt to load nodes from the library.
898
- library_load_results = self._attempt_load_nodes_from_library(
899
+ library_load_results = await asyncio.to_thread(
900
+ self._attempt_load_nodes_from_library,
899
901
  library_data=library_data,
900
902
  library=library,
901
903
  base_dir=base_dir,
@@ -1521,7 +1523,7 @@ class LibraryManager:
1521
1523
  for library_result in metadata_result.successful_libraries:
1522
1524
  if library_result.library_schema.name == LibraryManager.SANDBOX_LIBRARY_NAME:
1523
1525
  # Handle sandbox library - use the schema we already have
1524
- self._attempt_generate_sandbox_library_from_schema(
1526
+ await self._attempt_generate_sandbox_library_from_schema(
1525
1527
  library_schema=library_result.library_schema, sandbox_directory=library_result.file_path
1526
1528
  )
1527
1529
  else:
@@ -1853,7 +1855,7 @@ class LibraryManager:
1853
1855
  problems=problems,
1854
1856
  )
1855
1857
 
1856
- def _attempt_generate_sandbox_library_from_schema(
1858
+ async def _attempt_generate_sandbox_library_from_schema(
1857
1859
  self, library_schema: LibrarySchema, sandbox_directory: str
1858
1860
  ) -> None:
1859
1861
  """Generate sandbox library using an existing schema, loading actual node classes."""
@@ -1945,7 +1947,8 @@ class LibraryManager:
1945
1947
  return
1946
1948
 
1947
1949
  # Load nodes into the library
1948
- library_load_results = self._attempt_load_nodes_from_library(
1950
+ library_load_results = await asyncio.to_thread(
1951
+ self._attempt_load_nodes_from_library,
1949
1952
  library_data=library_data,
1950
1953
  library=library,
1951
1954
  base_dir=sandbox_library_dir,
@@ -2041,32 +2044,21 @@ class LibraryManager:
2041
2044
  config_mgr = GriptapeNodes.ConfigManager()
2042
2045
  user_libraries_section = "app_events.on_app_initialization_complete.libraries_to_register"
2043
2046
 
2044
- libraries_to_process = []
2045
-
2046
- # Add from config
2047
- config_libraries = config_mgr.get_config_value(user_libraries_section, default=[])
2048
- libraries_to_process.extend(config_libraries)
2049
-
2050
- # Add from workspace - recursive discovery of library JSON files
2051
- workspace_path = config_mgr.workspace_path
2052
- libraries_to_process.append(str(workspace_path))
2053
-
2054
- library_files = []
2047
+ discovered_libraries = set()
2055
2048
 
2056
2049
  def process_path(path: Path) -> None:
2057
2050
  """Process a path, handling both files and directories."""
2058
2051
  if path.is_dir():
2059
2052
  # Process all library JSON files recursively in the directory
2060
- library_files.extend(path.rglob(LibraryManager.LIBRARY_CONFIG_FILENAME))
2053
+ discovered_libraries.update(path.rglob(LibraryManager.LIBRARY_CONFIG_FILENAME))
2061
2054
  elif path.suffix == ".json":
2062
- library_files.append(path)
2063
-
2064
- # Process library paths
2065
- for library_to_process in libraries_to_process:
2066
- library_path = Path(library_to_process)
2055
+ discovered_libraries.add(path)
2067
2056
 
2068
- # Handle library config files and directories only (skip requirement specifiers)
2057
+ # Add from config
2058
+ config_libraries = config_mgr.get_config_value(user_libraries_section, default=[])
2059
+ for library_path_str in config_libraries:
2060
+ library_path = Path(library_path_str)
2069
2061
  if library_path.exists():
2070
2062
  process_path(library_path)
2071
2063
 
2072
- return library_files
2064
+ return list(discovered_libraries)
@@ -0,0 +1,364 @@
1
+ """MCP (Model Context Protocol) server management.
2
+
3
+ Handles MCP server configurations, enabling/disabling servers, and provides
4
+ event-based interface for frontend and backend interactions.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any
9
+
10
+ from griptape_nodes.retained_mode.events.mcp_events import (
11
+ CreateMCPServerRequest,
12
+ CreateMCPServerResultFailure,
13
+ CreateMCPServerResultSuccess,
14
+ DeleteMCPServerRequest,
15
+ DeleteMCPServerResultFailure,
16
+ DeleteMCPServerResultSuccess,
17
+ DisableMCPServerRequest,
18
+ DisableMCPServerResultFailure,
19
+ DisableMCPServerResultSuccess,
20
+ EnableMCPServerRequest,
21
+ EnableMCPServerResultFailure,
22
+ EnableMCPServerResultSuccess,
23
+ GetEnabledMCPServersRequest,
24
+ GetEnabledMCPServersResultFailure,
25
+ GetEnabledMCPServersResultSuccess,
26
+ GetMCPServerRequest,
27
+ GetMCPServerResultFailure,
28
+ GetMCPServerResultSuccess,
29
+ ListMCPServersRequest,
30
+ ListMCPServersResultFailure,
31
+ ListMCPServersResultSuccess,
32
+ UpdateMCPServerRequest,
33
+ UpdateMCPServerResultFailure,
34
+ UpdateMCPServerResultSuccess,
35
+ )
36
+ from griptape_nodes.retained_mode.managers.config_manager import ConfigManager
37
+ from griptape_nodes.retained_mode.managers.event_manager import EventManager
38
+ from griptape_nodes.retained_mode.managers.settings import MCPServerConfig
39
+
40
+ logger = logging.getLogger("griptape_nodes")
41
+
42
+
43
+ class MCPManager:
44
+ """Manager for MCP server configurations and operations."""
45
+
46
+ def __init__(self, event_manager: EventManager | None = None, config_manager: ConfigManager | None = None) -> None:
47
+ """Initialize the MCPManager.
48
+
49
+ Args:
50
+ event_manager: The EventManager instance to use for event handling.
51
+ config_manager: The ConfigManager instance to use for configuration management.
52
+ """
53
+ self.config_manager = config_manager
54
+ if event_manager is not None:
55
+ # Register event handlers
56
+ event_manager.assign_manager_to_request_type(ListMCPServersRequest, self.on_list_mcp_servers_request)
57
+ event_manager.assign_manager_to_request_type(GetMCPServerRequest, self.on_get_mcp_server_request)
58
+ event_manager.assign_manager_to_request_type(CreateMCPServerRequest, self.on_create_mcp_server_request)
59
+ event_manager.assign_manager_to_request_type(UpdateMCPServerRequest, self.on_update_mcp_server_request)
60
+ event_manager.assign_manager_to_request_type(DeleteMCPServerRequest, self.on_delete_mcp_server_request)
61
+ event_manager.assign_manager_to_request_type(EnableMCPServerRequest, self.on_enable_mcp_server_request)
62
+ event_manager.assign_manager_to_request_type(DisableMCPServerRequest, self.on_disable_mcp_server_request)
63
+ event_manager.assign_manager_to_request_type(
64
+ GetEnabledMCPServersRequest, self.on_get_enabled_mcp_servers_request
65
+ )
66
+
67
+ def _get_mcp_servers(self, filter_by: dict[str, Any] | None = None) -> list[MCPServerConfig]:
68
+ """Get the current MCP servers configuration from the config manager.
69
+
70
+ Args:
71
+ filter_by: Optional dict of field=value pairs to filter by.
72
+ Keys should match server config field names, values are the expected values.
73
+ """
74
+ if self.config_manager is None:
75
+ return []
76
+
77
+ mcp_config_data = self.config_manager.get_config_value("mcp_servers", default=[])
78
+ if not mcp_config_data:
79
+ return []
80
+
81
+ try:
82
+ servers = [MCPServerConfig.model_validate(server) for server in mcp_config_data]
83
+ if filter_by:
84
+ filtered_servers = []
85
+ for server in servers:
86
+ match = True
87
+ for field, value in filter_by.items():
88
+ if getattr(server, field, None) != value:
89
+ match = False
90
+ break
91
+ if match:
92
+ filtered_servers.append(server)
93
+ return filtered_servers
94
+ return servers # noqa: TRY300
95
+ except Exception as e:
96
+ logger.error("Failed to parse MCP servers configuration: %s", e)
97
+ return []
98
+
99
+ def _save_mcp_servers(self, servers: list[MCPServerConfig]) -> None:
100
+ """Save the MCP servers configuration to the config manager."""
101
+ if self.config_manager is None:
102
+ logger.warning("No config manager available, cannot save MCP configuration")
103
+ return
104
+
105
+ try:
106
+ self.config_manager.set_config_value("mcp_servers", [server.model_dump() for server in servers])
107
+ except Exception as e:
108
+ logger.error("Failed to save MCP servers configuration: %s", e)
109
+
110
+ def _update_server_fields(self, server_config: MCPServerConfig, request: UpdateMCPServerRequest) -> None:
111
+ """Update server configuration fields from request."""
112
+ # Map request fields to server config attributes
113
+ field_mapping = {
114
+ "new_name": "name",
115
+ "transport": "transport",
116
+ "enabled": "enabled",
117
+ "command": "command",
118
+ "args": "args",
119
+ "env": "env",
120
+ "cwd": "cwd",
121
+ "encoding": "encoding",
122
+ "encoding_error_handler": "encoding_error_handler",
123
+ "url": "url",
124
+ "headers": "headers",
125
+ "timeout": "timeout",
126
+ "sse_read_timeout": "sse_read_timeout",
127
+ "terminate_on_close": "terminate_on_close",
128
+ "description": "description",
129
+ "capabilities": "capabilities",
130
+ }
131
+
132
+ # Update fields that are not None
133
+ for request_field, config_field in field_mapping.items():
134
+ value = getattr(request, request_field, None)
135
+ if value is not None:
136
+ setattr(server_config, config_field, value)
137
+
138
+ def on_list_mcp_servers_request(
139
+ self, request: ListMCPServersRequest
140
+ ) -> ListMCPServersResultSuccess | ListMCPServersResultFailure:
141
+ """Handle list MCP servers request."""
142
+ try:
143
+ servers = self._get_mcp_servers()
144
+ except Exception as e:
145
+ logger.error("Failed to list MCP servers: %s", e)
146
+ return ListMCPServersResultFailure(result_details=f"Failed to list MCP servers: {e}")
147
+
148
+ if request.include_disabled:
149
+ servers_dict = {server.name: server.model_dump() for server in servers}
150
+ else:
151
+ enabled_servers = [server for server in servers if server.enabled]
152
+ servers_dict = {server.name: server.model_dump() for server in enabled_servers}
153
+
154
+ # Success path after exception handling
155
+ return ListMCPServersResultSuccess(
156
+ # Pydantic model.model_dump() returns dict[str, Any] which matches MCPServerConfig TypedDict structure
157
+ servers=servers_dict, # type: ignore[arg-type]
158
+ result_details=f"Successfully listed {len(servers_dict)} MCP servers",
159
+ )
160
+
161
+ def on_get_mcp_server_request(
162
+ self, request: GetMCPServerRequest
163
+ ) -> GetMCPServerResultSuccess | GetMCPServerResultFailure:
164
+ """Handle get MCP server request."""
165
+ servers = self._get_mcp_servers(filter_by={"name": request.name})
166
+ server_config = servers[0] if servers else None
167
+
168
+ if server_config is None:
169
+ return GetMCPServerResultFailure(result_details=f"Failed to get MCP server '{request.name}' - not found")
170
+
171
+ # Success path after exception handling
172
+ return GetMCPServerResultSuccess(
173
+ # Pydantic model.model_dump() returns dict[str, Any] which matches MCPServerConfig TypedDict structure
174
+ server_config=server_config.model_dump(), # type: ignore[arg-type]
175
+ result_details=f"Successfully retrieved MCP server '{request.name}'",
176
+ )
177
+
178
+ def on_create_mcp_server_request(
179
+ self, request: CreateMCPServerRequest
180
+ ) -> CreateMCPServerResultSuccess | CreateMCPServerResultFailure:
181
+ """Handle create MCP server request."""
182
+ try:
183
+ servers = self._get_mcp_servers()
184
+ except Exception as e:
185
+ logger.error("Failed to create MCP server '%s': %s", request.name, e)
186
+ return CreateMCPServerResultFailure(result_details=f"Failed to create MCP server '{request.name}': {e}")
187
+
188
+ # Check if server already exists
189
+ for server in servers:
190
+ if server.name == request.name:
191
+ return CreateMCPServerResultFailure(
192
+ result_details=f"Failed to create MCP server '{request.name}' - already exists"
193
+ )
194
+
195
+ # Create new server configuration
196
+ server_config = MCPServerConfig(
197
+ name=request.name,
198
+ enabled=request.enabled,
199
+ transport=request.transport,
200
+ # StdioConnection fields
201
+ command=request.command,
202
+ args=request.args or [],
203
+ env=request.env or {},
204
+ cwd=request.cwd,
205
+ encoding=request.encoding,
206
+ encoding_error_handler=request.encoding_error_handler,
207
+ # HTTP-based connection fields
208
+ url=request.url,
209
+ headers=request.headers,
210
+ timeout=request.timeout,
211
+ sse_read_timeout=request.sse_read_timeout,
212
+ terminate_on_close=request.terminate_on_close,
213
+ # Common fields
214
+ description=request.description,
215
+ capabilities=request.capabilities or [],
216
+ )
217
+
218
+ servers.append(server_config)
219
+
220
+ try:
221
+ self._save_mcp_servers(servers)
222
+ except Exception as e:
223
+ logger.error("Failed to save MCP server '%s': %s", request.name, e)
224
+ return CreateMCPServerResultFailure(result_details=f"Failed to save MCP server '{request.name}': {e}")
225
+
226
+ # Success path after exception handling
227
+ return CreateMCPServerResultSuccess(
228
+ name=request.name, result_details=f"Successfully created MCP server '{request.name}'"
229
+ )
230
+
231
+ def on_update_mcp_server_request(
232
+ self, request: UpdateMCPServerRequest
233
+ ) -> UpdateMCPServerResultSuccess | UpdateMCPServerResultFailure:
234
+ """Handle update MCP server request."""
235
+ servers = self._get_mcp_servers(filter_by={"name": request.name})
236
+ server_config = servers[0] if servers else None
237
+
238
+ if server_config is None:
239
+ return UpdateMCPServerResultFailure(
240
+ result_details=f"Failed to update MCP server '{request.name}' - not found"
241
+ )
242
+
243
+ # Update only provided fields
244
+ self._update_server_fields(server_config, request)
245
+
246
+ try:
247
+ self._save_mcp_servers(servers)
248
+ except Exception as e:
249
+ logger.error("Failed to save MCP server '%s': %s", request.name, e)
250
+ return UpdateMCPServerResultFailure(result_details=f"Failed to save MCP server '{request.name}': {e}")
251
+
252
+ # Success path after exception handling
253
+ return UpdateMCPServerResultSuccess(
254
+ name=request.name, result_details=f"Successfully updated MCP server '{request.name}'"
255
+ )
256
+
257
+ def on_delete_mcp_server_request(
258
+ self, request: DeleteMCPServerRequest
259
+ ) -> DeleteMCPServerResultSuccess | DeleteMCPServerResultFailure:
260
+ """Handle delete MCP server request."""
261
+ try:
262
+ servers = self._get_mcp_servers()
263
+
264
+ # Find and remove server by server_id
265
+ server_found = False
266
+ for i, server in enumerate(servers):
267
+ if server.name == request.name:
268
+ servers.pop(i)
269
+ server_found = True
270
+ break
271
+
272
+ self._save_mcp_servers(servers)
273
+
274
+ except Exception as e:
275
+ logger.error("Failed to delete MCP server '%s': %s", request.name, e)
276
+ return DeleteMCPServerResultFailure(result_details=f"Failed to delete MCP server '{request.name}': {e}")
277
+
278
+ if not server_found:
279
+ return DeleteMCPServerResultFailure(
280
+ result_details=f"Failed to delete MCP server '{request.name}' - not found"
281
+ )
282
+
283
+ # Success path after exception handling
284
+ return DeleteMCPServerResultSuccess(
285
+ name=request.name, result_details=f"Successfully deleted MCP server '{request.name}'"
286
+ )
287
+
288
+ def on_enable_mcp_server_request(
289
+ self, request: EnableMCPServerRequest
290
+ ) -> EnableMCPServerResultSuccess | EnableMCPServerResultFailure:
291
+ """Handle enable MCP server request."""
292
+ servers = self._get_mcp_servers()
293
+ server_config = None
294
+ for server in servers:
295
+ if server.name == request.name:
296
+ server_config = server
297
+ break
298
+
299
+ if server_config is None:
300
+ return EnableMCPServerResultFailure(
301
+ result_details=f"Failed to enable MCP server '{request.name}' - not found"
302
+ )
303
+
304
+ server_config.enabled = True
305
+
306
+ try:
307
+ self._save_mcp_servers(servers)
308
+ except Exception as e:
309
+ logger.error("Failed to save MCP server '%s': %s", request.name, e)
310
+ return EnableMCPServerResultFailure(result_details=f"Failed to save MCP server '{request.name}': {e}")
311
+
312
+ # Success path after exception handling
313
+ return EnableMCPServerResultSuccess(
314
+ name=request.name, result_details=f"Successfully enabled MCP server '{request.name}'"
315
+ )
316
+
317
+ def on_disable_mcp_server_request(
318
+ self, request: DisableMCPServerRequest
319
+ ) -> DisableMCPServerResultSuccess | DisableMCPServerResultFailure:
320
+ """Handle disable MCP server request."""
321
+ servers = self._get_mcp_servers()
322
+ server_config = None
323
+ for server in servers:
324
+ if server.name == request.name:
325
+ server_config = server
326
+ break
327
+
328
+ if server_config is None:
329
+ return DisableMCPServerResultFailure(
330
+ result_details=f"Failed to disable MCP server '{request.name}' - not found"
331
+ )
332
+
333
+ server_config.enabled = False
334
+
335
+ try:
336
+ self._save_mcp_servers(servers)
337
+ except Exception as e:
338
+ logger.error("Failed to save MCP server '%s': %s", request.name, e)
339
+ return DisableMCPServerResultFailure(result_details=f"Failed to save MCP server '{request.name}': {e}")
340
+
341
+ # Success path after exception handling
342
+ return DisableMCPServerResultSuccess(
343
+ name=request.name, result_details=f"Successfully disabled MCP server '{request.name}'"
344
+ )
345
+
346
+ def on_get_enabled_mcp_servers_request(
347
+ self,
348
+ request: GetEnabledMCPServersRequest, # noqa: ARG002
349
+ ) -> GetEnabledMCPServersResultSuccess | GetEnabledMCPServersResultFailure:
350
+ """Handle get enabled MCP servers request."""
351
+ try:
352
+ enabled_servers = self._get_mcp_servers(filter_by={"enabled": True})
353
+ servers_dict = {server.name: server.model_dump() for server in enabled_servers}
354
+
355
+ except Exception as e:
356
+ logger.error("Failed to get enabled MCP servers: %s", e)
357
+ return GetEnabledMCPServersResultFailure(result_details=f"Failed to get enabled MCP servers: {e}")
358
+
359
+ # Success path after exception handling
360
+ return GetEnabledMCPServersResultSuccess(
361
+ # Pydantic model.model_dump() returns dict[str, Any] which matches MCPServerConfig TypedDict structure
362
+ servers=servers_dict, # type: ignore[arg-type]
363
+ result_details=f"Successfully retrieved {len(servers_dict)} enabled MCP servers",
364
+ )