code-puppy 0.0.126__py3-none-any.whl → 0.0.128__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 (34) hide show
  1. code_puppy/__init__.py +1 -0
  2. code_puppy/agent.py +65 -69
  3. code_puppy/agents/agent_code_puppy.py +0 -3
  4. code_puppy/agents/runtime_manager.py +212 -0
  5. code_puppy/command_line/command_handler.py +56 -25
  6. code_puppy/command_line/mcp_commands.py +1298 -0
  7. code_puppy/command_line/meta_command_handler.py +3 -2
  8. code_puppy/command_line/model_picker_completion.py +21 -8
  9. code_puppy/main.py +52 -157
  10. code_puppy/mcp/__init__.py +23 -0
  11. code_puppy/mcp/async_lifecycle.py +237 -0
  12. code_puppy/mcp/circuit_breaker.py +218 -0
  13. code_puppy/mcp/config_wizard.py +437 -0
  14. code_puppy/mcp/dashboard.py +291 -0
  15. code_puppy/mcp/error_isolation.py +360 -0
  16. code_puppy/mcp/examples/retry_example.py +208 -0
  17. code_puppy/mcp/health_monitor.py +549 -0
  18. code_puppy/mcp/managed_server.py +346 -0
  19. code_puppy/mcp/manager.py +701 -0
  20. code_puppy/mcp/registry.py +412 -0
  21. code_puppy/mcp/retry_manager.py +321 -0
  22. code_puppy/mcp/server_registry_catalog.py +751 -0
  23. code_puppy/mcp/status_tracker.py +355 -0
  24. code_puppy/messaging/spinner/textual_spinner.py +6 -2
  25. code_puppy/model_factory.py +19 -4
  26. code_puppy/models.json +22 -4
  27. code_puppy/tui/app.py +19 -27
  28. code_puppy/tui/tests/test_agent_command.py +22 -15
  29. {code_puppy-0.0.126.data → code_puppy-0.0.128.data}/data/code_puppy/models.json +22 -4
  30. {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/METADATA +2 -3
  31. {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/RECORD +34 -18
  32. {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/WHEEL +0 -0
  33. {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/entry_points.txt +0 -0
  34. {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/licenses/LICENSE +0 -0
@@ -117,11 +117,12 @@ def handle_meta_command(command: str, console: Console) -> bool:
117
117
  new_input = update_model_in_input(command)
118
118
  if new_input is not None:
119
119
  from code_puppy.command_line.model_picker_completion import get_active_model
120
- from code_puppy.agent import get_code_generation_agent
120
+ from code_puppy.agents.runtime_manager import get_runtime_agent_manager
121
121
 
122
122
  model = get_active_model()
123
123
  # Make sure this is called for the test
124
- get_code_generation_agent(force_reload=True)
124
+ manager = get_runtime_agent_manager()
125
+ manager.reload_agent()
125
126
  console.print(
126
127
  f"[bold green]Active model set and loaded:[/bold green] [cyan]{model}[/cyan]"
127
128
  )
@@ -40,11 +40,11 @@ def set_active_model(model_name: str):
40
40
 
41
41
  class ModelNameCompleter(Completer):
42
42
  """
43
- A completer that triggers on '/m' to show available models from models.json.
44
- Only '/m' (not just '/') will trigger the dropdown.
43
+ A completer that triggers on '/model' to show available models from models.json.
44
+ Only '/model' (not just '/') will trigger the dropdown.
45
45
  """
46
46
 
47
- def __init__(self, trigger: str = "/m"):
47
+ def __init__(self, trigger: str = "/model"):
48
48
  self.trigger = trigger
49
49
  self.model_names = load_model_names()
50
50
 
@@ -70,14 +70,27 @@ class ModelNameCompleter(Completer):
70
70
 
71
71
 
72
72
  def update_model_in_input(text: str) -> Optional[str]:
73
- # If input starts with /m and a model name, set model and strip it out
73
+ # If input starts with /model or /m and a model name, set model and strip it out
74
74
  content = text.strip()
75
- if content.startswith("/m"):
76
- rest = content[2:].strip()
75
+
76
+ # Check for /model command
77
+ if content.startswith("/model"):
78
+ rest = content[6:].strip() # Remove '/model'
77
79
  for model in load_model_names():
78
80
  if rest == model:
79
81
  set_active_model(model)
80
- # Remove /mmodel from the input
82
+ # Remove /model from the input
83
+ idx = text.find("/model" + model)
84
+ if idx != -1:
85
+ new_text = (text[:idx] + text[idx + len("/model" + model) :]).strip()
86
+ return new_text
87
+ # Also check for legacy /m command for backward compatibility
88
+ elif content.startswith("/m"):
89
+ rest = content[2:].strip() # Remove '/m'
90
+ for model in load_model_names():
91
+ if rest == model:
92
+ set_active_model(model)
93
+ # Remove /m from the input
81
94
  idx = text.find("/m" + model)
82
95
  if idx != -1:
83
96
  new_text = (text[:idx] + text[idx + len("/m" + model) :]).strip()
@@ -86,7 +99,7 @@ def update_model_in_input(text: str) -> Optional[str]:
86
99
 
87
100
 
88
101
  async def get_input_with_model_completion(
89
- prompt_str: str = ">>> ", trigger: str = "/m", history_file: Optional[str] = None
102
+ prompt_str: str = ">>> ", trigger: str = "/model", history_file: Optional[str] = None
90
103
  ) -> str:
91
104
  history = FileHistory(os.path.expanduser(history_file)) if history_file else None
92
105
  session = PromptSession(
code_puppy/main.py CHANGED
@@ -12,7 +12,8 @@ from rich.syntax import Syntax
12
12
  from rich.text import Text
13
13
 
14
14
  from code_puppy import __version__, callbacks, plugins, state_management
15
- from code_puppy.agent import get_code_generation_agent, get_custom_usage_limits
15
+ from code_puppy.agent import get_custom_usage_limits
16
+ from code_puppy.agents.runtime_manager import get_runtime_agent_manager
16
17
  from code_puppy.command_line.prompt_toolkit_completion import (
17
18
  get_input_with_combined_completion,
18
19
  get_prompt_with_active_model,
@@ -250,9 +251,10 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
250
251
  emit_system_message(
251
252
  "Press [bold red]Ctrl+C[/bold red] during processing to cancel the current task or inference."
252
253
  )
253
- from code_puppy.command_line.command_handler import COMMANDS_HELP
254
+ from code_puppy.command_line.command_handler import get_commands_help
254
255
 
255
- emit_system_message(COMMANDS_HELP)
256
+ help_text = get_commands_help()
257
+ emit_system_message(help_text)
256
258
  try:
257
259
  from code_puppy.command_line.motd import print_motd
258
260
 
@@ -262,9 +264,12 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
262
264
 
263
265
  emit_warning(f"MOTD error: {e}")
264
266
  from code_puppy.messaging import emit_info
267
+ from code_puppy.agents.runtime_manager import get_runtime_agent_manager
265
268
 
266
269
  emit_info("[bold cyan]Initializing agent...[/bold cyan]")
267
- get_code_generation_agent()
270
+ # Initialize the runtime agent manager
271
+ agent_manager = get_runtime_agent_manager()
272
+ agent_manager.get_agent()
268
273
  if initial_command:
269
274
  from code_puppy.messaging import emit_info, emit_system_message
270
275
 
@@ -273,9 +278,6 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
273
278
  )
274
279
 
275
280
  try:
276
- # Get the agent (already loaded above)
277
- agent = get_code_generation_agent()
278
-
279
281
  # Check if any tool is waiting for user input before showing spinner
280
282
  try:
281
283
  from code_puppy.tools.command_runner import is_awaiting_user_input
@@ -286,44 +288,22 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
286
288
 
287
289
  # Run with or without spinner based on whether we're awaiting input
288
290
  if awaiting_input:
289
- # No spinner - just run the agent
290
- try:
291
- async with agent.run_mcp_servers():
292
- response = await agent.run(
293
- initial_command, usage_limits=get_custom_usage_limits()
294
- )
295
- except Exception as mcp_error:
296
- from code_puppy.messaging import emit_warning
297
-
298
- emit_warning(f"MCP server error: {str(mcp_error)}")
299
- emit_warning("Running without MCP servers...")
300
- # Run without MCP servers as fallback
301
- response = await agent.run(
302
- initial_command, usage_limits=get_custom_usage_limits()
303
- )
291
+ # No spinner - use agent_manager's run_with_mcp method
292
+ response = await agent_manager.run_with_mcp(
293
+ initial_command, usage_limits=get_custom_usage_limits()
294
+ )
304
295
  else:
305
296
  # Use our custom spinner for better compatibility with user input
306
297
  from code_puppy.messaging.spinner import ConsoleSpinner
307
298
 
308
299
  with ConsoleSpinner(console=display_console):
309
- try:
310
- async with agent.run_mcp_servers():
311
- response = await agent.run(
312
- initial_command, usage_limits=get_custom_usage_limits()
313
- )
314
- except Exception as mcp_error:
315
- from code_puppy.messaging import emit_warning
316
-
317
- emit_warning(f"MCP server error: {str(mcp_error)}")
318
- emit_warning("Running without MCP servers...")
319
- # Run without MCP servers as fallback
320
- response = await agent.run(
321
- initial_command, usage_limits=get_custom_usage_limits()
322
- )
323
- finally:
324
- set_message_history(
325
- prune_interrupted_tool_calls(get_message_history())
326
- )
300
+ # Use agent_manager's run_with_mcp method
301
+ response = await agent_manager.run_with_mcp(
302
+ initial_command, usage_limits=get_custom_usage_limits()
303
+ )
304
+ set_message_history(
305
+ prune_interrupted_tool_calls(get_message_history())
306
+ )
327
307
 
328
308
  agent_response = response.output
329
309
 
@@ -438,109 +418,35 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
438
418
  try:
439
419
  prettier_code_blocks()
440
420
 
441
- # Store agent's full response
442
- agent_response = None
443
-
444
- # Get the agent (uses cached version from early initialization)
445
- agent = get_code_generation_agent()
421
+ # No need to get agent directly - use manager's run methods
446
422
 
447
423
  # Use our custom spinner for better compatibility with user input
448
424
  from code_puppy.messaging import emit_warning
449
425
  from code_puppy.messaging.spinner import ConsoleSpinner
450
426
 
451
- # Create a simple flag to track cancellation locally
452
- local_cancelled = False
453
-
454
- # Run with spinner
455
- with ConsoleSpinner(console=display_console):
456
- # Use a separate asyncio task that we can cancel
457
- async def run_agent_task():
458
- try:
459
- async with agent.run_mcp_servers():
460
- return await agent.run(
461
- task,
462
- message_history=get_message_history(),
463
- usage_limits=get_custom_usage_limits(),
464
- )
465
- except Exception as mcp_error:
466
- # Handle MCP server errors
467
- emit_warning(f"MCP server error: {str(mcp_error)}")
468
- emit_warning("Running without MCP servers...")
469
- # Run without MCP servers as fallback
470
- return await agent.run(
471
- task,
472
- message_history=get_message_history(),
473
- usage_limits=get_custom_usage_limits(),
474
- )
475
- finally:
476
- set_message_history(
477
- prune_interrupted_tool_calls(get_message_history())
478
- )
479
-
480
- # Create the task
481
- agent_task = asyncio.create_task(run_agent_task())
482
-
483
- # Set up signal handling for Ctrl+C
484
- import signal
485
-
486
- from code_puppy.tools.command_runner import (
487
- kill_all_running_shell_processes,
427
+ # Use ConsoleSpinner for better user experience
428
+ try:
429
+ with ConsoleSpinner(console=display_console):
430
+ # The manager handles all cancellation logic internally
431
+ result = await agent_manager.run_with_mcp(
432
+ task,
433
+ message_history=get_message_history(),
434
+ usage_limits=get_custom_usage_limits(),
435
+ )
436
+ except asyncio.CancelledError:
437
+ # Agent was cancelled by user
438
+ result = None
439
+ except KeyboardInterrupt:
440
+ # Keyboard interrupt
441
+ emit_warning("\n⚠️ Caught KeyboardInterrupt in main")
442
+ result = None
443
+ finally:
444
+ set_message_history(
445
+ prune_interrupted_tool_calls(get_message_history())
488
446
  )
489
447
 
490
- original_handler = None
491
-
492
- # Ensure the interrupt handler only acts once per task
493
- handled = False
494
-
495
- def keyboard_interrupt_handler(sig, frame):
496
- nonlocal local_cancelled
497
- nonlocal handled
498
- if handled:
499
- return
500
- handled = True
501
- # First, nuke any running shell processes triggered by tools
502
- try:
503
- killed = kill_all_running_shell_processes()
504
- if killed:
505
- from code_puppy.messaging import emit_warning
506
-
507
- emit_warning(
508
- f"Cancelled {killed} running shell process(es)."
509
- )
510
- else:
511
- # Then cancel the agent task
512
- if not agent_task.done():
513
- state_management._message_history = (
514
- prune_interrupted_tool_calls(
515
- state_management._message_history
516
- )
517
- )
518
- agent_task.cancel()
519
- local_cancelled = True
520
- except Exception as e:
521
- from code_puppy.messaging import emit_warning
522
-
523
- emit_warning(f"Shell kill error: {e}")
524
- # Don't call the original handler
525
- # This prevents the application from exiting
526
-
527
- try:
528
- # Save original handler and set our custom one
529
- original_handler = signal.getsignal(signal.SIGINT)
530
- signal.signal(signal.SIGINT, keyboard_interrupt_handler)
531
-
532
- # Wait for the task to complete or be cancelled
533
- result = await agent_task
534
- except asyncio.CancelledError:
535
- # Task was cancelled by our handler
536
- pass
537
- finally:
538
- # Restore original signal handler
539
- if original_handler:
540
- signal.signal(signal.SIGINT, original_handler)
541
-
542
448
  # Check if the task was cancelled
543
- if local_cancelled:
449
+ if result is None:
544
450
  emit_warning("\n⚠️ Processing cancelled by user (Ctrl+C)")
545
451
  # Skip the rest of this loop iteration
546
452
  continue
@@ -605,37 +511,26 @@ async def execute_single_prompt(prompt: str, message_renderer) -> None:
605
511
  emit_info(f"[bold blue]Executing prompt:[/bold blue] {prompt}")
606
512
 
607
513
  try:
608
- # Get the agent
609
- agent = get_code_generation_agent()
610
-
611
- # Use our custom spinner for better compatibility with user input
514
+ # Get agent through runtime manager and use its run_with_mcp method
515
+ agent_manager = get_runtime_agent_manager()
516
+
612
517
  from code_puppy.messaging.spinner import ConsoleSpinner
613
-
614
- display_console = message_renderer.console
615
- with ConsoleSpinner(console=display_console):
616
- try:
617
- async with agent.run_mcp_servers():
618
- response = await agent.run(
619
- prompt, usage_limits=get_custom_usage_limits()
620
- )
621
- except Exception as mcp_error:
622
- from code_puppy.messaging import emit_warning
623
-
624
- emit_warning(f"MCP server error: {str(mcp_error)}")
625
- emit_warning("Running without MCP servers...")
626
- # Run without MCP servers as fallback
627
- response = await agent.run(
628
- prompt, usage_limits=get_custom_usage_limits()
629
- )
518
+ with ConsoleSpinner(console=message_renderer.console):
519
+ response = await agent_manager.run_with_mcp(
520
+ prompt,
521
+ usage_limits=get_custom_usage_limits()
522
+ )
630
523
 
631
524
  agent_response = response.output
632
525
  emit_system_message(
633
526
  f"\n[bold purple]AGENT RESPONSE: [/bold purple]\n{agent_response}"
634
527
  )
635
528
 
529
+ except asyncio.CancelledError:
530
+ from code_puppy.messaging import emit_warning
531
+ emit_warning("Execution cancelled by user")
636
532
  except Exception as e:
637
533
  from code_puppy.messaging import emit_error
638
-
639
534
  emit_error(f"Error executing prompt: {str(e)}")
640
535
 
641
536
 
@@ -0,0 +1,23 @@
1
+ """MCP (Model Context Protocol) management system for Code Puppy."""
2
+
3
+ from .managed_server import ManagedMCPServer, ServerConfig, ServerState
4
+ from .status_tracker import ServerStatusTracker, Event
5
+ from .manager import MCPManager, ServerInfo, get_mcp_manager
6
+ from .registry import ServerRegistry
7
+ from .error_isolation import MCPErrorIsolator, ErrorStats, ErrorCategory, QuarantinedServerError, get_error_isolator
8
+ from .circuit_breaker import CircuitBreaker, CircuitState, CircuitOpenError
9
+ from .retry_manager import RetryManager, RetryStats, get_retry_manager, retry_mcp_call
10
+ from .dashboard import MCPDashboard
11
+ from .config_wizard import MCPConfigWizard, run_add_wizard
12
+
13
+ __all__ = [
14
+ 'ManagedMCPServer', 'ServerConfig', 'ServerState',
15
+ 'ServerStatusTracker', 'Event',
16
+ 'MCPManager', 'ServerInfo', 'get_mcp_manager',
17
+ 'ServerRegistry',
18
+ 'MCPErrorIsolator', 'ErrorStats', 'ErrorCategory', 'QuarantinedServerError', 'get_error_isolator',
19
+ 'CircuitBreaker', 'CircuitState', 'CircuitOpenError',
20
+ 'RetryManager', 'RetryStats', 'get_retry_manager', 'retry_mcp_call',
21
+ 'MCPDashboard',
22
+ 'MCPConfigWizard', 'run_add_wizard'
23
+ ]
@@ -0,0 +1,237 @@
1
+ """
2
+ Async server lifecycle management using pydantic-ai's context managers.
3
+
4
+ This module properly manages MCP server lifecycles by maintaining async contexts
5
+ within the same task, allowing servers to start and stay running.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ from typing import Dict, Optional, Any, Union
11
+ from datetime import datetime
12
+ from dataclasses import dataclass
13
+ from contextlib import AsyncExitStack
14
+
15
+ from pydantic_ai.mcp import MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class ManagedServerContext:
22
+ """Represents a managed MCP server with its async context."""
23
+
24
+ server_id: str
25
+ server: Union[MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP]
26
+ exit_stack: AsyncExitStack
27
+ start_time: datetime
28
+ task: asyncio.Task # The task that manages this server's lifecycle
29
+
30
+
31
+ class AsyncServerLifecycleManager:
32
+ """
33
+ Manages MCP server lifecycles asynchronously.
34
+
35
+ This properly maintains async contexts within the same task,
36
+ allowing servers to start and stay running independently of agents.
37
+ """
38
+
39
+ def __init__(self):
40
+ """Initialize the async lifecycle manager."""
41
+ self._servers: Dict[str, ManagedServerContext] = {}
42
+ self._lock = asyncio.Lock()
43
+ logger.info("AsyncServerLifecycleManager initialized")
44
+
45
+ async def start_server(
46
+ self,
47
+ server_id: str,
48
+ server: Union[MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP]
49
+ ) -> bool:
50
+ """
51
+ Start an MCP server and maintain its context.
52
+
53
+ This creates a dedicated task that enters the server's context
54
+ and keeps it alive until explicitly stopped.
55
+
56
+ Args:
57
+ server_id: Unique identifier for the server
58
+ server: The pydantic-ai MCP server instance
59
+
60
+ Returns:
61
+ True if server started successfully, False otherwise
62
+ """
63
+ async with self._lock:
64
+ # Check if already running
65
+ if server_id in self._servers:
66
+ if self._servers[server_id].server.is_running:
67
+ logger.info(f"Server {server_id} is already running")
68
+ return True
69
+ else:
70
+ # Server exists but not running, clean it up
71
+ logger.warning(f"Server {server_id} exists but not running, cleaning up")
72
+ await self._stop_server_internal(server_id)
73
+
74
+ # Create a task that will manage this server's lifecycle
75
+ task = asyncio.create_task(
76
+ self._server_lifecycle_task(server_id, server),
77
+ name=f"mcp_server_{server_id}"
78
+ )
79
+
80
+ # Wait briefly for the server to start
81
+ await asyncio.sleep(0.1)
82
+
83
+ # Check if task failed immediately
84
+ if task.done():
85
+ try:
86
+ await task
87
+ except Exception as e:
88
+ logger.error(f"Failed to start server {server_id}: {e}")
89
+ return False
90
+
91
+ logger.info(f"Server {server_id} starting in background task")
92
+ return True
93
+
94
+ async def _server_lifecycle_task(
95
+ self,
96
+ server_id: str,
97
+ server: Union[MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP]
98
+ ) -> None:
99
+ """
100
+ Task that manages a server's lifecycle.
101
+
102
+ This task enters the server's context and keeps it alive
103
+ until the server is stopped or an error occurs.
104
+ """
105
+ exit_stack = AsyncExitStack()
106
+
107
+ try:
108
+ logger.info(f"Starting server lifecycle for {server_id}")
109
+
110
+ # Enter the server's context
111
+ await exit_stack.enter_async_context(server)
112
+
113
+ # Store the managed context
114
+ async with self._lock:
115
+ self._servers[server_id] = ManagedServerContext(
116
+ server_id=server_id,
117
+ server=server,
118
+ exit_stack=exit_stack,
119
+ start_time=datetime.now(),
120
+ task=asyncio.current_task()
121
+ )
122
+
123
+ logger.info(f"Server {server_id} started successfully")
124
+
125
+ # Keep the task alive until cancelled
126
+ while True:
127
+ await asyncio.sleep(1)
128
+
129
+ # Check if server is still running
130
+ if not server.is_running:
131
+ logger.warning(f"Server {server_id} stopped unexpectedly")
132
+ break
133
+
134
+ except asyncio.CancelledError:
135
+ logger.info(f"Server {server_id} lifecycle task cancelled")
136
+ raise
137
+ except Exception as e:
138
+ logger.error(f"Error in server {server_id} lifecycle: {e}")
139
+ finally:
140
+ # Clean up the context
141
+ await exit_stack.aclose()
142
+
143
+ # Remove from managed servers
144
+ async with self._lock:
145
+ if server_id in self._servers:
146
+ del self._servers[server_id]
147
+
148
+ logger.info(f"Server {server_id} lifecycle ended")
149
+
150
+ async def stop_server(self, server_id: str) -> bool:
151
+ """
152
+ Stop a running MCP server.
153
+
154
+ This cancels the lifecycle task, which properly exits the context.
155
+
156
+ Args:
157
+ server_id: ID of the server to stop
158
+
159
+ Returns:
160
+ True if server was stopped, False if not found
161
+ """
162
+ async with self._lock:
163
+ return await self._stop_server_internal(server_id)
164
+
165
+ async def _stop_server_internal(self, server_id: str) -> bool:
166
+ """
167
+ Internal method to stop a server (must be called with lock held).
168
+ """
169
+ if server_id not in self._servers:
170
+ logger.warning(f"Server {server_id} not found")
171
+ return False
172
+
173
+ context = self._servers[server_id]
174
+
175
+ # Cancel the lifecycle task
176
+ # This will cause the task to exit and clean up properly
177
+ context.task.cancel()
178
+
179
+ try:
180
+ await context.task
181
+ except asyncio.CancelledError:
182
+ pass # Expected
183
+
184
+ logger.info(f"Stopped server {server_id}")
185
+ return True
186
+
187
+ def is_running(self, server_id: str) -> bool:
188
+ """
189
+ Check if a server is running.
190
+
191
+ Args:
192
+ server_id: ID of the server
193
+
194
+ Returns:
195
+ True if server is running, False otherwise
196
+ """
197
+ context = self._servers.get(server_id)
198
+ return context.server.is_running if context else False
199
+
200
+ def list_servers(self) -> Dict[str, Dict[str, Any]]:
201
+ """
202
+ List all running servers.
203
+
204
+ Returns:
205
+ Dictionary of server IDs to server info
206
+ """
207
+ servers = {}
208
+ for server_id, context in self._servers.items():
209
+ uptime = (datetime.now() - context.start_time).total_seconds()
210
+ servers[server_id] = {
211
+ "type": context.server.__class__.__name__,
212
+ "is_running": context.server.is_running,
213
+ "uptime_seconds": uptime,
214
+ "start_time": context.start_time.isoformat()
215
+ }
216
+ return servers
217
+
218
+ async def stop_all(self) -> None:
219
+ """Stop all running servers."""
220
+ server_ids = list(self._servers.keys())
221
+
222
+ for server_id in server_ids:
223
+ await self.stop_server(server_id)
224
+
225
+ logger.info("All MCP servers stopped")
226
+
227
+
228
+ # Global singleton instance
229
+ _lifecycle_manager: Optional[AsyncServerLifecycleManager] = None
230
+
231
+
232
+ def get_lifecycle_manager() -> AsyncServerLifecycleManager:
233
+ """Get the global lifecycle manager instance."""
234
+ global _lifecycle_manager
235
+ if _lifecycle_manager is None:
236
+ _lifecycle_manager = AsyncServerLifecycleManager()
237
+ return _lifecycle_manager