fast-agent-mcp 0.2.12__py3-none-any.whl → 0.2.14__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 (36) hide show
  1. {fast_agent_mcp-0.2.12.dist-info → fast_agent_mcp-0.2.14.dist-info}/METADATA +1 -1
  2. {fast_agent_mcp-0.2.12.dist-info → fast_agent_mcp-0.2.14.dist-info}/RECORD +33 -33
  3. mcp_agent/agents/agent.py +2 -2
  4. mcp_agent/agents/base_agent.py +3 -3
  5. mcp_agent/agents/workflow/chain_agent.py +2 -2
  6. mcp_agent/agents/workflow/evaluator_optimizer.py +3 -3
  7. mcp_agent/agents/workflow/orchestrator_agent.py +3 -3
  8. mcp_agent/agents/workflow/parallel_agent.py +2 -2
  9. mcp_agent/agents/workflow/router_agent.py +2 -2
  10. mcp_agent/cli/commands/check_config.py +450 -0
  11. mcp_agent/cli/commands/setup.py +1 -1
  12. mcp_agent/cli/main.py +8 -15
  13. mcp_agent/core/agent_types.py +8 -8
  14. mcp_agent/core/direct_decorators.py +10 -8
  15. mcp_agent/core/direct_factory.py +4 -1
  16. mcp_agent/core/validation.py +6 -4
  17. mcp_agent/event_progress.py +6 -6
  18. mcp_agent/llm/augmented_llm.py +10 -2
  19. mcp_agent/llm/augmented_llm_passthrough.py +5 -3
  20. mcp_agent/llm/augmented_llm_playback.py +2 -1
  21. mcp_agent/llm/model_factory.py +7 -27
  22. mcp_agent/llm/provider_key_manager.py +83 -0
  23. mcp_agent/llm/provider_types.py +16 -0
  24. mcp_agent/llm/providers/augmented_llm_anthropic.py +5 -26
  25. mcp_agent/llm/providers/augmented_llm_deepseek.py +5 -24
  26. mcp_agent/llm/providers/augmented_llm_generic.py +2 -16
  27. mcp_agent/llm/providers/augmented_llm_openai.py +4 -26
  28. mcp_agent/llm/providers/augmented_llm_openrouter.py +17 -45
  29. mcp_agent/mcp/interfaces.py +2 -1
  30. mcp_agent/mcp_server/agent_server.py +335 -14
  31. mcp_agent/cli/commands/config.py +0 -11
  32. mcp_agent/executor/temporal.py +0 -383
  33. mcp_agent/executor/workflow.py +0 -195
  34. {fast_agent_mcp-0.2.12.dist-info → fast_agent_mcp-0.2.14.dist-info}/WHEEL +0 -0
  35. {fast_agent_mcp-0.2.12.dist-info → fast_agent_mcp-0.2.14.dist-info}/entry_points.txt +0 -0
  36. {fast_agent_mcp-0.2.12.dist-info → fast_agent_mcp-0.2.14.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,12 @@
1
- # src/mcp_agent/mcp_server/agent_server.py
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,267 @@ 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
- self.mcp_server.run(transport=transport)
146
+ # For synchronous run, we can use the simpler approach
147
+ try:
148
+ # Add any server attributes that might help with shutdown
149
+ if not hasattr(self.mcp_server, "_server_should_exit"):
150
+ self.mcp_server._server_should_exit = False
151
+
152
+ # Run the server
153
+ self.mcp_server.run(transport=transport)
154
+ except KeyboardInterrupt:
155
+ print("\nServer stopped by user (CTRL+C)")
156
+ except SystemExit as e:
157
+ # Handle normal exit
158
+ print(f"\nServer exiting with code {e.code}")
159
+ # Re-raise to allow normal exit process
160
+ raise
161
+ except Exception as e:
162
+ print(f"\nServer error: {e}")
163
+ finally:
164
+ # Run an async cleanup in a new event loop
165
+ try:
166
+ asyncio.run(self.shutdown())
167
+ except (SystemExit, KeyboardInterrupt):
168
+ # These are expected during shutdown
169
+ pass
170
+ else: # stdio
171
+ try:
172
+ self.mcp_server.run(transport=transport)
173
+ except KeyboardInterrupt:
174
+ print("\nServer stopped by user (CTRL+C)")
175
+ finally:
176
+ # Minimal cleanup for stdio
177
+ asyncio.run(self._cleanup_stdio())
87
178
 
88
179
  async def run_async(
89
180
  self, transport: str = "sse", host: str = "0.0.0.0", port: int = 8000
90
181
  ) -> None:
91
- """Run the MCP server asynchronously."""
182
+ """Run the MCP server asynchronously with improved shutdown handling."""
183
+ # Use different handling strategies based on transport type
92
184
  if transport == "sse":
185
+ # For SSE, use our enhanced shutdown handling
186
+ self._setup_signal_handlers()
187
+
93
188
  self.mcp_server.settings.host = host
94
189
  self.mcp_server.settings.port = port
190
+
191
+ # Start the server in a separate task so we can monitor it
192
+ self._server_task = asyncio.create_task(self._run_server_with_shutdown(transport))
193
+
95
194
  try:
96
- await self.mcp_server.run_sse_async()
195
+ # Wait for the server task to complete
196
+ await self._server_task
97
197
  except (asyncio.CancelledError, KeyboardInterrupt):
98
- print("Server Stopped (CTRL+C)")
99
- return
198
+ # Both cancellation and KeyboardInterrupt are expected during shutdown
199
+ logger.info("Server stopped via cancellation or interrupt")
200
+ print("\nServer stopped")
201
+ except SystemExit as e:
202
+ # Handle normal exit cleanly
203
+ logger.info(f"Server exiting with code {e.code}")
204
+ print(f"\nServer exiting with code {e.code}")
205
+ # If this is exit code 0, let it propagate for normal exit
206
+ if e.code == 0:
207
+ raise
208
+ except Exception as e:
209
+ logger.error(f"Server error: {e}", exc_info=True)
210
+ print(f"\nServer error: {e}")
211
+ finally:
212
+ # Only do minimal cleanup - don't try to be too clever
213
+ await self._cleanup_stdio()
214
+ print("\nServer shutdown complete.")
100
215
  else: # stdio
216
+ # For STDIO, use simpler approach that respects STDIO lifecycle
101
217
  try:
218
+ # Run directly without extra monitoring or signal handlers
219
+ # This preserves the natural lifecycle of STDIO connections
102
220
  await self.mcp_server.run_stdio_async()
103
221
  except (asyncio.CancelledError, KeyboardInterrupt):
104
- # Gracefully handle cancellation during shutdown
105
- print("Server Stopped (CTRL+C)")
106
- return
222
+ logger.info("Server stopped (CTRL+C)")
223
+ print("\nServer stopped (CTRL+C)")
224
+ except SystemExit as e:
225
+ # Handle normal exit cleanly
226
+ logger.info(f"Server exiting with code {e.code}")
227
+ print(f"\nServer exiting with code {e.code}")
228
+ # If this is exit code 0, let it propagate for normal exit
229
+ if e.code == 0:
230
+ raise
231
+ # Only perform minimal cleanup needed for STDIO
232
+ await self._cleanup_stdio()
233
+
234
+ async def _run_server_with_shutdown(self, transport: str):
235
+ """Run the server with proper shutdown handling."""
236
+ # This method is only used for SSE transport
237
+ if transport != "sse":
238
+ raise ValueError("This method should only be used with SSE transport")
239
+
240
+ # Start a monitor task for shutdown
241
+ shutdown_monitor = asyncio.create_task(self._monitor_shutdown())
242
+
243
+ try:
244
+ # Patch SSE server to track connections if needed
245
+ if hasattr(self.mcp_server, "_sse_transport") and self.mcp_server._sse_transport:
246
+ # Store the original connect_sse method
247
+ original_connect = self.mcp_server._sse_transport.connect_sse
248
+
249
+ # Create a wrapper that tracks connections
250
+ @asynccontextmanager
251
+ async def tracked_connect_sse(*args, **kwargs):
252
+ async with original_connect(*args, **kwargs) as streams:
253
+ self._active_connections.add(streams)
254
+ try:
255
+ yield streams
256
+ finally:
257
+ self._active_connections.discard(streams)
258
+
259
+ # Replace with our tracking version
260
+ self.mcp_server._sse_transport.connect_sse = tracked_connect_sse
261
+
262
+ # Run the server (SSE only)
263
+ await self.mcp_server.run_sse_async()
264
+ finally:
265
+ # Cancel the monitor when the server exits
266
+ shutdown_monitor.cancel()
267
+ try:
268
+ await shutdown_monitor
269
+ except asyncio.CancelledError:
270
+ pass
271
+
272
+ async def _monitor_shutdown(self):
273
+ """Monitor for shutdown signals and coordinate proper shutdown sequence."""
274
+ try:
275
+ # Wait for graceful shutdown request
276
+ await self._graceful_shutdown_event.wait()
277
+ logger.info("Graceful shutdown initiated")
278
+
279
+ # Two possible paths:
280
+ # 1. Wait for force shutdown
281
+ # 2. Wait for shutdown timeout
282
+ force_shutdown_task = asyncio.create_task(self._force_shutdown_event.wait())
283
+ timeout_task = asyncio.create_task(asyncio.sleep(self._shutdown_timeout))
284
+
285
+ done, pending = await asyncio.wait(
286
+ [force_shutdown_task, timeout_task], return_when=asyncio.FIRST_COMPLETED
287
+ )
288
+
289
+ # Cancel the remaining task
290
+ for task in pending:
291
+ task.cancel()
292
+
293
+ # Determine shutdown reason
294
+ if force_shutdown_task in done:
295
+ logger.info("Force shutdown requested by user")
296
+ print("\nForce shutdown initiated...")
297
+ else:
298
+ logger.info(f"Graceful shutdown timed out after {self._shutdown_timeout} seconds")
299
+ print(f"\nGraceful shutdown timed out after {self._shutdown_timeout} seconds")
300
+
301
+ os._exit(0)
302
+
303
+ except asyncio.CancelledError:
304
+ # Monitor was cancelled - clean exit
305
+ pass
306
+ except Exception as e:
307
+ logger.error(f"Error in shutdown monitor: {e}", exc_info=True)
308
+
309
+ async def _close_sse_connections(self):
310
+ """Force close all SSE connections."""
311
+ # Close tracked connections
312
+ for conn in list(self._active_connections):
313
+ try:
314
+ if hasattr(conn, "close"):
315
+ await conn.close()
316
+ elif hasattr(conn, "aclose"):
317
+ await conn.aclose()
318
+ except Exception as e:
319
+ logger.error(f"Error closing connection: {e}")
320
+ self._active_connections.discard(conn)
321
+
322
+ # Access the SSE transport if it exists to close stream writers
323
+ if (
324
+ hasattr(self.mcp_server, "_sse_transport")
325
+ and self.mcp_server._sse_transport is not None
326
+ ):
327
+ sse = self.mcp_server._sse_transport
328
+
329
+ # Close all read stream writers
330
+ if hasattr(sse, "_read_stream_writers"):
331
+ writers = list(sse._read_stream_writers.items())
332
+ for session_id, writer in writers:
333
+ try:
334
+ logger.debug(f"Closing SSE connection: {session_id}")
335
+ # Instead of aclose, try to close more gracefully
336
+ # Send a special event to notify client, then close
337
+ try:
338
+ if hasattr(writer, "send") and not getattr(writer, "_closed", False):
339
+ try:
340
+ # Try to send a close event if possible
341
+ await writer.send(Exception("Server shutting down"))
342
+ except (AttributeError, asyncio.CancelledError):
343
+ pass
344
+ except Exception:
345
+ pass
346
+
347
+ # Now close the stream
348
+ await writer.aclose()
349
+ sse._read_stream_writers.pop(session_id, None)
350
+ except Exception as e:
351
+ logger.error(f"Error closing SSE connection {session_id}: {e}")
352
+
353
+ # If we have a ASGI lifespan hook, try to signal closure
354
+ if (
355
+ hasattr(self.mcp_server, "_lifespan_state")
356
+ and self.mcp_server._lifespan_state == "started"
357
+ ):
358
+ logger.debug("Attempting to signal ASGI lifespan shutdown")
359
+ try:
360
+ if hasattr(self.mcp_server, "_on_shutdown"):
361
+ await self.mcp_server._on_shutdown()
362
+ except Exception as e:
363
+ logger.error(f"Error during ASGI lifespan shutdown: {e}")
107
364
 
108
365
  async def with_bridged_context(self, agent_context, mcp_context, func, *args, **kwargs):
109
366
  """
@@ -146,7 +403,71 @@ class AgentMCPServer:
146
403
  if hasattr(agent_context, "mcp_context"):
147
404
  delattr(agent_context, "mcp_context")
148
405
 
406
+ async def _cleanup_stdio(self):
407
+ """Minimal cleanup for STDIO transport to avoid keeping process alive."""
408
+ logger.info("Performing minimal STDIO cleanup")
409
+
410
+ # Just clean up agent resources directly without the full shutdown sequence
411
+ # This preserves the natural exit process for STDIO
412
+ for agent_name, agent in self.agent_app._agents.items():
413
+ try:
414
+ if hasattr(agent, "shutdown"):
415
+ await agent.shutdown()
416
+ except Exception as e:
417
+ logger.error(f"Error shutting down agent {agent_name}: {e}")
418
+
419
+ logger.info("STDIO cleanup complete")
420
+
149
421
  async def shutdown(self):
150
422
  """Gracefully shutdown the MCP server and its resources."""
151
- # Your MCP server may have additional cleanup code here
152
- pass
423
+ logger.info("Running full shutdown procedure")
424
+
425
+ # Skip if already in shutdown
426
+ if self._graceful_shutdown_event.is_set():
427
+ return
428
+
429
+ # Signal shutdown
430
+ self._graceful_shutdown_event.set()
431
+
432
+ try:
433
+ # Close SSE connections
434
+ await self._close_sse_connections()
435
+
436
+ # Close any resources in the exit stack
437
+ await self._exit_stack.aclose()
438
+
439
+ # Shutdown any agent resources
440
+ for agent_name, agent in self.agent_app._agents.items():
441
+ try:
442
+ if hasattr(agent, "shutdown"):
443
+ await agent.shutdown()
444
+ except Exception as e:
445
+ logger.error(f"Error shutting down agent {agent_name}: {e}")
446
+ except Exception as e:
447
+ # Log any errors but don't let them prevent shutdown
448
+ logger.error(f"Error during shutdown: {e}", exc_info=True)
449
+ finally:
450
+ logger.info("Full shutdown complete")
451
+
452
+ async def _cleanup_minimal(self):
453
+ """Perform minimal cleanup before simulating a KeyboardInterrupt."""
454
+ logger.info("Performing minimal cleanup before interrupt")
455
+
456
+ # Only close SSE connection writers directly
457
+ if (
458
+ hasattr(self.mcp_server, "_sse_transport")
459
+ and self.mcp_server._sse_transport is not None
460
+ ):
461
+ sse = self.mcp_server._sse_transport
462
+
463
+ # Close all read stream writers
464
+ if hasattr(sse, "_read_stream_writers"):
465
+ for session_id, writer in list(sse._read_stream_writers.items()):
466
+ try:
467
+ await writer.aclose()
468
+ except Exception:
469
+ # Ignore errors during cleanup
470
+ pass
471
+
472
+ # Clear active connections set to prevent further operations
473
+ self._active_connections.clear()
@@ -1,11 +0,0 @@
1
- from typing import NoReturn
2
-
3
- import typer
4
-
5
- app = typer.Typer()
6
-
7
-
8
- @app.command()
9
- def show() -> NoReturn:
10
- """Show the configuration."""
11
- raise NotImplementedError("The show configuration command has not been implemented yet")