fast-agent-mcp 0.2.12__py3-none-any.whl → 0.2.13__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_mcp-0.2.12.dist-info → fast_agent_mcp-0.2.13.dist-info}/METADATA +1 -1
- {fast_agent_mcp-0.2.12.dist-info → fast_agent_mcp-0.2.13.dist-info}/RECORD +6 -6
- mcp_agent/mcp_server/agent_server.py +254 -15
- {fast_agent_mcp-0.2.12.dist-info → fast_agent_mcp-0.2.13.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.2.12.dist-info → fast_agent_mcp-0.2.13.dist-info}/entry_points.txt +0 -0
- {fast_agent_mcp-0.2.12.dist-info → fast_agent_mcp-0.2.13.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: fast-agent-mcp
|
3
|
-
Version: 0.2.
|
3
|
+
Version: 0.2.13
|
4
4
|
Summary: Define, Prompt and Test MCP enabled Agents and Workflows
|
5
5
|
Author-email: Shaun Smith <fastagent@llmindset.co.uk>, Sarmad Qadri <sarmad@lastmileai.dev>
|
6
6
|
License: Apache License
|
@@ -102,7 +102,7 @@ mcp_agent/mcp/prompts/prompt_load.py,sha256=Zo0FogqWFEG5FtF1d9ZH-RWsCSSMsi5FIEQH
|
|
102
102
|
mcp_agent/mcp/prompts/prompt_server.py,sha256=SiUR2xYfd3vEpghnYRdzz2rFEMtAbCKx2xzUXgvz1g8,18501
|
103
103
|
mcp_agent/mcp/prompts/prompt_template.py,sha256=EejiqGkau8OizORNyKTUwUjrPof5V-hH1H_MBQoQfXw,15732
|
104
104
|
mcp_agent/mcp_server/__init__.py,sha256=zBU51ITHIEPScd9nRafnhEddsWqXRPAAvHhkrbRI2_4,155
|
105
|
-
mcp_agent/mcp_server/agent_server.py,sha256=
|
105
|
+
mcp_agent/mcp_server/agent_server.py,sha256=eMPUHcHgXNCldwOAh648D0TLNMPC_zSQtqShsT8s6Eg,16010
|
106
106
|
mcp_agent/resources/examples/data-analysis/analysis-campaign.py,sha256=QdNdo0-7LR4Uzw61hEU_jVKmWyk6A9YpGo81kMwVobM,7267
|
107
107
|
mcp_agent/resources/examples/data-analysis/analysis.py,sha256=M9z8Q4YC5OGuqSa5uefYmmfmctqMn-WqCSfg5LI407o,2609
|
108
108
|
mcp_agent/resources/examples/data-analysis/fastagent.config.yaml,sha256=ini94PHyJCfgpjcjHKMMbGuHs6LIj46F1NwY0ll5HVk,1609
|
@@ -143,8 +143,8 @@ mcp_agent/resources/examples/workflows/parallel.py,sha256=n0dFN26QvYd2wjgohcaUBf
|
|
143
143
|
mcp_agent/resources/examples/workflows/router.py,sha256=E4x_-c3l4YW9w1i4ARcDtkdeqIdbWEGfsMzwLYpdbVc,1677
|
144
144
|
mcp_agent/resources/examples/workflows/short_story.txt,sha256=X3y_1AyhLFN2AKzCKvucJtDgAFIJfnlbsbGZO5bBWu0,1187
|
145
145
|
mcp_agent/ui/console_display.py,sha256=TVGDtJ37hc6UG0ei9g7ZPZZfFNeS1MYozt-Mx8HsPCk,9752
|
146
|
-
fast_agent_mcp-0.2.
|
147
|
-
fast_agent_mcp-0.2.
|
148
|
-
fast_agent_mcp-0.2.
|
149
|
-
fast_agent_mcp-0.2.
|
150
|
-
fast_agent_mcp-0.2.
|
146
|
+
fast_agent_mcp-0.2.13.dist-info/METADATA,sha256=aPMXpckeF8JsbXqQ8gGFWXpN208nlwUqkXmeG4yKd-w,29940
|
147
|
+
fast_agent_mcp-0.2.13.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
148
|
+
fast_agent_mcp-0.2.13.dist-info/entry_points.txt,sha256=bRniFM5zk3Kix5z7scX0gf9VnmGQ2Cz_Q1Gh7Ir4W00,186
|
149
|
+
fast_agent_mcp-0.2.13.dist-info/licenses/LICENSE,sha256=cN3FxDURL9XuzE5mhK9L2paZo82LTfjwCYVT7e3j0e4,10939
|
150
|
+
fast_agent_mcp-0.2.13.dist-info/RECORD,,
|
@@ -1,6 +1,12 @@
|
|
1
|
-
|
1
|
+
"""
|
2
|
+
Enhanced AgentMCPServer with robust shutdown handling for SSE transport.
|
3
|
+
"""
|
2
4
|
|
3
5
|
import asyncio
|
6
|
+
import os
|
7
|
+
import signal
|
8
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
9
|
+
from typing import Set
|
4
10
|
|
5
11
|
from mcp.server.fastmcp import Context as MCPContext
|
6
12
|
from mcp.server.fastmcp import FastMCP
|
@@ -9,6 +15,9 @@ import mcp_agent
|
|
9
15
|
import mcp_agent.core
|
10
16
|
import mcp_agent.core.prompt
|
11
17
|
from mcp_agent.core.agent_app import AgentApp
|
18
|
+
from mcp_agent.logging.logger import get_logger
|
19
|
+
|
20
|
+
logger = get_logger(__name__)
|
12
21
|
|
13
22
|
|
14
23
|
class AgentMCPServer:
|
@@ -20,14 +29,30 @@ class AgentMCPServer:
|
|
20
29
|
server_name: str = "FastAgent-MCP-Server",
|
21
30
|
server_description: str | None = None,
|
22
31
|
) -> None:
|
32
|
+
"""Initialize the server with the provided agent app."""
|
23
33
|
self.agent_app = agent_app
|
24
34
|
self.mcp_server: FastMCP = FastMCP(
|
25
35
|
name=server_name,
|
26
36
|
instructions=server_description
|
27
37
|
or f"This server provides access to {len(agent_app._agents)} agents",
|
28
38
|
)
|
39
|
+
# Shutdown coordination
|
40
|
+
self._graceful_shutdown_event = asyncio.Event()
|
41
|
+
self._force_shutdown_event = asyncio.Event()
|
42
|
+
self._shutdown_timeout = 5.0 # Seconds to wait for graceful shutdown
|
43
|
+
|
44
|
+
# Resource management
|
45
|
+
self._exit_stack = AsyncExitStack()
|
46
|
+
self._active_connections: Set[any] = set()
|
47
|
+
|
48
|
+
# Server state
|
49
|
+
self._server_task = None
|
50
|
+
|
51
|
+
# Set up agent tools
|
29
52
|
self.setup_tools()
|
30
53
|
|
54
|
+
logger.info(f"AgentMCPServer initialized with {len(agent_app._agents)} agents")
|
55
|
+
|
31
56
|
def setup_tools(self) -> None:
|
32
57
|
"""Register all agents as MCP tools."""
|
33
58
|
for agent_name, agent in self.agent_app._agents.items():
|
@@ -43,7 +68,6 @@ class AgentMCPServer:
|
|
43
68
|
)
|
44
69
|
async def send_message(message: str, ctx: MCPContext) -> str:
|
45
70
|
"""Send a message to the agent and return its response."""
|
46
|
-
|
47
71
|
# Get the agent's context
|
48
72
|
agent_context = getattr(agent, "context", None)
|
49
73
|
|
@@ -76,34 +100,212 @@ class AgentMCPServer:
|
|
76
100
|
# that matches the structure that FastMCP expects (list of dicts with role/content)
|
77
101
|
return [{"role": msg.role, "content": msg.content} for msg in prompt_messages]
|
78
102
|
|
103
|
+
def _setup_signal_handlers(self):
|
104
|
+
"""Set up signal handlers for graceful and forced shutdown."""
|
105
|
+
loop = asyncio.get_running_loop()
|
106
|
+
|
107
|
+
def handle_signal(is_term=False):
|
108
|
+
# Use asyncio.create_task to handle the signal in an async-friendly way
|
109
|
+
asyncio.create_task(self._handle_shutdown_signal(is_term))
|
110
|
+
|
111
|
+
# Register handlers for SIGINT (Ctrl+C) and SIGTERM
|
112
|
+
for sig, is_term in [(signal.SIGINT, False), (signal.SIGTERM, True)]:
|
113
|
+
loop.add_signal_handler(sig, lambda term=is_term: handle_signal(term))
|
114
|
+
|
115
|
+
logger.debug("Signal handlers installed")
|
116
|
+
|
117
|
+
async def _handle_shutdown_signal(self, is_term=False):
|
118
|
+
"""Handle shutdown signals with proper escalation."""
|
119
|
+
signal_name = "SIGTERM" if is_term else "SIGINT (Ctrl+C)"
|
120
|
+
|
121
|
+
# If force shutdown already requested, exit immediately
|
122
|
+
if self._force_shutdown_event.is_set():
|
123
|
+
logger.info("Force shutdown already in progress, exiting immediately...")
|
124
|
+
os._exit(1)
|
125
|
+
|
126
|
+
# If graceful shutdown already in progress, escalate to forced
|
127
|
+
if self._graceful_shutdown_event.is_set():
|
128
|
+
logger.info(f"Second {signal_name} received. Forcing shutdown...")
|
129
|
+
self._force_shutdown_event.set()
|
130
|
+
# Allow a brief moment for final cleanup, then force exit
|
131
|
+
await asyncio.sleep(0.5)
|
132
|
+
os._exit(1)
|
133
|
+
|
134
|
+
# First signal - initiate graceful shutdown
|
135
|
+
logger.info(f"{signal_name} received. Starting graceful shutdown...")
|
136
|
+
print(f"\n{signal_name} received. Starting graceful shutdown...")
|
137
|
+
print("Press Ctrl+C again to force exit.")
|
138
|
+
self._graceful_shutdown_event.set()
|
139
|
+
|
79
140
|
def run(self, transport: str = "sse", host: str = "0.0.0.0", port: int = 8000) -> None:
|
80
|
-
"""Run the MCP server."""
|
141
|
+
"""Run the MCP server synchronously."""
|
81
142
|
if transport == "sse":
|
82
|
-
# For running as a web server
|
83
143
|
self.mcp_server.settings.host = host
|
84
144
|
self.mcp_server.settings.port = port
|
85
145
|
|
86
|
-
|
146
|
+
try:
|
147
|
+
self.mcp_server.run(transport=transport)
|
148
|
+
except KeyboardInterrupt:
|
149
|
+
print("\nServer stopped by user (CTRL+C)")
|
150
|
+
finally:
|
151
|
+
# Run an async cleanup in a new event loop
|
152
|
+
asyncio.run(self.shutdown())
|
87
153
|
|
88
154
|
async def run_async(
|
89
155
|
self, transport: str = "sse", host: str = "0.0.0.0", port: int = 8000
|
90
156
|
) -> None:
|
91
|
-
"""Run the MCP server asynchronously."""
|
157
|
+
"""Run the MCP server asynchronously with improved shutdown handling."""
|
158
|
+
# Use different handling strategies based on transport type
|
92
159
|
if transport == "sse":
|
160
|
+
# For SSE, use our enhanced shutdown handling
|
161
|
+
self._setup_signal_handlers()
|
162
|
+
|
93
163
|
self.mcp_server.settings.host = host
|
94
164
|
self.mcp_server.settings.port = port
|
165
|
+
|
166
|
+
# Start the server in a separate task so we can monitor it
|
167
|
+
self._server_task = asyncio.create_task(self._run_server_with_shutdown(transport))
|
168
|
+
|
95
169
|
try:
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
170
|
+
# Wait for the server task to complete
|
171
|
+
await self._server_task
|
172
|
+
except asyncio.CancelledError:
|
173
|
+
logger.info("Server task cancelled.")
|
174
|
+
print("\nServer task cancelled.")
|
175
|
+
except Exception as e:
|
176
|
+
logger.error(f"Server error: {e}", exc_info=True)
|
177
|
+
print(f"\nServer error: {e}")
|
178
|
+
finally:
|
179
|
+
# Ensure cleanup happens
|
180
|
+
await self.shutdown()
|
181
|
+
logger.info("Server shutdown complete.")
|
182
|
+
print("\nServer shutdown complete.")
|
100
183
|
else: # stdio
|
184
|
+
# For STDIO, use simpler approach that respects STDIO lifecycle
|
185
|
+
# STDIO will naturally terminate when streams close
|
101
186
|
try:
|
187
|
+
# Run directly without extra monitoring or signal handlers
|
188
|
+
# This preserves the natural lifecycle of STDIO connections
|
102
189
|
await self.mcp_server.run_stdio_async()
|
103
190
|
except (asyncio.CancelledError, KeyboardInterrupt):
|
104
|
-
|
105
|
-
print("
|
106
|
-
|
191
|
+
logger.info("Server stopped (CTRL+C)")
|
192
|
+
print("\nServer stopped (CTRL+C)")
|
193
|
+
|
194
|
+
# Only perform minimal cleanup needed for STDIO
|
195
|
+
# Don't use our full shutdown procedure which could keep process alive
|
196
|
+
await self._cleanup_stdio()
|
197
|
+
|
198
|
+
async def _run_server_with_shutdown(self, transport: str):
|
199
|
+
"""Run the server with proper shutdown handling."""
|
200
|
+
# This method is only used for SSE transport
|
201
|
+
if transport != "sse":
|
202
|
+
raise ValueError("This method should only be used with SSE transport")
|
203
|
+
|
204
|
+
# Start a monitor task for shutdown
|
205
|
+
shutdown_monitor = asyncio.create_task(self._monitor_shutdown())
|
206
|
+
|
207
|
+
try:
|
208
|
+
# Patch SSE server to track connections if needed
|
209
|
+
if hasattr(self.mcp_server, "_sse_transport") and self.mcp_server._sse_transport:
|
210
|
+
# Store the original connect_sse method
|
211
|
+
original_connect = self.mcp_server._sse_transport.connect_sse
|
212
|
+
|
213
|
+
# Create a wrapper that tracks connections
|
214
|
+
@asynccontextmanager
|
215
|
+
async def tracked_connect_sse(*args, **kwargs):
|
216
|
+
async with original_connect(*args, **kwargs) as streams:
|
217
|
+
self._active_connections.add(streams)
|
218
|
+
try:
|
219
|
+
yield streams
|
220
|
+
finally:
|
221
|
+
self._active_connections.discard(streams)
|
222
|
+
|
223
|
+
# Replace with our tracking version
|
224
|
+
self.mcp_server._sse_transport.connect_sse = tracked_connect_sse
|
225
|
+
|
226
|
+
# Run the server (SSE only)
|
227
|
+
await self.mcp_server.run_sse_async()
|
228
|
+
finally:
|
229
|
+
# Cancel the monitor when the server exits
|
230
|
+
shutdown_monitor.cancel()
|
231
|
+
try:
|
232
|
+
await shutdown_monitor
|
233
|
+
except asyncio.CancelledError:
|
234
|
+
pass
|
235
|
+
|
236
|
+
async def _monitor_shutdown(self):
|
237
|
+
"""Monitor for shutdown signals and coordinate proper shutdown sequence."""
|
238
|
+
try:
|
239
|
+
# Wait for graceful shutdown request
|
240
|
+
await self._graceful_shutdown_event.wait()
|
241
|
+
logger.info("Graceful shutdown initiated")
|
242
|
+
|
243
|
+
# Two possible paths:
|
244
|
+
# 1. Wait for force shutdown
|
245
|
+
# 2. Wait for shutdown timeout
|
246
|
+
force_shutdown_task = asyncio.create_task(self._force_shutdown_event.wait())
|
247
|
+
timeout_task = asyncio.create_task(asyncio.sleep(self._shutdown_timeout))
|
248
|
+
|
249
|
+
# Wait for either force shutdown or timeout
|
250
|
+
done, pending = await asyncio.wait(
|
251
|
+
[force_shutdown_task, timeout_task], return_when=asyncio.FIRST_COMPLETED
|
252
|
+
)
|
253
|
+
|
254
|
+
# Cancel the remaining task
|
255
|
+
for task in pending:
|
256
|
+
task.cancel()
|
257
|
+
|
258
|
+
# Determine the shutdown reason
|
259
|
+
if force_shutdown_task in done:
|
260
|
+
logger.info("Force shutdown requested")
|
261
|
+
print("\nForced shutdown initiated...")
|
262
|
+
else:
|
263
|
+
logger.info(f"Graceful shutdown timed out after {self._shutdown_timeout} seconds")
|
264
|
+
print(f"\nGraceful shutdown timed out after {self._shutdown_timeout} seconds")
|
265
|
+
|
266
|
+
# Force close any remaining SSE connections
|
267
|
+
await self._close_sse_connections()
|
268
|
+
|
269
|
+
# Cancel the server task if running
|
270
|
+
if self._server_task and not self._server_task.done():
|
271
|
+
logger.info("Cancelling server task")
|
272
|
+
self._server_task.cancel()
|
273
|
+
except asyncio.CancelledError:
|
274
|
+
# Monitor was cancelled - clean exit
|
275
|
+
pass
|
276
|
+
except Exception as e:
|
277
|
+
logger.error(f"Error in shutdown monitor: {e}", exc_info=True)
|
278
|
+
|
279
|
+
async def _close_sse_connections(self):
|
280
|
+
"""Force close all SSE connections."""
|
281
|
+
# Close tracked connections
|
282
|
+
for conn in list(self._active_connections):
|
283
|
+
try:
|
284
|
+
if hasattr(conn, "close"):
|
285
|
+
await conn.close()
|
286
|
+
elif hasattr(conn, "aclose"):
|
287
|
+
await conn.aclose()
|
288
|
+
except Exception as e:
|
289
|
+
logger.error(f"Error closing connection: {e}")
|
290
|
+
self._active_connections.discard(conn)
|
291
|
+
|
292
|
+
# Access the SSE transport if it exists to close stream writers
|
293
|
+
if (
|
294
|
+
hasattr(self.mcp_server, "_sse_transport")
|
295
|
+
and self.mcp_server._sse_transport is not None
|
296
|
+
):
|
297
|
+
sse = self.mcp_server._sse_transport
|
298
|
+
|
299
|
+
# Close all read stream writers
|
300
|
+
if hasattr(sse, "_read_stream_writers"):
|
301
|
+
writers = list(sse._read_stream_writers.items())
|
302
|
+
for session_id, writer in writers:
|
303
|
+
try:
|
304
|
+
logger.debug(f"Closing SSE connection: {session_id}")
|
305
|
+
await writer.aclose()
|
306
|
+
sse._read_stream_writers.pop(session_id, None)
|
307
|
+
except Exception as e:
|
308
|
+
logger.error(f"Error closing SSE connection {session_id}: {e}")
|
107
309
|
|
108
310
|
async def with_bridged_context(self, agent_context, mcp_context, func, *args, **kwargs):
|
109
311
|
"""
|
@@ -146,7 +348,44 @@ class AgentMCPServer:
|
|
146
348
|
if hasattr(agent_context, "mcp_context"):
|
147
349
|
delattr(agent_context, "mcp_context")
|
148
350
|
|
351
|
+
async def _cleanup_stdio(self):
|
352
|
+
"""Minimal cleanup for STDIO transport to avoid keeping process alive."""
|
353
|
+
logger.info("Performing minimal STDIO cleanup")
|
354
|
+
|
355
|
+
# Just clean up agent resources directly without the full shutdown sequence
|
356
|
+
# This preserves the natural exit process for STDIO
|
357
|
+
for agent_name, agent in self.agent_app._agents.items():
|
358
|
+
try:
|
359
|
+
if hasattr(agent, "shutdown"):
|
360
|
+
await agent.shutdown()
|
361
|
+
except Exception as e:
|
362
|
+
logger.error(f"Error shutting down agent {agent_name}: {e}")
|
363
|
+
|
364
|
+
logger.info("STDIO cleanup complete")
|
365
|
+
|
149
366
|
async def shutdown(self):
|
150
367
|
"""Gracefully shutdown the MCP server and its resources."""
|
151
|
-
|
152
|
-
|
368
|
+
logger.info("Running full shutdown procedure")
|
369
|
+
|
370
|
+
# Skip if already in shutdown
|
371
|
+
if self._graceful_shutdown_event.is_set():
|
372
|
+
return
|
373
|
+
|
374
|
+
# Signal shutdown
|
375
|
+
self._graceful_shutdown_event.set()
|
376
|
+
|
377
|
+
# Close SSE connections
|
378
|
+
await self._close_sse_connections()
|
379
|
+
|
380
|
+
# Close any resources in the exit stack
|
381
|
+
await self._exit_stack.aclose()
|
382
|
+
|
383
|
+
# Shutdown any agent resources
|
384
|
+
for agent_name, agent in self.agent_app._agents.items():
|
385
|
+
try:
|
386
|
+
if hasattr(agent, "shutdown"):
|
387
|
+
await agent.shutdown()
|
388
|
+
except Exception as e:
|
389
|
+
logger.error(f"Error shutting down agent {agent_name}: {e}")
|
390
|
+
|
391
|
+
logger.info("Full shutdown complete")
|
File without changes
|
File without changes
|
File without changes
|