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
File without changes
@@ -0,0 +1,95 @@
1
+ """Base transport connection abstract class."""
2
+
3
+ import asyncio
4
+ from collections.abc import Callable, Coroutine
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ from mcp import ClientSession
9
+
10
+ from gobby.mcp_proxy.models import ConnectionState, MCPServerConfig
11
+
12
+
13
+ class BaseTransportConnection:
14
+ """
15
+ Base class for MCP transport connections.
16
+
17
+ All transport implementations must provide:
18
+ - connect() -> ClientSession
19
+ - disconnect()
20
+ - is_connected property
21
+ - state property
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ config: MCPServerConfig,
27
+ auth_token: str | None = None,
28
+ token_refresh_callback: Callable[[], Coroutine[Any, Any, str]] | None = None,
29
+ ):
30
+ """
31
+ Initialize transport connection.
32
+
33
+ Args:
34
+ config: Server configuration
35
+ auth_token: Optional auth token
36
+ token_refresh_callback: Optional callback for token refresh
37
+ """
38
+ self.config = config
39
+ self._auth_token = auth_token
40
+ self._token_refresh_callback = token_refresh_callback
41
+ self._session: Any | None = None # ClientSession
42
+ self._transport_context: Any | None = None # Transport-specific context manager
43
+ self._state = ConnectionState.DISCONNECTED
44
+ self._last_health_check: datetime | None = None
45
+ self._consecutive_failures = 0
46
+
47
+ async def connect(self) -> Any:
48
+ """Connect and return ClientSession. Must be implemented by subclasses."""
49
+ raise NotImplementedError
50
+
51
+ async def disconnect(self) -> None:
52
+ """Disconnect from server. Must be implemented by subclasses."""
53
+ raise NotImplementedError
54
+
55
+ @property
56
+ def is_connected(self) -> bool:
57
+ """Check if connection is active."""
58
+ return self._state == ConnectionState.CONNECTED and self._session is not None
59
+
60
+ @property
61
+ def state(self) -> ConnectionState:
62
+ """Get current connection state."""
63
+ return self._state
64
+
65
+ @property
66
+ def session(self) -> ClientSession | None:
67
+ """Get the current client session, if connected."""
68
+ return self._session
69
+
70
+ def set_auth_token(self, token: str) -> None:
71
+ """Update authentication token."""
72
+ self._auth_token = token
73
+
74
+ async def health_check(self, timeout: float = 5.0) -> bool:
75
+ """
76
+ Check connection health.
77
+
78
+ Args:
79
+ timeout: Health check timeout in seconds
80
+
81
+ Returns:
82
+ True if healthy, False otherwise
83
+ """
84
+ if not self.is_connected or not self._session:
85
+ return False
86
+
87
+ try:
88
+ # Use asyncio.wait_for for timeout
89
+ await asyncio.wait_for(self._session.list_tools(), timeout)
90
+ self._last_health_check = datetime.now(UTC)
91
+ self._consecutive_failures = 0
92
+ return True
93
+ except (TimeoutError, Exception):
94
+ self._consecutive_failures += 1
95
+ return False
@@ -0,0 +1,44 @@
1
+ """Transport connection factory."""
2
+
3
+ from collections.abc import Callable, Coroutine
4
+ from typing import Any
5
+
6
+ from gobby.mcp_proxy.models import MCPServerConfig
7
+ from gobby.mcp_proxy.transports.base import BaseTransportConnection
8
+ from gobby.mcp_proxy.transports.http import HTTPTransportConnection
9
+ from gobby.mcp_proxy.transports.stdio import StdioTransportConnection
10
+ from gobby.mcp_proxy.transports.websocket import WebSocketTransportConnection
11
+
12
+
13
+ def create_transport_connection(
14
+ config: MCPServerConfig,
15
+ auth_token: str | None = None,
16
+ token_refresh_callback: Callable[[], Coroutine[Any, Any, str]] | None = None,
17
+ ) -> BaseTransportConnection:
18
+ """
19
+ Factory function to create appropriate transport connection.
20
+
21
+ Args:
22
+ config: Server configuration
23
+ auth_token: Optional auth token
24
+ token_refresh_callback: Optional token refresh callback
25
+
26
+ Returns:
27
+ Transport-specific connection instance
28
+
29
+ Raises:
30
+ ValueError: If transport type is unsupported
31
+ """
32
+ transport_map: dict[str, type[BaseTransportConnection]] = {
33
+ "http": HTTPTransportConnection,
34
+ "stdio": StdioTransportConnection,
35
+ "websocket": WebSocketTransportConnection,
36
+ }
37
+
38
+ transport_class = transport_map.get(config.transport)
39
+ if not transport_class:
40
+ raise ValueError(
41
+ f"Unsupported transport: {config.transport}. Supported: {list(transport_map.keys())}"
42
+ )
43
+
44
+ return transport_class(config, auth_token, token_refresh_callback)
@@ -0,0 +1,139 @@
1
+ """HTTP transport connection."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from typing import Any
6
+
7
+ from mcp import ClientSession
8
+ from mcp.client.streamable_http import streamablehttp_client
9
+
10
+ from gobby.mcp_proxy.models import ConnectionState, MCPError
11
+ from gobby.mcp_proxy.transports.base import BaseTransportConnection
12
+
13
+ logger = logging.getLogger("gobby.mcp.client")
14
+
15
+
16
+ class HTTPTransportConnection(BaseTransportConnection):
17
+ """HTTP/Streamable HTTP transport connection using MCP SDK.
18
+
19
+ Uses a dedicated background task to own the streamablehttp_client lifecycle,
20
+ ensuring that context entry and exit happen in the same task (required by anyio).
21
+ """
22
+
23
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
24
+ super().__init__(*args, **kwargs)
25
+ self._owner_task: asyncio.Task[None] | None = None
26
+ self._disconnect_event: asyncio.Event | None = None
27
+ self._session_ready: asyncio.Event | None = None
28
+ self._connection_error: Exception | None = None
29
+ self._session_context: ClientSession | None = None
30
+
31
+ async def connect(self) -> Any:
32
+ """Connect via HTTP transport using a dedicated owner task."""
33
+ if self._state == ConnectionState.CONNECTED and self._session is not None:
34
+ return self._session
35
+
36
+ # Clean up old connection if reconnecting
37
+ if self._owner_task is not None:
38
+ await self.disconnect()
39
+
40
+ self._state = ConnectionState.CONNECTING
41
+ self._connection_error = None
42
+
43
+ # Create synchronization events
44
+ self._disconnect_event = asyncio.Event()
45
+ self._session_ready = asyncio.Event()
46
+
47
+ # Start owner task that manages the connection lifecycle
48
+ self._owner_task = asyncio.create_task(
49
+ self._run_connection(), name=f"http-conn-{self.config.name}"
50
+ )
51
+
52
+ # Wait for connection to be ready or fail
53
+ timeout = self.config.connect_timeout
54
+ try:
55
+ await asyncio.wait_for(self._session_ready.wait(), timeout=timeout)
56
+ except TimeoutError as e:
57
+ self._disconnect_event.set()
58
+ await self._cleanup_owner_task()
59
+ self._state = ConnectionState.FAILED
60
+ raise MCPError(f"Connection timeout for {self.config.name} after {timeout}s") from e
61
+
62
+ if self._connection_error is not None:
63
+ error = self._connection_error
64
+ self._connection_error = None
65
+ await self._cleanup_owner_task()
66
+ self._state = ConnectionState.FAILED
67
+ raise error
68
+
69
+ return self._session
70
+
71
+ async def _run_connection(self) -> None:
72
+ """Background task that owns the streamablehttp_client lifecycle."""
73
+ if self._disconnect_event is None or self._session_ready is None:
74
+ raise RuntimeError("Connection events not initialized")
75
+
76
+ try:
77
+ # URL is required for HTTP transport
78
+ if not self.config.url:
79
+ raise ValueError("URL is required for HTTP transport")
80
+
81
+ async with streamablehttp_client(
82
+ self.config.url,
83
+ headers=self.config.headers,
84
+ ) as (read_stream, write_stream, _):
85
+ self._session_context = ClientSession(read_stream, write_stream)
86
+ async with self._session_context as session:
87
+ self._session = session
88
+ await self._session.initialize()
89
+
90
+ self._state = ConnectionState.CONNECTED
91
+ self._consecutive_failures = 0
92
+ logger.debug(f"Connected to HTTP MCP server: {self.config.name}")
93
+
94
+ # Signal that connection is ready
95
+ self._session_ready.set()
96
+
97
+ # Wait until disconnect is requested
98
+ await self._disconnect_event.wait()
99
+
100
+ logger.debug(f"Disconnect requested for {self.config.name}")
101
+
102
+ except Exception as e:
103
+ error_msg = str(e) if str(e) else f"{type(e).__name__}: Connection closed or timed out"
104
+ logger.error(f"Failed to connect to HTTP server '{self.config.name}': {error_msg}")
105
+
106
+ if isinstance(e, MCPError):
107
+ self._connection_error = e
108
+ else:
109
+ self._connection_error = MCPError(f"HTTP connection failed: {error_msg}")
110
+
111
+ self._session_ready.set() # Unblock waiter with error
112
+
113
+ finally:
114
+ self._session = None
115
+ self._session_context = None
116
+ self._state = ConnectionState.DISCONNECTED
117
+
118
+ async def _cleanup_owner_task(self) -> None:
119
+ """Clean up the owner task."""
120
+ if self._owner_task is not None:
121
+ if not self._owner_task.done():
122
+ self._owner_task.cancel()
123
+ try:
124
+ await asyncio.wait_for(self._owner_task, timeout=2.0)
125
+ except asyncio.CancelledError:
126
+ logger.debug(f"Owner task cancelled for {self.config.name}")
127
+ except TimeoutError:
128
+ logger.warning(f"Owner task cleanup timed out for {self.config.name}")
129
+ self._owner_task = None
130
+ self._disconnect_event = None
131
+ self._session_ready = None
132
+
133
+ async def disconnect(self) -> None:
134
+ """Disconnect from HTTP server by signaling the owner task."""
135
+ if self._disconnect_event is not None:
136
+ self._disconnect_event.set()
137
+
138
+ await self._cleanup_owner_task()
139
+ self._state = ConnectionState.DISCONNECTED
@@ -0,0 +1,213 @@
1
+ """Stdio transport connection."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import re
7
+ from collections.abc import Callable, Coroutine
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from mcp import ClientSession
11
+ from mcp.client.stdio import StdioServerParameters, stdio_client
12
+
13
+ from gobby.mcp_proxy.models import ConnectionState, MCPError
14
+ from gobby.mcp_proxy.transports.base import BaseTransportConnection
15
+
16
+ # Pattern for ${VAR} or ${VAR:-default} environment variable expansion
17
+ ENV_VAR_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
18
+
19
+
20
+ def _expand_env_var(value: str) -> str:
21
+ """Expand ${VAR} and ${VAR:-default} patterns in a string.
22
+
23
+ Args:
24
+ value: String that may contain ${VAR} patterns
25
+
26
+ Returns:
27
+ String with environment variables expanded
28
+ """
29
+
30
+ def replace_match(match: re.Match[str]) -> str:
31
+ var_name = match.group(1)
32
+ default_value = match.group(2) # None if no default specified
33
+
34
+ env_value = os.environ.get(var_name)
35
+
36
+ if env_value is not None and env_value != "":
37
+ return env_value
38
+ elif default_value is not None:
39
+ return default_value
40
+ else:
41
+ # Leave unchanged if no value and no default
42
+ return match.group(0)
43
+
44
+ return ENV_VAR_PATTERN.sub(replace_match, value)
45
+
46
+
47
+ def _expand_env_dict(env: dict[str, str] | None) -> dict[str, str] | None:
48
+ """Expand environment variables in env dict values.
49
+
50
+ Args:
51
+ env: Dictionary of environment variables (may contain ${VAR} patterns)
52
+
53
+ Returns:
54
+ Dictionary with expanded values, or None if input is None
55
+ """
56
+ if env is None:
57
+ return None
58
+
59
+ return {key: _expand_env_var(value) for key, value in env.items()}
60
+
61
+
62
+ def _expand_args(args: list[str] | None) -> list[str] | None:
63
+ """Expand environment variables in command args.
64
+
65
+ Args:
66
+ args: List of command arguments (may contain ${VAR} patterns)
67
+
68
+ Returns:
69
+ List with expanded values, or None if input is None
70
+ """
71
+ if args is None:
72
+ return None
73
+
74
+ return [_expand_env_var(arg) for arg in args]
75
+
76
+
77
+ if TYPE_CHECKING:
78
+ from gobby.mcp_proxy.models import MCPServerConfig
79
+
80
+ logger = logging.getLogger("gobby.mcp.client")
81
+
82
+
83
+ class StdioTransportConnection(BaseTransportConnection):
84
+ """Stdio transport connection using MCP SDK."""
85
+
86
+ def __init__(
87
+ self,
88
+ config: "MCPServerConfig",
89
+ auth_token: str | None = None,
90
+ token_refresh_callback: Callable[[], Coroutine[Any, Any, str]] | None = None,
91
+ ) -> None:
92
+ """Initialize stdio transport connection."""
93
+ super().__init__(config, auth_token, token_refresh_callback)
94
+ self._session_context: ClientSession | None = None
95
+ # Explicitly initialize transport context (inherited from base class, but
96
+ # ensures the attribute exists with proper type annotation for this transport)
97
+ self._transport_context: Any | None = None
98
+
99
+ async def connect(self) -> Any:
100
+ """Connect via stdio transport."""
101
+ if self._state == ConnectionState.CONNECTED:
102
+ return self._session
103
+
104
+ self._state = ConnectionState.CONNECTING
105
+
106
+ # Track what was entered for cleanup
107
+ transport_entered = False
108
+ session_entered = False
109
+
110
+ try:
111
+ # Create stdio server parameters
112
+ if self.config.command is None:
113
+ raise RuntimeError("Command is required for stdio transport")
114
+
115
+ # Expand ${VAR} patterns in args and env values
116
+ expanded_args = _expand_args(self.config.args) or []
117
+ expanded_env = _expand_env_dict(self.config.env)
118
+
119
+ params = StdioServerParameters(
120
+ command=self.config.command,
121
+ args=expanded_args,
122
+ env=expanded_env,
123
+ )
124
+
125
+ # Create stdio client context
126
+ self._transport_context = stdio_client(params)
127
+
128
+ # Enter the transport context to get streams
129
+ read_stream, write_stream = await self._transport_context.__aenter__()
130
+ transport_entered = True
131
+
132
+ # Save the context manager itself so we can call __aexit__ on it later
133
+ self._session_context = ClientSession(read_stream, write_stream)
134
+ self._session = await self._session_context.__aenter__()
135
+ session_entered = True
136
+
137
+ await self._session.initialize()
138
+
139
+ self._state = ConnectionState.CONNECTED
140
+ self._consecutive_failures = 0
141
+ logger.debug(f"Connected to stdio MCP server: {self.config.name}")
142
+
143
+ return self._session
144
+
145
+ except Exception as e:
146
+ # Handle exceptions with empty str() (EndOfStream, ClosedResourceError, CancelledError)
147
+ error_msg = str(e) if str(e) else f"{type(e).__name__}: Connection closed or timed out"
148
+ logger.error(f"Failed to connect to stdio server '{self.config.name}': {error_msg}")
149
+
150
+ # Cleanup in reverse order - session first, then transport
151
+ # Cleanup in reverse order - session first, then transport
152
+ session_ctx = self._session_context
153
+ if session_entered and session_ctx is not None:
154
+ try:
155
+ await session_ctx.__aexit__(None, None, None)
156
+ except Exception as cleanup_error:
157
+ logger.warning(
158
+ f"Error during session cleanup for {self.config.name}: {cleanup_error}"
159
+ )
160
+
161
+ transport_ctx = self._transport_context
162
+ if transport_entered and transport_ctx is not None:
163
+ try:
164
+ await transport_ctx.__aexit__(None, None, None)
165
+ except Exception as cleanup_error:
166
+ logger.warning(
167
+ f"Error during transport cleanup for {self.config.name}: {cleanup_error}"
168
+ )
169
+
170
+ # Reset state before raising
171
+ self._session = None
172
+ self._session_context = None
173
+ self._transport_context = None
174
+ self._state = ConnectionState.FAILED
175
+
176
+ # Re-raise wrapped in MCPError (don't double-wrap)
177
+ if isinstance(e, MCPError):
178
+ raise
179
+ raise MCPError(f"Stdio connection failed: {error_msg}") from e
180
+
181
+ async def disconnect(self) -> None:
182
+ """Disconnect from stdio server."""
183
+ # Exit session context manager (not the session object itself)
184
+ session_ctx = self._session_context
185
+ if session_ctx is not None:
186
+ try:
187
+ await asyncio.wait_for(session_ctx.__aexit__(None, None, None), timeout=2.0)
188
+ except TimeoutError:
189
+ logger.warning(f"Session close timed out for {self.config.name}")
190
+ except RuntimeError as e:
191
+ # Expected when exiting cancel scope from different task
192
+ if "cancel scope" not in str(e):
193
+ logger.warning(f"Error closing session for {self.config.name}: {e}")
194
+ except Exception as e:
195
+ logger.warning(f"Error closing session for {self.config.name}: {e}")
196
+ self._session_context = None
197
+ self._session = None
198
+
199
+ transport_ctx = self._transport_context
200
+ if transport_ctx is not None:
201
+ try:
202
+ await asyncio.wait_for(transport_ctx.__aexit__(None, None, None), timeout=2.0)
203
+ except TimeoutError:
204
+ logger.warning(f"Transport close timed out for {self.config.name}")
205
+ except RuntimeError as e:
206
+ # Expected when exiting cancel scope from different task
207
+ if "cancel scope" not in str(e):
208
+ logger.warning(f"Error closing transport for {self.config.name}: {e}")
209
+ except Exception as e:
210
+ logger.warning(f"Error closing transport for {self.config.name}: {e}")
211
+ self._transport_context = None
212
+
213
+ self._state = ConnectionState.DISCONNECTED
@@ -0,0 +1,136 @@
1
+ """WebSocket transport connection."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from collections.abc import Callable, Coroutine
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from mcp import ClientSession
9
+ from mcp.client.websocket import websocket_client
10
+
11
+ from gobby.mcp_proxy.models import ConnectionState, MCPError
12
+ from gobby.mcp_proxy.transports.base import BaseTransportConnection
13
+
14
+ if TYPE_CHECKING:
15
+ from gobby.config.mcp import MCPServerConfig
16
+
17
+ logger = logging.getLogger("gobby.mcp.client")
18
+
19
+
20
+ class WebSocketTransportConnection(BaseTransportConnection):
21
+ """WebSocket transport connection using MCP SDK."""
22
+
23
+ def __init__(
24
+ self,
25
+ config: "MCPServerConfig",
26
+ auth_token: str | None = None,
27
+ token_refresh_callback: Callable[[], Coroutine[Any, Any, str]] | None = None,
28
+ ) -> None:
29
+ """Initialize WebSocket transport connection."""
30
+ super().__init__(config, auth_token, token_refresh_callback)
31
+ self._session_context: ClientSession | None = None
32
+
33
+ async def connect(self) -> Any:
34
+ """Connect via WebSocket transport."""
35
+ if self._state == ConnectionState.CONNECTED:
36
+ return self._session
37
+
38
+ self._state = ConnectionState.CONNECTING
39
+
40
+ # Track what was entered for cleanup
41
+ transport_entered = False
42
+ session_entered = False
43
+
44
+ try:
45
+ # URL is required for WebSocket transport
46
+ if self.config.url is None:
47
+ raise RuntimeError("URL is required for WebSocket transport")
48
+
49
+ # Create WebSocket client context
50
+ self._transport_context = websocket_client(self.config.url)
51
+
52
+ # Enter the transport context to get streams
53
+ read_stream, write_stream = await self._transport_context.__aenter__()
54
+ transport_entered = True
55
+
56
+ # Save the context manager itself so we can call __aexit__ on it later
57
+ self._session_context = ClientSession(read_stream, write_stream)
58
+ self._session = await self._session_context.__aenter__()
59
+ session_entered = True
60
+
61
+ await self._session.initialize()
62
+
63
+ self._state = ConnectionState.CONNECTED
64
+ self._consecutive_failures = 0
65
+ logger.debug(f"Connected to WebSocket MCP server: {self.config.name}")
66
+
67
+ return self._session
68
+
69
+ except Exception as e:
70
+ # Handle exceptions with empty str() (EndOfStream, ClosedResourceError, CancelledError)
71
+ error_msg = str(e) if str(e) else f"{type(e).__name__}: Connection closed or timed out"
72
+ logger.error(f"Failed to connect to WebSocket server '{self.config.name}': {error_msg}")
73
+
74
+ # Cleanup in reverse order - session first, then transport
75
+ if session_entered and self._session_context is not None:
76
+ try:
77
+ await self._session_context.__aexit__(None, None, None)
78
+ except Exception as cleanup_error:
79
+ logger.warning(
80
+ f"Error during session cleanup for {self.config.name}: {cleanup_error}"
81
+ )
82
+
83
+ if transport_entered and self._transport_context is not None:
84
+ try:
85
+ await self._transport_context.__aexit__(None, None, None)
86
+ except Exception as cleanup_error:
87
+ logger.warning(
88
+ f"Error during transport cleanup for {self.config.name}: {cleanup_error}"
89
+ )
90
+
91
+ # Reset state before raising
92
+ self._session = None
93
+ self._session_context = None
94
+ self._transport_context = None
95
+ self._state = ConnectionState.FAILED
96
+
97
+ # Re-raise wrapped in MCPError (don't double-wrap)
98
+ if isinstance(e, MCPError):
99
+ raise
100
+ raise MCPError(f"WebSocket connection failed: {error_msg}") from e
101
+
102
+ async def disconnect(self) -> None:
103
+ """Disconnect from WebSocket server."""
104
+ # Exit session context manager (not the session object itself)
105
+ if self._session_context is not None:
106
+ try:
107
+ await asyncio.wait_for(
108
+ self._session_context.__aexit__(None, None, None), timeout=2.0
109
+ )
110
+ except TimeoutError:
111
+ logger.warning(f"Session close timed out for {self.config.name}")
112
+ except RuntimeError as e:
113
+ # Expected when exiting cancel scope from different task
114
+ if "cancel scope" not in str(e):
115
+ logger.warning(f"Error closing session for {self.config.name}: {e}")
116
+ except Exception as e:
117
+ logger.warning(f"Error closing session for {self.config.name}: {e}")
118
+ self._session_context = None
119
+ self._session = None
120
+
121
+ if self._transport_context is not None:
122
+ try:
123
+ await asyncio.wait_for(
124
+ self._transport_context.__aexit__(None, None, None), timeout=2.0
125
+ )
126
+ except TimeoutError:
127
+ logger.warning(f"Transport close timed out for {self.config.name}")
128
+ except RuntimeError as e:
129
+ # Expected when exiting cancel scope from different task
130
+ if "cancel scope" not in str(e):
131
+ logger.warning(f"Error closing transport for {self.config.name}: {e}")
132
+ except Exception as e:
133
+ logger.warning(f"Error closing transport for {self.config.name}: {e}")
134
+ self._transport_context = None
135
+
136
+ self._state = ConnectionState.DISCONNECTED