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,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
|
+
|