codepp 0.0.437__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 +10 -0
- code_puppy/__main__.py +10 -0
- code_puppy/agents/__init__.py +31 -0
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +117 -0
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +638 -0
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_helios.py +124 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +742 -0
- code_puppy/agents/agent_pack_leader.py +385 -0
- code_puppy/agents/agent_planning.py +165 -0
- code_puppy/agents/agent_python_programmer.py +169 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_scheduler.py +121 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +2156 -0
- code_puppy/agents/event_stream_handler.py +348 -0
- code_puppy/agents/json_agent.py +202 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +327 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +453 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +75 -0
- code_puppy/api/routers/sessions.py +234 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +692 -0
- code_puppy/chatgpt_codex_client.py +338 -0
- code_puppy/claude_cache_client.py +672 -0
- code_puppy/cli_runner.py +1073 -0
- code_puppy/command_line/__init__.py +1 -0
- code_puppy/command_line/add_model_menu.py +1092 -0
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +704 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +532 -0
- code_puppy/command_line/command_handler.py +293 -0
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +719 -0
- code_puppy/command_line/core_commands.py +867 -0
- code_puppy/command_line/diff_menu.py +865 -0
- code_puppy/command_line/file_path_completion.py +73 -0
- code_puppy/command_line/load_context_completion.py +52 -0
- code_puppy/command_line/mcp/__init__.py +10 -0
- code_puppy/command_line/mcp/base.py +32 -0
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +138 -0
- code_puppy/command_line/mcp/help_command.py +147 -0
- code_puppy/command_line/mcp/install_command.py +214 -0
- code_puppy/command_line/mcp/install_menu.py +705 -0
- code_puppy/command_line/mcp/list_command.py +94 -0
- code_puppy/command_line/mcp/logs_command.py +235 -0
- code_puppy/command_line/mcp/remove_command.py +82 -0
- code_puppy/command_line/mcp/restart_command.py +100 -0
- code_puppy/command_line/mcp/search_command.py +123 -0
- code_puppy/command_line/mcp/start_all_command.py +135 -0
- code_puppy/command_line/mcp/start_command.py +117 -0
- code_puppy/command_line/mcp/status_command.py +184 -0
- code_puppy/command_line/mcp/stop_all_command.py +112 -0
- code_puppy/command_line/mcp/stop_command.py +80 -0
- code_puppy/command_line/mcp/test_command.py +107 -0
- code_puppy/command_line/mcp/utils.py +129 -0
- code_puppy/command_line/mcp/wizard_utils.py +334 -0
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +197 -0
- code_puppy/command_line/model_settings_menu.py +932 -0
- code_puppy/command_line/motd.py +96 -0
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +342 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +846 -0
- code_puppy/command_line/session_commands.py +302 -0
- code_puppy/command_line/shell_passthrough.py +145 -0
- code_puppy/command_line/skills_completion.py +160 -0
- code_puppy/command_line/uc_menu.py +893 -0
- code_puppy/command_line/utils.py +93 -0
- code_puppy/command_line/wiggum_state.py +78 -0
- code_puppy/config.py +1770 -0
- code_puppy/error_logging.py +134 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +754 -0
- code_puppy/hook_engine/README.md +105 -0
- code_puppy/hook_engine/__init__.py +21 -0
- code_puppy/hook_engine/aliases.py +155 -0
- code_puppy/hook_engine/engine.py +221 -0
- code_puppy/hook_engine/executor.py +296 -0
- code_puppy/hook_engine/matcher.py +156 -0
- code_puppy/hook_engine/models.py +240 -0
- code_puppy/hook_engine/registry.py +106 -0
- code_puppy/hook_engine/validator.py +144 -0
- code_puppy/http_utils.py +361 -0
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +10 -0
- code_puppy/mcp_/__init__.py +66 -0
- code_puppy/mcp_/async_lifecycle.py +286 -0
- code_puppy/mcp_/blocking_startup.py +469 -0
- code_puppy/mcp_/captured_stdio_server.py +275 -0
- code_puppy/mcp_/circuit_breaker.py +290 -0
- code_puppy/mcp_/config_wizard.py +507 -0
- code_puppy/mcp_/dashboard.py +308 -0
- code_puppy/mcp_/error_isolation.py +407 -0
- code_puppy/mcp_/examples/retry_example.py +226 -0
- code_puppy/mcp_/health_monitor.py +589 -0
- code_puppy/mcp_/managed_server.py +428 -0
- code_puppy/mcp_/manager.py +807 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +451 -0
- code_puppy/mcp_/retry_manager.py +337 -0
- code_puppy/mcp_/server_registry_catalog.py +1126 -0
- code_puppy/mcp_/status_tracker.py +355 -0
- code_puppy/mcp_/system_tools.py +209 -0
- code_puppy/mcp_prompts/__init__.py +1 -0
- code_puppy/mcp_prompts/hook_creator.py +103 -0
- code_puppy/messaging/__init__.py +255 -0
- code_puppy/messaging/bus.py +613 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +361 -0
- code_puppy/messaging/messages.py +569 -0
- code_puppy/messaging/queue_console.py +271 -0
- code_puppy/messaging/renderers.py +311 -0
- code_puppy/messaging/rich_renderer.py +1158 -0
- code_puppy/messaging/spinner/__init__.py +83 -0
- code_puppy/messaging/spinner/console_spinner.py +240 -0
- code_puppy/messaging/spinner/spinner_base.py +95 -0
- code_puppy/messaging/subagent_console.py +460 -0
- code_puppy/model_factory.py +848 -0
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +168 -0
- code_puppy/models.json +174 -0
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +186 -0
- code_puppy/plugins/agent_skills/__init__.py +22 -0
- code_puppy/plugins/agent_skills/config.py +175 -0
- code_puppy/plugins/agent_skills/discovery.py +136 -0
- code_puppy/plugins/agent_skills/downloader.py +392 -0
- code_puppy/plugins/agent_skills/installer.py +22 -0
- code_puppy/plugins/agent_skills/metadata.py +219 -0
- code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
- code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
- code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
- code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
- code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
- code_puppy/plugins/agent_skills/skills_menu.py +781 -0
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +133 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
- code_puppy/plugins/antigravity_oauth/storage.py +288 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +863 -0
- code_puppy/plugins/antigravity_oauth/utils.py +168 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
- code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
- code_puppy/plugins/claude_code_hooks/config.py +137 -0
- code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
- code_puppy/plugins/claude_code_oauth/config.py +52 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
- code_puppy/plugins/claude_code_oauth/utils.py +640 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/hook_creator/__init__.py +1 -0
- code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
- code_puppy/plugins/hook_manager/__init__.py +1 -0
- code_puppy/plugins/hook_manager/config.py +290 -0
- code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
- code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/scheduler/__init__.py +1 -0
- code_puppy/plugins/scheduler/register_callbacks.py +88 -0
- code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
- code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/plugins/synthetic_status/__init__.py +1 -0
- code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
- code_puppy/plugins/synthetic_status/status_api.py +147 -0
- code_puppy/plugins/universal_constructor/__init__.py +13 -0
- code_puppy/plugins/universal_constructor/models.py +138 -0
- code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
- code_puppy/plugins/universal_constructor/registry.py +302 -0
- code_puppy/plugins/universal_constructor/sandbox.py +584 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +356 -0
- code_puppy/reopenable_async_client.py +232 -0
- code_puppy/round_robin_model.py +150 -0
- code_puppy/scheduler/__init__.py +41 -0
- code_puppy/scheduler/__main__.py +9 -0
- code_puppy/scheduler/cli.py +118 -0
- code_puppy/scheduler/config.py +126 -0
- code_puppy/scheduler/daemon.py +280 -0
- code_puppy/scheduler/executor.py +155 -0
- code_puppy/scheduler/platform.py +19 -0
- code_puppy/scheduler/platform_unix.py +22 -0
- code_puppy/scheduler/platform_win.py +32 -0
- code_puppy/session_storage.py +338 -0
- code_puppy/status_display.py +257 -0
- code_puppy/summarization_agent.py +176 -0
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +501 -0
- code_puppy/tools/agent_tools.py +603 -0
- code_puppy/tools/ask_user_question/__init__.py +26 -0
- code_puppy/tools/ask_user_question/constants.py +73 -0
- code_puppy/tools/ask_user_question/demo_tui.py +55 -0
- code_puppy/tools/ask_user_question/handler.py +232 -0
- code_puppy/tools/ask_user_question/models.py +304 -0
- code_puppy/tools/ask_user_question/registration.py +26 -0
- code_puppy/tools/ask_user_question/renderers.py +309 -0
- code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
- code_puppy/tools/ask_user_question/theme.py +155 -0
- code_puppy/tools/ask_user_question/tui_loop.py +423 -0
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +378 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +534 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +1346 -0
- code_puppy/tools/common.py +1409 -0
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +886 -0
- code_puppy/tools/file_operations.py +802 -0
- code_puppy/tools/scheduler_tools.py +412 -0
- code_puppy/tools/skills_tools.py +244 -0
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/tools/tools_content.py +51 -0
- code_puppy/tools/universal_constructor.py +889 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +82 -0
- codepp-0.0.437.dist-info/METADATA +766 -0
- codepp-0.0.437.dist-info/RECORD +288 -0
- codepp-0.0.437.dist-info/WHEEL +4 -0
- codepp-0.0.437.dist-info/entry_points.txt +3 -0
- codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom MCPServerStdio that captures stderr output properly.
|
|
3
|
+
|
|
4
|
+
This module provides a version of MCPServerStdio that captures subprocess
|
|
5
|
+
stderr output and makes it available through proper logging channels.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from contextlib import asynccontextmanager
|
|
12
|
+
from typing import AsyncIterator, Optional, Sequence
|
|
13
|
+
|
|
14
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
15
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
16
|
+
from mcp.shared.session import SessionMessage
|
|
17
|
+
from pydantic_ai.mcp import MCPServerStdio
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class StderrCapture:
|
|
23
|
+
"""
|
|
24
|
+
Captures stderr output using a pipe and background reader.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, name: str, handler: Optional[callable] = None):
|
|
28
|
+
"""
|
|
29
|
+
Initialize stderr capture.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
name: Name for this capture stream
|
|
33
|
+
handler: Optional function to call with captured lines
|
|
34
|
+
"""
|
|
35
|
+
self.name = name
|
|
36
|
+
self.handler = handler or self._default_handler
|
|
37
|
+
self._captured_lines = []
|
|
38
|
+
self._reader_task = None
|
|
39
|
+
self._pipe_r = None
|
|
40
|
+
self._pipe_w = None
|
|
41
|
+
|
|
42
|
+
def _default_handler(self, line: str):
|
|
43
|
+
"""Default handler that logs to Python logging."""
|
|
44
|
+
if line.strip():
|
|
45
|
+
logger.debug(f"[MCP {self.name}] {line.rstrip()}")
|
|
46
|
+
|
|
47
|
+
async def start_capture(self):
|
|
48
|
+
"""Start capturing stderr by creating a pipe and reader task."""
|
|
49
|
+
# Create a pipe for capturing stderr
|
|
50
|
+
self._pipe_r, self._pipe_w = os.pipe()
|
|
51
|
+
|
|
52
|
+
# Make the read end non-blocking
|
|
53
|
+
os.set_blocking(self._pipe_r, False)
|
|
54
|
+
|
|
55
|
+
# Start background task to read from pipe
|
|
56
|
+
self._reader_task = asyncio.create_task(self._read_pipe())
|
|
57
|
+
|
|
58
|
+
# Return the write end as the file descriptor for stderr
|
|
59
|
+
return self._pipe_w
|
|
60
|
+
|
|
61
|
+
async def _read_pipe(self):
|
|
62
|
+
"""Background task to read from the pipe."""
|
|
63
|
+
loop = asyncio.get_running_loop()
|
|
64
|
+
buffer = b""
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
while True:
|
|
68
|
+
# Use asyncio's add_reader for efficient async reading
|
|
69
|
+
future = asyncio.Future()
|
|
70
|
+
|
|
71
|
+
def read_callback(future=future):
|
|
72
|
+
try:
|
|
73
|
+
data = os.read(self._pipe_r, 4096)
|
|
74
|
+
future.set_result(data)
|
|
75
|
+
except BlockingIOError:
|
|
76
|
+
future.set_result(b"")
|
|
77
|
+
except Exception as e:
|
|
78
|
+
future.set_exception(e)
|
|
79
|
+
|
|
80
|
+
loop.add_reader(self._pipe_r, read_callback)
|
|
81
|
+
try:
|
|
82
|
+
data = await future
|
|
83
|
+
finally:
|
|
84
|
+
loop.remove_reader(self._pipe_r)
|
|
85
|
+
|
|
86
|
+
if not data:
|
|
87
|
+
await asyncio.sleep(0.1)
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
# Process the data
|
|
91
|
+
buffer += data
|
|
92
|
+
|
|
93
|
+
# Look for complete lines
|
|
94
|
+
while b"\n" in buffer:
|
|
95
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
96
|
+
line_str = line.decode("utf-8", errors="replace")
|
|
97
|
+
if line_str:
|
|
98
|
+
self._captured_lines.append(line_str)
|
|
99
|
+
self.handler(line_str)
|
|
100
|
+
|
|
101
|
+
except asyncio.CancelledError:
|
|
102
|
+
# Process any remaining buffer
|
|
103
|
+
if buffer:
|
|
104
|
+
line_str = buffer.decode("utf-8", errors="replace")
|
|
105
|
+
if line_str:
|
|
106
|
+
self._captured_lines.append(line_str)
|
|
107
|
+
self.handler(line_str)
|
|
108
|
+
raise
|
|
109
|
+
|
|
110
|
+
async def stop_capture(self):
|
|
111
|
+
"""Stop capturing and clean up."""
|
|
112
|
+
if self._reader_task:
|
|
113
|
+
self._reader_task.cancel()
|
|
114
|
+
try:
|
|
115
|
+
await self._reader_task
|
|
116
|
+
except asyncio.CancelledError:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
if self._pipe_r is not None:
|
|
120
|
+
os.close(self._pipe_r)
|
|
121
|
+
if self._pipe_w is not None:
|
|
122
|
+
os.close(self._pipe_w)
|
|
123
|
+
|
|
124
|
+
def get_captured_lines(self) -> list[str]:
|
|
125
|
+
"""Get all captured lines."""
|
|
126
|
+
return self._captured_lines.copy()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CapturedMCPServerStdio(MCPServerStdio):
|
|
130
|
+
"""
|
|
131
|
+
Extended MCPServerStdio that captures and handles stderr output.
|
|
132
|
+
|
|
133
|
+
This class captures stderr from the subprocess and makes it available
|
|
134
|
+
through proper logging channels instead of letting it pollute the console.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
command: str,
|
|
140
|
+
args: Sequence[str] = (),
|
|
141
|
+
env: dict[str, str] | None = None,
|
|
142
|
+
cwd: str | None = None,
|
|
143
|
+
stderr_handler: Optional[callable] = None,
|
|
144
|
+
**kwargs,
|
|
145
|
+
):
|
|
146
|
+
"""
|
|
147
|
+
Initialize captured stdio server.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
command: The command to run
|
|
151
|
+
args: Arguments for the command
|
|
152
|
+
env: Environment variables
|
|
153
|
+
cwd: Working directory
|
|
154
|
+
stderr_handler: Optional function to handle stderr lines
|
|
155
|
+
**kwargs: Additional arguments for MCPServerStdio
|
|
156
|
+
"""
|
|
157
|
+
super().__init__(command=command, args=args, env=env, cwd=cwd, **kwargs)
|
|
158
|
+
self.stderr_handler = stderr_handler
|
|
159
|
+
self._stderr_capture = None
|
|
160
|
+
self._captured_lines = []
|
|
161
|
+
|
|
162
|
+
@asynccontextmanager
|
|
163
|
+
async def client_streams(
|
|
164
|
+
self,
|
|
165
|
+
) -> AsyncIterator[
|
|
166
|
+
tuple[
|
|
167
|
+
MemoryObjectReceiveStream[SessionMessage | Exception],
|
|
168
|
+
MemoryObjectSendStream[SessionMessage],
|
|
169
|
+
]
|
|
170
|
+
]:
|
|
171
|
+
"""Create the streams for the MCP server with stderr capture."""
|
|
172
|
+
server = StdioServerParameters(
|
|
173
|
+
command=self.command, args=list(self.args), env=self.env, cwd=self.cwd
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Create stderr capture
|
|
177
|
+
def stderr_line_handler(line: str):
|
|
178
|
+
"""Handle captured stderr lines."""
|
|
179
|
+
self._captured_lines.append(line)
|
|
180
|
+
|
|
181
|
+
if self.stderr_handler:
|
|
182
|
+
self.stderr_handler(line)
|
|
183
|
+
else:
|
|
184
|
+
# Default: log at DEBUG level to avoid console spam
|
|
185
|
+
logger.debug(f"[MCP Server {self.command}] {line}")
|
|
186
|
+
|
|
187
|
+
self._stderr_capture = StderrCapture(self.command, stderr_line_handler)
|
|
188
|
+
|
|
189
|
+
# For now, use devnull for stderr to suppress output
|
|
190
|
+
# We'll capture it through other means if needed
|
|
191
|
+
with open(os.devnull, "w") as devnull:
|
|
192
|
+
async with stdio_client(server=server, errlog=devnull) as (
|
|
193
|
+
read_stream,
|
|
194
|
+
write_stream,
|
|
195
|
+
):
|
|
196
|
+
yield read_stream, write_stream
|
|
197
|
+
|
|
198
|
+
def get_captured_stderr(self) -> list[str]:
|
|
199
|
+
"""
|
|
200
|
+
Get all captured stderr lines.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
List of captured stderr lines
|
|
204
|
+
"""
|
|
205
|
+
return self._captured_lines.copy()
|
|
206
|
+
|
|
207
|
+
def clear_captured_stderr(self):
|
|
208
|
+
"""Clear the captured stderr buffer."""
|
|
209
|
+
self._captured_lines.clear()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class StderrCollector:
|
|
213
|
+
"""
|
|
214
|
+
A centralized collector for stderr from multiple MCP servers.
|
|
215
|
+
|
|
216
|
+
This can be used to aggregate stderr from all MCP servers in one place.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(self):
|
|
220
|
+
"""Initialize the collector."""
|
|
221
|
+
self.servers = {}
|
|
222
|
+
self.all_lines = []
|
|
223
|
+
|
|
224
|
+
def create_handler(self, server_name: str, emit_to_user: bool = False):
|
|
225
|
+
"""
|
|
226
|
+
Create a handler function for a specific server.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
server_name: Name to identify this server
|
|
230
|
+
emit_to_user: If True, emit stderr lines to user via emit_info
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Handler function that can be passed to CapturedMCPServerStdio
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def handler(line: str):
|
|
237
|
+
# Store with server identification
|
|
238
|
+
import time
|
|
239
|
+
|
|
240
|
+
entry = {"server": server_name, "line": line, "timestamp": time.time()}
|
|
241
|
+
|
|
242
|
+
if server_name not in self.servers:
|
|
243
|
+
self.servers[server_name] = []
|
|
244
|
+
|
|
245
|
+
self.servers[server_name].append(line)
|
|
246
|
+
self.all_lines.append(entry)
|
|
247
|
+
|
|
248
|
+
# Emit to user if requested
|
|
249
|
+
if emit_to_user:
|
|
250
|
+
from code_puppy.messaging import emit_info
|
|
251
|
+
|
|
252
|
+
emit_info(f"MCP {server_name}: {line}")
|
|
253
|
+
|
|
254
|
+
return handler
|
|
255
|
+
|
|
256
|
+
def get_server_output(self, server_name: str) -> list[str]:
|
|
257
|
+
"""Get all output from a specific server."""
|
|
258
|
+
return self.servers.get(server_name, []).copy()
|
|
259
|
+
|
|
260
|
+
def get_all_output(self) -> list[dict]:
|
|
261
|
+
"""Get all output from all servers with metadata."""
|
|
262
|
+
return self.all_lines.copy()
|
|
263
|
+
|
|
264
|
+
def clear(self, server_name: Optional[str] = None):
|
|
265
|
+
"""Clear captured output."""
|
|
266
|
+
if server_name:
|
|
267
|
+
if server_name in self.servers:
|
|
268
|
+
del self.servers[server_name]
|
|
269
|
+
# Also clear from all_lines
|
|
270
|
+
self.all_lines = [
|
|
271
|
+
entry for entry in self.all_lines if entry["server"] != server_name
|
|
272
|
+
]
|
|
273
|
+
else:
|
|
274
|
+
self.servers.clear()
|
|
275
|
+
self.all_lines.clear()
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Circuit breaker implementation for MCP servers to prevent cascading failures.
|
|
3
|
+
|
|
4
|
+
This module implements the circuit breaker pattern to protect against cascading
|
|
5
|
+
failures when MCP servers become unhealthy. The circuit breaker has three states:
|
|
6
|
+
- CLOSED: Normal operation, calls pass through
|
|
7
|
+
- OPEN: Calls are blocked and fail fast
|
|
8
|
+
- HALF_OPEN: Limited calls allowed to test recovery
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CircuitState(Enum):
|
|
22
|
+
"""Circuit breaker states."""
|
|
23
|
+
|
|
24
|
+
CLOSED = "closed" # Normal operation
|
|
25
|
+
OPEN = "open" # Blocking calls
|
|
26
|
+
HALF_OPEN = "half_open" # Testing recovery
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CircuitOpenError(Exception):
|
|
30
|
+
"""Raised when circuit breaker is in OPEN state."""
|
|
31
|
+
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CircuitBreaker:
|
|
36
|
+
"""
|
|
37
|
+
Circuit breaker to prevent cascading failures in MCP servers.
|
|
38
|
+
|
|
39
|
+
The circuit breaker monitors the success/failure rate of operations and
|
|
40
|
+
transitions between states to protect the system from unhealthy dependencies.
|
|
41
|
+
|
|
42
|
+
States:
|
|
43
|
+
- CLOSED: Normal operation, all calls allowed
|
|
44
|
+
- OPEN: Circuit is open, all calls fail fast with CircuitOpenError
|
|
45
|
+
- HALF_OPEN: Testing recovery, limited calls allowed
|
|
46
|
+
|
|
47
|
+
State Transitions:
|
|
48
|
+
- CLOSED → OPEN: After failure_threshold consecutive failures
|
|
49
|
+
- OPEN → HALF_OPEN: After timeout seconds
|
|
50
|
+
- HALF_OPEN → CLOSED: After success_threshold consecutive successes
|
|
51
|
+
- HALF_OPEN → OPEN: After any failure
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self, failure_threshold: int = 5, success_threshold: int = 2, timeout: int = 60
|
|
56
|
+
):
|
|
57
|
+
"""
|
|
58
|
+
Initialize circuit breaker.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
failure_threshold: Number of consecutive failures before opening circuit
|
|
62
|
+
success_threshold: Number of consecutive successes needed to close circuit from half-open
|
|
63
|
+
timeout: Seconds to wait before transitioning from OPEN to HALF_OPEN
|
|
64
|
+
"""
|
|
65
|
+
self.failure_threshold = failure_threshold
|
|
66
|
+
self.success_threshold = success_threshold
|
|
67
|
+
self.timeout = timeout
|
|
68
|
+
|
|
69
|
+
self._state = CircuitState.CLOSED
|
|
70
|
+
self._failure_count = 0
|
|
71
|
+
self._success_count = 0
|
|
72
|
+
self._last_failure_time = None
|
|
73
|
+
# NOTE: We use threading.Lock (not asyncio.Lock) because this lock is shared
|
|
74
|
+
# between synchronous callers (record_success/record_failure) and async callers
|
|
75
|
+
# (_on_success/_on_failure called from call()). This is safe because the critical
|
|
76
|
+
# sections are very short and CPU-bound only (counter increments, state transitions)
|
|
77
|
+
# — no I/O or awaits occur while the lock is held, so event loop blocking is negligible.
|
|
78
|
+
self._sync_lock = threading.Lock()
|
|
79
|
+
self._half_open_in_flight = False
|
|
80
|
+
|
|
81
|
+
logger.info(
|
|
82
|
+
f"Circuit breaker initialized: failure_threshold={failure_threshold}, "
|
|
83
|
+
f"success_threshold={success_threshold}, timeout={timeout}s"
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def call(self, func: Callable, *args, **kwargs) -> Any:
|
|
87
|
+
"""
|
|
88
|
+
Execute a function through the circuit breaker.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
func: Function to execute
|
|
92
|
+
*args: Positional arguments for the function
|
|
93
|
+
**kwargs: Keyword arguments for the function
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Result of the function call
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
CircuitOpenError: If circuit is in OPEN state
|
|
100
|
+
Exception: Any exception raised by the wrapped function
|
|
101
|
+
"""
|
|
102
|
+
with self._sync_lock:
|
|
103
|
+
current_state = self._get_current_state()
|
|
104
|
+
|
|
105
|
+
if current_state == CircuitState.OPEN:
|
|
106
|
+
logger.warning("Circuit breaker is OPEN, failing fast")
|
|
107
|
+
raise CircuitOpenError("Circuit breaker is open")
|
|
108
|
+
|
|
109
|
+
if current_state == CircuitState.HALF_OPEN:
|
|
110
|
+
if self._half_open_in_flight:
|
|
111
|
+
logger.warning(
|
|
112
|
+
"Circuit breaker HALF_OPEN with call already in flight, failing fast"
|
|
113
|
+
)
|
|
114
|
+
raise CircuitOpenError(
|
|
115
|
+
"Circuit breaker half-open test call already in flight"
|
|
116
|
+
)
|
|
117
|
+
# In half-open state, we're testing recovery
|
|
118
|
+
logger.info("Circuit breaker is HALF_OPEN, allowing test call")
|
|
119
|
+
self._half_open_in_flight = True
|
|
120
|
+
|
|
121
|
+
checked_state = current_state
|
|
122
|
+
|
|
123
|
+
# Execute the function outside the lock to avoid blocking other calls
|
|
124
|
+
try:
|
|
125
|
+
result = (
|
|
126
|
+
await func(*args, **kwargs)
|
|
127
|
+
if asyncio.iscoroutinefunction(func)
|
|
128
|
+
else func(*args, **kwargs)
|
|
129
|
+
)
|
|
130
|
+
await self._on_success(checked_state=checked_state)
|
|
131
|
+
return result
|
|
132
|
+
except Exception:
|
|
133
|
+
await self._on_failure(checked_state=checked_state)
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
def record_success(self) -> None:
|
|
137
|
+
"""Record a successful operation (synchronous)."""
|
|
138
|
+
with self._sync_lock:
|
|
139
|
+
self._on_success_sync()
|
|
140
|
+
|
|
141
|
+
def record_failure(self) -> None:
|
|
142
|
+
"""Record a failed operation (synchronous)."""
|
|
143
|
+
with self._sync_lock:
|
|
144
|
+
self._on_failure_sync()
|
|
145
|
+
|
|
146
|
+
def get_state(self) -> CircuitState:
|
|
147
|
+
"""Get current circuit breaker state."""
|
|
148
|
+
with self._sync_lock:
|
|
149
|
+
return self._get_current_state()
|
|
150
|
+
|
|
151
|
+
def is_open(self) -> bool:
|
|
152
|
+
"""Check if circuit breaker is in OPEN state."""
|
|
153
|
+
with self._sync_lock:
|
|
154
|
+
return self._get_current_state() == CircuitState.OPEN
|
|
155
|
+
|
|
156
|
+
def is_half_open(self) -> bool:
|
|
157
|
+
"""Check if circuit breaker is in HALF_OPEN state."""
|
|
158
|
+
with self._sync_lock:
|
|
159
|
+
return self._get_current_state() == CircuitState.HALF_OPEN
|
|
160
|
+
|
|
161
|
+
def is_closed(self) -> bool:
|
|
162
|
+
"""Check if circuit breaker is in CLOSED state."""
|
|
163
|
+
with self._sync_lock:
|
|
164
|
+
return self._get_current_state() == CircuitState.CLOSED
|
|
165
|
+
|
|
166
|
+
def reset(self) -> None:
|
|
167
|
+
"""Reset circuit breaker to CLOSED state and clear counters."""
|
|
168
|
+
with self._sync_lock:
|
|
169
|
+
logger.info("Resetting circuit breaker to CLOSED state")
|
|
170
|
+
self._state = CircuitState.CLOSED
|
|
171
|
+
self._failure_count = 0
|
|
172
|
+
self._success_count = 0
|
|
173
|
+
self._last_failure_time = None
|
|
174
|
+
self._half_open_in_flight = False
|
|
175
|
+
|
|
176
|
+
def force_open(self) -> None:
|
|
177
|
+
"""Force circuit breaker to OPEN state."""
|
|
178
|
+
with self._sync_lock:
|
|
179
|
+
logger.warning("Forcing circuit breaker to OPEN state")
|
|
180
|
+
self._state = CircuitState.OPEN
|
|
181
|
+
self._last_failure_time = time.time()
|
|
182
|
+
self._half_open_in_flight = False
|
|
183
|
+
|
|
184
|
+
def force_close(self) -> None:
|
|
185
|
+
"""Force circuit breaker to CLOSED state and reset counters."""
|
|
186
|
+
with self._sync_lock:
|
|
187
|
+
logger.info("Forcing circuit breaker to CLOSED state")
|
|
188
|
+
self._state = CircuitState.CLOSED
|
|
189
|
+
self._failure_count = 0
|
|
190
|
+
self._success_count = 0
|
|
191
|
+
self._last_failure_time = None
|
|
192
|
+
self._half_open_in_flight = False
|
|
193
|
+
|
|
194
|
+
def _get_current_state(self) -> CircuitState:
|
|
195
|
+
"""
|
|
196
|
+
Get the current state, handling automatic transitions.
|
|
197
|
+
|
|
198
|
+
This method handles the automatic transition from OPEN to HALF_OPEN
|
|
199
|
+
after the timeout period has elapsed.
|
|
200
|
+
"""
|
|
201
|
+
if self._state == CircuitState.OPEN and self._should_attempt_reset():
|
|
202
|
+
logger.info("Timeout reached, transitioning from OPEN to HALF_OPEN")
|
|
203
|
+
self._state = CircuitState.HALF_OPEN
|
|
204
|
+
self._success_count = 0 # Reset success counter for half-open testing
|
|
205
|
+
|
|
206
|
+
return self._state
|
|
207
|
+
|
|
208
|
+
def _should_attempt_reset(self) -> bool:
|
|
209
|
+
"""Check if enough time has passed to attempt reset from OPEN to HALF_OPEN."""
|
|
210
|
+
if self._last_failure_time is None:
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
return time.time() - self._last_failure_time >= self.timeout
|
|
214
|
+
|
|
215
|
+
def _on_success_sync(self, checked_state: CircuitState | None = None) -> None:
|
|
216
|
+
"""Handle successful operation (synchronous, no lock)."""
|
|
217
|
+
current_state = (
|
|
218
|
+
checked_state if checked_state is not None else self._get_current_state()
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if current_state == CircuitState.CLOSED:
|
|
222
|
+
if self._failure_count > 0:
|
|
223
|
+
logger.debug("Resetting failure count after success")
|
|
224
|
+
self._failure_count = 0
|
|
225
|
+
|
|
226
|
+
elif current_state == CircuitState.HALF_OPEN:
|
|
227
|
+
self._success_count += 1
|
|
228
|
+
logger.debug(
|
|
229
|
+
f"Success in HALF_OPEN state: {self._success_count}/{self.success_threshold}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
self._half_open_in_flight = False
|
|
233
|
+
|
|
234
|
+
if self._success_count >= self.success_threshold:
|
|
235
|
+
logger.info(
|
|
236
|
+
"Success threshold reached, transitioning from HALF_OPEN to CLOSED"
|
|
237
|
+
)
|
|
238
|
+
self._state = CircuitState.CLOSED
|
|
239
|
+
self._failure_count = 0
|
|
240
|
+
self._success_count = 0
|
|
241
|
+
self._last_failure_time = None
|
|
242
|
+
self._half_open_in_flight = False
|
|
243
|
+
|
|
244
|
+
def _on_failure_sync(self, checked_state: CircuitState | None = None) -> None:
|
|
245
|
+
"""Handle failed operation (synchronous, no lock)."""
|
|
246
|
+
current_state = (
|
|
247
|
+
checked_state if checked_state is not None else self._get_current_state()
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if current_state == CircuitState.CLOSED:
|
|
251
|
+
self._failure_count += 1
|
|
252
|
+
logger.debug(
|
|
253
|
+
f"Failure in CLOSED state: {self._failure_count}/{self.failure_threshold}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if self._failure_count >= self.failure_threshold:
|
|
257
|
+
logger.warning(
|
|
258
|
+
"Failure threshold reached, transitioning from CLOSED to OPEN"
|
|
259
|
+
)
|
|
260
|
+
self._state = CircuitState.OPEN
|
|
261
|
+
self._last_failure_time = time.time()
|
|
262
|
+
|
|
263
|
+
elif current_state == CircuitState.HALF_OPEN:
|
|
264
|
+
logger.warning("Failure in HALF_OPEN state, transitioning back to OPEN")
|
|
265
|
+
self._state = CircuitState.OPEN
|
|
266
|
+
self._success_count = 0
|
|
267
|
+
self._last_failure_time = time.time()
|
|
268
|
+
self._half_open_in_flight = False
|
|
269
|
+
|
|
270
|
+
async def _on_success(self, checked_state: CircuitState | None = None) -> None:
|
|
271
|
+
"""Handle successful operation.
|
|
272
|
+
|
|
273
|
+
This method is async to match the await call-site in call(), but the
|
|
274
|
+
underlying work is purely synchronous. We acquire threading.Lock (not
|
|
275
|
+
asyncio.Lock) because the same state is accessed from sync contexts
|
|
276
|
+
(record_success). The critical section is short and CPU-bound, so
|
|
277
|
+
holding a threading.Lock in an async method does not meaningfully
|
|
278
|
+
block the event loop.
|
|
279
|
+
"""
|
|
280
|
+
with self._sync_lock:
|
|
281
|
+
self._on_success_sync(checked_state=checked_state)
|
|
282
|
+
|
|
283
|
+
async def _on_failure(self, checked_state: CircuitState | None = None) -> None:
|
|
284
|
+
"""Handle failed operation.
|
|
285
|
+
|
|
286
|
+
See _on_success docstring for rationale on threading.Lock usage in
|
|
287
|
+
an async method.
|
|
288
|
+
"""
|
|
289
|
+
with self._sync_lock:
|
|
290
|
+
self._on_failure_sync(checked_state=checked_state)
|