code-puppy 0.0.131__tar.gz → 0.0.133__tar.gz

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 (112) hide show
  1. {code_puppy-0.0.131 → code_puppy-0.0.133}/PKG-INFO +1 -1
  2. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/command_line/mcp_commands.py +11 -78
  3. code_puppy-0.0.133/code_puppy/mcp/blocking_startup.py +404 -0
  4. code_puppy-0.0.133/code_puppy/mcp/captured_stdio_server.py +282 -0
  5. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/managed_server.py +55 -1
  6. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/screens/mcp_install_wizard.py +186 -5
  7. {code_puppy-0.0.131 → code_puppy-0.0.133}/pyproject.toml +1 -1
  8. {code_puppy-0.0.131 → code_puppy-0.0.133}/.gitignore +0 -0
  9. {code_puppy-0.0.131 → code_puppy-0.0.133}/LICENSE +0 -0
  10. {code_puppy-0.0.131 → code_puppy-0.0.133}/README.md +0 -0
  11. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/__init__.py +0 -0
  12. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/__main__.py +0 -0
  13. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/agent.py +0 -0
  14. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/agents/__init__.py +0 -0
  15. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/agents/agent_code_puppy.py +0 -0
  16. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/agents/agent_creator_agent.py +0 -0
  17. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/agents/agent_manager.py +0 -0
  18. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/agents/base_agent.py +0 -0
  19. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/agents/json_agent.py +0 -0
  20. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/agents/runtime_manager.py +0 -0
  21. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/callbacks.py +0 -0
  22. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/command_line/__init__.py +0 -0
  23. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/command_line/command_handler.py +0 -0
  24. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/command_line/file_path_completion.py +0 -0
  25. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/command_line/load_context_completion.py +0 -0
  26. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/command_line/meta_command_handler.py +0 -0
  27. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/command_line/model_picker_completion.py +0 -0
  28. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/command_line/motd.py +0 -0
  29. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/command_line/prompt_toolkit_completion.py +0 -0
  30. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/command_line/utils.py +0 -0
  31. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/config.py +0 -0
  32. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/http_utils.py +0 -0
  33. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/main.py +0 -0
  34. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/__init__.py +0 -0
  35. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/async_lifecycle.py +0 -0
  36. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/circuit_breaker.py +0 -0
  37. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/config_wizard.py +0 -0
  38. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/dashboard.py +0 -0
  39. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/error_isolation.py +0 -0
  40. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/examples/retry_example.py +0 -0
  41. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/health_monitor.py +0 -0
  42. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/manager.py +0 -0
  43. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/registry.py +0 -0
  44. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/retry_manager.py +0 -0
  45. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/server_registry_catalog.py +0 -0
  46. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/status_tracker.py +0 -0
  47. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/mcp/system_tools.py +0 -0
  48. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/message_history_processor.py +0 -0
  49. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/messaging/__init__.py +0 -0
  50. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/messaging/message_queue.py +0 -0
  51. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/messaging/queue_console.py +0 -0
  52. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/messaging/renderers.py +0 -0
  53. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/messaging/spinner/__init__.py +0 -0
  54. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/messaging/spinner/console_spinner.py +0 -0
  55. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/messaging/spinner/spinner_base.py +0 -0
  56. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/messaging/spinner/textual_spinner.py +0 -0
  57. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/model_factory.py +0 -0
  58. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/models.json +0 -0
  59. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/plugins/__init__.py +0 -0
  60. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/reopenable_async_client.py +0 -0
  61. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/state_management.py +0 -0
  62. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/status_display.py +0 -0
  63. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/summarization_agent.py +0 -0
  64. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/token_utils.py +0 -0
  65. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tools/__init__.py +0 -0
  66. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tools/command_runner.py +0 -0
  67. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tools/common.py +0 -0
  68. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tools/file_modifications.py +0 -0
  69. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tools/file_operations.py +0 -0
  70. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tools/token_check.py +0 -0
  71. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tools/tools_content.py +0 -0
  72. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/__init__.py +0 -0
  73. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/app.py +0 -0
  74. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/components/__init__.py +0 -0
  75. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/components/chat_view.py +0 -0
  76. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/components/command_history_modal.py +0 -0
  77. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/components/copy_button.py +0 -0
  78. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/components/custom_widgets.py +0 -0
  79. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/components/human_input_modal.py +0 -0
  80. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/components/input_area.py +0 -0
  81. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/components/sidebar.py +0 -0
  82. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/components/status_bar.py +0 -0
  83. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/messages.py +0 -0
  84. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/models/__init__.py +0 -0
  85. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/models/chat_message.py +0 -0
  86. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/models/command_history.py +0 -0
  87. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/models/enums.py +0 -0
  88. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/screens/__init__.py +0 -0
  89. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/screens/help.py +0 -0
  90. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/screens/settings.py +0 -0
  91. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/screens/tools.py +0 -0
  92. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/__init__.py +0 -0
  93. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_agent_command.py +0 -0
  94. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_chat_message.py +0 -0
  95. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_chat_view.py +0 -0
  96. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_command_history.py +0 -0
  97. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_copy_button.py +0 -0
  98. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_custom_widgets.py +0 -0
  99. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_disclaimer.py +0 -0
  100. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_enums.py +0 -0
  101. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_file_browser.py +0 -0
  102. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_help.py +0 -0
  103. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_history_file_reader.py +0 -0
  104. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_input_area.py +0 -0
  105. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_settings.py +0 -0
  106. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_sidebar.py +0 -0
  107. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_sidebar_history.py +0 -0
  108. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_sidebar_history_navigation.py +0 -0
  109. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_status_bar.py +0 -0
  110. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_timestamped_history.py +0 -0
  111. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/tui/tests/test_tools.py +0 -0
  112. {code_puppy-0.0.131 → code_puppy-0.0.133}/code_puppy/version_checker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-puppy
