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.
Files changed (261) hide show
  1. fast_agent/__init__.py +183 -0
  2. fast_agent/acp/__init__.py +19 -0
  3. fast_agent/acp/acp_aware_mixin.py +304 -0
  4. fast_agent/acp/acp_context.py +437 -0
  5. fast_agent/acp/content_conversion.py +136 -0
  6. fast_agent/acp/filesystem_runtime.py +427 -0
  7. fast_agent/acp/permission_store.py +269 -0
  8. fast_agent/acp/server/__init__.py +5 -0
  9. fast_agent/acp/server/agent_acp_server.py +1472 -0
  10. fast_agent/acp/slash_commands.py +1050 -0
  11. fast_agent/acp/terminal_runtime.py +408 -0
  12. fast_agent/acp/tool_permission_adapter.py +125 -0
  13. fast_agent/acp/tool_permissions.py +474 -0
  14. fast_agent/acp/tool_progress.py +814 -0
  15. fast_agent/agents/__init__.py +85 -0
  16. fast_agent/agents/agent_types.py +64 -0
  17. fast_agent/agents/llm_agent.py +350 -0
  18. fast_agent/agents/llm_decorator.py +1139 -0
  19. fast_agent/agents/mcp_agent.py +1337 -0
  20. fast_agent/agents/tool_agent.py +271 -0
  21. fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
  22. fast_agent/agents/workflow/chain_agent.py +212 -0
  23. fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
  24. fast_agent/agents/workflow/iterative_planner.py +652 -0
  25. fast_agent/agents/workflow/maker_agent.py +379 -0
  26. fast_agent/agents/workflow/orchestrator_models.py +218 -0
  27. fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
  28. fast_agent/agents/workflow/parallel_agent.py +250 -0
  29. fast_agent/agents/workflow/router_agent.py +353 -0
  30. fast_agent/cli/__init__.py +0 -0
  31. fast_agent/cli/__main__.py +73 -0
  32. fast_agent/cli/commands/acp.py +159 -0
  33. fast_agent/cli/commands/auth.py +404 -0
  34. fast_agent/cli/commands/check_config.py +783 -0
  35. fast_agent/cli/commands/go.py +514 -0
  36. fast_agent/cli/commands/quickstart.py +557 -0
  37. fast_agent/cli/commands/serve.py +143 -0
  38. fast_agent/cli/commands/server_helpers.py +114 -0
  39. fast_agent/cli/commands/setup.py +174 -0
  40. fast_agent/cli/commands/url_parser.py +190 -0
  41. fast_agent/cli/constants.py +40 -0
  42. fast_agent/cli/main.py +115 -0
  43. fast_agent/cli/terminal.py +24 -0
  44. fast_agent/config.py +798 -0
  45. fast_agent/constants.py +41 -0
  46. fast_agent/context.py +279 -0
  47. fast_agent/context_dependent.py +50 -0
  48. fast_agent/core/__init__.py +92 -0
  49. fast_agent/core/agent_app.py +448 -0
  50. fast_agent/core/core_app.py +137 -0
  51. fast_agent/core/direct_decorators.py +784 -0
  52. fast_agent/core/direct_factory.py +620 -0
  53. fast_agent/core/error_handling.py +27 -0
  54. fast_agent/core/exceptions.py +90 -0
  55. fast_agent/core/executor/__init__.py +0 -0
  56. fast_agent/core/executor/executor.py +280 -0
  57. fast_agent/core/executor/task_registry.py +32 -0
  58. fast_agent/core/executor/workflow_signal.py +324 -0
  59. fast_agent/core/fastagent.py +1186 -0
  60. fast_agent/core/logging/__init__.py +5 -0
  61. fast_agent/core/logging/events.py +138 -0
  62. fast_agent/core/logging/json_serializer.py +164 -0
  63. fast_agent/core/logging/listeners.py +309 -0
  64. fast_agent/core/logging/logger.py +278 -0
  65. fast_agent/core/logging/transport.py +481 -0
  66. fast_agent/core/prompt.py +9 -0
  67. fast_agent/core/prompt_templates.py +183 -0
  68. fast_agent/core/validation.py +326 -0
  69. fast_agent/event_progress.py +62 -0
  70. fast_agent/history/history_exporter.py +49 -0
  71. fast_agent/human_input/__init__.py +47 -0
  72. fast_agent/human_input/elicitation_handler.py +123 -0
  73. fast_agent/human_input/elicitation_state.py +33 -0
  74. fast_agent/human_input/form_elements.py +59 -0
  75. fast_agent/human_input/form_fields.py +256 -0
  76. fast_agent/human_input/simple_form.py +113 -0
  77. fast_agent/human_input/types.py +40 -0
  78. fast_agent/interfaces.py +310 -0
  79. fast_agent/llm/__init__.py +9 -0
  80. fast_agent/llm/cancellation.py +22 -0
  81. fast_agent/llm/fastagent_llm.py +931 -0
  82. fast_agent/llm/internal/passthrough.py +161 -0
  83. fast_agent/llm/internal/playback.py +129 -0
  84. fast_agent/llm/internal/silent.py +41 -0
  85. fast_agent/llm/internal/slow.py +38 -0
  86. fast_agent/llm/memory.py +275 -0
  87. fast_agent/llm/model_database.py +490 -0
  88. fast_agent/llm/model_factory.py +388 -0
  89. fast_agent/llm/model_info.py +102 -0
  90. fast_agent/llm/prompt_utils.py +155 -0
  91. fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
  92. fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
  93. fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
  94. fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
  95. fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
  96. fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
  97. fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
  98. fast_agent/llm/provider/google/google_converter.py +466 -0
  99. fast_agent/llm/provider/google/llm_google_native.py +681 -0
  100. fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
  101. fast_agent/llm/provider/openai/llm_azure.py +143 -0
  102. fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
  103. fast_agent/llm/provider/openai/llm_generic.py +35 -0
  104. fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
  105. fast_agent/llm/provider/openai/llm_groq.py +42 -0
  106. fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
  107. fast_agent/llm/provider/openai/llm_openai.py +1195 -0
  108. fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
  109. fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
  110. fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
  111. fast_agent/llm/provider/openai/llm_xai.py +38 -0
  112. fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
  113. fast_agent/llm/provider/openai/openai_multipart.py +169 -0
  114. fast_agent/llm/provider/openai/openai_utils.py +67 -0
  115. fast_agent/llm/provider/openai/responses.py +133 -0
  116. fast_agent/llm/provider_key_manager.py +139 -0
  117. fast_agent/llm/provider_types.py +34 -0
  118. fast_agent/llm/request_params.py +61 -0
  119. fast_agent/llm/sampling_converter.py +98 -0
  120. fast_agent/llm/stream_types.py +9 -0
  121. fast_agent/llm/usage_tracking.py +445 -0
  122. fast_agent/mcp/__init__.py +56 -0
  123. fast_agent/mcp/common.py +26 -0
  124. fast_agent/mcp/elicitation_factory.py +84 -0
  125. fast_agent/mcp/elicitation_handlers.py +164 -0
  126. fast_agent/mcp/gen_client.py +83 -0
  127. fast_agent/mcp/helpers/__init__.py +36 -0
  128. fast_agent/mcp/helpers/content_helpers.py +352 -0
  129. fast_agent/mcp/helpers/server_config_helpers.py +25 -0
  130. fast_agent/mcp/hf_auth.py +147 -0
  131. fast_agent/mcp/interfaces.py +92 -0
  132. fast_agent/mcp/logger_textio.py +108 -0
  133. fast_agent/mcp/mcp_agent_client_session.py +411 -0
  134. fast_agent/mcp/mcp_aggregator.py +2175 -0
  135. fast_agent/mcp/mcp_connection_manager.py +723 -0
  136. fast_agent/mcp/mcp_content.py +262 -0
  137. fast_agent/mcp/mime_utils.py +108 -0
  138. fast_agent/mcp/oauth_client.py +509 -0
  139. fast_agent/mcp/prompt.py +159 -0
  140. fast_agent/mcp/prompt_message_extended.py +155 -0
  141. fast_agent/mcp/prompt_render.py +84 -0
  142. fast_agent/mcp/prompt_serialization.py +580 -0
  143. fast_agent/mcp/prompts/__init__.py +0 -0
  144. fast_agent/mcp/prompts/__main__.py +7 -0
  145. fast_agent/mcp/prompts/prompt_constants.py +18 -0
  146. fast_agent/mcp/prompts/prompt_helpers.py +238 -0
  147. fast_agent/mcp/prompts/prompt_load.py +186 -0
  148. fast_agent/mcp/prompts/prompt_server.py +552 -0
  149. fast_agent/mcp/prompts/prompt_template.py +438 -0
  150. fast_agent/mcp/resource_utils.py +215 -0
  151. fast_agent/mcp/sampling.py +200 -0
  152. fast_agent/mcp/server/__init__.py +4 -0
  153. fast_agent/mcp/server/agent_server.py +613 -0
  154. fast_agent/mcp/skybridge.py +44 -0
  155. fast_agent/mcp/sse_tracking.py +287 -0
  156. fast_agent/mcp/stdio_tracking_simple.py +59 -0
  157. fast_agent/mcp/streamable_http_tracking.py +309 -0
  158. fast_agent/mcp/tool_execution_handler.py +137 -0
  159. fast_agent/mcp/tool_permission_handler.py +88 -0
  160. fast_agent/mcp/transport_tracking.py +634 -0
  161. fast_agent/mcp/types.py +24 -0
  162. fast_agent/mcp/ui_agent.py +48 -0
  163. fast_agent/mcp/ui_mixin.py +209 -0
  164. fast_agent/mcp_server_registry.py +89 -0
  165. fast_agent/py.typed +0 -0
  166. fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
  167. fast_agent/resources/examples/data-analysis/analysis.py +68 -0
  168. fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
  169. fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
  170. fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
  171. fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
  172. fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
  173. fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
  174. fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
  175. fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
  176. fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
  177. fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
  178. fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
  179. fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
  180. fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
  181. fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
  182. fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
  183. fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
  184. fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
  185. fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
  186. fast_agent/resources/examples/researcher/researcher.py +36 -0
  187. fast_agent/resources/examples/tensorzero/.env.sample +2 -0
  188. fast_agent/resources/examples/tensorzero/Makefile +31 -0
  189. fast_agent/resources/examples/tensorzero/README.md +56 -0
  190. fast_agent/resources/examples/tensorzero/agent.py +35 -0
  191. fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
  192. fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
  193. fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
  194. fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
  195. fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
  196. fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
  197. fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
  198. fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
  199. fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
  200. fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
  201. fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
  202. fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
  203. fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
  204. fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
  205. fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
  206. fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
  207. fast_agent/resources/examples/workflows/chaining.py +37 -0
  208. fast_agent/resources/examples/workflows/evaluator.py +77 -0
  209. fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
  210. fast_agent/resources/examples/workflows/graded_report.md +89 -0
  211. fast_agent/resources/examples/workflows/human_input.py +28 -0
  212. fast_agent/resources/examples/workflows/maker.py +156 -0
  213. fast_agent/resources/examples/workflows/orchestrator.py +70 -0
  214. fast_agent/resources/examples/workflows/parallel.py +56 -0
  215. fast_agent/resources/examples/workflows/router.py +69 -0
  216. fast_agent/resources/examples/workflows/short_story.md +13 -0
  217. fast_agent/resources/examples/workflows/short_story.txt +19 -0
  218. fast_agent/resources/setup/.gitignore +30 -0
  219. fast_agent/resources/setup/agent.py +28 -0
  220. fast_agent/resources/setup/fastagent.config.yaml +65 -0
  221. fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
  222. fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
  223. fast_agent/skills/__init__.py +9 -0
  224. fast_agent/skills/registry.py +235 -0
  225. fast_agent/tools/elicitation.py +369 -0
  226. fast_agent/tools/shell_runtime.py +402 -0
  227. fast_agent/types/__init__.py +59 -0
  228. fast_agent/types/conversation_summary.py +294 -0
  229. fast_agent/types/llm_stop_reason.py +78 -0
  230. fast_agent/types/message_search.py +249 -0
  231. fast_agent/ui/__init__.py +38 -0
  232. fast_agent/ui/console.py +59 -0
  233. fast_agent/ui/console_display.py +1080 -0
  234. fast_agent/ui/elicitation_form.py +946 -0
  235. fast_agent/ui/elicitation_style.py +59 -0
  236. fast_agent/ui/enhanced_prompt.py +1400 -0
  237. fast_agent/ui/history_display.py +734 -0
  238. fast_agent/ui/interactive_prompt.py +1199 -0
  239. fast_agent/ui/markdown_helpers.py +104 -0
  240. fast_agent/ui/markdown_truncator.py +1004 -0
  241. fast_agent/ui/mcp_display.py +857 -0
  242. fast_agent/ui/mcp_ui_utils.py +235 -0
  243. fast_agent/ui/mermaid_utils.py +169 -0
  244. fast_agent/ui/message_primitives.py +50 -0
  245. fast_agent/ui/notification_tracker.py +205 -0
  246. fast_agent/ui/plain_text_truncator.py +68 -0
  247. fast_agent/ui/progress_display.py +10 -0
  248. fast_agent/ui/rich_progress.py +195 -0
  249. fast_agent/ui/streaming.py +774 -0
  250. fast_agent/ui/streaming_buffer.py +449 -0
  251. fast_agent/ui/tool_display.py +422 -0
  252. fast_agent/ui/usage_display.py +204 -0
  253. fast_agent/utils/__init__.py +5 -0
  254. fast_agent/utils/reasoning_stream_parser.py +77 -0
  255. fast_agent/utils/time.py +22 -0
  256. fast_agent/workflow_telemetry.py +261 -0
  257. fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
  258. fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
  259. fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
  260. fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
  261. 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()