agentpool 2.1.9__py3-none-any.whl → 2.2.3__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 (174) hide show
  1. acp/__init__.py +13 -0
  2. acp/bridge/README.md +15 -2
  3. acp/bridge/__init__.py +3 -2
  4. acp/bridge/__main__.py +60 -19
  5. acp/bridge/ws_server.py +173 -0
  6. acp/bridge/ws_server_cli.py +89 -0
  7. acp/notifications.py +2 -1
  8. acp/stdio.py +39 -9
  9. acp/transports.py +362 -2
  10. acp/utils.py +15 -2
  11. agentpool/__init__.py +4 -1
  12. agentpool/agents/__init__.py +2 -0
  13. agentpool/agents/acp_agent/acp_agent.py +203 -88
  14. agentpool/agents/acp_agent/acp_converters.py +46 -21
  15. agentpool/agents/acp_agent/client_handler.py +157 -3
  16. agentpool/agents/acp_agent/session_state.py +4 -1
  17. agentpool/agents/agent.py +314 -107
  18. agentpool/agents/agui_agent/__init__.py +0 -2
  19. agentpool/agents/agui_agent/agui_agent.py +90 -21
  20. agentpool/agents/agui_agent/agui_converters.py +0 -131
  21. agentpool/agents/base_agent.py +163 -1
  22. agentpool/agents/claude_code_agent/claude_code_agent.py +626 -179
  23. agentpool/agents/claude_code_agent/converters.py +71 -3
  24. agentpool/agents/claude_code_agent/history.py +474 -0
  25. agentpool/agents/context.py +40 -0
  26. agentpool/agents/events/__init__.py +2 -0
  27. agentpool/agents/events/builtin_handlers.py +2 -1
  28. agentpool/agents/events/event_emitter.py +29 -2
  29. agentpool/agents/events/events.py +20 -0
  30. agentpool/agents/modes.py +54 -0
  31. agentpool/agents/tool_call_accumulator.py +213 -0
  32. agentpool/common_types.py +21 -0
  33. agentpool/config_resources/__init__.py +38 -1
  34. agentpool/config_resources/claude_code_agent.yml +3 -0
  35. agentpool/delegation/pool.py +37 -29
  36. agentpool/delegation/team.py +1 -0
  37. agentpool/delegation/teamrun.py +1 -0
  38. agentpool/diagnostics/__init__.py +53 -0
  39. agentpool/diagnostics/lsp_manager.py +1593 -0
  40. agentpool/diagnostics/lsp_proxy.py +41 -0
  41. agentpool/diagnostics/lsp_proxy_script.py +229 -0
  42. agentpool/diagnostics/models.py +398 -0
  43. agentpool/mcp_server/__init__.py +0 -2
  44. agentpool/mcp_server/client.py +12 -3
  45. agentpool/mcp_server/manager.py +25 -31
  46. agentpool/mcp_server/registries/official_registry_client.py +25 -0
  47. agentpool/mcp_server/tool_bridge.py +78 -66
  48. agentpool/messaging/__init__.py +0 -2
  49. agentpool/messaging/compaction.py +72 -197
  50. agentpool/messaging/message_history.py +12 -0
  51. agentpool/messaging/messages.py +52 -9
  52. agentpool/messaging/processing.py +3 -1
  53. agentpool/models/acp_agents/base.py +0 -22
  54. agentpool/models/acp_agents/mcp_capable.py +8 -148
  55. agentpool/models/acp_agents/non_mcp.py +129 -72
  56. agentpool/models/agents.py +35 -13
  57. agentpool/models/claude_code_agents.py +33 -2
  58. agentpool/models/manifest.py +43 -0
  59. agentpool/repomap.py +1 -1
  60. agentpool/resource_providers/__init__.py +9 -1
  61. agentpool/resource_providers/aggregating.py +52 -3
  62. agentpool/resource_providers/base.py +57 -1
  63. agentpool/resource_providers/mcp_provider.py +23 -0
  64. agentpool/resource_providers/plan_provider.py +130 -41
  65. agentpool/resource_providers/pool.py +2 -0
  66. agentpool/resource_providers/static.py +2 -0
  67. agentpool/sessions/__init__.py +2 -1
  68. agentpool/sessions/manager.py +31 -2
  69. agentpool/sessions/models.py +50 -0
  70. agentpool/skills/registry.py +13 -8
  71. agentpool/storage/manager.py +217 -1
  72. agentpool/testing.py +537 -19
  73. agentpool/utils/file_watcher.py +269 -0
  74. agentpool/utils/identifiers.py +121 -0
  75. agentpool/utils/pydantic_ai_helpers.py +46 -0
  76. agentpool/utils/streams.py +690 -1
  77. agentpool/utils/subprocess_utils.py +155 -0
  78. agentpool/utils/token_breakdown.py +461 -0
  79. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/METADATA +27 -7
  80. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/RECORD +170 -112
  81. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/WHEEL +1 -1
  82. agentpool_cli/__main__.py +4 -0
  83. agentpool_cli/serve_acp.py +41 -20
  84. agentpool_cli/serve_agui.py +87 -0
  85. agentpool_cli/serve_opencode.py +119 -0
  86. agentpool_commands/__init__.py +30 -0
  87. agentpool_commands/agents.py +74 -1
  88. agentpool_commands/history.py +62 -0
  89. agentpool_commands/mcp.py +176 -0
  90. agentpool_commands/models.py +56 -3
  91. agentpool_commands/tools.py +57 -0
  92. agentpool_commands/utils.py +51 -0
  93. agentpool_config/builtin_tools.py +77 -22
  94. agentpool_config/commands.py +24 -1
  95. agentpool_config/compaction.py +258 -0
  96. agentpool_config/mcp_server.py +131 -1
  97. agentpool_config/storage.py +46 -1
  98. agentpool_config/tools.py +7 -1
  99. agentpool_config/toolsets.py +92 -148
  100. agentpool_server/acp_server/acp_agent.py +134 -150
  101. agentpool_server/acp_server/commands/acp_commands.py +216 -51
  102. agentpool_server/acp_server/commands/docs_commands/fetch_repo.py +10 -10
  103. agentpool_server/acp_server/server.py +23 -79
  104. agentpool_server/acp_server/session.py +181 -19
  105. agentpool_server/opencode_server/.rules +95 -0
  106. agentpool_server/opencode_server/ENDPOINTS.md +362 -0
  107. agentpool_server/opencode_server/__init__.py +27 -0
  108. agentpool_server/opencode_server/command_validation.py +172 -0
  109. agentpool_server/opencode_server/converters.py +869 -0
  110. agentpool_server/opencode_server/dependencies.py +24 -0
  111. agentpool_server/opencode_server/input_provider.py +269 -0
  112. agentpool_server/opencode_server/models/__init__.py +228 -0
  113. agentpool_server/opencode_server/models/agent.py +53 -0
  114. agentpool_server/opencode_server/models/app.py +60 -0
  115. agentpool_server/opencode_server/models/base.py +26 -0
  116. agentpool_server/opencode_server/models/common.py +23 -0
  117. agentpool_server/opencode_server/models/config.py +37 -0
  118. agentpool_server/opencode_server/models/events.py +647 -0
  119. agentpool_server/opencode_server/models/file.py +88 -0
  120. agentpool_server/opencode_server/models/mcp.py +25 -0
  121. agentpool_server/opencode_server/models/message.py +162 -0
  122. agentpool_server/opencode_server/models/parts.py +190 -0
  123. agentpool_server/opencode_server/models/provider.py +81 -0
  124. agentpool_server/opencode_server/models/pty.py +43 -0
  125. agentpool_server/opencode_server/models/session.py +99 -0
  126. agentpool_server/opencode_server/routes/__init__.py +25 -0
  127. agentpool_server/opencode_server/routes/agent_routes.py +442 -0
  128. agentpool_server/opencode_server/routes/app_routes.py +139 -0
  129. agentpool_server/opencode_server/routes/config_routes.py +241 -0
  130. agentpool_server/opencode_server/routes/file_routes.py +392 -0
  131. agentpool_server/opencode_server/routes/global_routes.py +94 -0
  132. agentpool_server/opencode_server/routes/lsp_routes.py +319 -0
  133. agentpool_server/opencode_server/routes/message_routes.py +705 -0
  134. agentpool_server/opencode_server/routes/pty_routes.py +299 -0
  135. agentpool_server/opencode_server/routes/session_routes.py +1205 -0
  136. agentpool_server/opencode_server/routes/tui_routes.py +139 -0
  137. agentpool_server/opencode_server/server.py +430 -0
  138. agentpool_server/opencode_server/state.py +121 -0
  139. agentpool_server/opencode_server/time_utils.py +8 -0
  140. agentpool_storage/__init__.py +16 -0
  141. agentpool_storage/base.py +103 -0
  142. agentpool_storage/claude_provider.py +907 -0
  143. agentpool_storage/file_provider.py +129 -0
  144. agentpool_storage/memory_provider.py +61 -0
  145. agentpool_storage/models.py +3 -0
  146. agentpool_storage/opencode_provider.py +730 -0
  147. agentpool_storage/project_store.py +325 -0
  148. agentpool_storage/session_store.py +6 -0
  149. agentpool_storage/sql_provider/__init__.py +4 -2
  150. agentpool_storage/sql_provider/models.py +48 -0
  151. agentpool_storage/sql_provider/sql_provider.py +134 -1
  152. agentpool_storage/sql_provider/utils.py +10 -1
  153. agentpool_storage/text_log_provider.py +1 -0
  154. agentpool_toolsets/builtin/__init__.py +0 -8
  155. agentpool_toolsets/builtin/code.py +95 -56
  156. agentpool_toolsets/builtin/debug.py +16 -21
  157. agentpool_toolsets/builtin/execution_environment.py +99 -103
  158. agentpool_toolsets/builtin/file_edit/file_edit.py +115 -7
  159. agentpool_toolsets/builtin/skills.py +86 -4
  160. agentpool_toolsets/fsspec_toolset/__init__.py +13 -1
  161. agentpool_toolsets/fsspec_toolset/diagnostics.py +860 -73
  162. agentpool_toolsets/fsspec_toolset/grep.py +74 -2
  163. agentpool_toolsets/fsspec_toolset/image_utils.py +161 -0
  164. agentpool_toolsets/fsspec_toolset/toolset.py +159 -38
  165. agentpool_toolsets/mcp_discovery/__init__.py +5 -0
  166. agentpool_toolsets/mcp_discovery/data/mcp_servers.parquet +0 -0
  167. agentpool_toolsets/mcp_discovery/toolset.py +454 -0
  168. agentpool_toolsets/mcp_run_toolset.py +84 -6
  169. agentpool_toolsets/builtin/agent_management.py +0 -239
  170. agentpool_toolsets/builtin/history.py +0 -36
  171. agentpool_toolsets/builtin/integration.py +0 -85
  172. agentpool_toolsets/builtin/tool_management.py +0 -90
  173. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/entry_points.txt +0 -0
  174. {agentpool-2.1.9.dist-info → agentpool-2.2.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,454 @@
1
+ # mypy: disable-error-code="import-not-found,unused-ignore"
2
+ """MCP Discovery Toolset - dynamic MCP server exploration and tool execution.
3
+
4
+ This toolset provides a stable interface for agents to discover and use MCP servers
5
+ on-demand without needing to preload all tools upfront. This preserves prompt cache
6
+ stability while enabling access to the entire MCP ecosystem.
7
+
8
+ The toolset exposes three main capabilities:
9
+ 1. search_mcp_servers - Semantic search over 1000+ servers (uses pre-built index)
10
+ 2. list_mcp_tools - Get tools from a specific server (connects on-demand)
11
+ 3. call_mcp_tool - Execute a tool on any server (reuses connections)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from pydantic import HttpUrl
20
+
21
+ from agentpool.log import get_logger
22
+ from agentpool.mcp_server.client import MCPClient
23
+ from agentpool.mcp_server.registries.official_registry_client import (
24
+ MCPRegistryClient,
25
+ MCPRegistryError,
26
+ )
27
+ from agentpool.resource_providers import ResourceProvider
28
+
29
+
30
+ if TYPE_CHECKING:
31
+ from agentpool.agents.context import AgentContext
32
+ from agentpool.mcp_server.registries.official_registry_client import RegistryServer
33
+ from agentpool.tools.base import Tool
34
+ from agentpool_config.mcp_server import MCPServerConfig
35
+
36
+
37
+ logger = get_logger(__name__)
38
+
39
+ # Path to the pre-built semantic search index (parquet file loaded into LanceDB at runtime)
40
+ PARQUET_PATH = Path(__file__).parent / "data" / "mcp_servers.parquet"
41
+
42
+
43
+ class MCPDiscoveryToolset(ResourceProvider):
44
+ """Toolset for dynamic MCP server discovery and tool execution.
45
+
46
+ This toolset allows agents to:
47
+ - Search the MCP registry for servers by keyword
48
+ - List tools available on a specific server
49
+ - Call tools on any server without preloading
50
+
51
+ Connections are managed lazily - servers are only connected when needed,
52
+ and connections are kept alive for the session duration.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ name: str = "mcp_discovery",
58
+ registry_url: str = "https://registry.modelcontextprotocol.io",
59
+ allowed_servers: list[str] | None = None,
60
+ blocked_servers: list[str] | None = None,
61
+ ) -> None:
62
+ """Initialize the MCP Discovery toolset.
63
+
64
+ Args:
65
+ name: Name for this toolset provider
66
+ registry_url: Base URL for the MCP registry API
67
+ allowed_servers: If set, only these server names can be used
68
+ blocked_servers: Server names that cannot be used
69
+ """
70
+ super().__init__(name=name)
71
+ self._registry_url = registry_url
72
+ self._registry: MCPRegistryClient | None = None
73
+ self._connections: dict[str, MCPClient] = {}
74
+ self._server_cache: dict[str, RegistryServer] = {}
75
+ self._tools_cache: dict[str, list[dict[str, Any]]] = {}
76
+ self._allowed_servers = set(allowed_servers) if allowed_servers else None
77
+ self._blocked_servers = set(blocked_servers) if blocked_servers else set()
78
+ self._tools: list[Tool] | None = None
79
+ # Lazy-loaded semantic search components
80
+ self._db: Any = None
81
+ self._table: Any = None
82
+ self._embed_model: Any = None
83
+ self._tmpdir: str | None = None
84
+
85
+ def _get_registry(self) -> MCPRegistryClient:
86
+ """Get or create the registry client."""
87
+ if self._registry is None:
88
+ self._registry = MCPRegistryClient(base_url=self._registry_url)
89
+ return self._registry
90
+
91
+ def _get_search_index(self) -> Any:
92
+ """Get or create the LanceDB search index from parquet file."""
93
+ if self._table is not None:
94
+ return self._table
95
+
96
+ import tempfile
97
+
98
+ import lancedb # type: ignore[import-untyped]
99
+ import pyarrow as pa # type: ignore[import-untyped]
100
+ import pyarrow.parquet as pq # type: ignore[import-untyped]
101
+
102
+ if not PARQUET_PATH.exists():
103
+ msg = f"MCP registry index not found at {PARQUET_PATH}. Run build_mcp_registry_index.py"
104
+ raise FileNotFoundError(msg)
105
+
106
+ # Load parquet
107
+ arrow_table = pq.read_table(PARQUET_PATH)
108
+
109
+ # Convert vector column to fixed-size list for LanceDB vector search
110
+ # LanceDB requires fixed-size vectors, not variable-length lists
111
+ vectors = arrow_table.column("vector").to_pylist()
112
+ if vectors:
113
+ vec_dim = len(vectors[0])
114
+ fixed_vectors = pa.FixedSizeListArray.from_arrays(
115
+ pa.array([v for vec in vectors for v in vec], type=pa.float32()),
116
+ list_size=vec_dim,
117
+ )
118
+ # Replace the vector column
119
+ col_idx = arrow_table.schema.get_field_index("vector")
120
+ arrow_table = arrow_table.set_column(col_idx, "vector", fixed_vectors)
121
+
122
+ # Use a temp directory for LanceDB (it needs a path but we're loading from parquet)
123
+ if self._db is None:
124
+ self._tmpdir = tempfile.mkdtemp(prefix="mcp_discovery_")
125
+ self._db = lancedb.connect(self._tmpdir)
126
+
127
+ self._table = self._db.create_table("servers", arrow_table, mode="overwrite")
128
+ return self._table
129
+
130
+ def _get_embed_model(self) -> Any:
131
+ """Get or create the FastEmbed model for query embedding."""
132
+ if self._embed_model is not None:
133
+ return self._embed_model
134
+
135
+ from fastembed import TextEmbedding
136
+
137
+ self._embed_model = TextEmbedding("BAAI/bge-small-en-v1.5")
138
+ return self._embed_model
139
+
140
+ def _is_server_allowed(self, server_name: str) -> bool:
141
+ """Check if a server is allowed to be used."""
142
+ if server_name in self._blocked_servers:
143
+ return False
144
+ if self._allowed_servers is not None:
145
+ return server_name in self._allowed_servers
146
+ return True
147
+
148
+ async def _get_server_config(self, server_name: str) -> MCPServerConfig:
149
+ """Get connection config for a server from the registry."""
150
+ from agentpool_config.mcp_server import (
151
+ SSEMCPServerConfig,
152
+ StreamableHTTPMCPServerConfig,
153
+ )
154
+
155
+ # Check cache first
156
+ server: RegistryServer
157
+ if server_name in self._server_cache:
158
+ server = self._server_cache[server_name]
159
+ else:
160
+ # Use list_servers and find by name - more reliable than get_server
161
+ registry = self._get_registry()
162
+ all_servers = await registry.list_servers()
163
+ found_server: RegistryServer | None = None
164
+ for s in all_servers:
165
+ if s.name == server_name:
166
+ found_server = s
167
+ break
168
+ if found_server is None:
169
+ msg = f"Server {server_name!r} not found in registry"
170
+ raise MCPRegistryError(msg)
171
+ server = found_server
172
+ self._server_cache[server_name] = server
173
+
174
+ # Find a usable remote endpoint
175
+ for remote in server.remotes:
176
+ if remote.type == "sse":
177
+ return SSEMCPServerConfig(url=HttpUrl(remote.url))
178
+ if remote.type in ("streamable-http", "http"):
179
+ return StreamableHTTPMCPServerConfig(url=HttpUrl(remote.url))
180
+
181
+ msg = f"No supported remote transport for server {server_name!r}"
182
+ raise MCPRegistryError(msg)
183
+
184
+ async def _get_connection(self, server_name: str) -> MCPClient:
185
+ """Get or create a connection to a server."""
186
+ if server_name in self._connections:
187
+ client = self._connections[server_name]
188
+ if client.connected:
189
+ return client
190
+ # Connection dropped, remove and reconnect
191
+ del self._connections[server_name]
192
+
193
+ config = await self._get_server_config(server_name)
194
+ client = MCPClient(config=config)
195
+ await client.__aenter__()
196
+ self._connections[server_name] = client
197
+ logger.info("Connected to MCP server", server=server_name)
198
+ return client
199
+
200
+ async def _close_connections(self) -> None:
201
+ """Close all active connections."""
202
+ for name, client in list(self._connections.items()):
203
+ try:
204
+ await client.__aexit__(None, None, None)
205
+ logger.debug("Closed connection", server=name)
206
+ except Exception as e: # noqa: BLE001
207
+ logger.warning("Error closing connection", server=name, error=e)
208
+ self._connections.clear()
209
+
210
+ async def get_tools(self) -> list[Tool]:
211
+ """Get the discovery tools."""
212
+ if self._tools is not None:
213
+ return self._tools
214
+
215
+ self._tools = [
216
+ self.create_tool(
217
+ self.search_mcp_servers,
218
+ category="search",
219
+ read_only=True,
220
+ idempotent=True,
221
+ ),
222
+ self.create_tool(
223
+ self.list_mcp_tools,
224
+ category="search",
225
+ read_only=True,
226
+ idempotent=True,
227
+ ),
228
+ self.create_tool(
229
+ self.call_mcp_tool,
230
+ category="execute",
231
+ open_world=True,
232
+ ),
233
+ ]
234
+ return self._tools
235
+
236
+ async def search_mcp_servers( # noqa: D417
237
+ self,
238
+ agent_ctx: AgentContext,
239
+ query: str,
240
+ max_results: int = 10,
241
+ ) -> str:
242
+ """Search the MCP registry for servers matching a query.
243
+
244
+ Uses semantic search over 1000+ indexed MCP servers. The search understands
245
+ meaning, not just keywords - e.g., "web scraping" finds crawlers too.
246
+
247
+ Args:
248
+ query: Search term (e.g., "github issues", "database sql", "file system")
249
+ max_results: Maximum number of results to return
250
+
251
+ Returns:
252
+ List of matching servers with names and descriptions
253
+ """
254
+ await agent_ctx.events.tool_call_start(
255
+ title=f"Searching MCP servers: {query}",
256
+ kind="search",
257
+ )
258
+
259
+ try:
260
+ # Get embedding for query
261
+ model = self._get_embed_model()
262
+ query_embedding = next(iter(model.embed([query]))).tolist()
263
+
264
+ # Search the index
265
+ table = self._get_search_index()
266
+ results = table.search(query_embedding).limit(max_results * 2).to_arrow()
267
+
268
+ if len(results) == 0:
269
+ return f"No MCP servers found matching '{query}'"
270
+
271
+ # Format results, filtering by allowed/blocked
272
+ lines = [f"Found MCP servers matching '{query}':\n"]
273
+ count = 0
274
+ for i in range(len(results)):
275
+ name = results["name"][i].as_py()
276
+
277
+ # Filter by allowed/blocked
278
+ if not self._is_server_allowed(name):
279
+ continue
280
+
281
+ desc = results["description"][i].as_py()
282
+ version = results["version"][i].as_py()
283
+ has_remote = results["has_remote"][i].as_py()
284
+ remote_types = results["remote_types"][i].as_py()
285
+
286
+ lines.append(f"**{name}** (v{version})")
287
+ lines.append(f" {desc}")
288
+ if has_remote and remote_types:
289
+ lines.append(f" Transports: {remote_types}")
290
+ lines.append("")
291
+
292
+ count += 1
293
+ if count >= max_results:
294
+ break
295
+
296
+ if count == 0:
297
+ return f"No MCP servers found matching '{query}'"
298
+
299
+ lines[0] = f"Found {count} MCP servers matching '{query}':\n"
300
+ return "\n".join(lines)
301
+
302
+ except FileNotFoundError as e:
303
+ return f"Error: {e}"
304
+ except Exception as e:
305
+ logger.exception("Error searching MCP servers")
306
+ return f"Error searching MCP servers: {e}"
307
+
308
+ async def list_mcp_tools( # noqa: D417
309
+ self,
310
+ agent_ctx: AgentContext,
311
+ server_name: str,
312
+ ) -> str:
313
+ """List all tools available on a specific MCP server.
314
+
315
+ This connects to the server (if not already connected) and retrieves
316
+ the list of available tools with their descriptions and parameters.
317
+
318
+ Args:
319
+ server_name: Name of the MCP server (e.g., "com.github/github")
320
+
321
+ Returns:
322
+ List of tools with names, descriptions, and parameter schemas
323
+ """
324
+ await agent_ctx.events.tool_call_start(
325
+ title=f"Listing tools from: {server_name}",
326
+ kind="search",
327
+ )
328
+
329
+ if not self._is_server_allowed(server_name):
330
+ return f"Error: Server '{server_name}' is not allowed"
331
+
332
+ try:
333
+ # Check tools cache first
334
+ if server_name in self._tools_cache:
335
+ tools_data = self._tools_cache[server_name]
336
+ else:
337
+ client = await self._get_connection(server_name)
338
+ mcp_tools = await client.list_tools()
339
+
340
+ # Convert to serializable format and cache
341
+ tools_data = []
342
+ for tool in mcp_tools:
343
+ tool_info: dict[str, Any] = {
344
+ "name": tool.name,
345
+ "description": tool.description or "No description",
346
+ }
347
+ # Include parameter info
348
+ if tool.inputSchema:
349
+ props = tool.inputSchema.get("properties", {})
350
+ required = set(tool.inputSchema.get("required", []))
351
+ params = []
352
+ for pname, pschema in props.items():
353
+ param_str = pname
354
+ if pname in required:
355
+ param_str += " (required)"
356
+ if "type" in pschema:
357
+ param_str += f": {pschema['type']}"
358
+ if "description" in pschema:
359
+ param_str += f" - {pschema['description']}"
360
+ params.append(param_str)
361
+ if params:
362
+ tool_info["parameters"] = params
363
+ tools_data.append(tool_info)
364
+
365
+ self._tools_cache[server_name] = tools_data
366
+
367
+ if not tools_data:
368
+ return f"No tools found on server '{server_name}'"
369
+
370
+ # Format output
371
+ lines = [f"Tools available on **{server_name}** ({len(tools_data)} tools):\n"]
372
+ for tool_info in tools_data:
373
+ lines.append(f"### {tool_info['name']}")
374
+ lines.append(f"{tool_info['description']}")
375
+ if "parameters" in tool_info:
376
+ lines.append("Parameters:")
377
+ lines.extend(f" - {param}" for param in tool_info["parameters"])
378
+ lines.append("")
379
+
380
+ return "\n".join(lines)
381
+
382
+ except MCPRegistryError as e:
383
+ return f"Error: {e}"
384
+ except Exception as e:
385
+ logger.exception("Error listing MCP tools", server=server_name)
386
+ return f"Error listing tools from '{server_name}': {e}"
387
+
388
+ async def call_mcp_tool( # noqa: D417
389
+ self,
390
+ agent_ctx: AgentContext,
391
+ server_name: str,
392
+ tool_name: str,
393
+ arguments: dict[str, Any] | None = None,
394
+ ) -> str | Any:
395
+ """Call a tool on an MCP server.
396
+
397
+ Use this to execute a specific tool on an MCP server. The server
398
+ connection is reused if already established.
399
+
400
+ Args:
401
+ server_name: Name of the MCP server (e.g., "com.github/github")
402
+ tool_name: Name of the tool to call
403
+ arguments: Arguments to pass to the tool
404
+
405
+ Returns:
406
+ The result from the tool execution
407
+ """
408
+ await agent_ctx.events.tool_call_start(
409
+ title=f"Calling {tool_name} on {server_name}",
410
+ kind="execute",
411
+ )
412
+
413
+ if not self._is_server_allowed(server_name):
414
+ return f"Error: Server '{server_name}' is not allowed"
415
+
416
+ try:
417
+ client = await self._get_connection(server_name)
418
+
419
+ # Extract text content from result
420
+ from mcp.types import TextContent
421
+
422
+ result = await client._client.call_tool(tool_name, arguments or {})
423
+
424
+ # Process result content
425
+ text_parts = [
426
+ content.text for content in result.content if isinstance(content, TextContent)
427
+ ]
428
+
429
+ if text_parts:
430
+ return "\n".join(text_parts)
431
+
432
+ # Return raw result if no text content
433
+ return str(result)
434
+
435
+ except MCPRegistryError as e:
436
+ return f"Error: {e}"
437
+ except Exception as e:
438
+ logger.exception("Error calling MCP tool", server=server_name, tool=tool_name)
439
+ return f"Error calling '{tool_name}' on '{server_name}': {e}"
440
+
441
+ async def cleanup(self) -> None:
442
+ """Clean up resources."""
443
+ await self._close_connections()
444
+ if self._registry:
445
+ await self._registry.close()
446
+ self._registry = None
447
+ # Clean up temp directory used for LanceDB
448
+ if self._tmpdir:
449
+ import shutil
450
+
451
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
452
+ self._tmpdir = None
453
+ self._db = None
454
+ self._table = None
@@ -3,21 +3,37 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
- from typing import TYPE_CHECKING, Any, cast
6
+ from typing import TYPE_CHECKING, Any, Literal, Self, cast
7
7
 