3
- Version: 0.0.131
3
+ Version: 0.0.133
4
4
  Summary: Code generation agent
5
5
  Project-URL: repository, https://github.com/mpfaffenberger/code_puppy
6
6
  Project-URL: HomePage, https://github.com/mpfaffenberger/code_puppy
@@ -597,6 +597,12 @@ class MCPCommandHandler:
597
597
  import uuid
598
598
  group_id = str(uuid.uuid4())
599
599
 
600
+ # Check if in TUI mode and guide user to use Ctrl+T instead
601
+ if is_tui_mode() and not args:
602
+ emit_info("💡 In TUI mode, press Ctrl+T to open the MCP Install Wizard", message_group=group_id)
603
+ emit_info(" The wizard provides a better interface for browsing and installing MCP servers.", message_group=group_id)
604
+ return
605
+
600
606
  try:
601
607
  if args:
602
608
  # Parse JSON from arguments
@@ -1567,93 +1573,20 @@ class MCPCommandHandler:
1567
1573
 
1568
1574
  display, color = state_map.get(state, ("? Unk", "dim"))
1569
1575
  return Text(display, style=color)
1570
-
1571
- def _find_server_id_by_name(self, server_name: str) -> Optional[str]:
1572
- """
1573
- Find a server ID by its name.
1574
-
1575
- Args:
1576
- server_name: Name of the server to find
1577
-
1578
- Returns:
1579
- Server ID if found, None otherwise
1580
- """
1581
- try:
1582
- servers = self.manager.list_servers()
1583
- for server in servers:
1584
- if server.name.lower() == server_name.lower():
1585
- return server.id
1586
- return None
1587
- except Exception as e:
1588
- logger.error(f"Error finding server by name '{server_name}': {e}")
1589
- return None
1590
-
1591
- def _suggest_similar_servers(self, server_name: str, group_id: str = None) -> None:
1592
- """
1593
- Suggest similar server names when a server is not found.
1594
-
1595
- Args:
1596
- server_name: The server name that was not found
1597
- group_id: Optional message group ID for grouping related messages
1598
- """
1599
- try:
1600
- servers = self.manager.list_servers()
1601
- if not servers:
1602
- emit_info("No servers are registered", message_group=group_id)
1603
- return
1604
-
1605
- # Simple suggestion based on partial matching
1606
- suggestions = []
1607
- server_name_lower = server_name.lower()
1608
-
1609
- for server in servers:
1610
- if server_name_lower in server.name.lower():
1611
- suggestions.append(server.name)
1612
-
1613
- if suggestions:
1614
- emit_info(f"Did you mean: {', '.join(suggestions)}", message_group=group_id)
1615
- else:
1616
- server_names = [s.name for s in servers]
1617
- emit_info(f"Available servers: {', '.join(server_names)}", message_group=group_id)
1618
-
1619
- except Exception as e:
1620
- logger.error(f"Error suggesting similar servers: {e}")
1621
-
1622
- def _format_state_indicator(self, state: ServerState) -> Text:
1623
- """
1624
- Format a server state with appropriate color and icon.
1625
-
1626
- Args:
1627
- state: Server state to format
1628
-
1629
- Returns:
1630
- Rich Text object with colored state indicator
1631
- """
1632
- state_map = {
1633
- ServerState.RUNNING: ("✓ Run", "green"),
1634
- ServerState.STOPPED: ("✗ Stop", "red"),
1635
- ServerState.STARTING: ("↗ Start", "yellow"),
1636
- ServerState.STOPPING: ("↙ Stop", "yellow"),
1637
- ServerState.ERROR: ("⚠ Err", "red"),
1638
- ServerState.QUARANTINED: ("⏸ Quar", "yellow"),
1639
- }
1640
-
1641
- display, color = state_map.get(state, ("? Unk", "dim"))
1642
- return Text(display, style=color)
1643
-
1576
+
1644
1577
  def _format_uptime(self, uptime_seconds: Optional[float]) -> str:
