fast-agent-mcp 0.4.7__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.
- fast_agent/__init__.py +183 -0
- fast_agent/acp/__init__.py +19 -0
- fast_agent/acp/acp_aware_mixin.py +304 -0
- fast_agent/acp/acp_context.py +437 -0
- fast_agent/acp/content_conversion.py +136 -0
- fast_agent/acp/filesystem_runtime.py +427 -0
- fast_agent/acp/permission_store.py +269 -0
- fast_agent/acp/server/__init__.py +5 -0
- fast_agent/acp/server/agent_acp_server.py +1472 -0
- fast_agent/acp/slash_commands.py +1050 -0
- fast_agent/acp/terminal_runtime.py +408 -0
- fast_agent/acp/tool_permission_adapter.py +125 -0
- fast_agent/acp/tool_permissions.py +474 -0
- fast_agent/acp/tool_progress.py +814 -0
- fast_agent/agents/__init__.py +85 -0
- fast_agent/agents/agent_types.py +64 -0
- fast_agent/agents/llm_agent.py +350 -0
- fast_agent/agents/llm_decorator.py +1139 -0
- fast_agent/agents/mcp_agent.py +1337 -0
- fast_agent/agents/tool_agent.py +271 -0
- fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
- fast_agent/agents/workflow/chain_agent.py +212 -0
- fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
- fast_agent/agents/workflow/iterative_planner.py +652 -0
- fast_agent/agents/workflow/maker_agent.py +379 -0
- fast_agent/agents/workflow/orchestrator_models.py +218 -0
- fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
- fast_agent/agents/workflow/parallel_agent.py +250 -0
- fast_agent/agents/workflow/router_agent.py +353 -0
- fast_agent/cli/__init__.py +0 -0
- fast_agent/cli/__main__.py +73 -0
- fast_agent/cli/commands/acp.py +159 -0
- fast_agent/cli/commands/auth.py +404 -0
- fast_agent/cli/commands/check_config.py +783 -0
- fast_agent/cli/commands/go.py +514 -0
- fast_agent/cli/commands/quickstart.py +557 -0
- fast_agent/cli/commands/serve.py +143 -0
- fast_agent/cli/commands/server_helpers.py +114 -0
- fast_agent/cli/commands/setup.py +174 -0
- fast_agent/cli/commands/url_parser.py +190 -0
- fast_agent/cli/constants.py +40 -0
- fast_agent/cli/main.py +115 -0
- fast_agent/cli/terminal.py +24 -0
- fast_agent/config.py +798 -0
- fast_agent/constants.py +41 -0
- fast_agent/context.py +279 -0
- fast_agent/context_dependent.py +50 -0
- fast_agent/core/__init__.py +92 -0
- fast_agent/core/agent_app.py +448 -0
- fast_agent/core/core_app.py +137 -0
- fast_agent/core/direct_decorators.py +784 -0
- fast_agent/core/direct_factory.py +620 -0
- fast_agent/core/error_handling.py +27 -0
- fast_agent/core/exceptions.py +90 -0
- fast_agent/core/executor/__init__.py +0 -0
- fast_agent/core/executor/executor.py +280 -0
- fast_agent/core/executor/task_registry.py +32 -0
- fast_agent/core/executor/workflow_signal.py +324 -0
- fast_agent/core/fastagent.py +1186 -0
- fast_agent/core/logging/__init__.py +5 -0
- fast_agent/core/logging/events.py +138 -0
- fast_agent/core/logging/json_serializer.py +164 -0
- fast_agent/core/logging/listeners.py +309 -0
- fast_agent/core/logging/logger.py +278 -0
- fast_agent/core/logging/transport.py +481 -0
- fast_agent/core/prompt.py +9 -0
- fast_agent/core/prompt_templates.py +183 -0
- fast_agent/core/validation.py +326 -0
- fast_agent/event_progress.py +62 -0
- fast_agent/history/history_exporter.py +49 -0
- fast_agent/human_input/__init__.py +47 -0
- fast_agent/human_input/elicitation_handler.py +123 -0
- fast_agent/human_input/elicitation_state.py +33 -0
- fast_agent/human_input/form_elements.py +59 -0
- fast_agent/human_input/form_fields.py +256 -0
- fast_agent/human_input/simple_form.py +113 -0
- fast_agent/human_input/types.py +40 -0
- fast_agent/interfaces.py +310 -0
- fast_agent/llm/__init__.py +9 -0
- fast_agent/llm/cancellation.py +22 -0
- fast_agent/llm/fastagent_llm.py +931 -0
- fast_agent/llm/internal/passthrough.py +161 -0
- fast_agent/llm/internal/playback.py +129 -0
- fast_agent/llm/internal/silent.py +41 -0
- fast_agent/llm/internal/slow.py +38 -0
- fast_agent/llm/memory.py +275 -0
- fast_agent/llm/model_database.py +490 -0
- fast_agent/llm/model_factory.py +388 -0
- fast_agent/llm/model_info.py +102 -0
- fast_agent/llm/prompt_utils.py +155 -0
- fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
- fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
- fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
- fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
- fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
- fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
- fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
- fast_agent/llm/provider/google/google_converter.py +466 -0
- fast_agent/llm/provider/google/llm_google_native.py +681 -0
- fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
- fast_agent/llm/provider/openai/llm_azure.py +143 -0
- fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
- fast_agent/llm/provider/openai/llm_generic.py +35 -0
- fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
- fast_agent/llm/provider/openai/llm_groq.py +42 -0
- fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
- fast_agent/llm/provider/openai/llm_openai.py +1195 -0
- fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
- fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
- fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
- fast_agent/llm/provider/openai/llm_xai.py +38 -0
- fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
- fast_agent/llm/provider/openai/openai_multipart.py +169 -0
- fast_agent/llm/provider/openai/openai_utils.py +67 -0
- fast_agent/llm/provider/openai/responses.py +133 -0
- fast_agent/llm/provider_key_manager.py +139 -0
- fast_agent/llm/provider_types.py +34 -0
- fast_agent/llm/request_params.py +61 -0
- fast_agent/llm/sampling_converter.py +98 -0
- fast_agent/llm/stream_types.py +9 -0
- fast_agent/llm/usage_tracking.py +445 -0
- fast_agent/mcp/__init__.py +56 -0
- fast_agent/mcp/common.py +26 -0
- fast_agent/mcp/elicitation_factory.py +84 -0
- fast_agent/mcp/elicitation_handlers.py +164 -0
- fast_agent/mcp/gen_client.py +83 -0
- fast_agent/mcp/helpers/__init__.py +36 -0
- fast_agent/mcp/helpers/content_helpers.py +352 -0
- fast_agent/mcp/helpers/server_config_helpers.py +25 -0
- fast_agent/mcp/hf_auth.py +147 -0
- fast_agent/mcp/interfaces.py +92 -0
- fast_agent/mcp/logger_textio.py +108 -0
- fast_agent/mcp/mcp_agent_client_session.py +411 -0
- fast_agent/mcp/mcp_aggregator.py +2175 -0
- fast_agent/mcp/mcp_connection_manager.py +723 -0
- fast_agent/mcp/mcp_content.py +262 -0
- fast_agent/mcp/mime_utils.py +108 -0
- fast_agent/mcp/oauth_client.py +509 -0
- fast_agent/mcp/prompt.py +159 -0
- fast_agent/mcp/prompt_message_extended.py +155 -0
- fast_agent/mcp/prompt_render.py +84 -0
- fast_agent/mcp/prompt_serialization.py +580 -0
- fast_agent/mcp/prompts/__init__.py +0 -0
- fast_agent/mcp/prompts/__main__.py +7 -0
- fast_agent/mcp/prompts/prompt_constants.py +18 -0
- fast_agent/mcp/prompts/prompt_helpers.py +238 -0
- fast_agent/mcp/prompts/prompt_load.py +186 -0
- fast_agent/mcp/prompts/prompt_server.py +552 -0
- fast_agent/mcp/prompts/prompt_template.py +438 -0
- fast_agent/mcp/resource_utils.py +215 -0
- fast_agent/mcp/sampling.py +200 -0
- fast_agent/mcp/server/__init__.py +4 -0
- fast_agent/mcp/server/agent_server.py +613 -0
- fast_agent/mcp/skybridge.py +44 -0
- fast_agent/mcp/sse_tracking.py +287 -0
- fast_agent/mcp/stdio_tracking_simple.py +59 -0
- fast_agent/mcp/streamable_http_tracking.py +309 -0
- fast_agent/mcp/tool_execution_handler.py +137 -0
- fast_agent/mcp/tool_permission_handler.py +88 -0
- fast_agent/mcp/transport_tracking.py +634 -0
- fast_agent/mcp/types.py +24 -0
- fast_agent/mcp/ui_agent.py +48 -0
- fast_agent/mcp/ui_mixin.py +209 -0
- fast_agent/mcp_server_registry.py +89 -0
- fast_agent/py.typed +0 -0
- fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
- fast_agent/resources/examples/data-analysis/analysis.py +68 -0
- fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
- fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
- fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
- fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
- fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
- fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
- fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
- fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
- fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
- fast_agent/resources/examples/researcher/researcher.py +36 -0
- fast_agent/resources/examples/tensorzero/.env.sample +2 -0
- fast_agent/resources/examples/tensorzero/Makefile +31 -0
- fast_agent/resources/examples/tensorzero/README.md +56 -0
- fast_agent/resources/examples/tensorzero/agent.py +35 -0
- fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
- fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
- fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
- fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
- fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
- fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
- fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
- fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
- fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
- fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
- fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
- fast_agent/resources/examples/workflows/chaining.py +37 -0
- fast_agent/resources/examples/workflows/evaluator.py +77 -0
- fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
- fast_agent/resources/examples/workflows/graded_report.md +89 -0
- fast_agent/resources/examples/workflows/human_input.py +28 -0
- fast_agent/resources/examples/workflows/maker.py +156 -0
- fast_agent/resources/examples/workflows/orchestrator.py +70 -0
- fast_agent/resources/examples/workflows/parallel.py +56 -0
- fast_agent/resources/examples/workflows/router.py +69 -0
- fast_agent/resources/examples/workflows/short_story.md +13 -0
- fast_agent/resources/examples/workflows/short_story.txt +19 -0
- fast_agent/resources/setup/.gitignore +30 -0
- fast_agent/resources/setup/agent.py +28 -0
- fast_agent/resources/setup/fastagent.config.yaml +65 -0
- fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
- fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
- fast_agent/skills/__init__.py +9 -0
- fast_agent/skills/registry.py +235 -0
- fast_agent/tools/elicitation.py +369 -0
- fast_agent/tools/shell_runtime.py +402 -0
- fast_agent/types/__init__.py +59 -0
- fast_agent/types/conversation_summary.py +294 -0
- fast_agent/types/llm_stop_reason.py +78 -0
- fast_agent/types/message_search.py +249 -0
- fast_agent/ui/__init__.py +38 -0
- fast_agent/ui/console.py +59 -0
- fast_agent/ui/console_display.py +1080 -0
- fast_agent/ui/elicitation_form.py +946 -0
- fast_agent/ui/elicitation_style.py +59 -0
- fast_agent/ui/enhanced_prompt.py +1400 -0
- fast_agent/ui/history_display.py +734 -0
- fast_agent/ui/interactive_prompt.py +1199 -0
- fast_agent/ui/markdown_helpers.py +104 -0
- fast_agent/ui/markdown_truncator.py +1004 -0
- fast_agent/ui/mcp_display.py +857 -0
- fast_agent/ui/mcp_ui_utils.py +235 -0
- fast_agent/ui/mermaid_utils.py +169 -0
- fast_agent/ui/message_primitives.py +50 -0
- fast_agent/ui/notification_tracker.py +205 -0
- fast_agent/ui/plain_text_truncator.py +68 -0
- fast_agent/ui/progress_display.py +10 -0
- fast_agent/ui/rich_progress.py +195 -0
- fast_agent/ui/streaming.py +774 -0
- fast_agent/ui/streaming_buffer.py +449 -0
- fast_agent/ui/tool_display.py +422 -0
- fast_agent/ui/usage_display.py +204 -0
- fast_agent/utils/__init__.py +5 -0
- fast_agent/utils/reasoning_stream_parser.py +77 -0
- fast_agent/utils/time.py +22 -0
- fast_agent/workflow_telemetry.py +261 -0
- fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
- fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
- fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
- fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
- fast_agent_mcp-0.4.7.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Manages the lifecycle of multiple MCP server connections.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import traceback
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from typing import TYPE_CHECKING, AsyncGenerator, Callable, Union
|
|
9
|
+
|
|
10
|
+
from anyio import Event, Lock, create_task_group
|
|
11
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
12
|
+
from httpx import HTTPStatusError
|
|
13
|
+
from mcp import ClientSession
|
|
14
|
+
from mcp.client.stdio import (
|
|
15
|
+
StdioServerParameters,
|
|
16
|
+
get_default_environment,
|
|
17
|
+
)
|
|
18
|
+
from mcp.client.streamable_http import GetSessionIdCallback
|
|
19
|
+
from mcp.types import Implementation, JSONRPCMessage, ServerCapabilities
|
|
20
|
+
|
|
21
|
+
from fast_agent.config import MCPServerSettings
|
|
22
|
+
from fast_agent.context_dependent import ContextDependent
|
|
23
|
+
from fast_agent.core.exceptions import ServerInitializationError
|
|
24
|
+
from fast_agent.core.logging.logger import get_logger
|
|
25
|
+
from fast_agent.event_progress import ProgressAction
|
|
26
|
+
from fast_agent.mcp.logger_textio import get_stderr_handler
|
|
27
|
+
from fast_agent.mcp.mcp_agent_client_session import MCPAgentClientSession
|
|
28
|
+
from fast_agent.mcp.oauth_client import build_oauth_provider
|
|
29
|
+
from fast_agent.mcp.sse_tracking import tracking_sse_client
|
|
30
|
+
from fast_agent.mcp.stdio_tracking_simple import tracking_stdio_client
|
|
31
|
+
from fast_agent.mcp.streamable_http_tracking import tracking_streamablehttp_client
|
|
32
|
+
from fast_agent.mcp.transport_tracking import TransportChannelMetrics
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from mcp.client.auth import OAuthClientProvider
|
|
36
|
+
|
|
37
|
+
from fast_agent.context import Context
|
|
38
|
+
from fast_agent.mcp_server_registry import ServerRegistry
|
|
39
|
+
|
|
40
|
+
logger = get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StreamingContextAdapter:
|
|
44
|
+
"""Adapter to provide a 3-value context from a 2-value context manager"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, context_manager):
|
|
47
|
+
self.context_manager = context_manager
|
|
48
|
+
self.cm_instance = None
|
|
49
|
+
|
|
50
|
+
async def __aenter__(self):
|
|
51
|
+
self.cm_instance = await self.context_manager.__aenter__()
|
|
52
|
+
read_stream, write_stream = self.cm_instance
|
|
53
|
+
return read_stream, write_stream, None
|
|
54
|
+
|
|
55
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
56
|
+
return await self.context_manager.__aexit__(exc_type, exc_val, exc_tb)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _add_none_to_context(context_manager):
|
|
60
|
+
"""Helper to add a None value to context managers that return 2 values instead of 3"""
|
|
61
|
+
return StreamingContextAdapter(context_manager)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _prepare_headers_and_auth(
|
|
65
|
+
server_config: MCPServerSettings,
|
|
66
|
+
) -> tuple[dict[str, str], Union["OAuthClientProvider", None], set[str]]:
|
|
67
|
+
"""
|
|
68
|
+
Prepare request headers and determine if OAuth authentication should be used.
|
|
69
|
+
|
|
70
|
+
Returns a copy of the headers, an OAuth auth provider when applicable, and the set
|
|
71
|
+
of user-supplied authorization header keys.
|
|
72
|
+
"""
|
|
73
|
+
headers: dict[str, str] = dict(server_config.headers or {})
|
|
74
|
+
auth_header_keys = {"authorization", "x-hf-authorization"}
|
|
75
|
+
user_provided_auth_keys = {key for key in headers if key.lower() in auth_header_keys}
|
|
76
|
+
|
|
77
|
+
# OAuth is only relevant for SSE/HTTP transports and should be skipped when the
|
|
78
|
+
# user has already supplied explicit Authorization headers.
|
|
79
|
+
if server_config.transport not in ("sse", "http") or user_provided_auth_keys:
|
|
80
|
+
return headers, None, user_provided_auth_keys
|
|
81
|
+
|
|
82
|
+
oauth_auth = build_oauth_provider(server_config)
|
|
83
|
+
if oauth_auth is not None:
|
|
84
|
+
# Scrub Authorization headers so OAuth-managed credentials are the only ones sent.
|
|
85
|
+
for header_name in (
|
|
86
|
+
"Authorization",
|
|
87
|
+
"authorization",
|
|
88
|
+
"X-HF-Authorization",
|
|
89
|
+
"x-hf-authorization",
|
|
90
|
+
):
|
|
91
|
+
headers.pop(header_name, None)
|
|
92
|
+
|
|
93
|
+
return headers, oauth_auth, user_provided_auth_keys
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ServerConnection:
|
|
97
|
+
"""
|
|
98
|
+
Represents a long-lived MCP server connection, including:
|
|
99
|
+
- The ClientSession to the server
|
|
100
|
+
- The transport streams (via stdio/sse, etc.)
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
server_name: str,
|
|
106
|
+
server_config: MCPServerSettings,
|
|
107
|
+
transport_context_factory: Callable[
|
|
108
|
+
[],
|
|
109
|
+
AsyncGenerator[
|
|
110
|
+
tuple[
|
|
111
|
+
MemoryObjectReceiveStream[JSONRPCMessage | Exception],
|
|
112
|
+
MemoryObjectSendStream[JSONRPCMessage],
|
|
113
|
+
GetSessionIdCallback | None,
|
|
114
|
+
],
|
|
115
|
+
None,
|
|
116
|
+
],
|
|
117
|
+
],
|
|
118
|
+
client_session_factory: Callable[
|
|
119
|
+
[MemoryObjectReceiveStream, MemoryObjectSendStream, timedelta | None],
|
|
120
|
+
ClientSession,
|
|
121
|
+
],
|
|
122
|
+
) -> None:
|
|
123
|
+
self.server_name = server_name
|
|
124
|
+
self.server_config = server_config
|
|
125
|
+
self.session: ClientSession | None = None
|
|
126
|
+
self._client_session_factory = client_session_factory
|
|
127
|
+
self._transport_context_factory = transport_context_factory
|
|
128
|
+
# Signal that session is fully up and initialized
|
|
129
|
+
self._initialized_event = Event()
|
|
130
|
+
|
|
131
|
+
# Signal we want to shut down
|
|
132
|
+
self._shutdown_event = Event()
|
|
133
|
+
|
|
134
|
+
# Track error state
|
|
135
|
+
self._error_occurred = False
|
|
136
|
+
self._error_message = None
|
|
137
|
+
|
|
138
|
+
# Server instructions from initialization
|
|
139
|
+
self.server_instructions: str | None = None
|
|
140
|
+
self.server_capabilities: ServerCapabilities | None = None
|
|
141
|
+
self.server_implementation: Implementation | None = None
|
|
142
|
+
self.client_capabilities: dict | None = None
|
|
143
|
+
self.server_instructions_available: bool = False
|
|
144
|
+
self.server_instructions_enabled: bool = (
|
|
145
|
+
server_config.include_instructions if server_config else True
|
|
146
|
+
)
|
|
147
|
+
self.session_id: str | None = None
|
|
148
|
+
self._get_session_id_cb: GetSessionIdCallback | None = None
|
|
149
|
+
self.transport_metrics: TransportChannelMetrics | None = None
|
|
150
|
+
|
|
151
|
+
def is_healthy(self) -> bool:
|
|
152
|
+
"""Check if the server connection is healthy and ready to use."""
|
|
153
|
+
return self.session is not None and not self._error_occurred
|
|
154
|
+
|
|
155
|
+
def reset_error_state(self) -> None:
|
|
156
|
+
"""Reset the error state, allowing reconnection attempts."""
|
|
157
|
+
self._error_occurred = False
|
|
158
|
+
self._error_message = None
|
|
159
|
+
|
|
160
|
+
def request_shutdown(self) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Request the server to shut down. Signals the server lifecycle task to exit.
|
|
163
|
+
"""
|
|
164
|
+
self._shutdown_event.set()
|
|
165
|
+
|
|
166
|
+
async def wait_for_shutdown_request(self) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Wait until the shutdown event is set.
|
|
169
|
+
"""
|
|
170
|
+
await self._shutdown_event.wait()
|
|
171
|
+
|
|
172
|
+
async def initialize_session(self) -> None:
|
|
173
|
+
"""
|
|
174
|
+
Initializes the server connection and session.
|
|
175
|
+
Must be called within an async context.
|
|
176
|
+
"""
|
|
177
|
+
assert self.session, "Session must be created before initialization"
|
|
178
|
+
result = await self.session.initialize()
|
|
179
|
+
|
|
180
|
+
self.server_capabilities = result.capabilities
|
|
181
|
+
# InitializeResult exposes server info via `serverInfo`; keep fallback for older fields
|
|
182
|
+
implementation = getattr(result, "serverInfo", None)
|
|
183
|
+
if implementation is None:
|
|
184
|
+
implementation = getattr(result, "implementation", None)
|
|
185
|
+
self.server_implementation = implementation
|
|
186
|
+
|
|
187
|
+
raw_instructions = getattr(result, "instructions", None)
|
|
188
|
+
self.server_instructions_available = bool(raw_instructions)
|
|
189
|
+
|
|
190
|
+
# Store instructions if provided by the server and enabled in config
|
|
191
|
+
if self.server_config.include_instructions:
|
|
192
|
+
self.server_instructions = raw_instructions
|
|
193
|
+
if self.server_instructions:
|
|
194
|
+
logger.debug(
|
|
195
|
+
f"{self.server_name}: Received server instructions",
|
|
196
|
+
data={"instructions": self.server_instructions},
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
self.server_instructions = None
|
|
200
|
+
if self.server_instructions_available:
|
|
201
|
+
logger.debug(
|
|
202
|
+
f"{self.server_name}: Server instructions disabled by configuration",
|
|
203
|
+
data={"instructions": raw_instructions},
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
logger.debug(f"{self.server_name}: No server instructions provided")
|
|
207
|
+
|
|
208
|
+
# If there's an init hook, run it
|
|
209
|
+
|
|
210
|
+
# Now the session is ready for use
|
|
211
|
+
self._initialized_event.set()
|
|
212
|
+
|
|
213
|
+
async def wait_for_initialized(self) -> None:
|
|
214
|
+
"""
|
|
215
|
+
Wait until the session is fully initialized.
|
|
216
|
+
"""
|
|
217
|
+
await self._initialized_event.wait()
|
|
218
|
+
|
|
219
|
+
def create_session(
|
|
220
|
+
self,
|
|
221
|
+
read_stream: MemoryObjectReceiveStream,
|
|
222
|
+
send_stream: MemoryObjectSendStream,
|
|
223
|
+
) -> ClientSession:
|
|
224
|
+
"""
|
|
225
|
+
Create a new session instance for this server connection.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
read_timeout = (
|
|
229
|
+
timedelta(seconds=self.server_config.read_timeout_seconds)
|
|
230
|
+
if self.server_config.read_timeout_seconds
|
|
231
|
+
else None
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
session = self._client_session_factory(
|
|
235
|
+
read_stream,
|
|
236
|
+
send_stream,
|
|
237
|
+
read_timeout,
|
|
238
|
+
server_config=self.server_config,
|
|
239
|
+
transport_metrics=self.transport_metrics,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
self.session = session
|
|
243
|
+
self.client_capabilities = getattr(session, "client_capabilities", None)
|
|
244
|
+
|
|
245
|
+
return session
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
async def _server_lifecycle_task(server_conn: ServerConnection) -> None:
|
|
249
|
+
"""
|
|
250
|
+
Manage the lifecycle of a single server connection.
|
|
251
|
+
Runs inside the MCPConnectionManager's shared TaskGroup.
|
|
252
|
+
|
|
253
|
+
IMPORTANT: This function must NEVER raise an exception, as it runs in a shared
|
|
254
|
+
task group. Any exceptions must be caught and handled gracefully, with errors
|
|
255
|
+
recorded in server_conn._error_occurred and _error_message.
|
|
256
|
+
"""
|
|
257
|
+
server_name = server_conn.server_name
|
|
258
|
+
try:
|
|
259
|
+
transport_context = server_conn._transport_context_factory()
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
async with transport_context as (read_stream, write_stream, get_session_id_cb):
|
|
263
|
+
server_conn._get_session_id_cb = get_session_id_cb
|
|
264
|
+
|
|
265
|
+
if get_session_id_cb is not None:
|
|
266
|
+
try:
|
|
267
|
+
server_conn.session_id = get_session_id_cb()
|
|
268
|
+
except Exception:
|
|
269
|
+
logger.debug(f"{server_name}: Unable to retrieve session id from transport")
|
|
270
|
+
elif server_conn.server_config.transport == "stdio":
|
|
271
|
+
server_conn.session_id = "local"
|
|
272
|
+
|
|
273
|
+
server_conn.create_session(read_stream, write_stream)
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
async with server_conn.session:
|
|
277
|
+
await server_conn.initialize_session()
|
|
278
|
+
|
|
279
|
+
if get_session_id_cb is not None:
|
|
280
|
+
try:
|
|
281
|
+
server_conn.session_id = get_session_id_cb() or server_conn.session_id
|
|
282
|
+
except Exception:
|
|
283
|
+
logger.debug(f"{server_name}: Unable to refresh session id after init")
|
|
284
|
+
elif server_conn.server_config.transport == "stdio":
|
|
285
|
+
server_conn.session_id = "local"
|
|
286
|
+
|
|
287
|
+
await server_conn.wait_for_shutdown_request()
|
|
288
|
+
except Exception as session_exit_exc:
|
|
289
|
+
# Catch exceptions during session cleanup (e.g., when session was terminated)
|
|
290
|
+
# This prevents cleanup errors from propagating to the task group
|
|
291
|
+
logger.debug(
|
|
292
|
+
f"{server_name}: Exception during session cleanup (expected during reconnect): {session_exit_exc}"
|
|
293
|
+
)
|
|
294
|
+
except Exception as transport_exit_exc:
|
|
295
|
+
# Catch exceptions during transport cleanup
|
|
296
|
+
# This can happen when disconnecting a session that was already terminated
|
|
297
|
+
logger.debug(
|
|
298
|
+
f"{server_name}: Exception during transport cleanup (expected during reconnect): {transport_exit_exc}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
except HTTPStatusError as http_exc:
|
|
302
|
+
logger.error(
|
|
303
|
+
f"{server_name}: Lifecycle task encountered HTTP error: {http_exc}",
|
|
304
|
+
exc_info=True,
|
|
305
|
+
data={
|
|
306
|
+
"progress_action": ProgressAction.FATAL_ERROR,
|
|
307
|
+
"server_name": server_name,
|
|
308
|
+
},
|
|
309
|
+
)
|
|
310
|
+
server_conn._error_occurred = True
|
|
311
|
+
server_conn._error_message = f"HTTP Error: {http_exc.response.status_code} {http_exc.response.reason_phrase} for URL: {http_exc.request.url}"
|
|
312
|
+
server_conn._initialized_event.set()
|
|
313
|
+
# No raise - let get_server handle it with a friendly message
|
|
314
|
+
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
logger.error(
|
|
317
|
+
f"{server_name}: Lifecycle task encountered an error: {exc}",
|
|
318
|
+
exc_info=True,
|
|
319
|
+
data={
|
|
320
|
+
"progress_action": ProgressAction.FATAL_ERROR,
|
|
321
|
+
"server_name": server_name,
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
server_conn._error_occurred = True
|
|
325
|
+
|
|
326
|
+
if "ExceptionGroup" in type(exc).__name__ and hasattr(exc, "exceptions"):
|
|
327
|
+
# Handle ExceptionGroup better by extracting the actual errors
|
|
328
|
+
def extract_errors(exception_group):
|
|
329
|
+
"""Recursively extract meaningful errors from ExceptionGroups"""
|
|
330
|
+
messages = []
|
|
331
|
+
for subexc in exception_group.exceptions:
|
|
332
|
+
if "ExceptionGroup" in type(subexc).__name__ and hasattr(subexc, "exceptions"):
|
|
333
|
+
# Recursively handle nested ExceptionGroups
|
|
334
|
+
messages.extend(extract_errors(subexc))
|
|
335
|
+
elif isinstance(subexc, HTTPStatusError):
|
|
336
|
+
# Special handling for HTTP errors to make them more user-friendly
|
|
337
|
+
messages.append(
|
|
338
|
+
f"HTTP Error: {subexc.response.status_code} {subexc.response.reason_phrase} for URL: {subexc.request.url}"
|
|
339
|
+
)
|
|
340
|
+
else:
|
|
341
|
+
# Show the exception type and message, plus the root cause if available
|
|
342
|
+
error_msg = f"{type(subexc).__name__}: {subexc}"
|
|
343
|
+
messages.append(error_msg)
|
|
344
|
+
|
|
345
|
+
# If there's a root cause, show that too as it's often the most informative
|
|
346
|
+
if hasattr(subexc, "__cause__") and subexc.__cause__:
|
|
347
|
+
messages.append(
|
|
348
|
+
f"Caused by: {type(subexc.__cause__).__name__}: {subexc.__cause__}"
|
|
349
|
+
)
|
|
350
|
+
return messages
|
|
351
|
+
|
|
352
|
+
error_messages = extract_errors(exc)
|
|
353
|
+
# If we didn't extract any meaningful errors, fall back to the original exception
|
|
354
|
+
if not error_messages:
|
|
355
|
+
error_messages = [f"{type(exc).__name__}: {exc}"]
|
|
356
|
+
server_conn._error_message = error_messages
|
|
357
|
+
else:
|
|
358
|
+
# For regular exceptions, keep the traceback but format it more cleanly
|
|
359
|
+
server_conn._error_message = traceback.format_exception(exc)
|
|
360
|
+
|
|
361
|
+
# If there's an error, we should also set the event so that
|
|
362
|
+
# 'get_server' won't hang
|
|
363
|
+
server_conn._initialized_event.set()
|
|
364
|
+
# No raise - allow graceful exit
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class MCPConnectionManager(ContextDependent):
|
|
368
|
+
"""
|
|
369
|
+
Manages the lifecycle of multiple MCP server connections.
|
|
370
|
+
Integrates with the application context system for proper resource management.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
def __init__(
|
|
374
|
+
self, server_registry: "ServerRegistry", context: Union["Context", None] = None
|
|
375
|
+
) -> None:
|
|
376
|
+
super().__init__(context=context)
|
|
377
|
+
self.server_registry = server_registry
|
|
378
|
+
self.running_servers: dict[str, ServerConnection] = {}
|
|
379
|
+
self._lock = Lock()
|
|
380
|
+
# Manage our own task group - independent of task context
|
|
381
|
+
self._task_group = None
|
|
382
|
+
self._task_group_active = False
|
|
383
|
+
self._mcp_sse_filter_added = False
|
|
384
|
+
|
|
385
|
+
async def __aenter__(self):
|
|
386
|
+
# Create a task group that isn't tied to a specific task
|
|
387
|
+
self._task_group = create_task_group()
|
|
388
|
+
# Enter the task group context
|
|
389
|
+
await self._task_group.__aenter__()
|
|
390
|
+
self._task_group_active = True
|
|
391
|
+
self._tg = self._task_group
|
|
392
|
+
return self
|
|
393
|
+
|
|
394
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
395
|
+
"""Ensure clean shutdown of all connections before exiting."""
|
|
396
|
+
try:
|
|
397
|
+
# First request all servers to shutdown
|
|
398
|
+
await self.disconnect_all()
|
|
399
|
+
|
|
400
|
+
# Add a small delay to allow for clean shutdown
|
|
401
|
+
await asyncio.sleep(0.5)
|
|
402
|
+
|
|
403
|
+
# Then close the task group if it's active
|
|
404
|
+
if self._task_group_active:
|
|
405
|
+
await self._task_group.__aexit__(exc_type, exc_val, exc_tb)
|
|
406
|
+
self._task_group_active = False
|
|
407
|
+
self._task_group = None
|
|
408
|
+
self._tg = None
|
|
409
|
+
except Exception as e:
|
|
410
|
+
logger.error(f"Error during connection manager shutdown: {e}")
|
|
411
|
+
|
|
412
|
+
def _suppress_mcp_sse_errors(self) -> None:
|
|
413
|
+
"""Suppress MCP library's 'Error in sse_reader' messages."""
|
|
414
|
+
if self._mcp_sse_filter_added:
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
import logging
|
|
418
|
+
|
|
419
|
+
class MCPSSEErrorFilter(logging.Filter):
|
|
420
|
+
def filter(self, record):
|
|
421
|
+
return not (
|
|
422
|
+
record.name == "mcp.client.sse" and "Error in sse_reader" in record.getMessage()
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
mcp_sse_logger = logging.getLogger("mcp.client.sse")
|
|
426
|
+
mcp_sse_logger.addFilter(MCPSSEErrorFilter())
|
|
427
|
+
self._mcp_sse_filter_added = True
|
|
428
|
+
|
|
429
|
+
async def launch_server(
|
|
430
|
+
self,
|
|
431
|
+
server_name: str,
|
|
432
|
+
client_session_factory: Callable[
|
|
433
|
+
[MemoryObjectReceiveStream, MemoryObjectSendStream, timedelta | None],
|
|
434
|
+
ClientSession,
|
|
435
|
+
],
|
|
436
|
+
) -> ServerConnection:
|
|
437
|
+
"""
|
|
438
|
+
Connect to a server and return a RunningServer instance that will persist
|
|
439
|
+
until explicitly disconnected.
|
|
440
|
+
"""
|
|
441
|
+
# Create task group if it doesn't exist yet - make this method more resilient
|
|
442
|
+
if not self._task_group_active:
|
|
443
|
+
self._task_group = create_task_group()
|
|
444
|
+
await self._task_group.__aenter__()
|
|
445
|
+
self._task_group_active = True
|
|
446
|
+
self._tg = self._task_group
|
|
447
|
+
logger.info(f"Auto-created task group for server: {server_name}")
|
|
448
|
+
|
|
449
|
+
config = self.server_registry.get_server_config(server_name)
|
|
450
|
+
if not config:
|
|
451
|
+
raise ValueError(f"Server '{server_name}' not found in registry.")
|
|
452
|
+
|
|
453
|
+
logger.debug(f"{server_name}: Found server configuration=", data=config.model_dump())
|
|
454
|
+
|
|
455
|
+
timeline_steps = 20
|
|
456
|
+
timeline_seconds = 30
|
|
457
|
+
try:
|
|
458
|
+
ctx = self.context
|
|
459
|
+
except RuntimeError:
|
|
460
|
+
ctx = None
|
|
461
|
+
|
|
462
|
+
config_obj = getattr(ctx, "config", None)
|
|
463
|
+
timeline_config = getattr(config_obj, "mcp_timeline", None)
|
|
464
|
+
if timeline_config:
|
|
465
|
+
timeline_steps = getattr(timeline_config, "steps", timeline_steps)
|
|
466
|
+
timeline_seconds = getattr(timeline_config, "step_seconds", timeline_seconds)
|
|
467
|
+
|
|
468
|
+
transport_metrics = (
|
|
469
|
+
TransportChannelMetrics(
|
|
470
|
+
bucket_seconds=timeline_seconds,
|
|
471
|
+
bucket_count=timeline_steps,
|
|
472
|
+
)
|
|
473
|
+
if config.transport in ("http", "sse", "stdio")
|
|
474
|
+
else None
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
def transport_context_factory():
|
|
478
|
+
if config.transport == "stdio":
|
|
479
|
+
if not config.command:
|
|
480
|
+
raise ValueError(
|
|
481
|
+
f"Server '{server_name}' uses stdio transport but no command is specified"
|
|
482
|
+
)
|
|
483
|
+
server_params = StdioServerParameters(
|
|
484
|
+
command=config.command,
|
|
485
|
+
args=config.args if config.args is not None else [],
|
|
486
|
+
env={**get_default_environment(), **(config.env or {})},
|
|
487
|
+
cwd=config.cwd,
|
|
488
|
+
)
|
|
489
|
+
# Create custom error handler to ensure all output is captured
|
|
490
|
+
error_handler = get_stderr_handler(server_name)
|
|
491
|
+
# Explicitly ensure we're using our custom logger for stderr
|
|
492
|
+
logger.debug(f"{server_name}: Creating stdio client with custom error handler")
|
|
493
|
+
|
|
494
|
+
channel_hook = transport_metrics.record_event if transport_metrics else None
|
|
495
|
+
return _add_none_to_context(
|
|
496
|
+
tracking_stdio_client(
|
|
497
|
+
server_params, channel_hook=channel_hook, errlog=error_handler
|
|
498
|
+
)
|
|
499
|
+
)
|
|
500
|
+
elif config.transport == "sse":
|
|
501
|
+
if not config.url:
|
|
502
|
+
raise ValueError(
|
|
503
|
+
f"Server '{server_name}' uses sse transport but no url is specified"
|
|
504
|
+
)
|
|
505
|
+
# Suppress MCP library error spam
|
|
506
|
+
self._suppress_mcp_sse_errors()
|
|
507
|
+
headers, oauth_auth, user_auth_keys = _prepare_headers_and_auth(config)
|
|
508
|
+
if user_auth_keys:
|
|
509
|
+
logger.debug(
|
|
510
|
+
f"{server_name}: Using user-specified auth header(s); skipping OAuth provider.",
|
|
511
|
+
user_auth_headers=sorted(user_auth_keys),
|
|
512
|
+
)
|
|
513
|
+
channel_hook = None
|
|
514
|
+
if transport_metrics is not None:
|
|
515
|
+
|
|
516
|
+
def channel_hook(event):
|
|
517
|
+
try:
|
|
518
|
+
transport_metrics.record_event(event)
|
|
519
|
+
except Exception: # pragma: no cover - defensive guard
|
|
520
|
+
logger.debug(
|
|
521
|
+
"%s: transport metrics hook failed",
|
|
522
|
+
server_name,
|
|
523
|
+
exc_info=True,
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
return tracking_sse_client(
|
|
527
|
+
config.url,
|
|
528
|
+
headers,
|
|
529
|
+
sse_read_timeout=config.read_transport_sse_timeout_seconds,
|
|
530
|
+
auth=oauth_auth,
|
|
531
|
+
channel_hook=channel_hook,
|
|
532
|
+
)
|
|
533
|
+
elif config.transport == "http":
|
|
534
|
+
if not config.url:
|
|
535
|
+
raise ValueError(
|
|
536
|
+
f"Server '{server_name}' uses http transport but no url is specified"
|
|
537
|
+
)
|
|
538
|
+
headers, oauth_auth, user_auth_keys = _prepare_headers_and_auth(config)
|
|
539
|
+
if user_auth_keys:
|
|
540
|
+
logger.debug(
|
|
541
|
+
f"{server_name}: Using user-specified auth header(s); skipping OAuth provider.",
|
|
542
|
+
user_auth_headers=sorted(user_auth_keys),
|
|
543
|
+
)
|
|
544
|
+
channel_hook = None
|
|
545
|
+
if transport_metrics is not None:
|
|
546
|
+
|
|
547
|
+
def channel_hook(event):
|
|
548
|
+
try:
|
|
549
|
+
transport_metrics.record_event(event)
|
|
550
|
+
except Exception: # pragma: no cover - defensive guard
|
|
551
|
+
logger.debug(
|
|
552
|
+
"%s: transport metrics hook failed",
|
|
553
|
+
server_name,
|
|
554
|
+
exc_info=True,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
return tracking_streamablehttp_client(
|
|
558
|
+
config.url,
|
|
559
|
+
headers,
|
|
560
|
+
auth=oauth_auth,
|
|
561
|
+
channel_hook=channel_hook,
|
|
562
|
+
)
|
|
563
|
+
else:
|
|
564
|
+
raise ValueError(f"Unsupported transport: {config.transport}")
|
|
565
|
+
|
|
566
|
+
server_conn = ServerConnection(
|
|
567
|
+
server_name=server_name,
|
|
568
|
+
server_config=config,
|
|
569
|
+
transport_context_factory=transport_context_factory,
|
|
570
|
+
client_session_factory=client_session_factory,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
if transport_metrics is not None:
|
|
574
|
+
server_conn.transport_metrics = transport_metrics
|
|
575
|
+
|
|
576
|
+
async with self._lock:
|
|
577
|
+
# Check if already running
|
|
578
|
+
if server_name in self.running_servers:
|
|
579
|
+
return self.running_servers[server_name]
|
|
580
|
+
|
|
581
|
+
self.running_servers[server_name] = server_conn
|
|
582
|
+
self._tg.start_soon(_server_lifecycle_task, server_conn)
|
|
583
|
+
|
|
584
|
+
logger.info(f"{server_name}: Up and running with a persistent connection!")
|
|
585
|
+
return server_conn
|
|
586
|
+
|
|
587
|
+
async def get_server(
|
|
588
|
+
self,
|
|
589
|
+
server_name: str,
|
|
590
|
+
client_session_factory: Callable,
|
|
591
|
+
) -> ServerConnection:
|
|
592
|
+
"""
|
|
593
|
+
Get a running server instance, launching it if needed.
|
|
594
|
+
"""
|
|
595
|
+
# Get the server connection if it's already running and healthy
|
|
596
|
+
async with self._lock:
|
|
597
|
+
server_conn = self.running_servers.get(server_name)
|
|
598
|
+
if server_conn and server_conn.is_healthy():
|
|
599
|
+
return server_conn
|
|
600
|
+
|
|
601
|
+
# If server exists but isn't healthy, remove it so we can create a new one
|
|
602
|
+
if server_conn:
|
|
603
|
+
logger.info(f"{server_name}: Server exists but is unhealthy, recreating...")
|
|
604
|
+
self.running_servers.pop(server_name)
|
|
605
|
+
server_conn.request_shutdown()
|
|
606
|
+
|
|
607
|
+
# Launch the connection
|
|
608
|
+
server_conn = await self.launch_server(
|
|
609
|
+
server_name=server_name,
|
|
610
|
+
client_session_factory=client_session_factory,
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
# Wait until it's fully initialized, or an error occurs
|
|
614
|
+
await server_conn.wait_for_initialized()
|
|
615
|
+
|
|
616
|
+
# Check if the server is healthy after initialization
|
|
617
|
+
if not server_conn.is_healthy():
|
|
618
|
+
error_msg = server_conn._error_message or "Unknown error"
|
|
619
|
+
|
|
620
|
+
# Format the error message for better display
|
|
621
|
+
if isinstance(error_msg, list):
|
|
622
|
+
# Join the list with newlines for better readability
|
|
623
|
+
formatted_error = "\n".join(error_msg)
|
|
624
|
+
else:
|
|
625
|
+
formatted_error = str(error_msg)
|
|
626
|
+
|
|
627
|
+
raise ServerInitializationError(
|
|
628
|
+
f"MCP Server: '{server_name}': Failed to initialize - see details. Check fastagent.config.yaml?",
|
|
629
|
+
formatted_error,
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
return server_conn
|
|
633
|
+
|
|
634
|
+
async def get_server_capabilities(self, server_name: str) -> ServerCapabilities | None:
|
|
635
|
+
"""Get the capabilities of a specific server."""
|
|
636
|
+
server_conn = await self.get_server(
|
|
637
|
+
server_name, client_session_factory=MCPAgentClientSession
|
|
638
|
+
)
|
|
639
|
+
return server_conn.server_capabilities if server_conn else None
|
|
640
|
+
|
|
641
|
+
async def disconnect_server(self, server_name: str) -> None:
|
|
642
|
+
"""
|
|
643
|
+
Disconnect a specific server if it's running under this connection manager.
|
|
644
|
+
"""
|
|
645
|
+
logger.info(f"{server_name}: Disconnecting persistent connection to server...")
|
|
646
|
+
|
|
647
|
+
async with self._lock:
|
|
648
|
+
server_conn = self.running_servers.pop(server_name, None)
|
|
649
|
+
if server_conn:
|
|
650
|
+
server_conn.request_shutdown()
|
|
651
|
+
logger.info(f"{server_name}: Shutdown signal sent (lifecycle task will exit).")
|
|
652
|
+
else:
|
|
653
|
+
logger.info(f"{server_name}: No persistent connection found. Skipping server shutdown")
|
|
654
|
+
|
|
655
|
+
async def reconnect_server(
|
|
656
|
+
self,
|
|
657
|
+
server_name: str,
|
|
658
|
+
client_session_factory: Callable,
|
|
659
|
+
) -> "ServerConnection":
|
|
660
|
+
"""
|
|
661
|
+
Force reconnection to a server by disconnecting and re-establishing the connection.
|
|
662
|
+
|
|
663
|
+
This is used when a session has been terminated (e.g., 404 from server restart)
|
|
664
|
+
and we need to create a fresh connection with a new session.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
server_name: Name of the server to reconnect
|
|
668
|
+
client_session_factory: Factory function to create client sessions
|
|
669
|
+
|
|
670
|
+
Returns:
|
|
671
|
+
The new ServerConnection instance
|
|
672
|
+
"""
|
|
673
|
+
logger.info(f"{server_name}: Initiating reconnection...")
|
|
674
|
+
|
|
675
|
+
# First, disconnect the existing connection
|
|
676
|
+
await self.disconnect_server(server_name)
|
|
677
|
+
|
|
678
|
+
# Brief pause to allow cleanup
|
|
679
|
+
await asyncio.sleep(0.1)
|
|
680
|
+
|
|
681
|
+
# Launch a fresh connection
|
|
682
|
+
server_conn = await self.launch_server(
|
|
683
|
+
server_name=server_name,
|
|
684
|
+
client_session_factory=client_session_factory,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
# Wait for initialization
|
|
688
|
+
await server_conn.wait_for_initialized()
|
|
689
|
+
|
|
690
|
+
# Check if the reconnection was successful
|
|
691
|
+
if not server_conn.is_healthy():
|
|
692
|
+
error_msg = server_conn._error_message or "Unknown error during reconnection"
|
|
693
|
+
if isinstance(error_msg, list):
|
|
694
|
+
formatted_error = "\n".join(error_msg)
|
|
695
|
+
else:
|
|
696
|
+
formatted_error = str(error_msg)
|
|
697
|
+
|
|
698
|
+
raise ServerInitializationError(
|
|
699
|
+
f"MCP Server: '{server_name}': Failed to reconnect - see details.",
|
|
700
|
+
formatted_error,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
logger.info(f"{server_name}: Reconnection successful")
|
|
704
|
+
return server_conn
|
|
705
|
+
|
|
706
|
+
async def disconnect_all(self) -> None:
|
|
707
|
+
"""Disconnect all servers that are running under this connection manager."""
|
|
708
|
+
# Get a copy of servers to shutdown
|
|
709
|
+
servers_to_shutdown = []
|
|
710
|
+
|
|
711
|
+
async with self._lock:
|
|
712
|
+
if not self.running_servers:
|
|
713
|
+
return
|
|
714
|
+
|
|
715
|
+
# Make a copy of the servers to shut down
|
|
716
|
+
servers_to_shutdown = list(self.running_servers.items())
|
|
717
|
+
# Clear the dict immediately to prevent any new access
|
|
718
|
+
self.running_servers.clear()
|
|
719
|
+
|
|
720
|
+
# Release the lock before waiting for servers to shut down
|
|
721
|
+
for name, conn in servers_to_shutdown:
|
|
722
|
+
logger.info(f"{name}: Requesting shutdown...")
|
|
723
|
+
conn.request_shutdown()
|