8
8
  from schemez import OpenAIFunctionDefinition
9
9
 
10
+ from agentpool.log import get_logger
10
11
  from agentpool.resource_providers import ResourceProvider
11
12
 
12
13
 
13
14
  if TYPE_CHECKING:
15
+ from types import TracebackType
16
+
17
+ from mcp import ClientSession
14
18
  from mcp.types import CallToolResult
15
19
 
16
20
  from agentpool.tools.base import Tool
17
21
 
22
+ logger = get_logger(__name__)
23
+
18
24
 
19
25
  class McpRunTools(ResourceProvider):
20
- """Provider for MCP.run tools."""
26
+ """Provider for MCP.run tools.
27
+
28
+ Maintains a persistent SSE connection to MCP.run to receive tool change
29
+ notifications. Use as an async context manager to ensure proper cleanup.
30
+
31
+ Example:
32
+ async with McpRunTools("default") as provider:
33
+ tools = await provider.get_tools()
34
+ """
35
+
36
+ kind: Literal["mcp_run"] = "mcp_run"
21
37
 
22
38
  def __init__(self, entity_id: str, session_id: str | None = None) -> None:
23
39
  from mcp_run import Client, ClientConfig # type: ignore[import-untyped]
@@ -27,6 +43,51 @@ class McpRunTools(ResourceProvider):
27
43
  config = ClientConfig()
