gobby 0.2.6__py3-none-any.whl → 0.2.8__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -1,108 +1,37 @@
1
1
  """
2
2
  MCP routes for Gobby HTTP server.
3
3
 
4
- Provides MCP server management, tool discovery, and tool execution endpoints.
5
- Uses FastAPI dependency injection via Depends() for proper testability.
4
+ Thin router aggregation layer that composes endpoints from:
5
+ - endpoints/discovery.py - Tool listing and search
6
+ - endpoints/execution.py - Tool calls and schema retrieval
7
+ - endpoints/server.py - Server management
8
+ - endpoints/registry.py - Embedding, status, and refresh
6
9
  """
7
10
 
8
- import json
9
- import logging
10
- import re
11
- import time
12
- from typing import TYPE_CHECKING, Any
11
+ from fastapi import APIRouter
13
12
 
14
- from fastapi import APIRouter, Depends, HTTPException, Request
15
-
16
- from gobby.servers.routes.dependencies import (
17
- get_internal_manager,
18
- get_mcp_manager,
19
- get_metrics_manager,
20
- get_server,
13
+ from gobby.servers.routes.mcp.endpoints.discovery import (
14
+ list_all_mcp_tools,
15
+ recommend_mcp_tools,
16
+ search_mcp_tools,
17
+ )
18
+ from gobby.servers.routes.mcp.endpoints.execution import (
19
+ call_mcp_tool,
20
+ get_tool_schema,
21
+ list_mcp_tools,
22
+ mcp_proxy,
23
+ )
24
+ from gobby.servers.routes.mcp.endpoints.registry import (
25
+ embed_mcp_tools,
26
+ get_mcp_status,
27
+ refresh_mcp_tools,
28
+ )
29
+ from gobby.servers.routes.mcp.endpoints.server import (
30
+ add_mcp_server,
31
+ import_mcp_server,
32
+ list_mcp_servers,
33
+ remove_mcp_server,
21
34
  )
22
- from gobby.utils.metrics import get_metrics_collector
23
-
24
- if TYPE_CHECKING:
25
- from gobby.mcp_proxy.manager import MCPClientManager
26
- from gobby.mcp_proxy.metrics import ToolMetricsManager
27
- from gobby.mcp_proxy.registry_manager import InternalToolRegistryManager
28
- from gobby.servers.http import HTTPServer
29
-
30
- logger = logging.getLogger(__name__)
31
-
32
-
33
- def _process_tool_proxy_result(
34
- result: Any,
35
- server_name: str,
36
- tool_name: str,
37
- response_time_ms: float,
38
- metrics_collector: Any,
39
- ) -> dict[str, Any]:
40
- """
41
- Process tool proxy result with consistent metrics, logging, and error handling.
42
-
43
- Args:
44
- result: The result from tool_proxy.call_tool()
45
- server_name: Name of the MCP server
46
- tool_name: Name of the tool called
47
- response_time_ms: Response time in milliseconds
48
- metrics_collector: Metrics collector instance
49
-
50
- Returns:
51
- Wrapped result dict with success status and response time
52
-
53
- Raises:
54
- HTTPException: 404 if server not found/not configured
55
- """
56
- # Track metrics for tool-level failures vs successes
57
- if isinstance(result, dict) and result.get("success") is False:
58
- metrics_collector.inc_counter("mcp_tool_calls_failed_total")
59
-
60
- # Check structured error code first (preferred)
61
- error_code = result.get("error_code")
62
- if error_code in ("SERVER_NOT_FOUND", "SERVER_NOT_CONFIGURED"):
63
- # Normalize result to standard error shape while preserving existing fields
64
- normalized = {"success": False, "error": result.get("error", "Unknown error")}
65
- for key, value in result.items():
66
- if key not in normalized:
67
- normalized[key] = value
68
- raise HTTPException(status_code=404, detail=normalized)
69
-
70
- # Backward compatibility: fall back to regex matching if no error_code
71
- if not error_code:
72
- logger.debug(
73
- "ToolProxyService returned error without error_code - using regex fallback"
74
- )
75
- error_msg = str(result.get("error", ""))
76
- if re.search(r"server\s+(not\s+found|not\s+configured)", error_msg, re.IGNORECASE):
77
- normalized = {"success": False, "error": result.get("error", "Unknown error")}
78
- for key, value in result.items():
79
- if key not in normalized:
80
- normalized[key] = value
81
- raise HTTPException(status_code=404, detail=normalized)
82
-
83
- # Tool-level failure (not a transport error) - return failure envelope
84
- return {
85
- "success": False,
86
- "result": result,
87
- "response_time_ms": response_time_ms,
88
- }
89
- else:
90
- metrics_collector.inc_counter("mcp_tool_calls_succeeded_total")
91
- logger.debug(
92
- f"MCP tool call successful: {server_name}.{tool_name}",
93
- extra={
94
- "server": server_name,
95
- "tool": tool_name,
96
- "response_time_ms": response_time_ms,
97
- },
98
- )
99
-
100
- # Return 200 with wrapped result for success cases
101
- return {
102
- "success": True,
103
- "result": result,
104
- "response_time_ms": response_time_ms,
105
- }
106
35
 
107
36
 
108
37
  def create_mcp_router() -> APIRouter:
@@ -113,1414 +42,27 @@ def create_mcp_router() -> APIRouter:
113
42
  Configured APIRouter with MCP endpoints
114
43
  """
115
44
  router = APIRouter(prefix="/mcp", tags=["mcp"])
116
- metrics = get_metrics_collector()
117
-
118
- @router.get("/{server_name}/tools")
119
- async def list_mcp_tools(
120
- server_name: str,
121
- internal_manager: "InternalToolRegistryManager | None" = Depends(get_internal_manager),
122
- mcp_manager: "MCPClientManager | None" = Depends(get_mcp_manager),
123
- ) -> dict[str, Any]:
124
- """
125
- List available tools from an MCP server.
126
-
127
- Args:
128
- server_name: Name of the MCP server (e.g., "supabase", "context7")
129
- internal_manager: Internal tool registry manager (injected)
130
- mcp_manager: External MCP client manager (injected)
131
-
132
- Returns:
133
- List of available tools with their descriptions
134
- """
135
- start_time = time.perf_counter()
136
- metrics.inc_counter("http_requests_total")
137
-
138
- try:
139
- # Check internal registries first (gobby-tasks, gobby-memory, etc.)
140
- if internal_manager and internal_manager.is_internal(server_name):
141
- registry = internal_manager.get_registry(server_name)
142
- if registry:
143
- tools = registry.list_tools()
144
- response_time_ms = (time.perf_counter() - start_time) * 1000
145
- metrics.observe_histogram("list_mcp_tools", response_time_ms / 1000)
146
- return {
147
- "status": "success",
148
- "tools": tools,
149
- "tool_count": len(tools),
150
- "response_time_ms": response_time_ms,
151
- }
152
- raise HTTPException(
153
- status_code=404,
154
- detail={
155
- "success": False,
156
- "error": f"Internal server '{server_name}' not found",
157
- },
158
- )
159
-
160
- if mcp_manager is None:
161
- raise HTTPException(
162
- status_code=503, detail={"success": False, "error": "MCP manager not available"}
163
- )
164
-
165
- # Check if server is configured
166
- if not mcp_manager.has_server(server_name):
167
- raise HTTPException(
168
- status_code=404,
169
- detail={"success": False, "error": f"Unknown MCP server: '{server_name}'"},
170
- )
171
-
172
- # Use ensure_connected for lazy loading - connects on-demand if not connected
173
- try:
174
- session = await mcp_manager.ensure_connected(server_name)
175
- except KeyError as e:
176
- raise HTTPException(
177
- status_code=404, detail={"success": False, "error": str(e)}
178
- ) from e
179
- except Exception as e:
180
- raise HTTPException(
181
- status_code=503,
182
- detail={
183
- "success": False,
184
- "error": f"MCP server '{server_name}' connection failed: {e}",
185
- },
186
- ) from e
187
-
188
- # List tools using MCP SDK
189
- try:
190
- tools_result = await session.list_tools()
191
- tools = []
192
- for tool in tools_result.tools:
193
- tool_dict: dict[str, Any] = {
194
- "name": tool.name,
195
- "description": tool.description if hasattr(tool, "description") else None,
196
- }
197
-
198
- # Handle inputSchema
199
- if hasattr(tool, "inputSchema"):
200
- schema = tool.inputSchema
201
- if hasattr(schema, "model_dump"):
202
- tool_dict["inputSchema"] = schema.model_dump()
203
- elif isinstance(schema, dict):
204
- tool_dict["inputSchema"] = schema
205
- else:
206
- tool_dict["inputSchema"] = None
207
- else:
208
- tool_dict["inputSchema"] = None
209
-
210
- tools.append(tool_dict)
211
-
212
- response_time_ms = (time.perf_counter() - start_time) * 1000
213
-
214
- logger.debug(
215
- f"Listed {len(tools)} tools from {server_name}",
216
- extra={
217
- "server": server_name,
218
- "tool_count": len(tools),
219
- "response_time_ms": response_time_ms,
220
- },
221
- )
222
-
223
- return {
224
- "status": "success",
225
- "tools": tools,
226
- "tool_count": len(tools),
227
- "response_time_ms": response_time_ms,
228
- }
229
-
230
- except Exception as e:
231
- logger.error(
232
- f"Failed to list tools from {server_name}: {e}",
233
- exc_info=True,
234
- extra={"server": server_name},
235
- )
236
- raise HTTPException(
237
- status_code=500,
238
- detail={"success": False, "error": f"Failed to list tools: {e}"},
239
- ) from e
240
-
241
- except HTTPException:
242
- raise
243
- except Exception as e:
244
- metrics.inc_counter("http_requests_errors_total")
245
- logger.error(f"MCP list tools error: {server_name}", exc_info=True)
246
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
247
-
248
- @router.get("/servers")
249
- async def list_mcp_servers(
250
- internal_manager: "InternalToolRegistryManager | None" = Depends(get_internal_manager),
251
- mcp_manager: "MCPClientManager | None" = Depends(get_mcp_manager),
252
- ) -> dict[str, Any]:
253
- """
254
- List all configured MCP servers.
255
-
256
- Args:
257
- internal_manager: Internal tool registry manager (injected)
258
- mcp_manager: External MCP client manager (injected)
259
-
260
- Returns:
261
- List of servers with connection status
262
- """
263
- start_time = time.perf_counter()
264
- metrics.inc_counter("http_requests_total")
265
-
266
- try:
267
- server_list = []
268
-
269
- # Add internal servers (gobby-tasks, gobby-memory, etc.)
270
- if internal_manager:
271
- for registry in internal_manager.get_all_registries():
272
- server_list.append(
273
- {
274
- "name": registry.name,
275
- "state": "connected",
276
- "connected": True,
277
- "transport": "internal",
278
- }
279
- )
280
-
281
- # Add external MCP servers
282
- if mcp_manager:
283
- for config in mcp_manager.server_configs:
284
- health = mcp_manager.health.get(config.name)
285
- is_connected = config.name in mcp_manager.connections
286
- server_list.append(
287
- {
288
- "name": config.name,
289
- "state": health.state.value if health else "unknown",
290
- "connected": is_connected,
291
- "transport": config.transport,
292
- "enabled": config.enabled,
293
- }
294
- )
295
-
296
- response_time_ms = (time.perf_counter() - start_time) * 1000
297
-
298
- return {
299
- "servers": server_list,
300
- "total_count": len(server_list),
301
- "connected_count": len([s for s in server_list if s.get("connected")]),
302
- "response_time_ms": response_time_ms,
303
- }
304
-
305
- except Exception as e:
306
- metrics.inc_counter("http_requests_errors_total")
307
- logger.error(f"List MCP servers error: {e}", exc_info=True)
308
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
309
-
310
- @router.get("/tools")
311
- async def list_all_mcp_tools(
312
- server_filter: str | None = None,
313
- include_metrics: bool = False,
314
- project_id: str | None = None,
315
- server: "HTTPServer" = Depends(get_server),
316
- metrics_manager: "ToolMetricsManager | None" = Depends(get_metrics_manager),
317
- ) -> dict[str, Any]:
318
- """
319
- List tools from MCP servers.
320
-
321
- Args:
322
- server_filter: Optional server name to filter by
323
- include_metrics: When True, include call_count, success_rate, avg_latency for each tool
324
- project_id: Project ID for metrics lookup (uses current project if not specified)
325
-
326
- Returns:
327
- Dict of server names to tool lists
328
- """
329
- start_time = time.perf_counter()
330
- metrics.inc_counter("http_requests_total")
331
-
332
- try:
333
- tools_by_server: dict[str, list[dict[str, Any]]] = {}
334
-
335
- # Resolve project_id for metrics lookup
336
- resolved_project_id = None
337
- if include_metrics:
338
- try:
339
- resolved_project_id = server._resolve_project_id(project_id, cwd=None)
340
- except ValueError:
341
- # Project not initialized; skip metrics enrichment
342
- resolved_project_id = None
343
-
344
- # If specific server requested
345
- if server_filter:
346
- # Check internal first
347
- if server._internal_manager and server._internal_manager.is_internal(server_filter):
348
- registry = server._internal_manager.get_registry(server_filter)
349
- if registry:
350
- tools_by_server[server_filter] = registry.list_tools()
351
- elif server.mcp_manager and server.mcp_manager.has_server(server_filter):
352
- # Check if server is enabled before attempting connection
353
- server_config = server.mcp_manager._configs.get(server_filter)
354
- if server_config and not server_config.enabled:
355
- tools_by_server[server_filter] = []
356
- else:
357
- try:
358
- # Use ensure_connected for lazy loading
359
- session = await server.mcp_manager.ensure_connected(server_filter)
360
- tools_result = await session.list_tools()
361
- tools_list = []
362
- for t in tools_result.tools:
363
- desc = getattr(t, "description", "") or ""
364
- tools_list.append(
365
- {
366
- "name": t.name,
367
- "brief": desc[:100],
368
- }
369
- )
370
- tools_by_server[server_filter] = tools_list
371
- except Exception as e:
372
- logger.warning(f"Failed to list tools from {server_filter}: {e}")
373
- tools_by_server[server_filter] = []
374
- else:
375
- # Get tools from all servers
376
- # Internal servers
377
- if server._internal_manager:
378
- for registry in server._internal_manager.get_all_registries():
379
- tools_by_server[registry.name] = registry.list_tools()
380
-
381
- # External MCP servers - use ensure_connected for lazy loading
382
- if server.mcp_manager:
383
- for config in server.mcp_manager.server_configs:
384
- if config.enabled:
385
- try:
386
- session = await server.mcp_manager.ensure_connected(config.name)
387
- tools_result = await session.list_tools()
388
- tools_list = []
389
- for t in tools_result.tools:
390
- desc = getattr(t, "description", "") or ""
391
- tools_list.append(
392
- {
393
- "name": t.name,
394
- "brief": desc[:100],
395
- }
396
- )
397
- tools_by_server[config.name] = tools_list
398
- except Exception as e:
399
- logger.warning(f"Failed to list tools from {config.name}: {e}")
400
- tools_by_server[config.name] = []
401
-
402
- # Enrich with metrics if requested
403
- if include_metrics and metrics_manager and resolved_project_id:
404
- # Get all metrics for this project
405
- metrics_data = metrics_manager.get_metrics(project_id=resolved_project_id)
406
- metrics_by_key = {
407
- (m["server_name"], m["tool_name"]): m for m in metrics_data.get("tools", [])
408
- }
409
-
410
- for server_name, tools_list in tools_by_server.items():
411
- for tool in tools_list:
412
- # Guard against non-dict or missing-name entries
413
- if not isinstance(tool, dict) or "name" not in tool:
414
- continue
415
- tool_name = tool.get("name")
416
- key = (server_name, tool_name)
417
- if key in metrics_by_key:
418
- m = metrics_by_key[key]
419
- tool["call_count"] = m.get("call_count", 0)
420
- tool["success_rate"] = m.get("success_rate")
421
- tool["avg_latency_ms"] = m.get("avg_latency_ms")
422
- else:
423
- tool["call_count"] = 0
424
- tool["success_rate"] = None
425
- tool["avg_latency_ms"] = None
426
-
427
- response_time_ms = (time.perf_counter() - start_time) * 1000
428
-
429
- return {
430
- "tools": tools_by_server,
431
- "response_time_ms": response_time_ms,
432
- }
433
-
434
- except Exception as e:
435
- metrics.inc_counter("http_requests_errors_total")
436
- logger.error(f"List MCP tools error: {e}", exc_info=True)
437
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
438
-
439
- @router.post("/tools/schema")
440
- async def get_tool_schema(
441
- request: Request,
442
- server: "HTTPServer" = Depends(get_server),
443
- ) -> dict[str, Any]:
444
- """
445
- Get full schema for a specific tool.
446
-
447
- Request body:
448
- {
449
- "server_name": "supabase",
450
- "tool_name": "list_tables"
451
- }
452
-
453
- Returns:
454
- Tool schema with inputSchema
455
- """
456
- start_time = time.perf_counter()
457
- metrics.inc_counter("http_requests_total")
458
-
459
- try:
460
- body = await request.json()
461
- server_name = body.get("server_name")
462
- tool_name = body.get("tool_name")
463
-
464
- if not server_name or not tool_name:
465
- raise HTTPException(
466
- status_code=400,
467
- detail={"success": False, "error": "Required fields: server_name, tool_name"},
468
- )
469
-
470
- # Check internal first
471
- if server._internal_manager and server._internal_manager.is_internal(server_name):
472
- registry = server._internal_manager.get_registry(server_name)
473
- if registry:
474
- schema = registry.get_schema(tool_name)
475
- if schema:
476
- response_time_ms = (time.perf_counter() - start_time) * 1000
477
- return {
478
- "name": tool_name,
479
- "server": server_name,
480
- "inputSchema": schema,
481
- "response_time_ms": response_time_ms,
482
- }
483
- raise HTTPException(
484
- status_code=404,
485
- detail={
486
- "success": False,
487
- "error": f"Tool '{tool_name}' not found on server '{server_name}'",
488
- },
489
- )
490
-
491
- if server.mcp_manager is None:
492
- raise HTTPException(
493
- status_code=503,
494
- detail={"success": False, "error": "MCP manager not available"},
495
- )
496
-
497
- # Get from external MCP server
498
- try:
499
- schema = await server.mcp_manager.get_tool_input_schema(server_name, tool_name)
500
- response_time_ms = (time.perf_counter() - start_time) * 1000
501
-
502
- return {
503
- "name": tool_name,
504
- "server": server_name,
505
- "inputSchema": schema,
506
- "response_time_ms": response_time_ms,
507
- }
508
-
509
- except (KeyError, ValueError) as e:
510
- # Tool or server not found - 404
511
- raise HTTPException(
512
- status_code=404, detail={"success": False, "error": str(e)}
513
- ) from e
514
- except Exception as e:
515
- # Connection, timeout, or internal errors - 500
516
- logger.error(
517
- f"Failed to get tool schema {server_name}/{tool_name}: {e}", exc_info=True
518
- )
519
- raise HTTPException(
520
- status_code=500,
521
- detail={"success": False, "error": f"Failed to get tool schema: {e}"},
522
- ) from e
523
-
524
- except HTTPException:
525
- raise
526
- except Exception as e:
527
- metrics.inc_counter("http_requests_errors_total")
528
- logger.error(f"Get tool schema error: {e}", exc_info=True)
529
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
530
-
531
- @router.post("/tools/call")
532
- async def call_mcp_tool(
533
- request: Request,
534
- server: "HTTPServer" = Depends(get_server),
535
- ) -> dict[str, Any]:
536
- """
537
- Call an MCP tool.
538
-
539
- Request body:
540
- {
541
- "server_name": "supabase",
542
- "tool_name": "list_tables",
543
- "arguments": {}
544
- }
545
-
546
- Returns:
547
- Tool execution result
548
- """
549
- start_time = time.perf_counter()
550
- metrics.inc_counter("http_requests_total")
551
- metrics.inc_counter("mcp_tool_calls_total")
552
-
553
- try:
554
- body = await request.json()
555
- server_name = body.get("server_name")
556
- tool_name = body.get("tool_name")
557
- arguments = body.get("arguments", {})
558
-
559
- if not server_name or not tool_name:
560
- raise HTTPException(
561
- status_code=400,
562
- detail={"success": False, "error": "Required fields: server_name, tool_name"},
563
- )
564
-
565
- # Route through ToolProxyService for consistent error enrichment
566
- if server.tool_proxy:
567
- result = await server.tool_proxy.call_tool(server_name, tool_name, arguments)
568
- response_time_ms = (time.perf_counter() - start_time) * 1000
569
- return _process_tool_proxy_result(
570
- result, server_name, tool_name, response_time_ms, metrics
571
- )
572
-
573
- # Fallback: no tool_proxy available, use direct registry calls
574
- # Check internal first
575
- if server._internal_manager and server._internal_manager.is_internal(server_name):
576
- registry = server._internal_manager.get_registry(server_name)
577
- if registry:
578
- # Check if tool exists before calling - return helpful 404 if not
579
- if not registry.get_schema(tool_name):
580
- available = [t["name"] for t in registry.list_tools()]
581
- raise HTTPException(
582
- status_code=404,
583
- detail={
584
- "success": False,
585
- "error": f"Tool '{tool_name}' not found on '{server_name}'. "
586
- f"Available: {', '.join(available)}. "
587
- f"Use list_tools(server='{server_name}') to see all tools, "
588
- f"or get_tool_schema(server_name='{server_name}', tool_name='...') for full schema.",
589
- },
590
- )
591
- try:
592
- result = await registry.call(tool_name, arguments or {})
593
- response_time_ms = (time.perf_counter() - start_time) * 1000
594
- metrics.inc_counter("mcp_tool_calls_succeeded_total")
595
- return {
596
- "success": True,
597
- "result": result,
598
- "response_time_ms": response_time_ms,
599
- }
600
- except Exception as e:
601
- metrics.inc_counter("mcp_tool_calls_failed_total")
602
- error_msg = str(e) or f"{type(e).__name__}: (no message)"
603
- raise HTTPException(
604
- status_code=500,
605
- detail={"success": False, "error": error_msg},
606
- ) from e
607
-
608
- if server.mcp_manager is None:
609
- raise HTTPException(
610
- status_code=503,
611
- detail={"success": False, "error": "MCP manager not available"},
612
- )
613
-
614
- # Call external MCP tool
615
- try:
616
- result = await server.mcp_manager.call_tool(server_name, tool_name, arguments)
617
- response_time_ms = (time.perf_counter() - start_time) * 1000
618
- metrics.inc_counter("mcp_tool_calls_succeeded_total")
619
-
620
- return {
621
- "success": True,
622
- "result": result,
623
- "response_time_ms": response_time_ms,
624
- }
625
-
626
- except Exception as e:
627
- metrics.inc_counter("mcp_tool_calls_failed_total")
628
- error_msg = str(e) or f"{type(e).__name__}: (no message)"
629
- raise HTTPException(
630
- status_code=500, detail={"success": False, "error": error_msg}
631
- ) from e
632
-
633
- except HTTPException:
634
- raise
635
- except Exception as e:
636
- metrics.inc_counter("mcp_tool_calls_failed_total")
637
- error_msg = str(e) or f"{type(e).__name__}: (no message)"
638
- logger.error(f"Call MCP tool error: {error_msg}", exc_info=True)
639
- raise HTTPException(
640
- status_code=500, detail={"success": False, "error": error_msg}
641
- ) from e
642
-
643
- @router.post("/servers")
644
- async def add_mcp_server(
645
- request: Request,
646
- server: "HTTPServer" = Depends(get_server),
647
- ) -> dict[str, Any]:
648
- """
649
- Add a new MCP server configuration.
650
-
651
- Request body:
652
- {
653
- "name": "my-server",
654
- "transport": "http",
655
- "url": "https://...",
656
- "enabled": true
657
- }
658
-
659
- Returns:
660
- Success status
661
- """
662
- metrics.inc_counter("http_requests_total")
663
-
664
- try:
665
- body = await request.json()
666
- name = body.get("name")
667
- transport = body.get("transport")
668
-
669
- if not name or not transport:
670
- raise HTTPException(
671
- status_code=400,
672
- detail={"success": False, "error": "Required fields: name, transport"},
673
- )
674
-
675
- # Import here to avoid circular imports
676
- from gobby.mcp_proxy.models import MCPServerConfig
677
- from gobby.utils.project_context import get_project_context
678
-
679
- project_ctx = get_project_context()
680
- if not project_ctx or not project_ctx.get("id"):
681
- raise HTTPException(
682
- status_code=400,
683
- detail={
684
- "success": False,
685
- "error": "No current project found. Run 'gobby init'.",
686
- },
687
- )
688
- project_id = project_ctx["id"]
689
-
690
- config = MCPServerConfig(
691
- name=name,
692
- project_id=project_id,
693
- transport=transport,
694
- url=body.get("url"),
695
- command=body.get("command"),
696
- args=body.get("args"),
697
- env=body.get("env"),
698
- headers=body.get("headers"),
699
- enabled=body.get("enabled", True),
700
- )
701
-
702
- if server.mcp_manager is None:
703
- raise HTTPException(
704
- status_code=503,
705
- detail={"success": False, "error": "MCP manager not available"},
706
- )
707
-
708
- await server.mcp_manager.add_server(config)
709
-
710
- return {
711
- "success": True,
712
- "message": f"Added MCP server: {name}",
713
- }
714
-
715
- except ValueError as e:
716
- raise HTTPException(status_code=400, detail={"success": False, "error": str(e)}) from e
717
- except HTTPException:
718
- raise
719
- except Exception as e:
720
- metrics.inc_counter("http_requests_errors_total")
721
- logger.error(f"Add MCP server error: {e}", exc_info=True)
722
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
723
-
724
- @router.post("/servers/import")
725
- async def import_mcp_server(
726
- request: Request,
727
- server: "HTTPServer" = Depends(get_server),
728
- ) -> dict[str, Any]:
729
- """
730
- Import MCP server(s) from various sources.
731
-
732
- Request body:
733
- {
734
- "from_project": "other-project", # Import from project
735
- "github_url": "https://...", # Import from GitHub
736
- "query": "supabase mcp", # Search and import
737
- "servers": ["name1", "name2"] # Specific servers to import
738
- }
739
-
740
- Returns:
741
- Import result with imported/skipped/failed lists
742
- """
743
- metrics.inc_counter("http_requests_total")
744
-
745
- try:
746
- body = await request.json()
747
- from_project = body.get("from_project")
748
- github_url = body.get("github_url")
749
- query = body.get("query")
750
- servers = body.get("servers")
751
-
752
- if not from_project and not github_url and not query:
753
- raise HTTPException(
754
- status_code=400,
755
- detail={
756
- "success": False,
757
- "error": "Specify at least one: from_project, github_url, or query",
758
- },
759
- )
760
-
761
- # Get current project ID from context
762
- from gobby.utils.project_context import get_project_context
763
-
764
- project_ctx = get_project_context()
765
- if not project_ctx or not project_ctx.get("id"):
766
- raise HTTPException(
767
- status_code=400,
768
- detail={
769
- "success": False,
770
- "error": "No current project. Run 'gobby init' first.",
771
- },
772
- )
773
- current_project_id = project_ctx["id"]
774
-
775
- if not server.config:
776
- raise HTTPException(
777
- status_code=500,
778
- detail={"success": False, "error": "Daemon configuration not available"},
779
- )
780
-
781
- # Create importer
782
- from gobby.mcp_proxy.importer import MCPServerImporter
783
- from gobby.storage.database import LocalDatabase
784
-
785
- db = LocalDatabase()
786
- importer = MCPServerImporter(
787
- config=server.config,
788
- db=db,
789
- current_project_id=current_project_id,
790
- mcp_client_manager=server.mcp_manager,
791
- )
792
-
793
- # Execute import based on source
794
- if from_project:
795
- result = await importer.import_from_project(
796
- source_project=from_project,
797
- servers=servers,
798
- )
799
- elif github_url:
800
- result = await importer.import_from_github(github_url)
801
- elif query:
802
- result = await importer.import_from_query(query)
803
- else:
804
- result = {"success": False, "error": "No import source specified"}
805
-
806
- return result
807
-
808
- except HTTPException:
809
- raise
810
- except Exception as e:
811
- metrics.inc_counter("http_requests_errors_total")
812
- logger.error(f"Import MCP server error: {e}", exc_info=True)
813
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
814
-
815
- @router.delete("/servers/{name}")
816
- async def remove_mcp_server(
817
- name: str,
818
- server: "HTTPServer" = Depends(get_server),
819
- ) -> dict[str, Any]:
820
- """
821
- Remove an MCP server configuration.
822
-
823
- Args:
824
- name: Server name to remove
825
-
826
- Returns:
827
- Success status
828
- """
829
- metrics.inc_counter("http_requests_total")
830
-
831
- try:
832
- if server.mcp_manager is None:
833
- raise HTTPException(
834
- status_code=503,
835
- detail={"success": False, "error": "MCP manager not available"},
836
- )
837
-
838
- await server.mcp_manager.remove_server(name)
839
-
840
- return {
841
- "success": True,
842
- "message": f"Removed MCP server: {name}",
843
- }
844
-
845
- except ValueError as e:
846
- raise HTTPException(status_code=404, detail={"success": False, "error": str(e)}) from e
847
- except Exception as e:
848
- metrics.inc_counter("http_requests_errors_total")
849
- logger.error(f"Remove MCP server error: {e}", exc_info=True)
850
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
851
-
852
- @router.post("/tools/recommend")
853
- async def recommend_mcp_tools(
854
- request: Request,
855
- server: "HTTPServer" = Depends(get_server),
856
- ) -> dict[str, Any]:
857
- """
858
- Get AI-powered tool recommendations for a task.
859
-
860
- Request body:
861
- {
862
- "task_description": "I need to query a database",
863
- "agent_id": "optional-agent-id",
864
- "search_mode": "llm" | "semantic" | "hybrid",
865
- "top_k": 10,
866
- "min_similarity": 0.3,
867
- "cwd": "/path/to/project"
868
- }
869
-
870
- Returns:
871
- List of tool recommendations
872
- """
873
- start_time = time.perf_counter()
874
- metrics.inc_counter("http_requests_total")
875
-
876
- try:
877
- body = await request.json()
878
- task_description = body.get("task_description")
879
- agent_id = body.get("agent_id")
880
- search_mode = body.get("search_mode", "llm")
881
- top_k = body.get("top_k", 10)
882
- min_similarity = body.get("min_similarity", 0.3)
883
- cwd = body.get("cwd")
884
-
885
- if not task_description:
886
- raise HTTPException(
887
- status_code=400,
888
- detail={"success": False, "error": "Required field: task_description"},
889
- )
890
-
891
- # For semantic/hybrid modes, resolve project_id from cwd
892
- project_id = None
893
- if search_mode in ("semantic", "hybrid"):
894
- try:
895
- project_id = server._resolve_project_id(None, cwd)
896
- except ValueError as e:
897
- return {
898
- "success": False,
899
- "error": str(e),
900
- "task": task_description,
901
- "response_time_ms": (time.perf_counter() - start_time) * 1000,
902
- }
903
-
904
- # Use tools handler if available
905
- if server._tools_handler:
906
- result = await server._tools_handler.recommend_tools(
907
- task_description=task_description,
908
- agent_id=agent_id,
909
- search_mode=search_mode,
910
- top_k=top_k,
911
- min_similarity=min_similarity,
912
- project_id=project_id,
913
- )
914
- response_time_ms = (time.perf_counter() - start_time) * 1000
915
- result["response_time_ms"] = response_time_ms
916
- return result
917
-
918
- # Fallback: no tools handler
919
- return {
920
- "success": False,
921
- "error": "Tools handler not initialized",
922
- "recommendations": [],
923
- "response_time_ms": (time.perf_counter() - start_time) * 1000,
924
- }
925
-
926
- except HTTPException:
927
- raise
928
- except Exception as e:
929
- metrics.inc_counter("http_requests_errors_total")
930
- logger.error(f"Recommend tools error: {e}", exc_info=True)
931
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
932
-
933
- @router.post("/tools/search")
934
- async def search_mcp_tools(
935
- request: Request,
936
- server: "HTTPServer" = Depends(get_server),
937
- ) -> dict[str, Any]:
938
- """
939
- Search for tools using semantic similarity.
940
-
941
- Request body:
942
- {
943
- "query": "create a file",
944
- "top_k": 10,
945
- "min_similarity": 0.0,
946
- "server": "optional-server-filter",
947
- "cwd": "/path/to/project"
948
- }
949
-
950
- Returns:
951
- List of matching tools with similarity scores
952
- """
953
- start_time = time.perf_counter()
954
- metrics.inc_counter("http_requests_total")
955
-
956
- try:
957
- body = await request.json()
958
- query = body.get("query")
959
- top_k = body.get("top_k", 10)
960
- min_similarity = body.get("min_similarity", 0.0)
961
- server_filter = body.get("server")
962
- cwd = body.get("cwd")
963
-
964
- if not query:
965
- raise HTTPException(
966
- status_code=400,
967
- detail={"success": False, "error": "Required field: query"},
968
- )
969
-
970
- # Resolve project_id from cwd
971
- try:
972
- project_id = server._resolve_project_id(None, cwd)
973
- except ValueError as e:
974
- return {
975
- "success": False,
976
- "error": str(e),
977
- "query": query,
978
- "response_time_ms": (time.perf_counter() - start_time) * 1000,
979
- }
980
-
981
- # Use semantic search directly if available
982
- if server._tools_handler and server._tools_handler._semantic_search:
983
- try:
984
- semantic_search = server._tools_handler._semantic_search
985
-
986
- # Auto-generate embeddings if none exist
987
- existing = semantic_search.get_embeddings_for_project(project_id)
988
- if not existing and server._mcp_db_manager:
989
- logger.info(f"No embeddings for project {project_id}, generating...")
990
- await semantic_search.embed_all_tools(
991
- project_id=project_id,
992
- mcp_manager=server._mcp_db_manager,
993
- force=False,
994
- )
995
-
996
- results = await semantic_search.search_tools(
997
- query=query,
998
- project_id=project_id,
999
- top_k=top_k,
1000
- min_similarity=min_similarity,
1001
- server_filter=server_filter,
1002
- )
1003
- response_time_ms = (time.perf_counter() - start_time) * 1000
1004
- return {
1005
- "success": True,
1006
- "query": query,
1007
- "results": [r.to_dict() for r in results],
1008
- "total_results": len(results),
1009
- "response_time_ms": response_time_ms,
1010
- }
1011
- except Exception as e:
1012
- logger.error(f"Semantic search failed: {e}")
1013
- return {
1014
- "success": False,
1015
- "error": str(e),
1016
- "query": query,
1017
- "response_time_ms": (time.perf_counter() - start_time) * 1000,
1018
- }
1019
-
1020
- # Fallback: no semantic search
1021
- return {
1022
- "success": False,
1023
- "error": "Semantic search not configured",
1024
- "results": [],
1025
- "response_time_ms": (time.perf_counter() - start_time) * 1000,
1026
- }
1027
-
1028
- except HTTPException:
1029
- raise
1030
- except Exception as e:
1031
- metrics.inc_counter("http_requests_errors_total")
1032
- logger.error(f"Search tools error: {e}", exc_info=True)
1033
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
1034
-
1035
- @router.post("/tools/embed")
1036
- async def embed_mcp_tools(
1037
- request: Request,
1038
- server: "HTTPServer" = Depends(get_server),
1039
- ) -> dict[str, Any]:
1040
- """
1041
- Generate embeddings for all tools in a project.
1042
-
1043
- Request body:
1044
- {
1045
- "cwd": "/path/to/project",
1046
- "force": false
1047
- }
1048
-
1049
- Returns:
1050
- Embedding generation stats
1051
- """
1052
- start_time = time.perf_counter()
1053
- metrics.inc_counter("http_requests_total")
1054
-
1055
- try:
1056
- body = await request.json()
1057
- cwd = body.get("cwd")
1058
- force = body.get("force", False)
1059
-
1060
- # Resolve project_id from cwd
1061
- try:
1062
- project_id = server._resolve_project_id(None, cwd)
1063
- except ValueError as e:
1064
- return {
1065
- "success": False,
1066
- "error": str(e),
1067
- "response_time_ms": (time.perf_counter() - start_time) * 1000,
1068
- }
1069
-
1070
- # Use semantic search to embed all tools
1071
- if server._tools_handler and server._tools_handler._semantic_search:
1072
- try:
1073
- stats = await server._tools_handler._semantic_search.embed_all_tools(
1074
- project_id=project_id,
1075
- mcp_manager=server._mcp_db_manager,
1076
- force=force,
1077
- )
1078
- response_time_ms = (time.perf_counter() - start_time) * 1000
1079
- return {
1080
- "success": True,
1081
- "stats": stats,
1082
- "response_time_ms": response_time_ms,
1083
- }
1084
- except Exception as e:
1085
- logger.error(f"Embedding generation failed: {e}")
1086
- return {
1087
- "success": False,
1088
- "error": str(e),
1089
- "response_time_ms": (time.perf_counter() - start_time) * 1000,
1090
- }
1091
-
1092
- return {
1093
- "success": False,
1094
- "error": "Semantic search not configured",
1095
- "response_time_ms": (time.perf_counter() - start_time) * 1000,
1096
- }
1097
-
1098
- except HTTPException:
1099
- raise
1100
- except Exception as e:
1101
- metrics.inc_counter("http_requests_errors_total")
1102
- logger.error(f"Embed tools error: {e}", exc_info=True)
1103
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
1104
-
1105
- @router.get("/status")
1106
- async def get_mcp_status(
1107
- server: "HTTPServer" = Depends(get_server),
1108
- ) -> dict[str, Any]:
1109
- """
1110
- Get MCP proxy status and health.
1111
-
1112
- Returns:
1113
- Status summary with server counts and health info
1114
- """
1115
- start_time = time.perf_counter()
1116
- metrics.inc_counter("http_requests_total")
1117
-
1118
- try:
1119
- total_servers = 0
1120
- connected_servers = 0
1121
- cached_tools = 0
1122
- server_health: dict[str, dict[str, Any]] = {}
1123
-
1124
- # Count internal servers
1125
- if server._internal_manager:
1126
- for registry in server._internal_manager.get_all_registries():
1127
- total_servers += 1
1128
- connected_servers += 1
1129
- cached_tools += len(registry.list_tools())
1130
- server_health[registry.name] = {
1131
- "state": "connected",
1132
- "health": "healthy",
1133
- "failures": 0,
1134
- }
1135
-
1136
- # Count external servers
1137
- if server.mcp_manager:
1138
- for config in server.mcp_manager.server_configs:
1139
- total_servers += 1
1140
- health = server.mcp_manager.health.get(config.name)
1141
- is_connected = config.name in server.mcp_manager.connections
1142
- if is_connected:
1143
- connected_servers += 1
1144
-
1145
- server_health[config.name] = {
1146
- "state": health.state.value if health else "unknown",
1147
- "health": health.health.value if health else "unknown",
1148
- "failures": health.consecutive_failures if health else 0,
1149
- }
1150
-
1151
- response_time_ms = (time.perf_counter() - start_time) * 1000
1152
-
1153
- return {
1154
- "total_servers": total_servers,
1155
- "connected_servers": connected_servers,
1156
- "cached_tools": cached_tools,
1157
- "server_health": server_health,
1158
- "response_time_ms": response_time_ms,
1159
- }
1160
-
1161
- except Exception as e:
1162
- metrics.inc_counter("http_requests_errors_total")
1163
- logger.error(f"Get MCP status error: {e}", exc_info=True)
1164
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
1165
-
1166
- @router.post("/{server_name}/tools/{tool_name}")
1167
- async def mcp_proxy(
1168
- server_name: str,
1169
- tool_name: str,
1170
- request: Request,
1171
- server: "HTTPServer" = Depends(get_server),
1172
- ) -> dict[str, Any]:
1173
- """
1174
- Unified MCP proxy endpoint for calling MCP server tools.
1175
-
1176
- Args:
1177
- server_name: Name of the MCP server
1178
- tool_name: Name of the tool to call
1179
- request: FastAPI request with tool arguments in body
1180
-
1181
- Returns:
1182
- Tool execution result
1183
- """
1184
- start_time = time.perf_counter()
1185
- metrics.inc_counter("http_requests_total")
1186
- metrics.inc_counter("mcp_tool_calls_total")
1187
-
1188
- try:
1189
- # Parse request body as tool arguments
1190
- try:
1191
- args = await request.json()
1192
- except (json.JSONDecodeError, ValueError) as e:
1193
- raise HTTPException(
1194
- status_code=400,
1195
- detail={"success": False, "error": f"Invalid JSON in request body: {e}"},
1196
- ) from e
1197
-
1198
- # Route through ToolProxyService for consistent error enrichment
1199
- if server.tool_proxy:
1200
- result = await server.tool_proxy.call_tool(server_name, tool_name, args)
1201
- response_time_ms = (time.perf_counter() - start_time) * 1000
1202
- return _process_tool_proxy_result(
1203
- result, server_name, tool_name, response_time_ms, metrics
1204
- )
1205
-
1206
- # Fallback: no tool_proxy available, use direct registry calls
1207
- # Check internal registries first (gobby-tasks, gobby-memory, etc.)
1208
- if server._internal_manager and server._internal_manager.is_internal(server_name):
1209
- registry = server._internal_manager.get_registry(server_name)
1210
- if registry:
1211
- # Check if tool exists before calling - return helpful 404 if not
1212
- if not registry.get_schema(tool_name):
1213
- available = [t["name"] for t in registry.list_tools()]
1214
- raise HTTPException(
1215
- status_code=404,
1216
- detail={
1217
- "success": False,
1218
- "error": f"Tool '{tool_name}' not found on '{server_name}'. "
1219
- f"Available: {', '.join(available)}. "
1220
- f"Use list_tools(server='{server_name}') to see all tools, "
1221
- f"or get_tool_schema(server_name='{server_name}', tool_name='...') for full schema.",
1222
- },
1223
- )
1224
- try:
1225
- result = await registry.call(tool_name, args or {})
1226
- response_time_ms = (time.perf_counter() - start_time) * 1000
1227
- metrics.inc_counter("mcp_tool_calls_succeeded_total")
1228
- return {
1229
- "success": True,
1230
- "result": result,
1231
- "response_time_ms": response_time_ms,
1232
- }
1233
- except Exception as e:
1234
- metrics.inc_counter("mcp_tool_calls_failed_total")
1235
- error_msg = str(e) or f"{type(e).__name__}: (no message)"
1236
- raise HTTPException(
1237
- status_code=500, detail={"success": False, "error": error_msg}
1238
- ) from e
1239
- raise HTTPException(
1240
- status_code=404,
1241
- detail={
1242
- "success": False,
1243
- "error": f"Internal server '{server_name}' not found",
1244
- },
1245
- )
1246
-
1247
- if server.mcp_manager is None:
1248
- raise HTTPException(
1249
- status_code=503,
1250
- detail={"success": False, "error": "MCP manager not available"},
1251
- )
1252
-
1253
- # Call MCP tool
1254
- try:
1255
- result = await server.mcp_manager.call_tool(server_name, tool_name, args)
1256
-
1257
- response_time_ms = (time.perf_counter() - start_time) * 1000
1258
-
1259
- logger.debug(
1260
- f"MCP tool call successful: {server_name}.{tool_name}",
1261
- extra={
1262
- "server": server_name,
1263
- "tool": tool_name,
1264
- "response_time_ms": response_time_ms,
1265
- },
1266
- )
1267
-
1268
- metrics.inc_counter("mcp_tool_calls_succeeded_total")
1269
-
1270
- return {
1271
- "success": True,
1272
- "result": result,
1273
- "response_time_ms": response_time_ms,
1274
- }
1275
-
1276
- except ValueError as e:
1277
- metrics.inc_counter("mcp_tool_calls_failed_total")
1278
- logger.warning(
1279
- f"MCP tool not found: {server_name}.{tool_name}",
1280
- extra={"server": server_name, "tool": tool_name, "error": str(e)},
1281
- )
1282
- raise HTTPException(
1283
- status_code=404, detail={"success": False, "error": str(e)}
1284
- ) from e
1285
- except Exception as e:
1286
- metrics.inc_counter("mcp_tool_calls_failed_total")
1287
- error_msg = str(e) or f"{type(e).__name__}: (no message)"
1288
- logger.error(
1289
- f"MCP tool call error: {server_name}.{tool_name}",
1290
- exc_info=True,
1291
- extra={"server": server_name, "tool": tool_name},
1292
- )
1293
- raise HTTPException(
1294
- status_code=500, detail={"success": False, "error": error_msg}
1295
- ) from e
1296
-
1297
- except HTTPException:
1298
- raise
1299
- except Exception as e:
1300
- metrics.inc_counter("mcp_tool_calls_failed_total")
1301
- error_msg = str(e) or f"{type(e).__name__}: (no message)"
1302
- logger.error(f"MCP proxy error: {server_name}.{tool_name}", exc_info=True)
1303
- raise HTTPException(
1304
- status_code=500, detail={"success": False, "error": error_msg}
1305
- ) from e
1306
-
1307
- @router.post("/refresh")
1308
- async def refresh_mcp_tools(
1309
- request: Request,
1310
- server: "HTTPServer" = Depends(get_server),
1311
- ) -> dict[str, Any]:
1312
- """
1313
- Refresh MCP tools - detect schema changes and re-index as needed.
1314
-
1315
- Request body:
1316
- {
1317
- "cwd": "/path/to/project",
1318
- "force": false,
1319
- "server": "optional-server-filter"
1320
- }
1321
-
1322
- Returns:
1323
- Refresh stats with new/changed/unchanged tool counts
1324
- """
1325
- start_time = time.perf_counter()
1326
- metrics.inc_counter("http_requests_total")
1327
-
1328
- try:
1329
- body = await request.json()
1330
- cwd = body.get("cwd")
1331
- force = body.get("force", False)
1332
- server_filter = body.get("server")
1333
-
1334
- # Resolve project_id from cwd
1335
- try:
1336
- project_id = server._resolve_project_id(None, cwd)
1337
- except ValueError as e:
1338
- return {
1339
- "success": False,
1340
- "error": str(e),
1341
- "response_time_ms": (time.perf_counter() - start_time) * 1000,
1342
- }
1343
-
1344
- # Need schema hash manager and semantic search
1345
- if not server._mcp_db_manager:
1346
- return {
1347
- "success": False,
1348
- "error": "MCP database manager not configured",
1349
- "response_time_ms": (time.perf_counter() - start_time) * 1000,
1350
- }
1351
-
1352
- from gobby.mcp_proxy.schema_hash import SchemaHashManager, compute_schema_hash
1353
-
1354
- schema_hash_manager = SchemaHashManager(db=server._mcp_db_manager.db)
1355
- semantic_search = (
1356
- getattr(server._tools_handler, "_semantic_search", None)
1357
- if server._tools_handler
1358
- else None
1359
- )
1360
-
1361
- stats: dict[str, Any] = {
1362
- "servers_processed": 0,
1363
- "tools_new": 0,
1364
- "tools_changed": 0,
1365
- "tools_unchanged": 0,
1366
- "tools_removed": 0,
1367
- "embeddings_generated": 0,
1368
- "by_server": {},
1369
- }
1370
-
1371
- # Collect servers to process
1372
- servers_to_process: list[str] = []
1373
-
1374
- # Internal servers
1375
- if server._internal_manager:
1376
- for registry in server._internal_manager.get_all_registries():
1377
- if server_filter is None or registry.name == server_filter:
1378
- servers_to_process.append(registry.name)
1379
-
1380
- # External MCP servers
1381
- if server.mcp_manager:
1382
- for config in server.mcp_manager.server_configs:
1383
- if config.enabled:
1384
- if server_filter is None or config.name == server_filter:
1385
- servers_to_process.append(config.name)
1386
-
1387
- # Process each server
1388
- for server_name in servers_to_process:
1389
- try:
1390
- tools: list[dict[str, Any]] = []
1391
-
1392
- # Get tools from internal or external server
1393
- if server._internal_manager and server._internal_manager.is_internal(
1394
- server_name
1395
- ):
1396
- internal_registry = server._internal_manager.get_registry(server_name)
1397
- if internal_registry:
1398
- for t in internal_registry.list_tools():
1399
- tool_name = t.get("name", "")
1400
- tools.append(
1401
- {
1402
- "name": tool_name,
1403
- "description": t.get("description"),
1404
- "inputSchema": internal_registry.get_schema(tool_name),
1405
- }
1406
- )
1407
- elif server.mcp_manager:
1408
- try:
1409
- session = await server.mcp_manager.ensure_connected(server_name)
1410
- tools_result = await session.list_tools()
1411
- for t in tools_result.tools:
1412
- schema = None
1413
- if hasattr(t, "inputSchema"):
1414
- if hasattr(t.inputSchema, "model_dump"):
1415
- schema = t.inputSchema.model_dump()
1416
- elif isinstance(t.inputSchema, dict):
1417
- schema = t.inputSchema
1418
- tools.append(
1419
- {
1420
- "name": getattr(t, "name", ""),
1421
- "description": getattr(t, "description", ""),
1422
- "inputSchema": schema,
1423
- }
1424
- )
1425
- except Exception as e:
1426
- logger.warning(f"Failed to connect to {server_name}: {e}")
1427
- stats["by_server"][server_name] = {"error": str(e)}
1428
- continue
1429
-
1430
- # Check for schema changes
1431
- if force:
1432
- # Force mode: treat all as new
1433
- changes = {
1434
- "new": [t["name"] for t in tools],
1435
- "changed": [],
1436
- "unchanged": [],
1437
- }
1438
- else:
1439
- changes = schema_hash_manager.check_tools_for_changes(
1440
- server_name=server_name,
1441
- project_id=project_id,
1442
- tools=tools,
1443
- )
1444
-
1445
- server_stats = {
1446
- "new": len(changes["new"]),
1447
- "changed": len(changes["changed"]),
1448
- "unchanged": len(changes["unchanged"]),
1449
- "removed": 0,
1450
- "embeddings": 0,
1451
- }
1452
-
1453
- # Update schema hashes for new/changed tools
1454
- tools_to_embed = []
1455
- for tool in tools:
1456
- tool_name = tool["name"]
1457
- if tool_name in changes["new"] or tool_name in changes["changed"]:
1458
- schema = tool.get("inputSchema")
1459
- schema_hash = compute_schema_hash(schema)
1460
- schema_hash_manager.store_hash(
1461
- server_name=server_name,
1462
- tool_name=tool_name,
1463
- project_id=project_id,
1464
- schema_hash=schema_hash,
1465
- )
1466
- tools_to_embed.append(tool)
1467
- else:
1468
- # Just update verification time for unchanged
1469
- schema_hash_manager.update_verification_time(
1470
- server_name=server_name,
1471
- tool_name=tool_name,
1472
- project_id=project_id,
1473
- )
1474
-
1475
- # Clean up stale hashes
1476
- valid_tool_names = [t["name"] for t in tools]
1477
- removed = schema_hash_manager.cleanup_stale_hashes(
1478
- server_name=server_name,
1479
- project_id=project_id,
1480
- valid_tool_names=valid_tool_names,
1481
- )
1482
- server_stats["removed"] = removed
1483
-
1484
- # Generate embeddings for new/changed tools
1485
- if semantic_search and tools_to_embed:
1486
- for tool in tools_to_embed:
1487
- try:
1488
- await semantic_search.embed_tool(
1489
- server_name=server_name,
1490
- tool_name=tool["name"],
1491
- description=tool.get("description", ""),
1492
- input_schema=tool.get("inputSchema"),
1493
- project_id=project_id,
1494
- )
1495
- server_stats["embeddings"] += 1
1496
- except Exception as e:
1497
- logger.warning(f"Failed to embed {server_name}/{tool['name']}: {e}")
1498
-
1499
- stats["by_server"][server_name] = server_stats
1500
- stats["servers_processed"] += 1
1501
- stats["tools_new"] += server_stats["new"]
1502
- stats["tools_changed"] += server_stats["changed"]
1503
- stats["tools_unchanged"] += server_stats["unchanged"]
1504
- stats["tools_removed"] += server_stats["removed"]
1505
- stats["embeddings_generated"] += server_stats["embeddings"]
1506
-
1507
- except Exception as e:
1508
- logger.error(f"Error processing server {server_name}: {e}")
1509
- stats["by_server"][server_name] = {"error": str(e)}
1510
-
1511
- response_time_ms = (time.perf_counter() - start_time) * 1000
1512
- return {
1513
- "success": True,
1514
- "force": force,
1515
- "stats": stats,
1516
- "response_time_ms": response_time_ms,
1517
- }
1518
45
 
1519
- except HTTPException:
1520
- raise
1521
- except Exception as e:
1522
- metrics.inc_counter("http_requests_errors_total")
1523
- logger.error(f"Refresh tools error: {e}", exc_info=True)
1524
- raise HTTPException(status_code=500, detail={"success": False, "error": str(e)}) from e
46
+ # Execution endpoints from endpoints/execution.py
47
+ router.get("/{server_name}/tools")(list_mcp_tools)
48
+ router.post("/tools/schema")(get_tool_schema)
49
+ router.post("/tools/call")(call_mcp_tool)
50
+ router.post("/{server_name}/tools/{tool_name}")(mcp_proxy)
51
+
52
+ # Server management endpoints from endpoints/server.py
53
+ router.get("/servers")(list_mcp_servers)
54
+ router.post("/servers")(add_mcp_server)
55
+ router.post("/servers/import")(import_mcp_server)
56
+ router.delete("/servers/{name}")(remove_mcp_server)
57
+
58
+ # Discovery endpoints from endpoints/discovery.py
59
+ router.get("/tools")(list_all_mcp_tools)
60
+ router.post("/tools/recommend")(recommend_mcp_tools)
61
+ router.post("/tools/search")(search_mcp_tools)
62
+
63
+ # Registry endpoints from endpoints/registry.py
64
+ router.post("/tools/embed")(embed_mcp_tools)
65
+ router.get("/status")(get_mcp_status)
66
+ router.post("/refresh")(refresh_mcp_tools)
1525
67
 
1526
68
  return router