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,613 @@
1
+ """
2
+ Enhanced AgentMCPServer with robust shutdown handling for SSE transport.
3
+ """
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import signal
9
+ import time
10
+ from contextlib import AsyncExitStack, asynccontextmanager
11
+ from typing import Awaitable, Callable
12
+
13
+ from mcp.server.fastmcp import Context as MCPContext
14
+ from mcp.server.fastmcp import FastMCP
15
+
16
+ import fast_agent.core
17
+ import fast_agent.core.prompt
18
+ from fast_agent.core.fastagent import AgentInstance
19
+ from fast_agent.core.logging.logger import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class AgentMCPServer:
25
+ """Exposes FastAgent agents as MCP tools through an MCP server."""
26
+
27
+ def __init__(
28
+ self,
29
+ primary_instance: AgentInstance,
30
+ create_instance: Callable[[], Awaitable[AgentInstance]],
31
+ dispose_instance: Callable[[AgentInstance], Awaitable[None]],
32
+ instance_scope: str,
33
+ server_name: str = "FastAgent-MCP-Server",
34
+ server_description: str | None = None,
35
+ tool_description: str | None = None,
36
+ ) -> None:
37
+ """Initialize the server with the provided agent app."""
38
+ self.primary_instance = primary_instance
39
+ self._create_instance_task = create_instance
40
+ self._dispose_instance_task = dispose_instance
41
+ self._instance_scope = instance_scope
42
+ self.mcp_server: FastMCP = FastMCP(
43
+ name=server_name,
44
+ instructions=server_description
45
+ or f"This server provides access to {len(primary_instance.agents)} agents",
46
+ )
47
+ if self._instance_scope == "request":
48
+ # Ensure FastMCP does not attempt to maintain sessions for stateless mode
49
+ self.mcp_server.settings.stateless_http = True
50
+ self._tool_description = tool_description
51
+ self._shared_instance_active = True
52
+ # Shutdown coordination
53
+ self._graceful_shutdown_event = asyncio.Event()
54
+ self._force_shutdown_event = asyncio.Event()
55
+ self._shutdown_timeout = 5.0 # Seconds to wait for graceful shutdown
56
+
57
+ # Resource management
58
+ self._exit_stack = AsyncExitStack()
59
+ self._active_connections: set[any] = set()
60
+
61
+ # Server state
62
+ self._server_task = None
63
+
64
+ # Standard logging channel so we appear alongside Uvicorn/logging output
65
+ self.std_logger = logging.getLogger("fast_agent.server")
66
+
67
+ # Connection-scoped instance tracking
68
+ self._connection_instances: dict[int, AgentInstance] = {}
69
+ self._connection_cleanup_tasks: dict[int, Callable[[], Awaitable[None]]] = {}
70
+ self._connection_lock = asyncio.Lock()
71
+
72
+ # Set up agent tools
73
+ self.setup_tools()
74
+
75
+ logger.info(
76
+ f"AgentMCPServer initialized with {len(primary_instance.agents)} agents",
77
+ name="mcp_server_initialized",
78
+ agent_count=len(primary_instance.agents),
79
+ instance_scope=instance_scope,
80
+ )
81
+
82
+ def setup_tools(self) -> None:
83
+ """Register all agents as MCP tools."""
84
+ for agent_name in self.primary_instance.agents.keys():
85
+ self.register_agent_tools(agent_name)
86
+
87
+ def register_agent_tools(self, agent_name: str) -> None:
88
+ """Register tools for a specific agent."""
89
+
90
+ # Basic send message tool
91
+ tool_description = (
92
+ self._tool_description.format(agent=agent_name)
93
+ if self._tool_description and "{agent}" in self._tool_description
94
+ else self._tool_description
95
+ )
96
+
97
+ @self.mcp_server.tool(
98
+ name=f"{agent_name}_send",
99
+ description=tool_description or f"Send a message to the {agent_name} agent",
100
+ structured_output=False,
101
+ # MCP 1.10.1 turns every tool in to a structured output
102
+ )
103
+ async def send_message(message: str, ctx: MCPContext) -> str:
104
+ """Send a message to the agent and return its response."""
105
+ instance = await self._acquire_instance(ctx)
106
+ agent = instance.app[agent_name]
107
+ agent_context = getattr(agent, "context", None)
108
+
109
+ # Define the function to execute
110
+ async def execute_send():
111
+ start = time.perf_counter()
112
+ logger.info(
113
+ f"MCP request received for agent '{agent_name}'",
114
+ name="mcp_request_start",
115
+ agent=agent_name,
116
+ session=self._session_identifier(ctx),
117
+ )
118
+ self.std_logger.info(
119
+ "MCP request received for agent '%s' (scope=%s)",
120
+ agent_name,
121
+ self._instance_scope,
122
+ )
123
+
124
+ response = await agent.send(message)
125
+ duration = time.perf_counter() - start
126
+
127
+ logger.info(
128
+ f"Agent '{agent_name}' completed MCP request",
129
+ name="mcp_request_complete",
130
+ agent=agent_name,
131
+ duration=duration,
132
+ session=self._session_identifier(ctx),
133
+ )
134
+ self.std_logger.info(
135
+ "Agent '%s' completed MCP request in %.2fs (scope=%s)",
136
+ agent_name,
137
+ duration,
138
+ self._instance_scope,
139
+ )
140
+ return response
141
+
142
+ try:
143
+ # Execute with bridged context
144
+ if agent_context and ctx:
145
+ return await self.with_bridged_context(agent_context, ctx, execute_send)
146
+ return await execute_send()
147
+ finally:
148
+ await self._release_instance(ctx, instance)
149
+
150
+ # Register a history prompt for this agent
151
+ @self.mcp_server.prompt(
152
+ name=f"{agent_name}_history",
153
+ description=f"Conversation history for the {agent_name} agent",
154
+ )
155
+ async def get_history_prompt(ctx: MCPContext) -> list:
156
+ """Return the conversation history as MCP messages."""
157
+ instance = await self._acquire_instance(ctx)
158
+ agent = instance.app[agent_name]
159
+ try:
160
+ multipart_history = agent.message_history
161
+ if not multipart_history:
162
+ return []
163
+
164
+ # Convert the multipart message history to standard PromptMessages
165
+ prompt_messages = fast_agent.core.prompt.Prompt.from_multipart(multipart_history)
166
+ # In FastMCP, we need to return the raw list of messages
167
+ return [{"role": msg.role, "content": msg.content} for msg in prompt_messages]
168
+ finally:
169
+ await self._release_instance(ctx, instance, reuse_connection=True)
170
+
171
+ async def _acquire_instance(self, ctx: MCPContext | None) -> AgentInstance:
172
+ if self._instance_scope == "shared":
173
+ return self.primary_instance
174
+
175
+ if self._instance_scope == "request":
176
+ return await self._create_instance_task()
177
+
178
+ # Connection scope
179
+ assert ctx is not None, "Context is required for connection-scoped instances"
180
+ session_key = self._connection_key(ctx)
181
+ async with self._connection_lock:
182
+ instance = self._connection_instances.get(session_key)
183
+ if instance is None:
184
+ instance = await self._create_instance_task()
185
+ self._connection_instances[session_key] = instance
186
+ self._register_session_cleanup(ctx, session_key)
187
+ return instance
188
+
189
+ async def _release_instance(
190
+ self,
191
+ ctx: MCPContext | None,
192
+ instance: AgentInstance,
193
+ *,
194
+ reuse_connection: bool = False,
195
+ ) -> None:
196
+ if self._instance_scope == "request":
197
+ await self._dispose_instance_task(instance)
198
+ elif self._instance_scope == "connection" and reuse_connection is False:
199
+ # Connection-scoped instances persist until session cleanup
200
+ pass
201
+
202
+ def _connection_key(self, ctx: MCPContext) -> int:
203
+ return id(ctx.session)
204
+
205
+ def _register_session_cleanup(self, ctx: MCPContext, session_key: int) -> None:
206
+ async def cleanup() -> None:
207
+ instance = self._connection_instances.pop(session_key, None)
208
+ if instance is not None:
209
+ await self._dispose_instance_task(instance)
210
+
211
+ exit_stack = getattr(ctx.session, "_exit_stack", None)
212
+ if exit_stack is not None:
213
+ exit_stack.push_async_callback(cleanup)
214
+ else:
215
+ self._connection_cleanup_tasks[session_key] = cleanup
216
+
217
+ def _session_identifier(self, ctx: MCPContext | None) -> str | None:
218
+ if ctx is None:
219
+ return None
220
+ request = getattr(ctx.request_context, "request", None)
221
+ if request is not None:
222
+ return request.headers.get("mcp-session-id")
223
+ return None
224
+
225
+ async def _dispose_primary_instance(self) -> None:
226
+ if self._shared_instance_active:
227
+ try:
228
+ await self._dispose_instance_task(self.primary_instance)
229
+ finally:
230
+ self._shared_instance_active = False
231
+
232
+ async def _dispose_all_connection_instances(self) -> None:
233
+ pending_cleanups = list(self._connection_cleanup_tasks.values())
234
+ self._connection_cleanup_tasks.clear()
235
+ for cleanup in pending_cleanups:
236
+ await cleanup()
237
+
238
+ async with self._connection_lock:
239
+ instances = list(self._connection_instances.values())
240
+ self._connection_instances.clear()
241
+
242
+ for instance in instances:
243
+ await self._dispose_instance_task(instance)
244
+
245
+ def _setup_signal_handlers(self):
246
+ """Set up signal handlers for graceful and forced shutdown."""
247
+ loop = asyncio.get_running_loop()
248
+
249
+ def handle_signal(is_term=False):
250
+ # Use asyncio.create_task to handle the signal in an async-friendly way
251
+ asyncio.create_task(self._handle_shutdown_signal(is_term))
252
+
253
+ # Register handlers for SIGINT (Ctrl+C) and SIGTERM
254
+ for sig, is_term in [(signal.SIGINT, False), (signal.SIGTERM, True)]:
255
+ import platform
256
+
257
+ if platform.system() != "Windows":
258
+ loop.add_signal_handler(sig, lambda term=is_term: handle_signal(term))
259
+
260
+ logger.debug("Signal handlers installed")
261
+
262
+ async def _handle_shutdown_signal(self, is_term=False):
263
+ """Handle shutdown signals with proper escalation."""
264
+ signal_name = "SIGTERM" if is_term else "SIGINT (Ctrl+C)"
265
+
266
+ # If force shutdown already requested, exit immediately
267
+ if self._force_shutdown_event.is_set():
268
+ logger.info("Force shutdown already in progress, exiting immediately...")
269
+ os._exit(1)
270
+
271
+ # If graceful shutdown already in progress, escalate to forced
272
+ if self._graceful_shutdown_event.is_set():
273
+ logger.info(f"Second {signal_name} received. Forcing shutdown...")
274
+ self._force_shutdown_event.set()
275
+ # Allow a brief moment for final cleanup, then force exit
276
+ await asyncio.sleep(0.5)
277
+ os._exit(1)
278
+
279
+ # First signal - initiate graceful shutdown
280
+ logger.info(f"{signal_name} received. Starting graceful shutdown...")
281
+ print(f"\n{signal_name} received. Starting graceful shutdown...")
282
+ print("Press Ctrl+C again to force exit.")
283
+ self._graceful_shutdown_event.set()
284
+
285
+ def run(self, transport: str = "http", host: str = "0.0.0.0", port: int = 8000) -> None:
286
+ """Run the MCP server synchronously."""
287
+ if transport in ["sse", "http"]:
288
+ self.mcp_server.settings.host = host
289
+ self.mcp_server.settings.port = port
290
+
291
+ # For synchronous run, we can use the simpler approach
292
+ try:
293
+ # Add any server attributes that might help with shutdown
294
+ if not hasattr(self.mcp_server, "_server_should_exit"):
295
+ self.mcp_server._server_should_exit = False
296
+
297
+ # Run the server
298
+ self.mcp_server.run(transport=transport)
299
+ except KeyboardInterrupt:
300
+ print("\nServer stopped by user (CTRL+C)")
301
+ except SystemExit as e:
302
+ # Handle normal exit
303
+ print(f"\nServer exiting with code {e.code}")
304
+ # Re-raise to allow normal exit process
305
+ raise
306
+ except Exception as e:
307
+ print(f"\nServer error: {e}")
308
+ finally:
309
+ # Run an async cleanup in a new event loop
310
+ try:
311
+ asyncio.run(self.shutdown())
312
+ except (SystemExit, KeyboardInterrupt):
313
+ # These are expected during shutdown
314
+ pass
315
+ else: # stdio
316
+ try:
317
+ self.mcp_server.run(transport=transport)
318
+ except KeyboardInterrupt:
319
+ print("\nServer stopped by user (CTRL+C)")
320
+ finally:
321
+ # Minimal cleanup for stdio
322
+ asyncio.run(self._cleanup_stdio())
323
+
324
+ async def run_async(
325
+ self, transport: str = "http", host: str = "0.0.0.0", port: int = 8000
326
+ ) -> None:
327
+ """Run the MCP server asynchronously with improved shutdown handling."""
328
+ # Use different handling strategies based on transport type
329
+ if transport in ["sse", "http"]:
330
+ # For SSE/HTTP, use our enhanced shutdown handling
331
+ self._setup_signal_handlers()
332
+
333
+ self.mcp_server.settings.host = host
334
+ self.mcp_server.settings.port = port
335
+
336
+ # Start the server in a separate task so we can monitor it
337
+ self._server_task = asyncio.create_task(self._run_server_with_shutdown(transport))
338
+
339
+ try:
340
+ # Wait for the server task to complete
341
+ await self._server_task
342
+ except (asyncio.CancelledError, KeyboardInterrupt):
343
+ # Both cancellation and KeyboardInterrupt are expected during shutdown
344
+ logger.info("Server stopped via cancellation or interrupt")
345
+ print("\nServer stopped")
346
+ except SystemExit as e:
347
+ # Handle normal exit cleanly
348
+ logger.info(f"Server exiting with code {e.code}")
349
+ print(f"\nServer exiting with code {e.code}")
350
+ # If this is exit code 0, let it propagate for normal exit
351
+ if e.code == 0:
352
+ raise
353
+ except Exception as e:
354
+ logger.error(f"Server error: {e}", exc_info=True)
355
+ print(f"\nServer error: {e}")
356
+ finally:
357
+ # Only do minimal cleanup - don't try to be too clever
358
+ await self._cleanup_stdio()
359
+ print("\nServer shutdown complete.")
360
+ else: # stdio
361
+ # For STDIO, use simpler approach that respects STDIO lifecycle
362
+ try:
363
+ # Run directly without extra monitoring or signal handlers
364
+ # This preserves the natural lifecycle of STDIO connections
365
+ await self.mcp_server.run_stdio_async()
366
+ except (asyncio.CancelledError, KeyboardInterrupt):
367
+ logger.info("Server stopped (CTRL+C)")
368
+ print("\nServer stopped (CTRL+C)")
369
+ except SystemExit as e:
370
+ # Handle normal exit cleanly
371
+ logger.info(f"Server exiting with code {e.code}")
372
+ print(f"\nServer exiting with code {e.code}")
373
+ # If this is exit code 0, let it propagate for normal exit
374
+ if e.code == 0:
375
+ raise
376
+ # Only perform minimal cleanup needed for STDIO
377
+ await self._cleanup_stdio()
378
+
379
+ async def _run_server_with_shutdown(self, transport: str):
380
+ """Run the server with proper shutdown handling."""
381
+ # This method is used for SSE/HTTP transport
382
+ if transport not in ["sse", "http"]:
383
+ raise ValueError("This method should only be used with SSE or HTTP transport")
384
+
385
+ # Start a monitor task for shutdown
386
+ shutdown_monitor = asyncio.create_task(self._monitor_shutdown())
387
+
388
+ try:
389
+ # Patch SSE server to track connections if needed
390
+ if hasattr(self.mcp_server, "_sse_transport") and self.mcp_server._sse_transport:
391
+ # Store the original connect_sse method
392
+ original_connect = self.mcp_server._sse_transport.connect_sse
393
+
394
+ # Create a wrapper that tracks connections
395
+ @asynccontextmanager
396
+ async def tracked_connect_sse(*args, **kwargs):
397
+ async with original_connect(*args, **kwargs) as streams:
398
+ self._active_connections.add(streams)
399
+ try:
400
+ yield streams
401
+ finally:
402
+ self._active_connections.discard(streams)
403
+
404
+ # Replace with our tracking version
405
+ self.mcp_server._sse_transport.connect_sse = tracked_connect_sse
406
+
407
+ # Run the server based on transport type
408
+ if transport == "sse":
409
+ await self.mcp_server.run_sse_async()
410
+ elif transport == "http":
411
+ await self.mcp_server.run_streamable_http_async()
412
+ finally:
413
+ # Cancel the monitor when the server exits
414
+ shutdown_monitor.cancel()
415
+ try:
416
+ await shutdown_monitor
417
+ except asyncio.CancelledError:
418
+ pass
419
+
420
+ async def _monitor_shutdown(self):
421
+ """Monitor for shutdown signals and coordinate proper shutdown sequence."""
422
+ try:
423
+ # Wait for graceful shutdown request
424
+ await self._graceful_shutdown_event.wait()
425
+ logger.info("Graceful shutdown initiated")
426
+
427
+ # Two possible paths:
428
+ # 1. Wait for force shutdown
429
+ # 2. Wait for shutdown timeout
430
+ force_shutdown_task = asyncio.create_task(self._force_shutdown_event.wait())
431
+ timeout_task = asyncio.create_task(asyncio.sleep(self._shutdown_timeout))
432
+
433
+ done, pending = await asyncio.wait(
434
+ [force_shutdown_task, timeout_task], return_when=asyncio.FIRST_COMPLETED
435
+ )
436
+
437
+ # Cancel the remaining task
438
+ for task in pending:
439
+ task.cancel()
440
+
441
+ # Determine shutdown reason
442
+ if force_shutdown_task in done:
443
+ logger.info("Force shutdown requested by user")
444
+ print("\nForce shutdown initiated...")
445
+ else:
446
+ logger.info(f"Graceful shutdown timed out after {self._shutdown_timeout} seconds")
447
+ print(f"\nGraceful shutdown timed out after {self._shutdown_timeout} seconds")
448
+
449
+ os._exit(0)
450
+
451
+ except asyncio.CancelledError:
452
+ # Monitor was cancelled - clean exit
453
+ pass
454
+ except Exception as e:
455
+ logger.error(f"Error in shutdown monitor: {e}", exc_info=True)
456
+
457
+ async def _close_sse_connections(self):
458
+ """Force close all SSE connections."""
459
+ # Close tracked connections
460
+ for conn in list(self._active_connections):
461
+ try:
462
+ if hasattr(conn, "close"):
463
+ await conn.close()
464
+ elif hasattr(conn, "aclose"):
465
+ await conn.aclose()
466
+ except Exception as e:
467
+ logger.error(f"Error closing connection: {e}")
468
+ self._active_connections.discard(conn)
469
+
470
+ # Access the SSE transport if it exists to close stream writers
471
+ if (
472
+ hasattr(self.mcp_server, "_sse_transport")
473
+ and self.mcp_server._sse_transport is not None
474
+ ):
475
+ sse = self.mcp_server._sse_transport
476
+
477
+ # Close all read stream writers
478
+ if hasattr(sse, "_read_stream_writers"):
479
+ writers = list(sse._read_stream_writers.items())
480
+ for session_id, writer in writers:
481
+ try:
482
+ logger.debug(f"Closing SSE connection: {session_id}")
483
+ # Instead of aclose, try to close more gracefully
484
+ # Send a special event to notify client, then close
485
+ try:
486
+ if hasattr(writer, "send") and not getattr(writer, "_closed", False):
487
+ try:
488
+ # Try to send a close event if possible
489
+ await writer.send(Exception("Server shutting down"))
490
+ except (AttributeError, asyncio.CancelledError):
491
+ pass
492
+ except Exception:
493
+ pass
494
+
495
+ # Now close the stream
496
+ await writer.aclose()
497
+ sse._read_stream_writers.pop(session_id, None)
498
+ except Exception as e:
499
+ logger.error(f"Error closing SSE connection {session_id}: {e}")
500
+
501
+ # If we have a ASGI lifespan hook, try to signal closure
502
+ if (
503
+ hasattr(self.mcp_server, "_lifespan_state")
504
+ and self.mcp_server._lifespan_state == "started"
505
+ ):
506
+ logger.debug("Attempting to signal ASGI lifespan shutdown")
507
+ try:
508
+ if hasattr(self.mcp_server, "_on_shutdown"):
509
+ await self.mcp_server._on_shutdown()
510
+ except Exception as e:
511
+ logger.error(f"Error during ASGI lifespan shutdown: {e}")
512
+
513
+ async def with_bridged_context(self, agent_context, mcp_context, func, *args, **kwargs):
514
+ """
515
+ Execute a function with bridged context between MCP and agent
516
+
517
+ Args:
518
+ agent_context: The agent's context object
519
+ mcp_context: The MCP context from the tool call
520
+ func: The function to execute
521
+ args, kwargs: Arguments to pass to the function
522
+ """
523
+ # Store original progress reporter if it exists
524
+ original_progress_reporter = None
525
+ if hasattr(agent_context, "progress_reporter"):
526
+ original_progress_reporter = agent_context.progress_reporter
527
+
528
+ # Store MCP context in agent context for nested calls
529
+ agent_context.mcp_context = mcp_context
530
+
531
+ # Create bridged progress reporter
532
+ async def bridged_progress(progress, total=None) -> None:
533
+ if mcp_context:
534
+ await mcp_context.report_progress(progress, total)
535
+ if original_progress_reporter:
536
+ await original_progress_reporter(progress, total)
537
+
538
+ # Install bridged progress reporter
539
+ if hasattr(agent_context, "progress_reporter"):
540
+ agent_context.progress_reporter = bridged_progress
541
+
542
+ try:
543
+ # Call the function
544
+ return await func(*args, **kwargs)
545
+ finally:
546
+ # Restore original progress reporter
547
+ if hasattr(agent_context, "progress_reporter"):
548
+ agent_context.progress_reporter = original_progress_reporter
549
+
550
+ # Remove MCP context reference
551
+ if hasattr(agent_context, "mcp_context"):
552
+ delattr(agent_context, "mcp_context")
553
+
554
+ async def _cleanup_stdio(self):
555
+ """Minimal cleanup for STDIO transport to avoid keeping process alive."""
556
+ logger.info("Performing minimal STDIO cleanup")
557
+
558
+ await self._dispose_primary_instance()
559
+ await self._dispose_all_connection_instances()
560
+
561
+ logger.info("STDIO cleanup complete")
562
+
563
+ async def shutdown(self):
564
+ """Gracefully shutdown the MCP server and its resources."""
565
+ logger.info("Running full shutdown procedure")
566
+
567
+ # Skip if already in shutdown
568
+ if self._graceful_shutdown_event.is_set():
569
+ return
570
+
571
+ # Signal shutdown
572
+ self._graceful_shutdown_event.set()
573
+
574
+ try:
575
+ # Close SSE connections
576
+ await self._close_sse_connections()
577
+
578
+ # Close any resources in the exit stack
579
+ await self._exit_stack.aclose()
580
+
581
+ # Dispose connection-scoped instances
582
+ await self._dispose_all_connection_instances()
583
+
584
+ # Dispose shared instance if still active
585
+ await self._dispose_primary_instance()
586
+ except Exception as e:
587
+ # Log any errors but don't let them prevent shutdown
588
+ logger.error(f"Error during shutdown: {e}", exc_info=True)
589
+ finally:
590
+ logger.info("Full shutdown complete")
591
+
592
+ async def _cleanup_minimal(self):
593
+ """Perform minimal cleanup before simulating a KeyboardInterrupt."""
594
+ logger.info("Performing minimal cleanup before interrupt")
595
+
596
+ # Only close SSE connection writers directly
597
+ if (
598
+ hasattr(self.mcp_server, "_sse_transport")
599
+ and self.mcp_server._sse_transport is not None
600
+ ):
601
+ sse = self.mcp_server._sse_transport
602
+
603
+ # Close all read stream writers
604
+ if hasattr(sse, "_read_stream_writers"):
605
+ for session_id, writer in list(sse._read_stream_writers.items()):
606
+ try:
607
+ await writer.aclose()
608
+ except Exception:
609
+ # Ignore errors during cleanup
610
+ pass
611
+
612
+ # Clear active connections set to prevent further operations
613
+ self._active_connections.clear()
@@ -0,0 +1,44 @@
1
+
2
+ from pydantic import AnyUrl, BaseModel, Field
3
+
4
+ SKYBRIDGE_MIME_TYPE = "text/html+skybridge"
5
+
6
+
7
+ class SkybridgeResourceConfig(BaseModel):
8
+ """Represents a Skybridge (apps SDK) resource exposed by an MCP server."""
9
+
10
+ uri: AnyUrl
11
+ mime_type: str | None = None
12
+ is_skybridge: bool = False
13
+ warning: str | None = None
14
+
15
+
16
+ class SkybridgeToolConfig(BaseModel):
17
+ """Represents Skybridge metadata discovered for a tool."""
18
+
19
+ tool_name: str
20
+ namespaced_tool_name: str
21
+ template_uri: AnyUrl | None = None
22
+ resource_uri: AnyUrl | None = None
23
+ is_valid: bool = False
24
+ warning: str | None = None
25
+
26
+ @property
27
+ def display_name(self) -> str:
28
+ return self.namespaced_tool_name or self.tool_name
29
+
30
+
31
+ class SkybridgeServerConfig(BaseModel):
32
+ """Skybridge configuration discovered for a specific MCP server."""
33
+
34
+ server_name: str
35
+ supports_resources: bool = False
36
+ ui_resources: list[SkybridgeResourceConfig] = Field(default_factory=list)
37
+ warnings: list[str] = Field(default_factory=list)
38
+ tools: list[SkybridgeToolConfig] = Field(default_factory=list)
39
+
40
+ @property
41
+ def enabled(self) -> bool:
42
+ """Return True when at least one resource advertises the Skybridge MIME type."""
43
+ return any(resource.is_skybridge for resource in self.ui_resources)
44
+