gobby 0.2.5__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 (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
gobby/storage/mcp.py ADDED
@@ -0,0 +1,680 @@
1
+ """Local MCP server and tool storage manager."""
2
+
3
+ import json
4
+ import logging
5
+ import uuid
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from gobby.storage.database import DatabaseProtocol
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class MCPServer:
18
+ """MCP server configuration model."""
19
+
20
+ id: str
21
+ name: str
22
+ transport: str
23
+ url: str | None
24
+ command: str | None
25
+ args: list[str] | None
26
+ env: dict[str, str] | None
27
+ headers: dict[str, str] | None
28
+ enabled: bool
29
+ description: str | None
30
+ created_at: str
31
+ updated_at: str
32
+ project_id: str # Required - all servers must belong to a project
33
+
34
+ @classmethod
35
+ def from_row(cls, row: Any) -> "MCPServer":
36
+ """Create MCPServer from database row."""
37
+ return cls(
38
+ id=row["id"],
39
+ name=row["name"],
40
+ transport=row["transport"],
41
+ url=row["url"],
42
+ command=row["command"],
43
+ args=json.loads(row["args"]) if row["args"] else None,
44
+ env=json.loads(row["env"]) if row["env"] else None,
45
+ headers=json.loads(row["headers"]) if row["headers"] else None,
46
+ enabled=bool(row["enabled"]),
47
+ description=row["description"],
48
+ created_at=row["created_at"],
49
+ updated_at=row["updated_at"],
50
+ project_id=row["project_id"],
51
+ )
52
+
53
+ def to_dict(self) -> dict[str, Any]:
54
+ """Convert to dictionary."""
55
+ return {
56
+ "id": self.id,
57
+ "name": self.name,
58
+ "project_id": self.project_id,
59
+ "transport": self.transport,
60
+ "url": self.url,
61
+ "command": self.command,
62
+ "args": self.args,
63
+ "env": self.env,
64
+ "headers": self.headers,
65
+ "enabled": self.enabled,
66
+ "description": self.description,
67
+ "created_at": self.created_at,
68
+ "updated_at": self.updated_at,
69
+ }
70
+
71
+ def to_config(self) -> dict[str, Any]:
72
+ """Convert to MCP config format."""
73
+ config: dict[str, Any] = {
74
+ "name": self.name,
75
+ "transport": self.transport,
76
+ "enabled": self.enabled,
77
+ }
78
+ if self.project_id:
79
+ config["project_id"] = self.project_id
80
+ if self.url:
81
+ config["url"] = self.url
82
+ if self.command:
83
+ config["command"] = self.command
84
+ if self.args:
85
+ config["args"] = self.args
86
+ if self.env:
87
+ config["env"] = self.env
88
+ if self.headers:
89
+ config["headers"] = self.headers
90
+ if self.description:
91
+ config["description"] = self.description
92
+ return config
93
+
94
+
95
+ @dataclass
96
+ class Tool:
97
+ """MCP tool model."""
98
+
99
+ id: str
100
+ mcp_server_id: str
101
+ name: str
102
+ description: str | None
103
+ input_schema: dict[str, Any] | None
104
+ created_at: str
105
+ updated_at: str
106
+
107
+ @classmethod
108
+ def from_row(cls, row: Any) -> "Tool":
109
+ """Create Tool from database row."""
110
+ return cls(
111
+ id=row["id"],
112
+ mcp_server_id=row["mcp_server_id"],
113
+ name=row["name"],
114
+ description=row["description"],
115
+ input_schema=json.loads(row["input_schema"]) if row["input_schema"] else None,
116
+ created_at=row["created_at"],
117
+ updated_at=row["updated_at"],
118
+ )
119
+
120
+ def to_dict(self) -> dict[str, Any]:
121
+ """Convert to dictionary."""
122
+ return {
123
+ "id": self.id,
124
+ "mcp_server_id": self.mcp_server_id,
125
+ "name": self.name,
126
+ "description": self.description,
127
+ "input_schema": self.input_schema,
128
+ "created_at": self.created_at,
129
+ "updated_at": self.updated_at,
130
+ }
131
+
132
+
133
+ class LocalMCPManager:
134
+ """Manager for local MCP server and tool storage."""
135
+
136
+ def __init__(self, db: DatabaseProtocol):
137
+ """Initialize with database connection."""
138
+ self.db = db
139
+
140
+ def upsert(
141
+ self,
142
+ name: str,
143
+ transport: str,
144
+ project_id: str,
145
+ url: str | None = None,
146
+ command: str | None = None,
147
+ args: list[str] | None = None,
148
+ env: dict[str, str] | None = None,
149
+ headers: dict[str, str] | None = None,
150
+ enabled: bool = True,
151
+ description: str | None = None,
152
+ ) -> MCPServer:
153
+ """
154
+ Insert or update an MCP server in the database.
155
+
156
+ Server name is normalized to lowercase.
157
+ Uniqueness is enforced on (name, project_id) - same name can exist
158
+ in different projects.
159
+
160
+ Args:
161
+ name: Server name (normalized to lowercase)
162
+ transport: Transport type (http, stdio, websocket)
163
+ project_id: Required project ID - all servers must belong to a project
164
+ """
165
+ # Normalize server name to lowercase
166
+ name = name.lower()
167
+
168
+ server_id = str(uuid.uuid4())
169
+ now = datetime.now(UTC).isoformat()
170
+
171
+ self.db.execute(
172
+ """
173
+ INSERT INTO mcp_servers (
174
+ id, name, project_id, transport, url, command, args, env, headers,
175
+ enabled, description, created_at, updated_at
176
+ )
177
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
178
+ ON CONFLICT(name, project_id) DO UPDATE SET
179
+ transport = excluded.transport,
180
+ url = excluded.url,
181
+ command = excluded.command,
182
+ args = excluded.args,
183
+ env = excluded.env,
184
+ headers = excluded.headers,
185
+ enabled = excluded.enabled,
186
+ description = COALESCE(excluded.description, description),
187
+ updated_at = excluded.updated_at
188
+ """,
189
+ (
190
+ server_id,
191
+ name,
192
+ project_id,
193
+ transport,
194
+ url,
195
+ command,
196
+ json.dumps(args) if args else None,
197
+ json.dumps(env) if env else None,
198
+ json.dumps(headers) if headers else None,
199
+ 1 if enabled else 0,
200
+ description,
201
+ now,
202
+ now,
203
+ ),
204
+ )
205
+
206
+ server = self.get_server(name, project_id=project_id)
207
+ if not server:
208
+ raise RuntimeError(f"Failed to retrieve server '{name}' after upsert")
209
+ return server
210
+
211
+ def get_server(self, name: str, project_id: str) -> MCPServer | None:
212
+ """
213
+ Get server by name (case-insensitive lookup).
214
+
215
+ Args:
216
+ name: Server name
217
+ project_id: Required project ID
218
+ """
219
+ # Normalize to lowercase for lookup
220
+ name = name.lower()
221
+
222
+ row = self.db.fetchone(
223
+ "SELECT * FROM mcp_servers WHERE name = ? AND project_id = ?",
224
+ (name, project_id),
225
+ )
226
+
227
+ return MCPServer.from_row(row) if row else None
228
+
229
+ def get_server_by_id(self, server_id: str) -> MCPServer | None:
230
+ """Get server by ID."""
231
+ row = self.db.fetchone("SELECT * FROM mcp_servers WHERE id = ?", (server_id,))
232
+ return MCPServer.from_row(row) if row else None
233
+
234
+ def list_servers(
235
+ self,
236
+ project_id: str,
237
+ enabled_only: bool = True,
238
+ ) -> list[MCPServer]:
239
+ """
240
+ List MCP servers for a project.
241
+
242
+ Args:
243
+ project_id: Required project ID
244
+ enabled_only: Only return enabled servers
245
+
246
+ Returns:
247
+ List of servers for the project.
248
+ """
249
+ conditions = ["project_id = ?"]
250
+ params: list[Any] = [project_id]
251
+
252
+ if enabled_only:
253
+ conditions.append("enabled = 1")
254
+
255
+ # nosec B608: where_clause built from hardcoded condition strings, values parameterized
256
+ where_clause = " AND ".join(conditions)
257
+ query = f"SELECT * FROM mcp_servers WHERE {where_clause} ORDER BY name" # nosec B608
258
+ rows = self.db.fetchall(query, tuple(params))
259
+
260
+ return [MCPServer.from_row(row) for row in rows]
261
+
262
+ def list_all_servers(self, enabled_only: bool = True) -> list[MCPServer]:
263
+ """
264
+ List all MCP servers across all projects.
265
+
266
+ Used by the daemon to load all servers on startup.
267
+
268
+ Args:
269
+ enabled_only: Only return enabled servers
270
+
271
+ Returns:
272
+ List of all servers.
273
+ """
274
+ if enabled_only:
275
+ query = "SELECT * FROM mcp_servers WHERE enabled = 1 ORDER BY name"
276
+ else:
277
+ query = "SELECT * FROM mcp_servers ORDER BY name"
278
+ rows = self.db.fetchall(query, ())
279
+ return [MCPServer.from_row(row) for row in rows]
280
+
281
+ def update_server(self, name: str, project_id: str, **fields: Any) -> MCPServer | None:
282
+ """
283
+ Update server fields.
284
+
285
+ Args:
286
+ name: Server name
287
+ project_id: Required project ID
288
+ """
289
+ server = self.get_server(name, project_id=project_id)
290
+ if not server:
291
+ return None
292
+
293
+ allowed = {
294
+ "transport",
295
+ "url",
296
+ "command",
297
+ "args",
298
+ "env",
299
+ "headers",
300
+ "enabled",
301
+ "description",
302
+ }
303
+ fields = {k: v for k, v in fields.items() if k in allowed}
304
+ if not fields:
305
+ return server
306
+
307
+ # Serialize JSON fields
308
+ if "args" in fields and fields["args"] is not None:
309
+ fields["args"] = json.dumps(fields["args"])
310
+ if "env" in fields and fields["env"] is not None:
311
+ fields["env"] = json.dumps(fields["env"])
312
+ if "headers" in fields and fields["headers"] is not None:
313
+ fields["headers"] = json.dumps(fields["headers"])
314
+ if "enabled" in fields:
315
+ fields["enabled"] = 1 if fields["enabled"] else 0
316
+
317
+ fields["updated_at"] = datetime.now(UTC).isoformat()
318
+
319
+ # nosec B608: Fields validated against allowlist above, values parameterized
320
+ set_clause = ", ".join(f"{k} = ?" for k in fields)
321
+ # Update by server ID to be precise
322
+ values = list(fields.values()) + [server.id]
323
+
324
+ self.db.execute(
325
+ f"UPDATE mcp_servers SET {set_clause} WHERE id = ?", # nosec B608
326
+ tuple(values),
327
+ )
328
+
329
+ return self.get_server(name, project_id=project_id)
330
+
331
+ def remove_server(self, name: str, project_id: str) -> bool:
332
+ """
333
+ Remove server by name (cascades to tools). Case-insensitive.
334
+
335
+ Args:
336
+ name: Server name
337
+ project_id: Required project ID
338
+ """
339
+ name = name.lower()
340
+ cursor = self.db.execute(
341
+ "DELETE FROM mcp_servers WHERE name = ? AND project_id = ?",
342
+ (name, project_id),
343
+ )
344
+ return cursor.rowcount > 0
345
+
346
+ def cache_tools(self, server_name: str, tools: list[dict[str, Any]], project_id: str) -> int:
347
+ """
348
+ Cache tools for a server.
349
+
350
+ Replaces existing tools for the server.
351
+
352
+ Args:
353
+ server_name: Server name
354
+ tools: List of tool definitions with name, description, and inputSchema (or args)
355
+ project_id: Required project ID
356
+
357
+ Returns:
358
+ Number of tools cached
359
+ """
360
+ server = self.get_server(server_name, project_id=project_id)
361
+ if not server:
362
+ logger.warning(f"Server not found: {server_name}")
363
+ return 0
364
+
365
+ # Delete existing tools
366
+ self.db.execute("DELETE FROM tools WHERE mcp_server_id = ?", (server.id,))
367
+
368
+ # Insert new tools
369
+ now = datetime.now(UTC).isoformat()
370
+ for tool in tools:
371
+ tool_id = str(uuid.uuid4())
372
+ # Handle both 'inputSchema' and 'args' keys (internal vs MCP standard)
373
+ input_schema = tool.get("inputSchema") or tool.get("args")
374
+ # Normalize tool name to lowercase
375
+ tool_name = (tool.get("name") or "").lower()
376
+ self.db.execute(
377
+ """
378
+ INSERT INTO tools (id, mcp_server_id, name, description, input_schema, created_at, updated_at)
379
+ VALUES (?, ?, ?, ?, ?, ?, ?)
380
+ """,
381
+ (
382
+ tool_id,
383
+ server.id,
384
+ tool_name,
385
+ tool.get("description"),
386
+ json.dumps(input_schema) if input_schema else None,
387
+ now,
388
+ now,
389
+ ),
390
+ )
391
+
392
+ return len(tools)
393
+
394
+ def get_cached_tools(self, server_name: str, project_id: str) -> list[Tool]:
395
+ """
396
+ Get cached tools for a server.
397
+
398
+ Args:
399
+ server_name: Server name
400
+ project_id: Required project ID
401
+ """
402
+ server = self.get_server(server_name, project_id=project_id)
403
+ if not server:
404
+ return []
405
+
406
+ rows = self.db.fetchall(
407
+ "SELECT * FROM tools WHERE mcp_server_id = ? ORDER BY name",
408
+ (server.id,),
409
+ )
410
+ return [Tool.from_row(row) for row in rows]
411
+
412
+ def refresh_tools_incremental(
413
+ self,
414
+ server_name: str,
415
+ tools: list[dict[str, Any]],
416
+ project_id: str,
417
+ schema_hash_manager: Any | None = None,
418
+ ) -> dict[str, Any]:
419
+ """
420
+ Incrementally refresh tools for a server.
421
+
422
+ Only updates tools that have changed based on schema hash comparison.
423
+ New tools are added, changed tools are updated, removed tools are deleted.
424
+
425
+ Args:
426
+ server_name: Server name
427
+ tools: List of current tool definitions from the server
428
+ project_id: Required project ID
429
+ schema_hash_manager: Optional SchemaHashManager for change detection.
430
+ If not provided, falls back to full cache_tools() behavior.
431
+
432
+ Returns:
433
+ Dict with refresh statistics:
434
+ - added: number of new tools added
435
+ - updated: number of changed tools updated
436
+ - removed: number of stale tools removed
437
+ - unchanged: number of unchanged tools skipped
438
+ - total: total tools after refresh
439
+ """
440
+ from gobby.mcp_proxy.schema_hash import compute_schema_hash
441
+
442
+ server = self.get_server(server_name, project_id=project_id)
443
+ if not server:
444
+ logger.warning(f"Server not found: {server_name}")
445
+ return {"added": 0, "updated": 0, "removed": 0, "unchanged": 0, "total": 0}
446
+
447
+ stats = {"added": 0, "updated": 0, "removed": 0, "unchanged": 0}
448
+ now = datetime.now(UTC).isoformat()
449
+
450
+ # Build map of current tools by name
451
+ current_tool_names = set()
452
+ for tool in tools:
453
+ tool_name = (tool.get("name") or "").lower()
454
+ current_tool_names.add(tool_name)
455
+
456
+ # Get existing tools
457
+ existing_tools = {t.name: t for t in self.get_cached_tools(server_name, project_id)}
458
+
459
+ # Detect changes using schema hash if manager available
460
+ if schema_hash_manager:
461
+ changes = schema_hash_manager.check_tools_for_changes(server_name, project_id, tools)
462
+ new_tools = set(changes["new"])
463
+ changed_tools = set(changes["changed"])
464
+ else:
465
+ # Without hash manager, treat all as potentially changed
466
+ new_tools = current_tool_names - set(existing_tools.keys())
467
+ changed_tools = current_tool_names & set(existing_tools.keys())
468
+
469
+ # Process each tool
470
+ for tool in tools:
471
+ tool_name = (tool.get("name") or "").lower()
472
+ input_schema = tool.get("inputSchema") or tool.get("args")
473
+
474
+ if tool_name in new_tools:
475
+ # Add new tool
476
+ tool_id = str(uuid.uuid4())
477
+ self.db.execute(
478
+ """
479
+ INSERT INTO tools (id, mcp_server_id, name, description, input_schema, created_at, updated_at)
480
+ VALUES (?, ?, ?, ?, ?, ?, ?)
481
+ """,
482
+ (
483
+ tool_id,
484
+ server.id,
485
+ tool_name,
486
+ tool.get("description"),
487
+ json.dumps(input_schema) if input_schema else None,
488
+ now,
489
+ now,
490
+ ),
491
+ )
492
+ stats["added"] += 1
493
+
494
+ # Store hash for new tool
495
+ if schema_hash_manager:
496
+ schema_hash = compute_schema_hash(input_schema)
497
+ schema_hash_manager.store_hash(server_name, tool_name, project_id, schema_hash)
498
+
499
+ elif tool_name in changed_tools:
500
+ # Update changed tool
501
+ existing = existing_tools[tool_name]
502
+ self.db.execute(
503
+ """
504
+ UPDATE tools
505
+ SET description = ?, input_schema = ?, updated_at = ?
506
+ WHERE id = ?
507
+ """,
508
+ (
509
+ tool.get("description"),
510
+ json.dumps(input_schema) if input_schema else None,
511
+ now,
512
+ existing.id,
513
+ ),
514
+ )
515
+ stats["updated"] += 1
516
+
517
+ # Update hash for changed tool
518
+ if schema_hash_manager:
519
+ schema_hash = compute_schema_hash(input_schema)
520
+ schema_hash_manager.store_hash(server_name, tool_name, project_id, schema_hash)
521
+
522
+ else:
523
+ # Unchanged tool - just update verification time
524
+ stats["unchanged"] += 1
525
+ if schema_hash_manager:
526
+ schema_hash_manager.update_verification_time(server_name, tool_name, project_id)
527
+
528
+ # Remove stale tools (tools that no longer exist on server)
529
+ stale_tools = set(existing_tools.keys()) - current_tool_names
530
+ for tool_name in stale_tools:
531
+ existing = existing_tools[tool_name]
532
+ self.db.execute("DELETE FROM tools WHERE id = ?", (existing.id,))
533
+ stats["removed"] += 1
534
+
535
+ # Cleanup stale hashes
536
+ if schema_hash_manager:
537
+ schema_hash_manager.cleanup_stale_hashes(
538
+ server_name, project_id, list(current_tool_names)
539
+ )
540
+
541
+ stats["total"] = len(tools)
542
+ logger.debug(
543
+ f"Incremental refresh for {server_name}: "
544
+ f"+{stats['added']} ~{stats['updated']} -{stats['removed']} ={stats['unchanged']}"
545
+ )
546
+ return stats
547
+
548
+ def import_from_mcp_json(self, path: str | Path, project_id: str) -> int:
549
+ """
550
+ Import servers from .mcp.json file.
551
+
552
+ Supports both formats:
553
+ - Claude Code format: {"mcpServers": {"server_name": {...}, ...}}
554
+ - Gobby format: {"servers": [{"name": "server_name", ...}, ...]}
555
+
556
+ Args:
557
+ path: Path to .mcp.json file
558
+ project_id: Required project ID
559
+
560
+ Returns:
561
+ Number of servers imported
562
+ """
563
+ path = Path(path)
564
+ if not path.exists():
565
+ return 0
566
+
567
+ try:
568
+ with open(path) as f:
569
+ data = json.load(f)
570
+ except (json.JSONDecodeError, OSError) as e:
571
+ logger.error(f"Failed to read {path}: {e}")
572
+ return 0
573
+
574
+ imported = 0
575
+
576
+ # Handle Gobby format: {"servers": [{"name": "...", ...}, ...]}
577
+ if "servers" in data and isinstance(data["servers"], list):
578
+ for config in data["servers"]:
579
+ name = config.get("name")
580
+ if not name:
581
+ continue
582
+
583
+ transport = config.get("transport", "stdio")
584
+ self.upsert(
585
+ name=name,
586
+ transport=transport,
587
+ url=config.get("url"),
588
+ command=config.get("command"),
589
+ args=config.get("args"),
590
+ env=config.get("env"),
591
+ headers=config.get("headers"),
592
+ enabled=config.get("enabled", True),
593
+ description=config.get("description"),
594
+ project_id=project_id,
595
+ )
596
+ imported += 1
597
+
598
+ # Handle Claude Code format: {"mcpServers": {"server_name": {...}, ...}}
599
+ elif "mcpServers" in data and isinstance(data["mcpServers"], dict):
600
+ for name, config in data["mcpServers"].items():
601
+ transport = config.get("transport", "stdio")
602
+ self.upsert(
603
+ name=name,
604
+ transport=transport,
605
+ url=config.get("url"),
606
+ command=config.get("command"),
607
+ args=config.get("args"),
608
+ env=config.get("env"),
609
+ headers=config.get("headers"),
610
+ enabled=config.get("enabled", True),
611
+ description=config.get("description"),
612
+ project_id=project_id,
613
+ )
614
+ imported += 1
615
+
616
+ return imported
617
+
618
+ def import_tools_from_filesystem(
619
+ self, project_id: str, tools_dir: str | Path | None = None
620
+ ) -> int:
621
+ """
622
+ Import tool schemas from filesystem directory.
623
+
624
+ Reads tool JSON files from ~/.gobby/tools/<server_name>/<tool_name>.json
625
+ and caches them in the database for servers that exist in the project.
626
+
627
+ Args:
628
+ project_id: Required project ID
629
+ tools_dir: Path to tools directory (default: ~/.gobby/tools)
630
+
631
+ Returns:
632
+ Number of tools imported
633
+ """
634
+ if tools_dir is None:
635
+ tools_dir = Path.home() / ".gobby" / "tools"
636
+ else:
637
+ tools_dir = Path(tools_dir)
638
+
639
+ if not tools_dir.exists():
640
+ return 0
641
+
642
+ total_imported = 0
643
+
644
+ # Iterate through server directories
645
+ for server_dir in tools_dir.iterdir():
646
+ if not server_dir.is_dir() or server_dir.name.startswith("."):
647
+ continue
648
+
649
+ server_name = server_dir.name
650
+
651
+ # Check if server exists in database for this project
652
+ server = self.get_server(server_name, project_id=project_id)
653
+ if not server:
654
+ logger.debug(f"Skipping tools for unknown server: {server_name}")
655
+ continue
656
+
657
+ # Collect all tool schemas for this server
658
+ tools = []
659
+ for tool_file in server_dir.glob("*.json"):
660
+ try:
661
+ with open(tool_file) as f:
662
+ tool_data = json.load(f)
663
+ tools.append(
664
+ {
665
+ "name": tool_data.get("name", tool_file.stem),
666
+ "description": tool_data.get("description"),
667
+ "inputSchema": tool_data.get("inputSchema", {}),
668
+ }
669
+ )
670
+ except (json.JSONDecodeError, OSError) as e:
671
+ logger.warning(f"Failed to read tool file {tool_file}: {e}")
672
+ continue
673
+
674
+ # Cache tools to database
675
+ if tools:
676
+ count = self.cache_tools(server_name, tools, project_id=project_id)
677
+ total_imported += count
678
+ logger.info(f"Imported {count} tools for server '{server_name}'")
679
+
680
+ return total_imported