28
44
  self.client = Client(session_id=id_, config=config)
29
45
  self._tools: list[Tool] | None = None
46
+ self._session: ClientSession | None = None
47
+ self._mcp_client_ctx: Any = None # Context manager for persistent connection
48
+
49
+ async def __aenter__(self) -> Self:
50
+ """Start persistent SSE connection."""
51
+ # Create MCPClient from mcp_run
52
+ mcp_client = self.client.mcp_sse()
53
+ self._mcp_client_ctx = mcp_client.connect()
54
+ self._session = await self._mcp_client_ctx.__aenter__()
55
+
56
+ # Set up notification handler for tool changes
57
+ # The MCP ClientSession dispatches notifications via _received_notification
58
+ # We monkey-patch it to intercept ToolListChangedNotification
59
+ original_handler = self._session._received_notification
60
+
61
+ async def notification_handler(notification: Any) -> None:
62
+ from mcp.types import ToolListChangedNotification
63
+
64
+ if isinstance(notification.root, ToolListChangedNotification):
65
+ logger.info("MCP.run tool list changed notification received")
66
+ await self._on_tools_changed()
67
+ # Call original handler
68
+ await original_handler(notification)
69
+
70
+ self._session._received_notification = notification_handler # type: ignore[method-assign]
71
+
72
+ return self
73
+
74
+ async def __aexit__(
75
+ self,
76
+ exc_type: type[BaseException] | None,
77
+ exc_val: BaseException | None,
78
+ exc_tb: TracebackType | None,
79
+ ) -> None:
80
+ """Close persistent SSE connection."""
81
+ if self._mcp_client_ctx:
82
+ await self._mcp_client_ctx.__aexit__(exc_type, exc_val, exc_tb)
83
+ self._mcp_client_ctx = None
84
+ self._session = None
85
+
86
+ async def _on_tools_changed(self) -> None:
87
+ """Handle tool list change notification."""
88
+ logger.info("MCP.run tools changed, refreshing cache")
89
+ self._tools = None
90
+ await self.tools_changed.emit(self.create_change_event("tools"))
30
91
 
