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
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Manager for multiple MCP client connections.
|
|
3
|
+
|
|
4
|
+
Supports lazy initialization where servers are connected on-demand
|
|
5
|
+
rather than at startup, reducing resource usage and startup time.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Coroutine
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from mcp import ClientSession
|
|
15
|
+
|
|
16
|
+
from gobby.mcp_proxy.lazy import (
|
|
17
|
+
CircuitBreakerOpen,
|
|
18
|
+
LazyServerConnector,
|
|
19
|
+
RetryConfig,
|
|
20
|
+
)
|
|
21
|
+
from gobby.mcp_proxy.models import (
|
|
22
|
+
ConnectionState,
|
|
23
|
+
HealthState,
|
|
24
|
+
MCPConnectionHealth,
|
|
25
|
+
MCPError,
|
|
26
|
+
MCPServerConfig,
|
|
27
|
+
)
|
|
28
|
+
from gobby.mcp_proxy.transports.base import BaseTransportConnection
|
|
29
|
+
from gobby.mcp_proxy.transports.factory import create_transport_connection
|
|
30
|
+
|
|
31
|
+
# Alias for backward compatibility with tests
|
|
32
|
+
_create_transport_connection = create_transport_connection
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"MCPClientManager",
|
|
36
|
+
"MCPServerConfig",
|
|
37
|
+
"ConnectionState",
|
|
38
|
+
"HealthState",
|
|
39
|
+
"MCPConnectionHealth",
|
|
40
|
+
"MCPError",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("gobby.mcp.manager")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MCPClientManager:
|
|
47
|
+
"""
|
|
48
|
+
Manages multiple MCP client connections with shared authentication.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
server_configs: list[MCPServerConfig] | None = None,
|
|
54
|
+
token_refresh_callback: Callable[[], Coroutine[Any, Any, str]] | None = None,
|
|
55
|
+
health_check_interval: float = 60.0,
|
|
56
|
+
external_id: str | None = None,
|
|
57
|
+
project_path: str | None = None,
|
|
58
|
+
project_id: str | None = None,
|
|
59
|
+
mcp_db_manager: Any | None = None,
|
|
60
|
+
lazy_connect: bool = True,
|
|
61
|
+
preconnect_servers: list[str] | None = None,
|
|
62
|
+
connection_timeout: float = 30.0,
|
|
63
|
+
max_connection_retries: int = 3,
|
|
64
|
+
metrics_manager: Any | None = None,
|
|
65
|
+
):
|
|
66
|
+
"""
|
|
67
|
+
Initialize manager.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
server_configs: Initial list of server configurations
|
|
71
|
+
token_refresh_callback: Async callback that returns fresh auth token
|
|
72
|
+
health_check_interval: Seconds between health checks
|
|
73
|
+
external_id: Optional external ID (e.g. CLI key)
|
|
74
|
+
project_path: Optional project path
|
|
75
|
+
project_id: Optional project ID
|
|
76
|
+
mcp_db_manager: LocalMCPManager instance for database-backed server/tool storage.
|
|
77
|
+
When provided with project_id, loads servers from the database automatically.
|
|
78
|
+
lazy_connect: If True, defer connections until first use (default: True)
|
|
79
|
+
preconnect_servers: List of server names to connect eagerly even in lazy mode
|
|
80
|
+
connection_timeout: Timeout in seconds for connection attempts
|
|
81
|
+
max_connection_retries: Maximum retry attempts for failed connections
|
|
82
|
+
metrics_manager: ToolMetricsManager instance for recording call metrics
|
|
83
|
+
"""
|
|
84
|
+
self._connections: dict[str, BaseTransportConnection] = {}
|
|
85
|
+
self._configs: dict[str, MCPServerConfig] = {}
|
|
86
|
+
# Changed to public health attribute to match tests
|
|
87
|
+
self.health: dict[str, MCPConnectionHealth] = {}
|
|
88
|
+
self._token_refresh_callback = token_refresh_callback
|
|
89
|
+
self._health_check_interval = health_check_interval
|
|
90
|
+
self._health_check_task: asyncio.Task[None] | None = None
|
|
91
|
+
self._reconnect_tasks: set[asyncio.Task[None]] = set()
|
|
92
|
+
self._auth_token: str | None = None
|
|
93
|
+
self._running = False
|
|
94
|
+
self.external_id = external_id
|
|
95
|
+
self.project_path = project_path
|
|
96
|
+
self.project_id = project_id
|
|
97
|
+
self.mcp_db_manager = mcp_db_manager
|
|
98
|
+
self.metrics_manager = metrics_manager
|
|
99
|
+
|
|
100
|
+
# Lazy connection settings
|
|
101
|
+
self.lazy_connect = lazy_connect
|
|
102
|
+
self.preconnect_servers = set(preconnect_servers or [])
|
|
103
|
+
self.connection_timeout = connection_timeout
|
|
104
|
+
self.max_connection_retries = max_connection_retries
|
|
105
|
+
|
|
106
|
+
# Initialize lazy connector with retry config
|
|
107
|
+
self._lazy_connector = LazyServerConnector(
|
|
108
|
+
retry_config=RetryConfig(max_retries=max_connection_retries),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Load server configs from database if not provided explicitly
|
|
112
|
+
if server_configs is None and mcp_db_manager is not None:
|
|
113
|
+
if project_id:
|
|
114
|
+
# Load servers for specific project
|
|
115
|
+
db_servers = mcp_db_manager.list_servers(
|
|
116
|
+
project_id=project_id,
|
|
117
|
+
enabled_only=False,
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
# Load all servers (daemon startup)
|
|
121
|
+
db_servers = mcp_db_manager.list_all_servers(enabled_only=False)
|
|
122
|
+
|
|
123
|
+
for s in db_servers:
|
|
124
|
+
config = MCPServerConfig(
|
|
125
|
+
name=s.name,
|
|
126
|
+
transport=s.transport,
|
|
127
|
+
url=s.url,
|
|
128
|
+
command=s.command,
|
|
129
|
+
args=s.args,
|
|
130
|
+
env=s.env,
|
|
131
|
+
headers=s.headers,
|
|
132
|
+
enabled=s.enabled,
|
|
133
|
+
description=s.description,
|
|
134
|
+
project_id=s.project_id,
|
|
135
|
+
tools=self._load_tools_from_db(mcp_db_manager, s.name, s.project_id),
|
|
136
|
+
)
|
|
137
|
+
self._configs[config.name] = config
|
|
138
|
+
# Register with lazy connector for deferred connection
|
|
139
|
+
self._lazy_connector.register_server(config.name)
|
|
140
|
+
logger.info(f"Loaded {len(self._configs)} MCP servers from database")
|
|
141
|
+
elif server_configs:
|
|
142
|
+
for config in server_configs:
|
|
143
|
+
self._configs[config.name] = config
|
|
144
|
+
# Register with lazy connector for deferred connection
|
|
145
|
+
self._lazy_connector.register_server(config.name)
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _load_tools_from_db(
|
|
149
|
+
mcp_db_manager: Any, server_name: str, project_id: str
|
|
150
|
+
) -> list[dict[str, str]] | None:
|
|
151
|
+
"""
|
|
152
|
+
Load cached tools from database for a server.
|
|
153
|
+
|
|
154
|
+
Returns lightweight tool metadata for MCPServerConfig.tools field.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
tools = mcp_db_manager.get_cached_tools(server_name, project_id=project_id)
|
|
158
|
+
if not tools:
|
|
159
|
+
return None
|
|
160
|
+
return [
|
|
161
|
+
{
|
|
162
|
+
"name": tool.name,
|
|
163
|
+
"brief": (tool.description or "")[:100], # Truncate to brief
|
|
164
|
+
}
|
|
165
|
+
for tool in tools
|
|
166
|
+
]
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.warning(f"Failed to load cached tools for '{server_name}': {e}")
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def connections(self) -> dict[str, BaseTransportConnection]:
|
|
173
|
+
"""Get active connections."""
|
|
174
|
+
return self._connections
|
|
175
|
+
|
|
176
|
+
def list_connections(self) -> list[MCPServerConfig]:
|
|
177
|
+
"""List active server connections."""
|
|
178
|
+
return [self._configs[name] for name in self._connections.keys()]
|
|
179
|
+
|
|
180
|
+
def get_available_servers(self) -> list[str]:
|
|
181
|
+
"""Get list of available server names."""
|
|
182
|
+
return list(self._configs.keys())
|
|
183
|
+
|
|
184
|
+
def get_client(self, server_name: str) -> BaseTransportConnection:
|
|
185
|
+
"""Get client connection by name."""
|
|
186
|
+
if server_name not in self._configs:
|
|
187
|
+
raise ValueError(f"Unknown MCP server: '{server_name}'")
|
|
188
|
+
if server_name in self._connections:
|
|
189
|
+
return self._connections[server_name]
|
|
190
|
+
raise ValueError(f"Client '{server_name}' not connected")
|
|
191
|
+
|
|
192
|
+
def has_server(self, server_name: str) -> bool:
|
|
193
|
+
"""Check if server is configured and exists."""
|
|
194
|
+
return server_name in self._configs
|
|
195
|
+
|
|
196
|
+
async def add_server(self, config: MCPServerConfig) -> dict[str, Any]:
|
|
197
|
+
"""Add and connect to a server."""
|
|
198
|
+
if config.name in self._configs:
|
|
199
|
+
raise ValueError(f"MCP server '{config.name}' already exists")
|
|
200
|
+
|
|
201
|
+
self._configs[config.name] = config
|
|
202
|
+
|
|
203
|
+
# Persist to database if manager is available
|
|
204
|
+
if self.mcp_db_manager and config.project_id:
|
|
205
|
+
self.mcp_db_manager.upsert(
|
|
206
|
+
name=config.name,
|
|
207
|
+
transport=config.transport,
|
|
208
|
+
project_id=config.project_id,
|
|
209
|
+
url=config.url,
|
|
210
|
+
command=config.command,
|
|
211
|
+
args=config.args,
|
|
212
|
+
env=config.env,
|
|
213
|
+
headers=config.headers,
|
|
214
|
+
enabled=config.enabled,
|
|
215
|
+
description=config.description,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
tool_schemas: list[dict[str, Any]] = []
|
|
219
|
+
# Attempt connect
|
|
220
|
+
if config.enabled:
|
|
221
|
+
session = await self._connect_server(config)
|
|
222
|
+
if session:
|
|
223
|
+
try:
|
|
224
|
+
tools_result = await session.list_tools()
|
|
225
|
+
# Convert Tool objects to dicts
|
|
226
|
+
for t in tools_result.tools:
|
|
227
|
+
tool_schemas.append(
|
|
228
|
+
{
|
|
229
|
+
"name": t.name,
|
|
230
|
+
"description": getattr(t, "description", "") or "",
|
|
231
|
+
"inputSchema": getattr(t, "inputSchema", {}) or {},
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.warning(f"Failed to list tools for {config.name}: {e}")
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
"success": True,
|
|
239
|
+
"name": config.name,
|
|
240
|
+
"full_tool_schemas": tool_schemas,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async def remove_server(self, name: str, project_id: str | None = None) -> dict[str, Any]:
|
|
244
|
+
"""Remove a server."""
|
|
245
|
+
if name not in self._configs:
|
|
246
|
+
raise ValueError(f"MCP server '{name}' not found")
|
|
247
|
+
|
|
248
|
+
# Get project_id from config if not provided
|
|
249
|
+
config = self._configs[name]
|
|
250
|
+
effective_project_id = project_id or config.project_id
|
|
251
|
+
|
|
252
|
+
# Disconnect
|
|
253
|
+
if name in self._connections:
|
|
254
|
+
await self._connections[name].disconnect()
|
|
255
|
+
del self._connections[name]
|
|
256
|
+
|
|
257
|
+
del self._configs[name]
|
|
258
|
+
if name in self.health:
|
|
259
|
+
del self.health[name]
|
|
260
|
+
|
|
261
|
+
# Remove from database if manager is available
|
|
262
|
+
if self.mcp_db_manager and effective_project_id:
|
|
263
|
+
self.mcp_db_manager.remove_server(name, effective_project_id)
|
|
264
|
+
|
|
265
|
+
return {"success": True, "name": name}
|
|
266
|
+
|
|
267
|
+
async def get_health_report(self) -> dict[str, Any]:
|
|
268
|
+
"""Get async health report."""
|
|
269
|
+
return self.get_server_health()
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def server_configs(self) -> list[MCPServerConfig]:
|
|
273
|
+
"""Get all server configurations."""
|
|
274
|
+
return list(self._configs.values())
|
|
275
|
+
|
|
276
|
+
async def connect_all(self, configs: list[MCPServerConfig] | None = None) -> dict[str, bool]:
|
|
277
|
+
"""
|
|
278
|
+
Connect to multiple MCP servers.
|
|
279
|
+
|
|
280
|
+
In lazy mode (default), only connects servers in preconnect_servers list.
|
|
281
|
+
In eager mode (lazy_connect=False), connects all enabled servers.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
configs: List of server configurations. If None, uses registered configs.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
Dict mapping server names to success status
|
|
288
|
+
"""
|
|
289
|
+
self._running = True
|
|
290
|
+
results = {}
|
|
291
|
+
|
|
292
|
+
configs_to_connect = configs if configs is not None else self.server_configs
|
|
293
|
+
|
|
294
|
+
# Store configs if provided
|
|
295
|
+
if configs:
|
|
296
|
+
for config in configs:
|
|
297
|
+
self._configs[config.name] = config
|
|
298
|
+
self._lazy_connector.register_server(config.name)
|
|
299
|
+
|
|
300
|
+
# Initialize health tracking for all configs
|
|
301
|
+
for config in self.server_configs:
|
|
302
|
+
if config.name not in self.health:
|
|
303
|
+
self.health[config.name] = MCPConnectionHealth(
|
|
304
|
+
name=config.name,
|
|
305
|
+
state=ConnectionState.DISCONNECTED,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
# Start health check task if not running
|
|
309
|
+
if self._health_check_task is None:
|
|
310
|
+
self._health_check_task = asyncio.create_task(self._monitor_health())
|
|
311
|
+
|
|
312
|
+
# In lazy mode, only connect preconnect servers
|
|
313
|
+
if self.lazy_connect:
|
|
314
|
+
configs_to_connect = [
|
|
315
|
+
c for c in configs_to_connect if c.name in self.preconnect_servers
|
|
316
|
+
]
|
|
317
|
+
if configs_to_connect:
|
|
318
|
+
logger.info(
|
|
319
|
+
f"Lazy mode: preconnecting {len(configs_to_connect)} servers "
|
|
320
|
+
f"({', '.join(c.name for c in configs_to_connect)})"
|
|
321
|
+
)
|
|
322
|
+
else:
|
|
323
|
+
logger.info(
|
|
324
|
+
f"Lazy mode: no preconnect servers configured, "
|
|
325
|
+
f"{len(self._configs)} servers available on-demand"
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Connect concurrently
|
|
329
|
+
connect_tasks = []
|
|
330
|
+
bound_configs = []
|
|
331
|
+
for config in configs_to_connect:
|
|
332
|
+
if not config.enabled:
|
|
333
|
+
logger.debug(f"Skipping disabled server: {config.name}")
|
|
334
|
+
results[config.name] = False
|
|
335
|
+
continue
|
|
336
|
+
|
|
337
|
+
task = asyncio.create_task(self._connect_server(config))
|
|
338
|
+
connect_tasks.append(task)
|
|
339
|
+
bound_configs.append(config)
|
|
340
|
+
|
|
341
|
+
if not connect_tasks:
|
|
342
|
+
return results
|
|
343
|
+
|
|
344
|
+
task_results = await asyncio.gather(*connect_tasks, return_exceptions=True)
|
|
345
|
+
|
|
346
|
+
for config, result in zip(bound_configs, task_results, strict=False):
|
|
347
|
+
if isinstance(result, Exception):
|
|
348
|
+
logger.error(f"Failed to connect to {config.name}: {result}")
|
|
349
|
+
results[config.name] = False
|
|
350
|
+
else:
|
|
351
|
+
results[config.name] = bool(result)
|
|
352
|
+
if result:
|
|
353
|
+
self._lazy_connector.mark_connected(config.name)
|
|
354
|
+
|
|
355
|
+
return results
|
|
356
|
+
|
|
357
|
+
def get_lazy_connection_states(self) -> dict[str, dict[str, Any]]:
|
|
358
|
+
"""
|
|
359
|
+
Get lazy connection states for all registered servers.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
Dict mapping server names to connection state info including:
|
|
363
|
+
- is_connected: Whether server is connected
|
|
364
|
+
- configured_at: When server was configured
|
|
365
|
+
- connected_at: When server was connected (if connected)
|
|
366
|
+
- last_error: Last error message (if any)
|
|
367
|
+
- circuit_state: Circuit breaker state (closed/open/half_open)
|
|
368
|
+
"""
|
|
369
|
+
return self._lazy_connector.get_all_states()
|
|
370
|
+
|
|
371
|
+
async def health_check_all(self) -> dict[str, Any]:
|
|
372
|
+
"""Perform immediate health check on all connections."""
|
|
373
|
+
tasks = []
|
|
374
|
+
server_names = []
|
|
375
|
+
|
|
376
|
+
for name, connection in self._connections.items():
|
|
377
|
+
if connection.is_connected:
|
|
378
|
+
tasks.append(connection.health_check(timeout=5.0))
|
|
379
|
+
server_names.append(name)
|
|
380
|
+
|
|
381
|
+
if not tasks:
|
|
382
|
+
return {}
|
|
383
|
+
|
|
384
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
385
|
+
|
|
386
|
+
health_status = {}
|
|
387
|
+
for name, result in zip(server_names, results, strict=False):
|
|
388
|
+
if isinstance(result, Exception) or result is False:
|
|
389
|
+
self.health[name].record_failure("Health check failed")
|
|
390
|
+
health_status[name] = False
|
|
391
|
+
else:
|
|
392
|
+
self.health[name].record_success()
|
|
393
|
+
health_status[name] = True
|
|
394
|
+
|
|
395
|
+
return health_status
|
|
396
|
+
|
|
397
|
+
async def _connect_server(self, config: MCPServerConfig) -> ClientSession | None:
|
|
398
|
+
"""Connect to a single server."""
|
|
399
|
+
# Ensure health record exists before we try to update it
|
|
400
|
+
if config.name not in self.health:
|
|
401
|
+
self.health[config.name] = MCPConnectionHealth(
|
|
402
|
+
name=config.name, state=ConnectionState.DISCONNECTED
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
# Create transport if doesn't exist or if config changed
|
|
407
|
+
# (Simplification: always recreate for now if not connected)
|
|
408
|
+
if config.name not in self._connections:
|
|
409
|
+
connection = create_transport_connection(
|
|
410
|
+
config,
|
|
411
|
+
self._auth_token,
|
|
412
|
+
self._token_refresh_callback,
|
|
413
|
+
)
|
|
414
|
+
self._connections[config.name] = connection
|
|
415
|
+
|
|
416
|
+
connection = self._connections[config.name]
|
|
417
|
+
|
|
418
|
+
# Update health state
|
|
419
|
+
self.health[config.name].state = ConnectionState.CONNECTING
|
|
420
|
+
|
|
421
|
+
session = await connection.connect()
|
|
422
|
+
|
|
423
|
+
# Update health state
|
|
424
|
+
self.health[config.name].state = ConnectionState.CONNECTED
|
|
425
|
+
self.health[config.name].record_success()
|
|
426
|
+
|
|
427
|
+
return cast(ClientSession | None, session)
|
|
428
|
+
|
|
429
|
+
except Exception as e:
|
|
430
|
+
self.health[config.name].state = ConnectionState.FAILED
|
|
431
|
+
self.health[config.name].record_failure(str(e))
|
|
432
|
+
raise
|
|
433
|
+
|
|
434
|
+
async def disconnect_all(self) -> None:
|
|
435
|
+
"""Disconnect all active connections."""
|
|
436
|
+
self._running = False
|
|
437
|
+
|
|
438
|
+
if self._health_check_task:
|
|
439
|
+
self._health_check_task.cancel()
|
|
440
|
+
try:
|
|
441
|
+
await self._health_check_task
|
|
442
|
+
except asyncio.CancelledError:
|
|
443
|
+
pass
|
|
444
|
+
self._health_check_task = None
|
|
445
|
+
|
|
446
|
+
# Cancel any pending reconnect tasks
|
|
447
|
+
for task in list(self._reconnect_tasks):
|
|
448
|
+
task.cancel()
|
|
449
|
+
if self._reconnect_tasks:
|
|
450
|
+
await asyncio.gather(*self._reconnect_tasks, return_exceptions=True)
|
|
451
|
+
self._reconnect_tasks.clear()
|
|
452
|
+
|
|
453
|
+
async def disconnect_with_timeout(name: str, connection: Any) -> None:
|
|
454
|
+
try:
|
|
455
|
+
await asyncio.wait_for(connection.disconnect(), timeout=5.0)
|
|
456
|
+
except TimeoutError:
|
|
457
|
+
logger.warning(f"Connection disconnect timed out for {name}")
|
|
458
|
+
except Exception as e:
|
|
459
|
+
logger.warning(f"Error disconnecting {name}: {e}")
|
|
460
|
+
|
|
461
|
+
tasks = []
|
|
462
|
+
for name, connection in self._connections.items():
|
|
463
|
+
if connection.is_connected:
|
|
464
|
+
tasks.append(asyncio.create_task(disconnect_with_timeout(name, connection)))
|
|
465
|
+
self.health[name].state = ConnectionState.DISCONNECTED
|
|
466
|
+
|
|
467
|
+
if tasks:
|
|
468
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
469
|
+
|
|
470
|
+
self._connections.clear()
|
|
471
|
+
|
|
472
|
+
async def ensure_connected(self, server_name: str) -> ClientSession:
|
|
473
|
+
"""
|
|
474
|
+
Ensure a server is connected, connecting lazily if needed.
|
|
475
|
+
|
|
476
|
+
This is the main entry point for lazy connection. It handles:
|
|
477
|
+
- First-time connection for unconfigured servers
|
|
478
|
+
- Reconnection for disconnected servers
|
|
479
|
+
- Circuit breaker protection against repeated failures
|
|
480
|
+
- Exponential backoff retry on connection failure
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
server_name: Name of server to connect
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Active ClientSession for the server
|
|
487
|
+
|
|
488
|
+
Raises:
|
|
489
|
+
KeyError: If server not configured
|
|
490
|
+
CircuitBreakerOpen: If circuit breaker is open (too many failures)
|
|
491
|
+
MCPError: If connection fails after retries
|
|
492
|
+
"""
|
|
493
|
+
if server_name not in self._configs:
|
|
494
|
+
raise KeyError(f"Server '{server_name}' not configured")
|
|
495
|
+
|
|
496
|
+
config = self._configs[server_name]
|
|
497
|
+
|
|
498
|
+
# Check if server is disabled
|
|
499
|
+
if not config.enabled:
|
|
500
|
+
raise MCPError(f"Server '{server_name}' is disabled")
|
|
501
|
+
|
|
502
|
+
# Check if already connected
|
|
503
|
+
if server_name in self._connections:
|
|
504
|
+
connection = self._connections[server_name]
|
|
505
|
+
if connection.is_connected and connection.session:
|
|
506
|
+
return connection.session
|
|
507
|
+
|
|
508
|
+
# Check circuit breaker
|
|
509
|
+
if not self._lazy_connector.can_attempt_connection(server_name):
|
|
510
|
+
state = self._lazy_connector.get_state(server_name)
|
|
511
|
+
if state and state.circuit_breaker.last_failure_time:
|
|
512
|
+
elapsed = time.time() - state.circuit_breaker.last_failure_time
|
|
513
|
+
recovery_in = max(0, state.circuit_breaker.recovery_timeout - elapsed)
|
|
514
|
+
raise CircuitBreakerOpen(server_name, recovery_in)
|
|
515
|
+
raise MCPError(f"Circuit breaker open for '{server_name}'")
|
|
516
|
+
|
|
517
|
+
# Use lock to prevent concurrent connection attempts
|
|
518
|
+
async with self._lazy_connector.get_connection_lock(server_name):
|
|
519
|
+
# Double-check after acquiring lock
|
|
520
|
+
if server_name in self._connections:
|
|
521
|
+
connection = self._connections[server_name]
|
|
522
|
+
if connection.is_connected and connection.session:
|
|
523
|
+
return connection.session
|
|
524
|
+
|
|
525
|
+
# Attempt connection with retry
|
|
526
|
+
retry_config = self._lazy_connector.retry_config
|
|
527
|
+
last_error: Exception | None = None
|
|
528
|
+
|
|
529
|
+
for attempt in range(retry_config.max_retries + 1):
|
|
530
|
+
try:
|
|
531
|
+
state = self._lazy_connector.get_state(server_name)
|
|
532
|
+
if state:
|
|
533
|
+
state.record_connection_attempt()
|
|
534
|
+
|
|
535
|
+
session = await asyncio.wait_for(
|
|
536
|
+
self._connect_server(config),
|
|
537
|
+
timeout=self.connection_timeout,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
if session:
|
|
541
|
+
self._lazy_connector.mark_connected(server_name)
|
|
542
|
+
return session
|
|
543
|
+
else:
|
|
544
|
+
raise MCPError(f"Connection returned no session for '{server_name}'")
|
|
545
|
+
|
|
546
|
+
except TimeoutError:
|
|
547
|
+
last_error = MCPError(f"Connection timeout after {self.connection_timeout}s")
|
|
548
|
+
self._lazy_connector.mark_failed(server_name, str(last_error))
|
|
549
|
+
except Exception as e:
|
|
550
|
+
last_error = e
|
|
551
|
+
self._lazy_connector.mark_failed(server_name, str(e))
|
|
552
|
+
|
|
553
|
+
# If not last attempt, wait with exponential backoff
|
|
554
|
+
if attempt < retry_config.max_retries:
|
|
555
|
+
delay = retry_config.get_delay(attempt)
|
|
556
|
+
logger.warning(
|
|
557
|
+
f"Connection to '{server_name}' failed (attempt {attempt + 1}/"
|
|
558
|
+
f"{retry_config.max_retries + 1}), retrying in {delay:.1f}s: {last_error}"
|
|
559
|
+
)
|
|
560
|
+
await asyncio.sleep(delay)
|
|
561
|
+
|
|
562
|
+
# All retries exhausted
|
|
563
|
+
raise MCPError(
|
|
564
|
+
f"Failed to connect to '{server_name}' after "
|
|
565
|
+
f"{retry_config.max_retries + 1} attempts: {last_error}"
|
|
566
|
+
) from last_error
|
|
567
|
+
|
|
568
|
+
async def get_client_session(self, server_name: str) -> ClientSession:
|
|
569
|
+
"""
|
|
570
|
+
Get active MCP client session for server, connecting lazily if needed.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
server_name: Name of server
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Active ClientSession
|
|
577
|
+
|
|
578
|
+
Raises:
|
|
579
|
+
KeyError: If server not configured
|
|
580
|
+
MCPError: If not connected and connection fails
|
|
581
|
+
"""
|
|
582
|
+
# Use ensure_connected for lazy connection
|
|
583
|
+
return await self.ensure_connected(server_name)
|
|
584
|
+
|
|
585
|
+
async def call_tool(
|
|
586
|
+
self,
|
|
587
|
+
server_name: str,
|
|
588
|
+
tool_name: str,
|
|
589
|
+
arguments: dict[str, Any] | None = None,
|
|
590
|
+
timeout: float | None = None,
|
|
591
|
+
) -> Any:
|
|
592
|
+
"""Call a tool on a specific server."""
|
|
593
|
+
start_time = time.perf_counter()
|
|
594
|
+
success = False
|
|
595
|
+
try:
|
|
596
|
+
session = await self.get_client_session(server_name)
|
|
597
|
+
if timeout:
|
|
598
|
+
result = await asyncio.wait_for(
|
|
599
|
+
session.call_tool(tool_name, arguments or {}), timeout=timeout
|
|
600
|
+
)
|
|
601
|
+
else:
|
|
602
|
+
result = await session.call_tool(tool_name, arguments or {})
|
|
603
|
+
self.health[server_name].record_success()
|
|
604
|
+
success = True
|
|
605
|
+
return result
|
|
606
|
+
except Exception as e:
|
|
607
|
+
if server_name in self.health:
|
|
608
|
+
self.health[server_name].record_failure(str(e))
|
|
609
|
+
raise
|
|
610
|
+
finally:
|
|
611
|
+
# Record metrics if manager is configured
|
|
612
|
+
if self.metrics_manager:
|
|
613
|
+
latency_ms = (time.perf_counter() - start_time) * 1000
|
|
614
|
+
# Get project_id from server config (servers are project-scoped)
|
|
615
|
+
server_config = self._configs.get(server_name)
|
|
616
|
+
metrics_project_id = server_config.project_id if server_config else self.project_id
|
|
617
|
+
if metrics_project_id:
|
|
618
|
+
try:
|
|
619
|
+
self.metrics_manager.record_call(
|
|
620
|
+
server_name=server_name,
|
|
621
|
+
tool_name=tool_name,
|
|
622
|
+
project_id=metrics_project_id,
|
|
623
|
+
latency_ms=latency_ms,
|
|
624
|
+
success=success,
|
|
625
|
+
)
|
|
626
|
+
except Exception:
|
|
627
|
+
# Don't let metrics recording failures affect tool calls
|
|
628
|
+
logger.debug(f"Failed to record metrics for {server_name}.{tool_name}")
|
|
629
|
+
|
|
630
|
+
async def read_resource(self, server_name: str, uri: str) -> Any:
|
|
631
|
+
"""Read a resource from a specific server."""
|
|
632
|
+
try:
|
|
633
|
+
session = await self.get_client_session(server_name)
|
|
634
|
+
# Ensure uri is string and cast for type checker if needed,
|
|
635
|
+
# though runtime usually handles string -> AnyUrl coercion in pydantic
|
|
636
|
+
result = await session.read_resource(cast(Any, str(uri)))
|
|
637
|
+
self.health[server_name].record_success()
|
|
638
|
+
return result
|
|
639
|
+
except Exception as e:
|
|
640
|
+
if server_name in self.health:
|
|
641
|
+
self.health[server_name].record_failure(str(e))
|
|
642
|
+
raise
|
|
643
|
+
|
|
644
|
+
async def list_tools(self, server_name: str | None = None) -> dict[str, list[dict[str, Any]]]:
|
|
645
|
+
"""
|
|
646
|
+
List tools from one or all servers.
|
|
647
|
+
|
|
648
|
+
Args:
|
|
649
|
+
server_name: Optional single server name
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
Dict mapping server names to tool lists
|
|
653
|
+
"""
|
|
654
|
+
results = {}
|
|
655
|
+
servers = [server_name] if server_name else self._connections.keys()
|
|
656
|
+
|
|
657
|
+
for name in servers:
|
|
658
|
+
try:
|
|
659
|
+
session = await self.get_client_session(name)
|
|
660
|
+
tools = await session.list_tools()
|
|
661
|
+
# Assuming tools is a ListToolsResult or similar Pydantic model
|
|
662
|
+
# We need to serialize it or return it as is.
|
|
663
|
+
# Inspecting mcp-python-sdk, list_tools returns ListToolsResult.
|
|
664
|
+
# Let's return the raw object or access .tools
|
|
665
|
+
if hasattr(tools, "tools"):
|
|
666
|
+
results[name] = [
|
|
667
|
+
{
|
|
668
|
+
"name": t.name,
|
|
669
|
+
"description": getattr(t, "description", "") or "",
|
|
670
|
+
"inputSchema": getattr(t, "inputSchema", {}) or {},
|
|
671
|
+
}
|
|
672
|
+
for t in tools.tools
|
|
673
|
+
]
|
|
674
|
+
else:
|
|
675
|
+
results[name] = []
|
|
676
|
+
|
|
677
|
+
self.health[name].record_success()
|
|
678
|
+
except Exception as e:
|
|
679
|
+
logger.warning(f"Failed to list tools for {name}: {e}")
|
|
680
|
+
self.health[name].record_failure(str(e))
|
|
681
|
+
results[name] = []
|
|
682
|
+
|
|
683
|
+
return results
|
|
684
|
+
|
|
685
|
+
async def get_tool_input_schema(self, server_name: str, tool_name: str) -> dict[str, Any]:
|
|
686
|
+
"""Get full inputSchema for a specific tool."""
|
|
687
|
+
|
|
688
|
+
# This is an optimization. Instead of calling list_tools again,
|
|
689
|
+
# we try to fetch it. But standard MCP list_tools returns everything.
|
|
690
|
+
# So we just filter the output of list_tools.
|
|
691
|
+
|
|
692
|
+
tools = await self.list_tools(server_name)
|
|
693
|
+
server_tools = tools.get(server_name, [])
|
|
694
|
+
|
|
695
|
+
for tool in server_tools:
|
|
696
|
+
# tool might be an object or dict
|
|
697
|
+
t_name = getattr(tool, "name", tool.get("name") if isinstance(tool, dict) else None)
|
|
698
|
+
if t_name == tool_name:
|
|
699
|
+
# Return schema
|
|
700
|
+
if isinstance(tool, dict) and "inputSchema" in tool:
|
|
701
|
+
return cast(dict[str, Any], tool["inputSchema"])
|
|
702
|
+
|
|
703
|
+
raise MCPError(f"Tool {tool_name} not found on server {server_name}")
|
|
704
|
+
|
|
705
|
+
async def _monitor_health(self) -> None:
|
|
706
|
+
"""Background task to monitor connection health."""
|
|
707
|
+
while self._running:
|
|
708
|
+
try:
|
|
709
|
+
await asyncio.sleep(self._health_check_interval)
|
|
710
|
+
|
|
711
|
+
tasks = []
|
|
712
|
+
server_names = []
|
|
713
|
+
|
|
714
|
+
for name, connection in self._connections.items():
|
|
715
|
+
if connection.is_connected:
|
|
716
|
+
tasks.append(connection.health_check(timeout=5.0))
|
|
717
|
+
server_names.append(name)
|
|
718
|
+
|
|
719
|
+
if not tasks:
|
|
720
|
+
continue
|
|
721
|
+
|
|
722
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
723
|
+
|
|
724
|
+
for name, result in zip(server_names, results, strict=False):
|
|
725
|
+
if isinstance(result, Exception) or result is False:
|
|
726
|
+
# Health check failed
|
|
727
|
+
self.health[name].record_failure("Health check failed")
|
|
728
|
+
logger.warning(f"Health check failed for {name}")
|
|
729
|
+
|
|
730
|
+
# Trigger reconnect if critical
|
|
731
|
+
if self.health[name].health == HealthState.UNHEALTHY:
|
|
732
|
+
logger.info(f"Attempting reconnection for unhealthy server: {name}")
|
|
733
|
+
task = asyncio.create_task(self._reconnect(name))
|
|
734
|
+
self._reconnect_tasks.add(task)
|
|
735
|
+
task.add_done_callback(self._reconnect_tasks.discard)
|
|
736
|
+
else:
|
|
737
|
+
self.health[name].record_success()
|
|
738
|
+
|
|
739
|
+
except asyncio.CancelledError:
|
|
740
|
+
break
|
|
741
|
+
except Exception as e:
|
|
742
|
+
logger.error(f"Error in health monitor: {e}")
|
|
743
|
+
|
|
744
|
+
async def _reconnect(self, server_name: str) -> None:
|
|
745
|
+
"""Attempt to reconnect a server."""
|
|
746
|
+
if server_name not in self._configs:
|
|
747
|
+
return
|
|
748
|
+
|
|
749
|
+
config = self._configs[server_name]
|
|
750
|
+
try:
|
|
751
|
+
logger.info(f"Reconnecting {server_name}...")
|
|
752
|
+
await self._connect_server(config)
|
|
753
|
+
logger.info(f"Successfully reconnected {server_name}")
|
|
754
|
+
except Exception as e:
|
|
755
|
+
logger.error(f"Reconnection failed for {server_name}: {e}")
|
|
756
|
+
|
|
757
|
+
def get_server_health(self) -> dict[str, dict[str, Any]]:
|
|
758
|
+
"""Get health status for all servers."""
|
|
759
|
+
return {
|
|
760
|
+
name: {
|
|
761
|
+
"state": status.state.value,
|
|
762
|
+
"health": status.health.value,
|
|
763
|
+
"last_check": (
|
|
764
|
+
status.last_health_check.isoformat() if status.last_health_check else None
|
|
765
|
+
),
|
|
766
|
+
"failures": status.consecutive_failures,
|
|
767
|
+
"response_time_ms": status.response_time_ms,
|
|
768
|
+
}
|
|
769
|
+
for name, status in self.health.items()
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
def add_server_config(self, config: MCPServerConfig) -> None:
|
|
773
|
+
"""Register a new server configuration."""
|
|
774
|
+
self._configs[config.name] = config
|
|
775
|
+
if config.name not in self.health:
|
|
776
|
+
self.health[config.name] = MCPConnectionHealth(
|
|
777
|
+
name=config.name, state=ConnectionState.DISCONNECTED
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def remove_server_config(self, name: str) -> None:
|
|
781
|
+
"""Remove a server configuration.
|
|
782
|
+
|
|
783
|
+
Raises RuntimeError if a connection exists for the server,
|
|
784
|
+
forcing callers to disconnect first.
|
|
785
|
+
"""
|
|
786
|
+
if name in self._connections:
|
|
787
|
+
# Raise instead of async cleanup to keep this method synchronous.
|
|
788
|
+
# Callers must explicitly disconnect before removing config.
|
|
789
|
+
logger.warning(
|
|
790
|
+
f"Removing config for '{name}' but connection still exists. "
|
|
791
|
+
"You should disconnect the server first."
|
|
792
|
+
)
|
|
793
|
+
raise RuntimeError(
|
|
794
|
+
f"Cannot remove config for connected server '{name}'. Disconnect it first."
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
if name in self._configs:
|
|
798
|
+
del self._configs[name]
|