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.
- gobby/__init__.py +3 -0
- gobby/adapters/__init__.py +30 -0
- gobby/adapters/base.py +93 -0
- gobby/adapters/claude_code.py +276 -0
- gobby/adapters/codex.py +1292 -0
- gobby/adapters/gemini.py +343 -0
- gobby/agents/__init__.py +37 -0
- gobby/agents/codex_session.py +120 -0
- gobby/agents/constants.py +112 -0
- gobby/agents/context.py +362 -0
- gobby/agents/definitions.py +133 -0
- gobby/agents/gemini_session.py +111 -0
- gobby/agents/registry.py +618 -0
- gobby/agents/runner.py +968 -0
- gobby/agents/session.py +259 -0
- gobby/agents/spawn.py +916 -0
- gobby/agents/spawners/__init__.py +77 -0
- gobby/agents/spawners/base.py +142 -0
- gobby/agents/spawners/cross_platform.py +266 -0
- gobby/agents/spawners/embedded.py +225 -0
- gobby/agents/spawners/headless.py +226 -0
- gobby/agents/spawners/linux.py +125 -0
- gobby/agents/spawners/macos.py +277 -0
- gobby/agents/spawners/windows.py +308 -0
- gobby/agents/tty_config.py +319 -0
- gobby/autonomous/__init__.py +32 -0
- gobby/autonomous/progress_tracker.py +447 -0
- gobby/autonomous/stop_registry.py +269 -0
- gobby/autonomous/stuck_detector.py +383 -0
- gobby/cli/__init__.py +67 -0
- gobby/cli/__main__.py +8 -0
- gobby/cli/agents.py +529 -0
- gobby/cli/artifacts.py +266 -0
- gobby/cli/daemon.py +329 -0
- gobby/cli/extensions.py +526 -0
- gobby/cli/github.py +263 -0
- gobby/cli/init.py +53 -0
- gobby/cli/install.py +614 -0
- gobby/cli/installers/__init__.py +37 -0
- gobby/cli/installers/antigravity.py +65 -0
- gobby/cli/installers/claude.py +363 -0
- gobby/cli/installers/codex.py +192 -0
- gobby/cli/installers/gemini.py +294 -0
- gobby/cli/installers/git_hooks.py +377 -0
- gobby/cli/installers/shared.py +737 -0
- gobby/cli/linear.py +250 -0
- gobby/cli/mcp.py +30 -0
- gobby/cli/mcp_proxy.py +698 -0
- gobby/cli/memory.py +304 -0
- gobby/cli/merge.py +384 -0
- gobby/cli/projects.py +79 -0
- gobby/cli/sessions.py +622 -0
- gobby/cli/tasks/__init__.py +30 -0
- gobby/cli/tasks/_utils.py +658 -0
- gobby/cli/tasks/ai.py +1025 -0
- gobby/cli/tasks/commits.py +169 -0
- gobby/cli/tasks/crud.py +685 -0
- gobby/cli/tasks/deps.py +135 -0
- gobby/cli/tasks/labels.py +63 -0
- gobby/cli/tasks/main.py +273 -0
- gobby/cli/tasks/search.py +178 -0
- gobby/cli/tui.py +34 -0
- gobby/cli/utils.py +513 -0
- gobby/cli/workflows.py +927 -0
- gobby/cli/worktrees.py +481 -0
- gobby/config/__init__.py +129 -0
- gobby/config/app.py +551 -0
- gobby/config/extensions.py +167 -0
- gobby/config/features.py +472 -0
- gobby/config/llm_providers.py +98 -0
- gobby/config/logging.py +66 -0
- gobby/config/mcp.py +346 -0
- gobby/config/persistence.py +247 -0
- gobby/config/servers.py +141 -0
- gobby/config/sessions.py +250 -0
- gobby/config/tasks.py +784 -0
- gobby/hooks/__init__.py +104 -0
- gobby/hooks/artifact_capture.py +213 -0
- gobby/hooks/broadcaster.py +243 -0
- gobby/hooks/event_handlers.py +723 -0
- gobby/hooks/events.py +218 -0
- gobby/hooks/git.py +169 -0
- gobby/hooks/health_monitor.py +171 -0
- gobby/hooks/hook_manager.py +856 -0
- gobby/hooks/hook_types.py +575 -0
- gobby/hooks/plugins.py +813 -0
- gobby/hooks/session_coordinator.py +396 -0
- gobby/hooks/verification_runner.py +268 -0
- gobby/hooks/webhooks.py +339 -0
- gobby/install/claude/commands/gobby/bug.md +51 -0
- gobby/install/claude/commands/gobby/chore.md +51 -0
- gobby/install/claude/commands/gobby/epic.md +52 -0
- gobby/install/claude/commands/gobby/eval.md +235 -0
- gobby/install/claude/commands/gobby/feat.md +49 -0
- gobby/install/claude/commands/gobby/nit.md +52 -0
- gobby/install/claude/commands/gobby/ref.md +52 -0
- gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
- gobby/install/claude/hooks/hook_dispatcher.py +364 -0
- gobby/install/claude/hooks/validate_settings.py +102 -0
- gobby/install/claude/hooks-template.json +118 -0
- gobby/install/codex/hooks/hook_dispatcher.py +153 -0
- gobby/install/codex/prompts/forget.md +7 -0
- gobby/install/codex/prompts/memories.md +7 -0
- gobby/install/codex/prompts/recall.md +7 -0
- gobby/install/codex/prompts/remember.md +13 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
- gobby/install/gemini/hooks-template.json +138 -0
- gobby/install/shared/plugins/code_guardian.py +456 -0
- gobby/install/shared/plugins/example_notify.py +331 -0
- gobby/integrations/__init__.py +10 -0
- gobby/integrations/github.py +145 -0
- gobby/integrations/linear.py +145 -0
- gobby/llm/__init__.py +40 -0
- gobby/llm/base.py +120 -0
- gobby/llm/claude.py +578 -0
- gobby/llm/claude_executor.py +503 -0
- gobby/llm/codex.py +322 -0
- gobby/llm/codex_executor.py +513 -0
- gobby/llm/executor.py +316 -0
- gobby/llm/factory.py +34 -0
- gobby/llm/gemini.py +258 -0
- gobby/llm/gemini_executor.py +339 -0
- gobby/llm/litellm.py +287 -0
- gobby/llm/litellm_executor.py +303 -0
- gobby/llm/resolver.py +499 -0
- gobby/llm/service.py +236 -0
- gobby/mcp_proxy/__init__.py +29 -0
- gobby/mcp_proxy/actions.py +175 -0
- gobby/mcp_proxy/daemon_control.py +198 -0
- gobby/mcp_proxy/importer.py +436 -0
- gobby/mcp_proxy/lazy.py +325 -0
- gobby/mcp_proxy/manager.py +798 -0
- gobby/mcp_proxy/metrics.py +609 -0
- gobby/mcp_proxy/models.py +139 -0
- gobby/mcp_proxy/registries.py +215 -0
- gobby/mcp_proxy/schema_hash.py +381 -0
- gobby/mcp_proxy/semantic_search.py +706 -0
- gobby/mcp_proxy/server.py +549 -0
- gobby/mcp_proxy/services/__init__.py +0 -0
- gobby/mcp_proxy/services/fallback.py +306 -0
- gobby/mcp_proxy/services/recommendation.py +224 -0
- gobby/mcp_proxy/services/server_mgmt.py +214 -0
- gobby/mcp_proxy/services/system.py +72 -0
- gobby/mcp_proxy/services/tool_filter.py +231 -0
- gobby/mcp_proxy/services/tool_proxy.py +309 -0
- gobby/mcp_proxy/stdio.py +565 -0
- gobby/mcp_proxy/tools/__init__.py +27 -0
- gobby/mcp_proxy/tools/agents.py +1103 -0
- gobby/mcp_proxy/tools/artifacts.py +207 -0
- gobby/mcp_proxy/tools/hub.py +335 -0
- gobby/mcp_proxy/tools/internal.py +337 -0
- gobby/mcp_proxy/tools/memory.py +543 -0
- gobby/mcp_proxy/tools/merge.py +422 -0
- gobby/mcp_proxy/tools/metrics.py +283 -0
- gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
- gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
- gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
- gobby/mcp_proxy/tools/orchestration/review.py +736 -0
- gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
- gobby/mcp_proxy/tools/session_messages.py +1056 -0
- gobby/mcp_proxy/tools/task_dependencies.py +219 -0
- gobby/mcp_proxy/tools/task_expansion.py +591 -0
- gobby/mcp_proxy/tools/task_github.py +393 -0
- gobby/mcp_proxy/tools/task_linear.py +379 -0
- gobby/mcp_proxy/tools/task_orchestration.py +77 -0
- gobby/mcp_proxy/tools/task_readiness.py +522 -0
- gobby/mcp_proxy/tools/task_sync.py +351 -0
- gobby/mcp_proxy/tools/task_validation.py +843 -0
- gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
- gobby/mcp_proxy/tools/tasks/_context.py +112 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
- gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
- gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
- gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
- gobby/mcp_proxy/tools/tasks/_search.py +215 -0
- gobby/mcp_proxy/tools/tasks/_session.py +125 -0
- gobby/mcp_proxy/tools/workflows.py +973 -0
- gobby/mcp_proxy/tools/worktrees.py +1264 -0
- gobby/mcp_proxy/transports/__init__.py +0 -0
- gobby/mcp_proxy/transports/base.py +95 -0
- gobby/mcp_proxy/transports/factory.py +44 -0
- gobby/mcp_proxy/transports/http.py +139 -0
- gobby/mcp_proxy/transports/stdio.py +213 -0
- gobby/mcp_proxy/transports/websocket.py +136 -0
- gobby/memory/backends/__init__.py +116 -0
- gobby/memory/backends/mem0.py +408 -0
- gobby/memory/backends/memu.py +485 -0
- gobby/memory/backends/null.py +111 -0
- gobby/memory/backends/openmemory.py +537 -0
- gobby/memory/backends/sqlite.py +304 -0
- gobby/memory/context.py +87 -0
- gobby/memory/manager.py +1001 -0
- gobby/memory/protocol.py +451 -0
- gobby/memory/search/__init__.py +66 -0
- gobby/memory/search/text.py +127 -0
- gobby/memory/viz.py +258 -0
- gobby/prompts/__init__.py +13 -0
- gobby/prompts/defaults/expansion/system.md +119 -0
- gobby/prompts/defaults/expansion/user.md +48 -0
- gobby/prompts/defaults/external_validation/agent.md +72 -0
- gobby/prompts/defaults/external_validation/external.md +63 -0
- gobby/prompts/defaults/external_validation/spawn.md +83 -0
- gobby/prompts/defaults/external_validation/system.md +6 -0
- gobby/prompts/defaults/features/import_mcp.md +22 -0
- gobby/prompts/defaults/features/import_mcp_github.md +17 -0
- gobby/prompts/defaults/features/import_mcp_search.md +16 -0
- gobby/prompts/defaults/features/recommend_tools.md +32 -0
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
- gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
- gobby/prompts/defaults/features/server_description.md +20 -0
- gobby/prompts/defaults/features/server_description_system.md +6 -0
- gobby/prompts/defaults/features/task_description.md +31 -0
- gobby/prompts/defaults/features/task_description_system.md +6 -0
- gobby/prompts/defaults/features/tool_summary.md +17 -0
- gobby/prompts/defaults/features/tool_summary_system.md +6 -0
- gobby/prompts/defaults/research/step.md +58 -0
- gobby/prompts/defaults/validation/criteria.md +47 -0
- gobby/prompts/defaults/validation/validate.md +38 -0
- gobby/prompts/loader.py +346 -0
- gobby/prompts/models.py +113 -0
- gobby/py.typed +0 -0
- gobby/runner.py +488 -0
- gobby/search/__init__.py +23 -0
- gobby/search/protocol.py +104 -0
- gobby/search/tfidf.py +232 -0
- gobby/servers/__init__.py +7 -0
- gobby/servers/http.py +636 -0
- gobby/servers/models.py +31 -0
- gobby/servers/routes/__init__.py +23 -0
- gobby/servers/routes/admin.py +416 -0
- gobby/servers/routes/dependencies.py +118 -0
- gobby/servers/routes/mcp/__init__.py +24 -0
- gobby/servers/routes/mcp/hooks.py +135 -0
- gobby/servers/routes/mcp/plugins.py +121 -0
- gobby/servers/routes/mcp/tools.py +1337 -0
- gobby/servers/routes/mcp/webhooks.py +159 -0
- gobby/servers/routes/sessions.py +582 -0
- gobby/servers/websocket.py +766 -0
- gobby/sessions/__init__.py +13 -0
- gobby/sessions/analyzer.py +322 -0
- gobby/sessions/lifecycle.py +240 -0
- gobby/sessions/manager.py +563 -0
- gobby/sessions/processor.py +225 -0
- gobby/sessions/summary.py +532 -0
- gobby/sessions/transcripts/__init__.py +41 -0
- gobby/sessions/transcripts/base.py +125 -0
- gobby/sessions/transcripts/claude.py +386 -0
- gobby/sessions/transcripts/codex.py +143 -0
- gobby/sessions/transcripts/gemini.py +195 -0
- gobby/storage/__init__.py +21 -0
- gobby/storage/agents.py +409 -0
- gobby/storage/artifact_classifier.py +341 -0
- gobby/storage/artifacts.py +285 -0
- gobby/storage/compaction.py +67 -0
- gobby/storage/database.py +357 -0
- gobby/storage/inter_session_messages.py +194 -0
- gobby/storage/mcp.py +680 -0
- gobby/storage/memories.py +562 -0
- gobby/storage/merge_resolutions.py +550 -0
- gobby/storage/migrations.py +860 -0
- gobby/storage/migrations_legacy.py +1359 -0
- gobby/storage/projects.py +166 -0
- gobby/storage/session_messages.py +251 -0
- gobby/storage/session_tasks.py +97 -0
- gobby/storage/sessions.py +817 -0
- gobby/storage/task_dependencies.py +223 -0
- gobby/storage/tasks/__init__.py +42 -0
- gobby/storage/tasks/_aggregates.py +180 -0
- gobby/storage/tasks/_crud.py +449 -0
- gobby/storage/tasks/_id.py +104 -0
- gobby/storage/tasks/_lifecycle.py +311 -0
- gobby/storage/tasks/_manager.py +889 -0
- gobby/storage/tasks/_models.py +300 -0
- gobby/storage/tasks/_ordering.py +119 -0
- gobby/storage/tasks/_path_cache.py +110 -0
- gobby/storage/tasks/_queries.py +343 -0
- gobby/storage/tasks/_search.py +143 -0
- gobby/storage/workflow_audit.py +393 -0
- gobby/storage/worktrees.py +547 -0
- gobby/sync/__init__.py +29 -0
- gobby/sync/github.py +333 -0
- gobby/sync/linear.py +304 -0
- gobby/sync/memories.py +284 -0
- gobby/sync/tasks.py +641 -0
- gobby/tasks/__init__.py +8 -0
- gobby/tasks/build_verification.py +193 -0
- gobby/tasks/commits.py +633 -0
- gobby/tasks/context.py +747 -0
- gobby/tasks/criteria.py +342 -0
- gobby/tasks/enhanced_validator.py +226 -0
- gobby/tasks/escalation.py +263 -0
- gobby/tasks/expansion.py +626 -0
- gobby/tasks/external_validator.py +764 -0
- gobby/tasks/issue_extraction.py +171 -0
- gobby/tasks/prompts/expand.py +327 -0
- gobby/tasks/research.py +421 -0
- gobby/tasks/tdd.py +352 -0
- gobby/tasks/tree_builder.py +263 -0
- gobby/tasks/validation.py +712 -0
- gobby/tasks/validation_history.py +357 -0
- gobby/tasks/validation_models.py +89 -0
- gobby/tools/__init__.py +0 -0
- gobby/tools/summarizer.py +170 -0
- gobby/tui/__init__.py +5 -0
- gobby/tui/api_client.py +281 -0
- gobby/tui/app.py +327 -0
- gobby/tui/screens/__init__.py +25 -0
- gobby/tui/screens/agents.py +333 -0
- gobby/tui/screens/chat.py +450 -0
- gobby/tui/screens/dashboard.py +377 -0
- gobby/tui/screens/memory.py +305 -0
- gobby/tui/screens/metrics.py +231 -0
- gobby/tui/screens/orchestrator.py +904 -0
- gobby/tui/screens/sessions.py +412 -0
- gobby/tui/screens/tasks.py +442 -0
- gobby/tui/screens/workflows.py +289 -0
- gobby/tui/screens/worktrees.py +174 -0
- gobby/tui/widgets/__init__.py +21 -0
- gobby/tui/widgets/chat.py +210 -0
- gobby/tui/widgets/conductor.py +104 -0
- gobby/tui/widgets/menu.py +132 -0
- gobby/tui/widgets/message_panel.py +160 -0
- gobby/tui/widgets/review_gate.py +224 -0
- gobby/tui/widgets/task_tree.py +99 -0
- gobby/tui/widgets/token_budget.py +166 -0
- gobby/tui/ws_client.py +258 -0
- gobby/utils/__init__.py +3 -0
- gobby/utils/daemon_client.py +235 -0
- gobby/utils/git.py +222 -0
- gobby/utils/id.py +38 -0
- gobby/utils/json_helpers.py +161 -0
- gobby/utils/logging.py +376 -0
- gobby/utils/machine_id.py +135 -0
- gobby/utils/metrics.py +589 -0
- gobby/utils/project_context.py +182 -0
- gobby/utils/project_init.py +263 -0
- gobby/utils/status.py +256 -0
- gobby/utils/validation.py +80 -0
- gobby/utils/version.py +23 -0
- gobby/workflows/__init__.py +4 -0
- gobby/workflows/actions.py +1310 -0
- gobby/workflows/approval_flow.py +138 -0
- gobby/workflows/artifact_actions.py +103 -0
- gobby/workflows/audit_helpers.py +110 -0
- gobby/workflows/autonomous_actions.py +286 -0
- gobby/workflows/context_actions.py +394 -0
- gobby/workflows/definitions.py +130 -0
- gobby/workflows/detection_helpers.py +208 -0
- gobby/workflows/engine.py +485 -0
- gobby/workflows/evaluator.py +669 -0
- gobby/workflows/git_utils.py +96 -0
- gobby/workflows/hooks.py +169 -0
- gobby/workflows/lifecycle_evaluator.py +613 -0
- gobby/workflows/llm_actions.py +70 -0
- gobby/workflows/loader.py +333 -0
- gobby/workflows/mcp_actions.py +60 -0
- gobby/workflows/memory_actions.py +272 -0
- gobby/workflows/premature_stop.py +164 -0
- gobby/workflows/session_actions.py +139 -0
- gobby/workflows/state_actions.py +123 -0
- gobby/workflows/state_manager.py +104 -0
- gobby/workflows/stop_signal_actions.py +163 -0
- gobby/workflows/summary_actions.py +344 -0
- gobby/workflows/task_actions.py +249 -0
- gobby/workflows/task_enforcement_actions.py +901 -0
- gobby/workflows/templates.py +52 -0
- gobby/workflows/todo_actions.py +84 -0
- gobby/workflows/webhook.py +223 -0
- gobby/workflows/webhook_executor.py +399 -0
- gobby/worktrees/__init__.py +5 -0
- gobby/worktrees/git.py +690 -0
- gobby/worktrees/merge/__init__.py +20 -0
- gobby/worktrees/merge/conflict_parser.py +177 -0
- gobby/worktrees/merge/resolver.py +485 -0
- gobby-0.2.5.dist-info/METADATA +351 -0
- gobby-0.2.5.dist-info/RECORD +383 -0
- gobby-0.2.5.dist-info/WHEEL +5 -0
- gobby-0.2.5.dist-info/entry_points.txt +2 -0
- gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
- 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
|