31
92
  async def get_tools(self) -> list[Tool]:
32
93
  """Get tools from MCP.run."""
@@ -36,10 +97,20 @@ class McpRunTools(ResourceProvider):
36
97
 
37
98
  self._tools = []
38
99
  for name, tool in self.client.tools.items():
39
-
40
- async def run(tool_name: str = name, **input_dict: Any) -> CallToolResult:
41
- async with self.client.mcp_sse().connect() as session:
42
- return await session.call_tool(tool_name, arguments=input_dict) # type: ignore[no-any-return]
100
+ # Capture session for use in tool calls
101
+ session = self._session
102
+
103
+ async def run(
104
+ tool_name: str = name,
105
+ _session: ClientSession | None = session,
106
+ **input_dict: Any,
107
+ ) -> CallToolResult:
108
+ if _session is not None:
109
+ # Use persistent session if available
110
+ return await _session.call_tool(tool_name, arguments=input_dict)
111
+ # Fallback to creating a new connection (when not using context manager)
112
+ async with self.client.mcp_sse().connect() as new_session:
113
+ return await new_session.call_tool(tool_name, arguments=input_dict) # type: ignore[no-any-return]
43
114
 
44
115
  run.__name__ = name
45
116
  wrapped_tool = self.create_tool(
@@ -49,6 +120,13 @@ class McpRunTools(ResourceProvider):
49
120
 
50
121
  return self._tools
51
122
 
123
+ async def refresh_tools(self) -> None:
124
+ """Manually refresh tools from MCP.run and emit change event."""
125
+ logger.info("Manually refreshing MCP.run tools")
126
+ self._tools = None
127
+ await self.get_tools()
128
+ await self.tools_changed.emit(self.create_change_event("tools"))
129
+
52
130
 
53
131
  if __name__ == "__main__":
54
132
  import anyio