emdash-core 0.1.7__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 (187) hide show
  1. emdash_core/__init__.py +3 -0
  2. emdash_core/agent/__init__.py +37 -0
  3. emdash_core/agent/agents.py +225 -0
  4. emdash_core/agent/code_reviewer.py +476 -0
  5. emdash_core/agent/compaction.py +143 -0
  6. emdash_core/agent/context_manager.py +140 -0
  7. emdash_core/agent/events.py +338 -0
  8. emdash_core/agent/handlers.py +224 -0
  9. emdash_core/agent/inprocess_subagent.py +377 -0
  10. emdash_core/agent/mcp/__init__.py +50 -0
  11. emdash_core/agent/mcp/client.py +346 -0
  12. emdash_core/agent/mcp/config.py +302 -0
  13. emdash_core/agent/mcp/manager.py +496 -0
  14. emdash_core/agent/mcp/tool_factory.py +213 -0
  15. emdash_core/agent/prompts/__init__.py +38 -0
  16. emdash_core/agent/prompts/main_agent.py +104 -0
  17. emdash_core/agent/prompts/subagents.py +131 -0
  18. emdash_core/agent/prompts/workflow.py +136 -0
  19. emdash_core/agent/providers/__init__.py +34 -0
  20. emdash_core/agent/providers/base.py +143 -0
  21. emdash_core/agent/providers/factory.py +80 -0
  22. emdash_core/agent/providers/models.py +220 -0
  23. emdash_core/agent/providers/openai_provider.py +463 -0
  24. emdash_core/agent/providers/transformers_provider.py +217 -0
  25. emdash_core/agent/research/__init__.py +81 -0
  26. emdash_core/agent/research/agent.py +143 -0
  27. emdash_core/agent/research/controller.py +254 -0
  28. emdash_core/agent/research/critic.py +428 -0
  29. emdash_core/agent/research/macros.py +469 -0
  30. emdash_core/agent/research/planner.py +449 -0
  31. emdash_core/agent/research/researcher.py +436 -0
  32. emdash_core/agent/research/state.py +523 -0
  33. emdash_core/agent/research/synthesizer.py +594 -0
  34. emdash_core/agent/reviewer_profile.py +475 -0
  35. emdash_core/agent/rules.py +123 -0
  36. emdash_core/agent/runner.py +601 -0
  37. emdash_core/agent/session.py +262 -0
  38. emdash_core/agent/spec_schema.py +66 -0
  39. emdash_core/agent/specification.py +479 -0
  40. emdash_core/agent/subagent.py +397 -0
  41. emdash_core/agent/subagent_prompts.py +13 -0
  42. emdash_core/agent/toolkit.py +482 -0
  43. emdash_core/agent/toolkits/__init__.py +64 -0
  44. emdash_core/agent/toolkits/base.py +96 -0
  45. emdash_core/agent/toolkits/explore.py +47 -0
  46. emdash_core/agent/toolkits/plan.py +55 -0
  47. emdash_core/agent/tools/__init__.py +141 -0
  48. emdash_core/agent/tools/analytics.py +436 -0
  49. emdash_core/agent/tools/base.py +131 -0
  50. emdash_core/agent/tools/coding.py +484 -0
  51. emdash_core/agent/tools/github_mcp.py +592 -0
  52. emdash_core/agent/tools/history.py +13 -0
  53. emdash_core/agent/tools/modes.py +153 -0
  54. emdash_core/agent/tools/plan.py +206 -0
  55. emdash_core/agent/tools/plan_write.py +135 -0
  56. emdash_core/agent/tools/search.py +412 -0
  57. emdash_core/agent/tools/spec.py +341 -0
  58. emdash_core/agent/tools/task.py +262 -0
  59. emdash_core/agent/tools/task_output.py +204 -0
  60. emdash_core/agent/tools/tasks.py +454 -0
  61. emdash_core/agent/tools/traversal.py +588 -0
  62. emdash_core/agent/tools/web.py +179 -0
  63. emdash_core/analytics/__init__.py +5 -0
  64. emdash_core/analytics/engine.py +1286 -0
  65. emdash_core/api/__init__.py +5 -0
  66. emdash_core/api/agent.py +308 -0
  67. emdash_core/api/agents.py +154 -0
  68. emdash_core/api/analyze.py +264 -0
  69. emdash_core/api/auth.py +173 -0
  70. emdash_core/api/context.py +77 -0
  71. emdash_core/api/db.py +121 -0
  72. emdash_core/api/embed.py +131 -0
  73. emdash_core/api/feature.py +143 -0
  74. emdash_core/api/health.py +93 -0
  75. emdash_core/api/index.py +162 -0
  76. emdash_core/api/plan.py +110 -0
  77. emdash_core/api/projectmd.py +210 -0
  78. emdash_core/api/query.py +320 -0
  79. emdash_core/api/research.py +122 -0
  80. emdash_core/api/review.py +161 -0
  81. emdash_core/api/router.py +76 -0
  82. emdash_core/api/rules.py +116 -0
  83. emdash_core/api/search.py +119 -0
  84. emdash_core/api/spec.py +99 -0
  85. emdash_core/api/swarm.py +223 -0
  86. emdash_core/api/tasks.py +109 -0
  87. emdash_core/api/team.py +120 -0
  88. emdash_core/auth/__init__.py +17 -0
  89. emdash_core/auth/github.py +389 -0
  90. emdash_core/config.py +74 -0
  91. emdash_core/context/__init__.py +52 -0
  92. emdash_core/context/models.py +50 -0
  93. emdash_core/context/providers/__init__.py +11 -0
  94. emdash_core/context/providers/base.py +74 -0
  95. emdash_core/context/providers/explored_areas.py +183 -0
  96. emdash_core/context/providers/touched_areas.py +360 -0
  97. emdash_core/context/registry.py +73 -0
  98. emdash_core/context/reranker.py +199 -0
  99. emdash_core/context/service.py +260 -0
  100. emdash_core/context/session.py +352 -0
  101. emdash_core/core/__init__.py +104 -0
  102. emdash_core/core/config.py +454 -0
  103. emdash_core/core/exceptions.py +55 -0
  104. emdash_core/core/models.py +265 -0
  105. emdash_core/core/review_config.py +57 -0
  106. emdash_core/db/__init__.py +67 -0
  107. emdash_core/db/auth.py +134 -0
  108. emdash_core/db/models.py +91 -0
  109. emdash_core/db/provider.py +222 -0
  110. emdash_core/db/providers/__init__.py +5 -0
  111. emdash_core/db/providers/supabase.py +452 -0
  112. emdash_core/embeddings/__init__.py +24 -0
  113. emdash_core/embeddings/indexer.py +534 -0
  114. emdash_core/embeddings/models.py +192 -0
  115. emdash_core/embeddings/providers/__init__.py +7 -0
  116. emdash_core/embeddings/providers/base.py +112 -0
  117. emdash_core/embeddings/providers/fireworks.py +141 -0
  118. emdash_core/embeddings/providers/openai.py +104 -0
  119. emdash_core/embeddings/registry.py +146 -0
  120. emdash_core/embeddings/service.py +215 -0
  121. emdash_core/graph/__init__.py +26 -0
  122. emdash_core/graph/builder.py +134 -0
  123. emdash_core/graph/connection.py +692 -0
  124. emdash_core/graph/schema.py +416 -0
  125. emdash_core/graph/writer.py +667 -0
  126. emdash_core/ingestion/__init__.py +7 -0
  127. emdash_core/ingestion/change_detector.py +150 -0
  128. emdash_core/ingestion/git/__init__.py +5 -0
  129. emdash_core/ingestion/git/commit_analyzer.py +196 -0
  130. emdash_core/ingestion/github/__init__.py +6 -0
  131. emdash_core/ingestion/github/pr_fetcher.py +296 -0
  132. emdash_core/ingestion/github/task_extractor.py +100 -0
  133. emdash_core/ingestion/orchestrator.py +540 -0
  134. emdash_core/ingestion/parsers/__init__.py +10 -0
  135. emdash_core/ingestion/parsers/base_parser.py +66 -0
  136. emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
  137. emdash_core/ingestion/parsers/class_extractor.py +154 -0
  138. emdash_core/ingestion/parsers/function_extractor.py +202 -0
  139. emdash_core/ingestion/parsers/import_analyzer.py +119 -0
  140. emdash_core/ingestion/parsers/python_parser.py +123 -0
  141. emdash_core/ingestion/parsers/registry.py +72 -0
  142. emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
  143. emdash_core/ingestion/parsers/typescript_parser.py +278 -0
  144. emdash_core/ingestion/repository.py +346 -0
  145. emdash_core/models/__init__.py +38 -0
  146. emdash_core/models/agent.py +68 -0
  147. emdash_core/models/index.py +77 -0
  148. emdash_core/models/query.py +113 -0
  149. emdash_core/planning/__init__.py +7 -0
  150. emdash_core/planning/agent_api.py +413 -0
  151. emdash_core/planning/context_builder.py +265 -0
  152. emdash_core/planning/feature_context.py +232 -0
  153. emdash_core/planning/feature_expander.py +646 -0
  154. emdash_core/planning/llm_explainer.py +198 -0
  155. emdash_core/planning/similarity.py +509 -0
  156. emdash_core/planning/team_focus.py +821 -0
  157. emdash_core/server.py +153 -0
  158. emdash_core/sse/__init__.py +5 -0
  159. emdash_core/sse/stream.py +196 -0
  160. emdash_core/swarm/__init__.py +17 -0
  161. emdash_core/swarm/merge_agent.py +383 -0
  162. emdash_core/swarm/session_manager.py +274 -0
  163. emdash_core/swarm/swarm_runner.py +226 -0
  164. emdash_core/swarm/task_definition.py +137 -0
  165. emdash_core/swarm/worker_spawner.py +319 -0
  166. emdash_core/swarm/worktree_manager.py +278 -0
  167. emdash_core/templates/__init__.py +10 -0
  168. emdash_core/templates/defaults/agent-builder.md.template +82 -0
  169. emdash_core/templates/defaults/focus.md.template +115 -0
  170. emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
  171. emdash_core/templates/defaults/pr-review.md.template +80 -0
  172. emdash_core/templates/defaults/project.md.template +85 -0
  173. emdash_core/templates/defaults/research_critic.md.template +112 -0
  174. emdash_core/templates/defaults/research_planner.md.template +85 -0
  175. emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
  176. emdash_core/templates/defaults/reviewer.md.template +81 -0
  177. emdash_core/templates/defaults/spec.md.template +41 -0
  178. emdash_core/templates/defaults/tasks.md.template +78 -0
  179. emdash_core/templates/loader.py +296 -0
  180. emdash_core/utils/__init__.py +45 -0
  181. emdash_core/utils/git.py +84 -0
  182. emdash_core/utils/image.py +502 -0
  183. emdash_core/utils/logger.py +51 -0
  184. emdash_core-0.1.7.dist-info/METADATA +35 -0
  185. emdash_core-0.1.7.dist-info/RECORD +187 -0
  186. emdash_core-0.1.7.dist-info/WHEEL +4 -0
  187. emdash_core-0.1.7.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,496 @@