1645
1578
  """
1646
1579
  Format uptime in a human-readable format.
1647
-
1580
+
1648
1581
  Args:
1649
1582
  uptime_seconds: Uptime in seconds, or None
1650
-
1583
+
1651
1584
  Returns:
1652
1585
  Formatted uptime string
1653
1586
  """
1654
1587
  if uptime_seconds is None or uptime_seconds <= 0:
1655
1588
  return "-"
1656
-
1589
+
1657
1590
  # Convert to readable format
1658
1591
  if uptime_seconds < 60:
1659
1592
  return f"{int(uptime_seconds)}s"
@@ -1665,7 +1598,7 @@ class MCPCommandHandler:
1665
1598
  hours = int(uptime_seconds // 3600)
1666
1599
  minutes = int((uptime_seconds % 3600) // 60)
1667
1600
  return f"{hours}h {minutes}m"
1668
-
1601
+
1669
1602
  def _show_detailed_server_status(self, server_id: str, server_name: str, group_id: str = None) -> None:
1670
1603
  """
1671
1604
  Show comprehensive status information for a specific server.
@@ -0,0 +1,404 @@
1
+ """
2
+ MCP Server with blocking startup capability and stderr capture.
3
+
4
+ This module provides MCP servers that:
5
+ 1. Capture stderr output from stdio servers
6
+ 2. Block until fully initialized before allowing operations
7
+ 3. Emit stderr to users via emit_info with message groups
8
+ """
9
+
10
+ import asyncio
11
+ import os
12
+ import tempfile
13
+ import threading
14
+ import uuid
15
+ from typing import Optional, Callable, List
16
+ from contextlib import asynccontextmanager
17
+ from pydantic_ai.mcp import MCPServerStdio
18
+ from mcp.client.stdio import StdioServerParameters, stdio_client
19
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
20
+ from mcp.shared.session import SessionMessage
21
+ from code_puppy.messaging import emit_info
22
+
23
+
24
+ class StderrFileCapture:
25
+ """Captures stderr to a file and monitors it in a background thread."""
26
+
27
+ def __init__(self, server_name: str, emit_to_user: bool = True, message_group: Optional[uuid.UUID] = None):
28
+ self.server_name = server_name
29
+ self.emit_to_user = emit_to_user
30
+ self.message_group = message_group or uuid.uuid4()
31
+ self.temp_file = None
32
+ self.temp_path = None
33
+ self.monitor_thread = None
34
+ self.stop_monitoring = threading.Event()
35
+ self.captured_lines = []
36
+
37
+ def start(self):
38
+ """Start capture by creating temp file and monitor thread."""
39
+ # Create temp file
40
+ self.temp_file = tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.err')
41
+ self.temp_path = self.temp_file.name
42
+
43
+ # Start monitoring thread
44
+ self.stop_monitoring.clear()
45
+ self.monitor_thread = threading.Thread(target=self._monitor_file)
46
+ self.monitor_thread.daemon = True
47
+ self.monitor_thread.start()
48
+
49
+ return self.temp_file
50
+
51
+ def _monitor_file(self):
52
+ """Monitor the temp file for new content."""
53
+ if not self.temp_path:
54
+ return
55
+
56
+ last_pos = 0
57
+ while not self.stop_monitoring.is_set():
58
+ try:
59
+ with open(self.temp_path, 'r') as f:
60
+ f.seek(last_pos)
61
+ new_content = f.read()
62
+ if new_content:
63
+ last_pos = f.tell()
64
+ # Process new lines
65
+ for line in new_content.splitlines():
66
+ if line.strip():
67
+ self.captured_lines.append(line)
68
+ if self.emit_to_user:
69
+ emit_info(
70
+ f"[bold white on blue] MCP {self.server_name} [/bold white on blue] {line}",
71
+ style="dim cyan",
72
+ message_group=self.message_group
73
+ )
74
+
75
+ except Exception:
76
+ pass # File might not exist yet or be deleted
77
+
78
+ self.stop_monitoring.wait(0.1) # Check every 100ms
79
+
80
+ def stop(self):
81
+ """Stop monitoring and clean up."""
82
+ self.stop_monitoring.set()
83
+ if self.monitor_thread:
84
+ self.monitor_thread.join(timeout=1)
85
+
86
+ if self.temp_file:
87
+ try:
88
+ self.temp_file.close()
89
+ except:
90
+ pass
91
+
92
+ if self.temp_path and os.path.exists(self.temp_path):
93
+ try:
94
+ # Read any remaining content
95
+ with open(self.temp_path, 'r') as f:
96
+ content = f.read()
97
+ for line in content.splitlines():
98
+ if line.strip() and line not in self.captured_lines:
99
+ self.captured_lines.append(line)
100
+ if self.emit_to_user:
101
+ emit_info(
102
+ f"[bold white on blue] MCP {self.server_name} [/bold white on blue] {line}",
103
+ style="dim cyan",
104
+ message_group=self.message_group
105
+ )
106
+
107
+ os.unlink(self.temp_path)
108
+ except:
109
+ pass
110
+
111
+ def get_captured_lines(self) -> List[str]:
112
+ """Get all captured lines."""
113
+ return self.captured_lines.copy()
114
+
115
+
116
+ class SimpleCapturedMCPServerStdio(MCPServerStdio):
117
+ """
118
+ MCPServerStdio that captures stderr to a file and optionally emits to user.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ command: str,
124
+ args=(),
125
+ env=None,
126
+ cwd=None,
127
+ emit_stderr: bool = True,
128
+ message_group: Optional[uuid.UUID] = None,
129
+ **kwargs
130
+ ):
131
+ super().__init__(command=command, args=args, env=env, cwd=cwd, **kwargs)
132
+ self.emit_stderr = emit_stderr
133
+ self.message_group = message_group or uuid.uuid4()
134
+ self._stderr_capture = None
135
+
136
+ @asynccontextmanager
137
+ async def client_streams(self):
138
+ """Create streams with stderr capture."""
139
+ server = StdioServerParameters(
140
+ command=self.command,
141
+ args=list(self.args),
142
+ env=self.env,
143
+ cwd=self.cwd
144
+ )
145
+
146
+ # Create stderr capture
147
+ server_name = getattr(self, 'tool_prefix', self.command)
148
+ self._stderr_capture = StderrFileCapture(server_name, self.emit_stderr, self.message_group)
149
+ stderr_file = self._stderr_capture.start()
150
+
151
+ try:
152
+ async with stdio_client(server=server, errlog=stderr_file) as (read_stream, write_stream):
153
+ yield read_stream, write_stream
154
+ finally:
155
+ self._stderr_capture.stop()
156
+
157
+ def get_captured_stderr(self) -> List[str]:
158
+ """Get captured stderr lines."""
159
+ if self._stderr_capture:
160
+ return self._stderr_capture.get_captured_lines()
161
+ return []
162
+
163
+
164
+ class BlockingMCPServerStdio(SimpleCapturedMCPServerStdio):
165
+ """
166
+ MCP Server that blocks until fully initialized.
167
+
168
+ This server ensures that initialization is complete before
169
+ allowing any operations, preventing race conditions.
170
+ """
171
+
172
+ def __init__(self, *args, **kwargs):
173
+ super().__init__(*args, **kwargs)
174
+ self._initialized = asyncio.Event()
175
+ self._init_error: Optional[Exception] = None
176
+ self._initialization_task = None
177
+
178
+ async def __aenter__(self):
179
+ """Enter context and track initialization."""
180
+ try:
181
+ # Start initialization
182
+ result = await super().__aenter__()
183
+
184
+ # Mark as initialized
185
+ self._initialized.set()
186
+
187
+ # Emit success message
188
+ server_name = getattr(self, 'tool_prefix', self.command)
189
+ emit_info(
190
+ f"✅ MCP Server '{server_name}' initialized successfully",
191
+ style="green",
192
+ message_group=self.message_group
193
+ )
194
+
195
+ return result
196
+
197
+ except Exception as e:
198
+ # Store error and mark as initialized (with error)
199
+ self._init_error = e
200
+ self._initialized.set()
201
+
202
+ # Emit error message
203
+ server_name = getattr(self, 'tool_prefix', self.command)
204
+ emit_info(
205
+ f"❌ MCP Server '{server_name}' failed to initialize: {e}",
206
+ style="red",
207
+ message_group=self.message_group
208
+ )
209
+
210
+ raise
211
+
212
+ async def wait_until_ready(self, timeout: float = 30.0) -> bool:
213
+ """
214
+ Wait until the server is ready.
215
+
216
+ Args:
217
+ timeout: Maximum time to wait in seconds
218
+
219
+ Returns:
220
+ True if server is ready, False if timeout or error
221
+
222
+ Raises:
223
+ TimeoutError: If server doesn't initialize within timeout
224
+ Exception: If server initialization failed
225
+ """
226
+ try:
227
+ await asyncio.wait_for(self._initialized.wait(), timeout=timeout)
228
+
229
+ # Check if there was an initialization error
230
+ if self._init_error:
231
+ raise self._init_error
232
+
233
+ return True
234
+
235
+ except asyncio.TimeoutError:
236
+ server_name = getattr(self, 'tool_prefix', self.command)
237
+ raise TimeoutError(f"Server '{server_name}' initialization timeout after {timeout}s")
238
+
239
+ async def ensure_ready(self, timeout: float = 30.0):
240
+ """
241
+ Ensure server is ready before proceeding.
242
+
243
+ This is a convenience method that raises if not ready.
244
+
245
+ Args:
246
+ timeout: Maximum time to wait in seconds
247
+
248
+ Raises:
249
+ TimeoutError: If server doesn't initialize within timeout
250
+ Exception: If server initialization failed
251
+ """
252
+ await self.wait_until_ready(timeout)
253
+
254
+ def is_ready(self) -> bool:
255
+ """
256
+ Check if server is ready without blocking.
257
+
258
+ Returns:
259
+ True if server is initialized and ready
260
+ """
261
+ return self._initialized.is_set() and self._init_error is None
262
+
263
+
264
+ class StartupMonitor:
265
+ """
266
+ Monitor for tracking multiple server startups.
267
+
268
+ This class helps coordinate startup of multiple MCP servers
269
+ and ensures all are ready before proceeding.
270
+ """
271
+
272
+ def __init__(self, message_group: Optional[uuid.UUID] = None):
273
+ self.servers = {}
274
+ self.startup_times = {}
275
+ self.message_group = message_group or uuid.uuid4()
276
+
277
+ def add_server(self, name: str, server: BlockingMCPServerStdio):
278
+ """Add a server to monitor."""
279
+ self.servers[name] = server
280
+
281
+ async def wait_all_ready(self, timeout: float = 30.0) -> dict:
282
+ """
283
+ Wait for all servers to be ready.
284
+
285
+ Args:
286
+ timeout: Maximum time to wait for all servers
287
+
288
+ Returns:
289
+ Dictionary of server names to ready status
290
+ """
291
+ import time
292
+ results = {}
293
+
294
+ # Create tasks for all servers
295
+ async def wait_server(name: str, server: BlockingMCPServerStdio):
296
+ start = time.time()
297
+ try:
298
+ await server.wait_until_ready(timeout)
299
+ self.startup_times[name] = time.time() - start
300
+ results[name] = True
301
+ emit_info(
302
+ f" {name}: Ready in {self.startup_times[name]:.2f}s",
303
+ style="dim green",
304
+ message_group=self.message_group
305
+ )
306
+ except Exception as e:
307
+ self.startup_times[name] = time.time() - start
308
+ results[name] = False
309
+ emit_info(
310
+ f" {name}: Failed after {self.startup_times[name]:.2f}s - {e}",
311
+ style="dim red",
312
+ message_group=self.message_group
313
+ )
314
+
315
+ # Wait for all servers in parallel
316
+ emit_info(
317
+ f"⏳ Waiting for {len(self.servers)} MCP servers to initialize...",
318
+ style="cyan",
319
+ message_group=self.message_group
320
+ )
321
+
322
+ tasks = [
323
+ asyncio.create_task(wait_server(name, server))
324
+ for name, server in self.servers.items()
325
+ ]
326
+
327
+ await asyncio.gather(*tasks, return_exceptions=True)
328
+
329
+ # Report summary
330
+ ready_count = sum(1 for r in results.values() if r)
331
+ total_count = len(results)
332
+
333
+ if ready_count == total_count:
334
+ emit_info(
335
+ f"✅ All {total_count} servers ready!",
336
+ style="green bold",
337
+ message_group=self.message_group
338
+ )
339
+ else:
340
+ emit_info(
341
+ f"⚠️ {ready_count}/{total_count} servers ready",
342
+ style="yellow",
343
+ message_group=self.message_group
344
+ )
345
+
346
+ return results
347
+
348
+ def get_startup_report(self) -> str:
349
+ """Get a report of startup times."""
350
+ lines = ["Server Startup Times:"]
351
+ for name, time_taken in self.startup_times.items():
352
+ status = "✅" if self.servers[name].is_ready() else "❌"
353
+ lines.append(f" {status} {name}: {time_taken:.2f}s")
354
+ return "\n".join(lines)
355
+
356
+
357
+ async def start_servers_with_blocking(*servers: BlockingMCPServerStdio, timeout: float = 30.0, message_group: Optional[uuid.UUID] = None):
358
+ """
359
+ Start multiple servers and wait for all to be ready.
360
+
361
+ Args:
362
+ *servers: Variable number of BlockingMCPServerStdio instances
363
+ timeout: Maximum time to wait for all servers
364
+ message_group: Optional UUID for grouping log messages
365
+
366
+ Returns:
367
+ List of ready servers
368
+
369
+ Example:
370
+ server1 = BlockingMCPServerStdio(...)
371
+ server2 = BlockingMCPServerStdio(...)
372
+ ready = await start_servers_with_blocking(server1, server2)
373
+ """
374
+ monitor = StartupMonitor(message_group=message_group)
375
+
376
+ for i, server in enumerate(servers):
377
+ name = getattr(server, 'tool_prefix', f"server-{i}")
378
+ monitor.add_server(name, server)
379
+
380
+ # Start all servers
381
+ async def start_server(server):
382
+ async with server:
383
+ await asyncio.sleep(0.1) # Keep context alive briefly
384
+ return server
385
+
386
+ # Start servers in parallel
387
+ server_tasks = [
388
+ asyncio.create_task(start_server(server))
389
+ for server in servers
390
+ ]
391
+
392
+ # Wait for all to be ready
393
+ results = await monitor.wait_all_ready(timeout)
394
+
395
+ # Get the report
396
+ emit_info(monitor.get_startup_report(), message_group=monitor.message_group)
397
+
398
+ # Return ready servers
399
+ ready_servers = [
400
+ server for name, server in monitor.servers.items()
401
+ if results.get(name, False)
402
+ ]
403
+
404
+ return ready_servers