code-puppy 0.0.127__py3-none-any.whl → 0.0.129__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.
- code_puppy/__init__.py +1 -0
- code_puppy/agent.py +65 -69
- code_puppy/agents/agent_code_puppy.py +0 -3
- code_puppy/agents/runtime_manager.py +231 -0
- code_puppy/command_line/command_handler.py +56 -25
- code_puppy/command_line/mcp_commands.py +1298 -0
- code_puppy/command_line/meta_command_handler.py +3 -2
- code_puppy/command_line/model_picker_completion.py +21 -8
- code_puppy/http_utils.py +1 -1
- code_puppy/main.py +99 -158
- code_puppy/mcp/__init__.py +23 -0
- code_puppy/mcp/async_lifecycle.py +237 -0
- code_puppy/mcp/circuit_breaker.py +218 -0
- code_puppy/mcp/config_wizard.py +437 -0
- code_puppy/mcp/dashboard.py +291 -0
- code_puppy/mcp/error_isolation.py +360 -0
- code_puppy/mcp/examples/retry_example.py +208 -0
- code_puppy/mcp/health_monitor.py +549 -0
- code_puppy/mcp/managed_server.py +346 -0
- code_puppy/mcp/manager.py +701 -0
- code_puppy/mcp/registry.py +412 -0
- code_puppy/mcp/retry_manager.py +321 -0
- code_puppy/mcp/server_registry_catalog.py +751 -0
- code_puppy/mcp/status_tracker.py +355 -0
- code_puppy/messaging/spinner/textual_spinner.py +6 -2
- code_puppy/model_factory.py +19 -4
- code_puppy/models.json +8 -6
- code_puppy/tui/app.py +19 -27
- code_puppy/tui/tests/test_agent_command.py +22 -15
- {code_puppy-0.0.127.data → code_puppy-0.0.129.data}/data/code_puppy/models.json +8 -6
- {code_puppy-0.0.127.dist-info → code_puppy-0.0.129.dist-info}/METADATA +4 -3
- {code_puppy-0.0.127.dist-info → code_puppy-0.0.129.dist-info}/RECORD +35 -19
- {code_puppy-0.0.127.dist-info → code_puppy-0.0.129.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.127.dist-info → code_puppy-0.0.129.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.127.dist-info → code_puppy-0.0.129.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.
|
|
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
|
-
|
|
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 '/
|
|
44
|
-
Only '/
|
|
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 = "/
|
|
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
|
-
|
|
76
|
-
|
|
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 /
|
|
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 = "/
|
|
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/http_utils.py
CHANGED
|
@@ -90,7 +90,7 @@ def create_reopenable_async_client(
|
|
|
90
90
|
timeout: int = 180,
|
|
91
91
|
verify: Union[bool, str] = None,
|
|
92
92
|
headers: Optional[Dict[str, str]] = None,
|
|
93
|
-
) -> Union[
|
|
93
|
+
) -> Union[ReopenableAsyncClient, httpx.AsyncClient]:
|
|
94
94
|
if verify is None:
|
|
95
95
|
verify = get_cert_bundle_path()
|
|
96
96
|
|
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
|
|
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
|
|
254
|
+
from code_puppy.command_line.command_handler import get_commands_help
|
|
254
255
|
|
|
255
|
-
|
|
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
|
-
|
|
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 -
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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,110 +418,82 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
|
|
|
438
418
|
try:
|
|
439
419
|
prettier_code_blocks()
|
|
440
420
|
|
|
441
|
-
#
|
|
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
|
|
452
|
-
|
|
427
|
+
# Create a task that mimics TUI behavior - avoid signal handler conflicts
|
|
428
|
+
current_task = None
|
|
429
|
+
signal_handled = False # Prevent multiple signal handler calls (reset per task)
|
|
430
|
+
|
|
431
|
+
async def run_task():
|
|
432
|
+
# Use the simpler run() method instead of run_with_mcp() to avoid signal handler
|
|
433
|
+
agent = agent_manager.get_agent()
|
|
434
|
+
async with agent:
|
|
435
|
+
return await agent.run(
|
|
436
|
+
task,
|
|
437
|
+
message_history=get_message_history(),
|
|
438
|
+
usage_limits=get_custom_usage_limits(),
|
|
439
|
+
)
|
|
453
440
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
441
|
+
def handle_keyboard_interrupt():
|
|
442
|
+
"""Handle Ctrl+C like TUI does - kill processes but only cancel task if no processes killed"""
|
|
443
|
+
nonlocal signal_handled
|
|
444
|
+
if signal_handled:
|
|
445
|
+
return
|
|
446
|
+
signal_handled = True
|
|
447
|
+
|
|
448
|
+
from code_puppy.tools.command_runner import kill_all_running_shell_processes
|
|
449
|
+
|
|
450
|
+
killed = kill_all_running_shell_processes()
|
|
451
|
+
if killed:
|
|
452
|
+
emit_warning(f"🔥 Cancelled {killed} running shell process(es)")
|
|
453
|
+
# Don't cancel the agent task - let it continue processing
|
|
454
|
+
# Shell processes killed, but agent continues running
|
|
455
|
+
else:
|
|
456
|
+
# Only cancel the agent task if NO processes were killed
|
|
457
|
+
if current_task and not current_task.done():
|
|
458
|
+
current_task.cancel()
|
|
459
|
+
emit_warning("⚠️ Processing cancelled by user")
|
|
460
|
+
|
|
461
|
+
# Set up proper signal handling to override asyncio's default behavior
|
|
462
|
+
import signal
|
|
463
|
+
|
|
464
|
+
def signal_handler(sig, frame):
|
|
465
|
+
"""Handle Ctrl+C by killing processes and cancelling the current task"""
|
|
466
|
+
handle_keyboard_interrupt()
|
|
467
|
+
|
|
468
|
+
# Replace asyncio's SIGINT handler with our own
|
|
469
|
+
original_handler = signal.signal(signal.SIGINT, signal_handler)
|
|
470
|
+
|
|
471
|
+
# Use ConsoleSpinner for better user experience
|
|
472
|
+
try:
|
|
473
|
+
with ConsoleSpinner(console=display_console):
|
|
474
|
+
current_task = asyncio.create_task(run_task())
|
|
475
|
+
result = await current_task
|
|
476
|
+
except asyncio.CancelledError:
|
|
477
|
+
# Agent was cancelled by our signal handler
|
|
478
|
+
result = None
|
|
479
|
+
except KeyboardInterrupt:
|
|
480
|
+
# Fallback - handle Ctrl+C if it gets through as KeyboardInterrupt
|
|
481
|
+
emit_warning("\n⚠️ Caught KeyboardInterrupt")
|
|
482
|
+
handle_keyboard_interrupt()
|
|
483
|
+
result = None
|
|
484
|
+
finally:
|
|
485
|
+
# Restore original signal handler
|
|
486
|
+
if 'original_handler' in locals():
|
|
487
|
+
signal.signal(signal.SIGINT, original_handler)
|
|
488
|
+
set_message_history(
|
|
489
|
+
prune_interrupted_tool_calls(get_message_history())
|
|
488
490
|
)
|
|
489
491
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
#
|
|
493
|
-
|
|
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
|
-
# Check if the task was cancelled
|
|
543
|
-
if local_cancelled:
|
|
544
|
-
emit_warning("\n⚠️ Processing cancelled by user (Ctrl+C)")
|
|
492
|
+
# Check if the task was cancelled (but don't show message if we just killed processes)
|
|
493
|
+
if result is None:
|
|
494
|
+
# Only show cancellation message if we actually cancelled the agent task
|
|
495
|
+
# If we just killed shell processes, the agent should continue normally
|
|
496
|
+
pass # Don't always show this message
|
|
545
497
|
# Skip the rest of this loop iteration
|
|
546
498
|
continue
|
|
547
499
|
# Get the structured response
|
|
@@ -605,37 +557,26 @@ async def execute_single_prompt(prompt: str, message_renderer) -> None:
|
|
|
605
557
|
emit_info(f"[bold blue]Executing prompt:[/bold blue] {prompt}")
|
|
606
558
|
|
|
607
559
|
try:
|
|
608
|
-
# Get
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
# Use our custom spinner for better compatibility with user input
|
|
560
|
+
# Get agent through runtime manager and use its run_with_mcp method
|
|
561
|
+
agent_manager = get_runtime_agent_manager()
|
|
562
|
+
|
|
612
563
|
from code_puppy.messaging.spinner import ConsoleSpinner
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
)
|
|
564
|
+
with ConsoleSpinner(console=message_renderer.console):
|
|
565
|
+
response = await agent_manager.run_with_mcp(
|
|
566
|
+
prompt,
|
|
567
|
+
usage_limits=get_custom_usage_limits()
|
|
568
|
+
)
|
|
630
569
|
|
|
631
570
|
agent_response = response.output
|
|
632
571
|
emit_system_message(
|
|
633
572
|
f"\n[bold purple]AGENT RESPONSE: [/bold purple]\n{agent_response}"
|
|
634
573
|
)
|
|
635
574
|
|
|
575
|
+
except asyncio.CancelledError:
|
|
576
|
+
from code_puppy.messaging import emit_warning
|
|
577
|
+
emit_warning("Execution cancelled by user")
|
|
636
578
|
except Exception as e:
|
|
637
579
|
from code_puppy.messaging import emit_error
|
|
638
|
-
|
|
639
580
|
emit_error(f"Error executing prompt: {str(e)}")
|
|
640
581
|
|
|
641
582
|
|
|
@@ -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
|