1
+ """MCP Server Manager for lifecycle and tool registry.
2
+
3
+ This module provides the MCPServerManager class that handles:
4
+ - Loading MCP config from .emdash/mcp.json
5
+ - Starting/stopping MCP servers
6
+ - Registering tools from all servers
7
+ - Handling tool name collisions
8
+ """
9
+
10
+ import atexit
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from .client import GenericMCPClient, MCPResponse, MCPToolInfo, MCPError
15
+ from .config import MCPConfigFile, MCPServerConfig, get_default_mcp_config_path, ensure_mcp_config
16
+ from ...utils.logger import log
17
+
18
+
19
+ class MCPServerManager:
20
+ """Manages lifecycle of multiple MCP servers.
21
+
22
+ This class is responsible for:
23
+ - Loading MCP server configurations from file
24
+ - Starting servers on demand (lazy initialization)
25
+ - Maintaining a unified tool registry from all servers
26
+ - Handling tool name collisions with prefixing
27
+ - Graceful shutdown of all servers
28
+
29
+ Example:
30
+ manager = MCPServerManager(config_path=Path(".emdash/mcp.json"))
31
+
32
+ # List all available tools from all servers
33
+ tools = manager.get_all_tools()
34
+
35
+ # Call a tool (server starts automatically if needed)
36
+ result = manager.call_tool("read_file", {"path": "/tmp/test.txt"})
37
+
38
+ # Cleanup
39
+ manager.shutdown_all()
40
+ """
41
+
42
+ def __init__(self, config_path: Optional[Path] = None, repo_root: Optional[Path] = None):
43
+ """Initialize the MCP server manager.
44
+
45
+ Args:
46
+ config_path: Path to mcp.json config file. If None, uses default.
47
+ repo_root: Repository root for default config path resolution.
48
+ """
49
+ self.repo_root = repo_root or Path.cwd()
50
+ self.config_path = config_path or get_default_mcp_config_path(self.repo_root)
51
+
52
+ self._config: Optional[MCPConfigFile] = None
53
+ self._clients: dict[str, GenericMCPClient] = {}
54
+ self._tool_registry: dict[str, tuple[str, MCPToolInfo]] = {} # tool_name -> (server_name, tool_info)
55
+ self._started = False
56
+
57
+ # Register atexit handler for cleanup
58
+ atexit.register(self._cleanup)
59
+
60
+ def _cleanup(self) -> None:
61
+ """Cleanup handler for atexit."""
62
+ try:
63
+ self.shutdown_all()
64
+ except Exception as e:
65
+ log.warning(f"Error during MCP cleanup: {e}")
66
+
67
+ def load_config(self) -> MCPConfigFile:
68
+ """Load MCP configuration from file, creating default if needed.
69
+
70
+ Returns:
71
+ MCPConfigFile with loaded server configurations
72
+ """
73
+ if self._config is None:
74
+ self._config = ensure_mcp_config(self.config_path)
75
+ return self._config
76
+
77
+ def reload_config(self) -> MCPConfigFile:
78
+ """Reload configuration from file.
79
+
80
+ Returns:
81
+ MCPConfigFile with fresh configuration
82
+ """
83
+ self._config = MCPConfigFile.load(self.config_path)
84
+ return self._config
85
+
86
+ def save_config(self) -> None:
87
+ """Save current configuration to file."""
88
+ if self._config:
89
+ self._config.save(self.config_path)
90
+
91
+ def get_enabled_servers(self) -> list[MCPServerConfig]:
92
+ """Get list of enabled server configurations.
93
+
94
+ Returns:
95
+ List of enabled MCPServerConfig instances
96
+ """
97
+ config = self.load_config()
98
+ return config.get_enabled_servers()
99
+
100
+ def _resolve_args(self, args: list[str], resolved_env: dict[str, str]) -> list[str]:
101
+ """Resolve ${VAR} placeholders in args using resolved env.
102
+
103
+ Args:
104
+ args: List of argument strings
105
+ resolved_env: Already-resolved environment variables
106
+
107
+ Returns:
108
+ List of args with placeholders resolved
109
+ """
110
+ import re
111
+ pattern = re.compile(r"\$\{([^}]+)\}")
112
+ resolved = []
113
+ for arg in args:
114
+ def replace_var(m):
115
+ var_name = m.group(1)
116
+ return resolved_env.get(var_name, "")
117
+ resolved.append(pattern.sub(replace_var, arg))
118
+ return resolved
119
+
120
+ def start_server(self, name: str) -> GenericMCPClient:
121
+ """Start an MCP server by name.
122
+
123
+ Args:
124
+ name: Server name from configuration
125
+
126
+ Returns:
127
+ Started GenericMCPClient instance
128
+
129
+ Raises:
130
+ MCPError: If server not found in config or fails to start
131
+ """
132
+ # Check if already running
133
+ if name in self._clients and self._clients[name].is_running:
134
+ return self._clients[name]
135
+
136
+ config = self.load_config()
137
+ server_config = config.get_server(name)
138
+
139
+ if not server_config:
140
+ raise MCPError(f"MCP server '{name}' not found in configuration")
141
+
142
+ if not server_config.enabled:
143
+ raise MCPError(f"MCP server '{name}' is disabled")
144
+
145
+ # Resolve env vars first, then args
146
+ resolved_env = server_config.get_resolved_env()
147
+ resolved_args = self._resolve_args(server_config.args, resolved_env)
148
+
149
+ # Create and start client
150
+ client = GenericMCPClient(
151
+ name=server_config.name,
152
+ command=server_config.command,
153
+ args=resolved_args,
154
+ env=resolved_env,
155
+ timeout=server_config.timeout,
156
+ )
157
+
158
+ try:
159
+ client.start()
160
+ self._clients[name] = client
161
+
162
+ # Register tools from this server
163
+ self._register_server_tools(name, client)
164
+
165
+ return client
166
+ except Exception as e:
167
+ log.error(f"Failed to start MCP server '{name}': {e}")
168
+ raise
169
+
170
+ def stop_server(self, name: str) -> bool:
171
+ """Stop an MCP server by name.
172
+
173
+ Args:
174
+ name: Server name
175
+
176
+ Returns:
177
+ True if server was stopped, False if not running
178
+ """
179
+ if name not in self._clients:
180
+ return False
181
+
182
+ client = self._clients[name]
183
+ client.stop()
184
+ del self._clients[name]
185
+
186
+ # Unregister tools from this server
187
+ self._unregister_server_tools(name)
188
+
189
+ return True
190
+
191
+ def start_all_enabled(self) -> list[str]:
192
+ """Start all enabled MCP servers.
193
+
194
+ Returns:
195
+ List of server names that were started successfully
196
+ """
197
+ started = []
198
+ for server_config in self.get_enabled_servers():
199
+ try:
200
+ self.start_server(server_config.name)
201
+ started.append(server_config.name)
202
+ except MCPError as e:
203
+ log.warning(f"Failed to start MCP server '{server_config.name}': {e}")
204
+
205
+ self._started = True
206
+ return started
207
+
208
+ def shutdown_all(self) -> None:
209
+ """Stop all running MCP servers."""
210
+ for name in list(self._clients.keys()):
211
+ try:
212
+ self.stop_server(name)
213
+ except Exception as e:
214
+ log.warning(f"Error stopping MCP server '{name}': {e}")
215
+
216
+ self._clients.clear()
217
+ self._tool_registry.clear()
218
+ self._started = False
219
+
220
+ def _register_server_tools(self, server_name: str, client: GenericMCPClient) -> None:
221
+ """Register tools from a server into the unified registry.
222
+
223
+ Handles name collisions by prefixing with server name.
224
+
225
+ Args:
226
+ server_name: Name of the server
227
+ client: Started client to get tools from
228
+ """
229
+ try:
230
+ tools = client.list_tools()
231
+ except MCPError as e:
232
+ log.warning(f"Failed to list tools from '{server_name}': {e}")
233
+ return
234
+
235
+ for tool in tools:
236
+ # Check for name collision
237
+ if tool.name in self._tool_registry:
238
+ existing_server, _ = self._tool_registry[tool.name]
239
+ # Use prefixed name for collision
240
+ prefixed_name = f"{server_name}_{tool.name}"
241
+ log.warning(
242
+ f"Tool name collision: '{tool.name}' exists in '{existing_server}'. "
243
+ f"Registering as '{prefixed_name}' for server '{server_name}'."
244
+ )
245
+ self._tool_registry[prefixed_name] = (server_name, tool)
246
+ else:
247
+ self._tool_registry[tool.name] = (server_name, tool)
248
+
249
+ log.info(f"Registered {len(tools)} tools from MCP server '{server_name}'")
250
+
251
+ def _unregister_server_tools(self, server_name: str) -> None:
252
+ """Remove tools from a server from the registry.
253
+
254
+ Args:
255
+ server_name: Name of the server
256
+ """
257
+ to_remove = [
258
+ name for name, (srv, _) in self._tool_registry.items()
259
+ if srv == server_name
260
+ ]
261
+ for name in to_remove:
262
+ del self._tool_registry[name]
263
+
264
+ def get_all_tools(self) -> list[tuple[str, str, MCPToolInfo]]:
265
+ """Get all registered tools from all servers.
266
+
267
+ Starts all enabled servers if not already started.
268
+
269
+ Returns:
270
+ List of (tool_name, server_name, MCPToolInfo) tuples
271
+ """
272
+ if not self._started:
273
+ self.start_all_enabled()
274
+
275
+ return [
276
+ (name, server_name, tool_info)
277
+ for name, (server_name, tool_info) in self._tool_registry.items()
278
+ ]
279
+
280
+ def get_tool(self, name: str) -> Optional[tuple[str, MCPToolInfo]]:
281
+ """Get a tool by name.
282
+
283
+ Args:
284
+ name: Tool name (may be prefixed for collisions)
285
+
286
+ Returns:
287
+ (server_name, MCPToolInfo) tuple or None if not found
288
+ """
289
+ if not self._started:
290
+ self.start_all_enabled()
291
+
292
+ return self._tool_registry.get(name)
293
+
294
+ def call_tool(self, tool_name: str, arguments: dict) -> MCPResponse:
295
+ """Call a tool by name.
296
+
297
+ Automatically routes to the correct server.
298
+
299
+ Args:
300
+ tool_name: Tool name (may be prefixed)
301
+ arguments: Tool arguments
302
+
303
+ Returns:
304
+ MCPResponse from the tool call
305
+
306
+ Raises:
307
+ MCPError: If tool not found or call fails
308
+ """
309
+ tool_info = self.get_tool(tool_name)
310
+ if not tool_info:
311
+ raise MCPError(f"Tool '{tool_name}' not found in any MCP server")
312
+
313
+ server_name, mcp_tool = tool_info
314
+
315
+ # Get the client, start if needed
316
+ if server_name not in self._clients:
317
+ self.start_server(server_name)
318
+
319
+ client = self._clients[server_name]
320
+
321
+ # Use original tool name (strip prefix if present)
322
+ original_name = mcp_tool.name
323
+
324
+ return client.call_tool(original_name, arguments)
325
+
326
+ def add_server(self, config: MCPServerConfig) -> None:
327
+ """Add a new server to configuration.
328
+
329
+ Args:
330
+ config: Server configuration to add
331
+ """
332
+ self.load_config()
333
+ self._config.add_server(config)
334
+ self.save_config()
335
+ log.info(f"Added MCP server '{config.name}' to configuration")
336
+
337
+ def remove_server(self, name: str) -> bool:
338
+ """Remove a server from configuration.
339
+
340
+ Also stops the server if running.
341
+
342
+ Args:
343
+ name: Server name to remove
344
+
345
+ Returns:
346
+ True if removed, False if not found
347
+ """
348
+ # Stop if running
349
+ if name in self._clients:
350
+ self.stop_server(name)
351
+
352
+ self.load_config()
353
+ if self._config.remove_server(name):
354
+ self.save_config()
355
+ log.info(f"Removed MCP server '{name}' from configuration")
356
+ return True
357
+ return False
358
+
359
+ def enable_server(self, name: str) -> bool:
360
+ """Enable a server.
361
+
362
+ Args:
363
+ name: Server name
364
+
365
+ Returns:
366
+ True if enabled, False if not found
367
+ """
368
+ self.load_config()
369
+ server = self._config.get_server(name)
370
+ if server:
371
+ server.enabled = True
372
+ self.save_config()
373
+ return True
374
+ return False
375
+
376
+ def disable_server(self, name: str) -> bool:
377
+ """Disable a server.
378
+
379
+ Also stops the server if running.
380
+
381
+ Args:
382
+ name: Server name
383
+
384
+ Returns:
385
+ True if disabled, False if not found
386
+ """
387
+ # Stop if running
388
+ if name in self._clients:
389
+ self.stop_server(name)
390
+
391
+ self.load_config()
392
+ server = self._config.get_server(name)
393
+ if server:
394
+ server.enabled = False
395
+ self.save_config()
396
+ return True
397
+ return False
398
+
399
+ def list_servers(self) -> list[dict]:
400
+ """List all configured servers with their status.
401
+
402
+ Returns:
403
+ List of server info dicts with keys:
404
+ - name: Server name
405
+ - command: Server command
406
+ - enabled: Whether enabled
407
+ - running: Whether currently running
408
+ - tool_count: Number of tools (if running)
409
+ """
410
+ self.load_config()
411
+ servers = []
412
+
413
+ for name, server_config in self._config.servers.items():
414
+ is_running = name in self._clients and self._clients[name].is_running
415
+ tool_count = len([
416
+ 1 for _, (srv, _) in self._tool_registry.items()
417
+ if srv == name
418
+ ]) if is_running else 0
419
+
420
+ servers.append({
421
+ "name": name,
422
+ "command": server_config.command,
423
+ "args": server_config.args,
424
+ "enabled": server_config.enabled,
425
+ "running": is_running,
426
+ "tool_count": tool_count,
427
+ })
428
+
429
+ return servers
430
+
431
+ def describe_server(self, name: str) -> Optional[dict]:
432
+ """Get detailed information about a server including its tools.
433
+
434
+ Args:
435
+ name: Server name
436
+
437
+ Returns:
438
+ Dict with server details and tools, or None if not found
439
+ """
440
+ self.load_config()
441
+ server_config = self._config.get_server(name)
442
+
443
+ if not server_config:
444
+ return None
445
+
446
+ is_running = name in self._clients and self._clients[name].is_running
447
+
448
+ # Get tools for this server
449
+ tools = []
450
+ if is_running:
451
+ for tool_name, (srv, tool_info) in self._tool_registry.items():
452
+ if srv == name:
453
+ tools.append({
454
+ "name": tool_name,
455
+ "original_name": tool_info.name,
456
+ "description": tool_info.description,
457
+ "input_schema": tool_info.input_schema,
458
+ })
459
+
460
+ return {
461
+ "name": name,
462
+ "command": server_config.command,
463
+ "args": server_config.args,
464
+ "env": server_config.env,
465
+ "enabled": server_config.enabled,
466
+ "timeout": server_config.timeout,
467
+ "running": is_running,
468
+ "tools": tools,
469
+ }
470
+
471
+
472
+ # Global manager instance (lazy initialization)
473
+ _manager: Optional[MCPServerManager] = None
474
+
475
+
476
+ def get_mcp_manager(config_path: Optional[Path] = None) -> MCPServerManager:
477
+ """Get or create the global MCP manager instance.
478
+
479
+ Args:
480
+ config_path: Optional path to override default config location
481
+
482
+ Returns:
483
+ MCPServerManager instance
484
+ """
485
+ global _manager
486
+ if _manager is None:
487
+ _manager = MCPServerManager(config_path=config_path)
488
+ return _manager
489
+
490
+
491
+ def reset_mcp_manager() -> None:
492
+ """Reset the global MCP manager (for testing)."""
493
+ global _manager
494
+ if _manager:
495
+ _manager.shutdown_all()
496
+ _manager = None
@@ -0,0 +1,213 @@
1
+ """Tool factory for creating BaseTool instances from MCP servers.
2
+
3
+ This module provides functions to dynamically create agent tools
4
+ from MCP server tool definitions.
5
+ """
6
+
7
+ import json
8
+ from typing import Any, Optional
9
+
10
+ from ..tools.base import BaseTool, ToolResult, ToolCategory
11
+ from .manager import MCPServerManager
12
+ from .client import MCPToolInfo, MCPError
13
+ from ...utils.logger import log
14
+
15
+
16
+ class MCPDynamicTool(BaseTool):
17
+ """A tool dynamically created from an MCP server tool definition.
18
+
19
+ This allows MCP tools to be used seamlessly alongside native tools
20
+ in the agent toolkit.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ mcp_manager: MCPServerManager,
26
+ tool_name: str,
27
+ tool_info: MCPToolInfo,
28
+ server_name: str,
29
+ connection: Any = None,
30
+ ):
31
+ """Initialize the dynamic tool.
32
+
33
+ Args:
34
+ mcp_manager: MCP server manager for calling tools
35
+ tool_name: Tool name (may be prefixed)
36
+ tool_info: Tool info from MCP server
37
+ server_name: Name of the source server
38
+ connection: Optional Kuzu connection (for compatibility)
39
+ """
40
+ super().__init__(connection)
41
+ self._mcp_manager = mcp_manager
42
+ self._tool_name = tool_name
43
+ self._tool_info = tool_info
44
+ self._server_name = server_name
45
+
46
+ # Set BaseTool attributes
47
+ self.name = tool_name
48
+ self.description = tool_info.description or f"MCP tool: {tool_name}"
49
+ self.category = self._infer_category(tool_name, tool_info)
50
+
51
+ def _infer_category(self, name: str, info: MCPToolInfo) -> ToolCategory:
52
+ """Infer tool category from name and description.
53
+
54
+ Args:
55
+ name: Tool name
56
+ info: Tool info
57
+
58
+ Returns:
59
+ Inferred ToolCategory
60
+ """
61
+ name_lower = name.lower()
62
+ desc_lower = (info.description or "").lower()
63
+
64
+ # Search-related
65
+ if any(kw in name_lower for kw in ["search", "find", "query", "grep"]):
66
+ return ToolCategory.SEARCH
67
+
68
+ # Traversal-related
69
+ if any(kw in name_lower for kw in ["expand", "caller", "callee", "dependency", "neighbor"]):
70
+ return ToolCategory.TRAVERSAL
71
+
72
+ # Analytics-related
73
+ if any(kw in name_lower for kw in ["pagerank", "community", "importance", "metric"]):
74
+ return ToolCategory.ANALYTICS
75
+
76
+ # History-related (PRs, commits)
77
+ if any(kw in name_lower for kw in ["pr", "commit", "history", "github"]):
78
+ return ToolCategory.HISTORY
79
+
80
+ # Default to search
81
+ return ToolCategory.SEARCH
82
+
83
+ def execute(self, **kwargs) -> ToolResult:
84
+ """Execute the MCP tool.
85
+
86
+ Args:
87
+ **kwargs: Tool arguments
88
+
89
+ Returns:
90
+ ToolResult with tool output
91
+ """
92
+ try:
93
+ response = self._mcp_manager.call_tool(self._tool_name, kwargs)
94
+
95
+ if response.is_error:
96
+ return ToolResult.error_result(
97
+ f"MCP tool error: {response.get_text()}",
98
+ )
99
+
100
+ # Parse response content
101
+ result_data = self._parse_response(response)
102
+
103
+ return ToolResult.success_result(
104
+ data=result_data,
105
+ metadata={
106
+ "server": self._server_name,
107
+ "tool": self._tool_name,
108
+ },
109
+ )
110
+
111
+ except MCPError as e:
112
+ return ToolResult.error_result(
113
+ f"MCP error: {str(e)}",
114
+ suggestions=["Check if the MCP server is running"],
115
+ )
116
+ except Exception as e:
117
+ log.exception(f"MCP tool execution error: {self._tool_name}")
118
+ return ToolResult.error_result(
119
+ f"Tool execution failed: {str(e)}",
120
+ )
121
+
122
+ def _parse_response(self, response) -> dict:
123
+ """Parse MCP response into result data.
124
+
125
+ Args:
126
+ response: MCPResponse
127
+
128
+ Returns:
129
+ Parsed result dict
130
+ """
131
+ result = {"content": [], "text": ""}
132
+
133
+ for item in response.content:
134
+ if item.get("type") == "text":
135
+ text = item.get("text", "")
136
+ result["text"] += text + "\n"
137
+
138
+ # Try to parse as JSON
139
+ try:
140
+ parsed = json.loads(text)
141
+ if isinstance(parsed, dict):
142
+ result.update(parsed)
143
+ elif isinstance(parsed, list):
144
+ result["results"] = parsed
145
+ except json.JSONDecodeError:
146
+ pass
147
+
148
+ result["content"].append(item)
149
+
150
+ result["text"] = result["text"].strip()
151
+ return result
152
+
153
+ def get_schema(self) -> dict:
154
+ """Get OpenAI function calling schema.
155
+
156
+ Returns:
157
+ OpenAI function schema dict
158
+ """
159
+ input_schema = self._tool_info.input_schema or {}
160
+
161
+ # Convert MCP schema to OpenAI format
162
+ parameters = {
163
+ "type": "object",
164
+ "properties": input_schema.get("properties", {}),
165
+ "required": input_schema.get("required", []),
166
+ }
167
+
168
+ return {
169
+ "type": "function",
170
+ "function": {
171
+ "name": self.name,
172
+ "description": self.description,
173
+ "parameters": parameters,
174
+ },
175
+ }
176
+
177
+
178
+ def create_tools_from_mcp(
179
+ manager: MCPServerManager,
180
+ connection: Any = None,
181
+ ) -> list[BaseTool]:
182
+ """Create BaseTool instances from all MCP server tools.
183
+
184
+ This function queries all enabled MCP servers and creates
185
+ dynamic tool wrappers for each tool they provide.
186
+
187
+ Args:
188
+ manager: MCP server manager
189
+ connection: Optional Kuzu connection
190
+
191
+ Returns:
192
+ List of MCPDynamicTool instances
193
+ """
194
+ tools = []
195
+
196
+ try:
197
+ all_tools = manager.get_all_tools()
198
+
199
+ for tool_name, server_name, tool_info in all_tools:
200
+ tool = MCPDynamicTool(
201
+ mcp_manager=manager,
202
+ tool_name=tool_name,
203
+ tool_info=tool_info,
204
+ server_name=server_name,
205
+ connection=connection,
206
+ )
207
+ tools.append(tool)
208
+ log.debug(f"Created MCP tool: {tool_name} from {server_name}")
209
+
210
+ except Exception as e:
211
+ log.warning(f"Failed to create tools from MCP: {e}")
212
+
213
+ return tools