code-puppy 0.0.287__py3-none-any.whl → 0.0.323__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 +3 -1
- code_puppy/agents/agent_code_puppy.py +5 -4
- code_puppy/agents/agent_creator_agent.py +22 -18
- code_puppy/agents/agent_manager.py +2 -2
- code_puppy/agents/base_agent.py +496 -102
- code_puppy/callbacks.py +8 -0
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/cli_runner.py +795 -0
- code_puppy/command_line/add_model_menu.py +19 -16
- code_puppy/command_line/attachments.py +10 -5
- code_puppy/command_line/autosave_menu.py +269 -41
- code_puppy/command_line/colors_menu.py +515 -0
- code_puppy/command_line/command_handler.py +10 -24
- code_puppy/command_line/config_commands.py +106 -25
- code_puppy/command_line/core_commands.py +32 -20
- code_puppy/command_line/mcp/add_command.py +3 -16
- code_puppy/command_line/mcp/base.py +0 -3
- code_puppy/command_line/mcp/catalog_server_installer.py +15 -15
- code_puppy/command_line/mcp/custom_server_form.py +66 -5
- code_puppy/command_line/mcp/custom_server_installer.py +17 -17
- code_puppy/command_line/mcp/edit_command.py +15 -22
- code_puppy/command_line/mcp/handler.py +7 -2
- code_puppy/command_line/mcp/help_command.py +2 -2
- code_puppy/command_line/mcp/install_command.py +10 -14
- code_puppy/command_line/mcp/install_menu.py +2 -6
- code_puppy/command_line/mcp/list_command.py +2 -2
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +7 -2
- code_puppy/command_line/mcp/search_command.py +16 -10
- code_puppy/command_line/mcp/start_all_command.py +16 -6
- code_puppy/command_line/mcp/start_command.py +12 -10
- code_puppy/command_line/mcp/status_command.py +4 -5
- code_puppy/command_line/mcp/stop_all_command.py +5 -1
- code_puppy/command_line/mcp/stop_command.py +6 -4
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/wizard_utils.py +20 -16
- code_puppy/command_line/model_settings_menu.py +53 -7
- code_puppy/command_line/motd.py +1 -1
- code_puppy/command_line/pin_command_completion.py +82 -7
- code_puppy/command_line/prompt_toolkit_completion.py +32 -9
- code_puppy/command_line/session_commands.py +11 -4
- code_puppy/config.py +217 -53
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/keymap.py +126 -0
- code_puppy/main.py +5 -745
- code_puppy/mcp_/__init__.py +17 -0
- code_puppy/mcp_/blocking_startup.py +63 -36
- code_puppy/mcp_/captured_stdio_server.py +1 -1
- code_puppy/mcp_/config_wizard.py +4 -4
- code_puppy/mcp_/dashboard.py +15 -6
- code_puppy/mcp_/managed_server.py +25 -5
- code_puppy/mcp_/manager.py +65 -0
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/mcp_/registry.py +6 -6
- code_puppy/messaging/__init__.py +184 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +3 -3
- code_puppy/messaging/messages.py +470 -0
- code_puppy/messaging/renderers.py +43 -141
- code_puppy/messaging/rich_renderer.py +900 -0
- code_puppy/messaging/spinner/console_spinner.py +39 -2
- code_puppy/model_factory.py +292 -53
- code_puppy/model_utils.py +57 -48
- code_puppy/models.json +19 -5
- code_puppy/plugins/__init__.py +152 -10
- code_puppy/plugins/chatgpt_oauth/config.py +20 -12
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +30 -13
- code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
- code_puppy/plugins/claude_code_oauth/config.py +15 -11
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
- code_puppy/plugins/claude_code_oauth/utils.py +6 -1
- code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
- code_puppy/plugins/oauth_puppy_html.py +3 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -134
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +77 -3
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/session_storage.py +2 -1
- code_puppy/status_display.py +7 -5
- code_puppy/terminal_utils.py +126 -0
- code_puppy/tools/agent_tools.py +131 -70
- code_puppy/tools/browser/browser_control.py +10 -14
- code_puppy/tools/browser/browser_interactions.py +20 -28
- code_puppy/tools/browser/browser_locators.py +27 -29
- code_puppy/tools/browser/browser_navigation.py +9 -9
- code_puppy/tools/browser/browser_screenshot.py +12 -14
- code_puppy/tools/browser/browser_scripts.py +17 -29
- code_puppy/tools/browser/browser_workflows.py +24 -25
- code_puppy/tools/browser/camoufox_manager.py +22 -26
- code_puppy/tools/command_runner.py +410 -88
- code_puppy/tools/common.py +51 -38
- code_puppy/tools/file_modifications.py +98 -24
- code_puppy/tools/file_operations.py +113 -202
- code_puppy/version_checker.py +28 -13
- {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/METADATA +3 -8
- code_puppy-0.0.323.dist-info/RECORD +168 -0
- code_puppy/tui_state.py +0 -55
- code_puppy-0.0.287.dist-info/RECORD +0 -153
- {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
code_puppy/mcp_/__init__.py
CHANGED
|
@@ -17,6 +17,15 @@ from .error_isolation import (
|
|
|
17
17
|
)
|
|
18
18
|
from .managed_server import ManagedMCPServer, ServerConfig, ServerState
|
|
19
19
|
from .manager import MCPManager, ServerInfo, get_mcp_manager
|
|
20
|
+
from .mcp_logs import (
|
|
21
|
+
clear_logs,
|
|
22
|
+
get_log_file_path,
|
|
23
|
+
get_log_stats,
|
|
24
|
+
get_mcp_logs_dir,
|
|
25
|
+
list_servers_with_logs,
|
|
26
|
+
read_logs,
|
|
27
|
+
write_log,
|
|
28
|
+
)
|
|
20
29
|
from .registry import ServerRegistry
|
|
21
30
|
from .retry_manager import RetryManager, RetryStats, get_retry_manager, retry_mcp_call
|
|
22
31
|
from .status_tracker import Event, ServerStatusTracker
|
|
@@ -46,4 +55,12 @@ __all__ = [
|
|
|
46
55
|
"MCPDashboard",
|
|
47
56
|
"MCPConfigWizard",
|
|
48
57
|
"run_add_wizard",
|
|
58
|
+
# Log management
|
|
59
|
+
"get_mcp_logs_dir",
|
|
60
|
+
"get_log_file_path",
|
|
61
|
+
"read_logs",
|
|
62
|
+
"write_log",
|
|
63
|
+
"clear_logs",
|
|
64
|
+
"list_servers_with_logs",
|
|
65
|
+
"get_log_stats",
|
|
49
66
|
]
|
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
MCP Server with blocking startup capability and stderr capture.
|
|
3
3
|
|
|
4
4
|
This module provides MCP servers that:
|
|
5
|
-
1. Capture stderr output from stdio servers
|
|
5
|
+
1. Capture stderr output from stdio servers to persistent log files
|
|
6
6
|
2. Block until fully initialized before allowing operations
|
|
7
|
-
3.
|
|
7
|
+
3. Optionally emit stderr to users (disabled by default to reduce console noise)
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
11
|
import os
|
|
12
|
-
import tempfile
|
|
13
12
|
import threading
|
|
14
13
|
import uuid
|
|
15
14
|
from contextlib import asynccontextmanager
|
|
@@ -18,64 +17,80 @@ from typing import List, Optional
|
|
|
18
17
|
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
19
18
|
from pydantic_ai.mcp import MCPServerStdio
|
|
20
19
|
|
|
20
|
+
from code_puppy.mcp_.mcp_logs import get_log_file_path, rotate_log_if_needed, write_log
|
|
21
21
|
from code_puppy.messaging import emit_info
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
class StderrFileCapture:
|
|
25
|
-
"""
|
|
25
|
+
"""
|
|
26
|
+
Captures stderr to a persistent log file and optionally monitors it.
|
|
27
|
+
|
|
28
|
+
Logs are written to ~/.code_puppy/mcp_logs/<server_name>.log
|
|
29
|
+
"""
|
|
26
30
|
|
|
27
31
|
def __init__(
|
|
28
32
|
self,
|
|
29
33
|
server_name: str,
|
|
30
|
-
emit_to_user: bool =
|
|
34
|
+
emit_to_user: bool = False, # Disabled by default to reduce console noise
|
|
31
35
|
message_group: Optional[uuid.UUID] = None,
|
|
32
36
|
):
|
|
33
37
|
self.server_name = server_name
|
|
34
38
|
self.emit_to_user = emit_to_user
|
|
35
39
|
self.message_group = message_group or uuid.uuid4()
|
|
36
|
-
self.
|
|
37
|
-
self.
|
|
40
|
+
self.log_file = None
|
|
41
|
+
self.log_path = None
|
|
38
42
|
self.monitor_thread = None
|
|
39
43
|
self.stop_monitoring = threading.Event()
|
|
40
44
|
self.captured_lines = []
|
|
45
|
+
self._last_read_pos = 0
|
|
41
46
|
|
|
42
47
|
def start(self):
|
|
43
|
-
"""Start capture by
|
|
44
|
-
#
|
|
45
|
-
self.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
self.
|
|
48
|
+
"""Start capture by opening persistent log file and monitor thread."""
|
|
49
|
+
# Rotate log if needed
|
|
50
|
+
rotate_log_if_needed(self.server_name)
|
|
51
|
+
|
|
52
|
+
# Get persistent log path
|
|
53
|
+
self.log_path = get_log_file_path(self.server_name)
|
|
54
|
+
|
|
55
|
+
# Write startup marker
|
|
56
|
+
write_log(self.server_name, "--- Server starting ---", "INFO")
|
|
57
|
+
|
|
58
|
+
# Open log file for appending stderr
|
|
59
|
+
self.log_file = open(self.log_path, "a", encoding="utf-8")
|
|
49
60
|
|
|
50
|
-
# Start monitoring thread
|
|
61
|
+
# Start monitoring thread only if we need to emit to user or capture lines
|
|
51
62
|
self.stop_monitoring.clear()
|
|
52
63
|
self.monitor_thread = threading.Thread(target=self._monitor_file)
|
|
53
64
|
self.monitor_thread.daemon = True
|
|
54
65
|
self.monitor_thread.start()
|
|
55
66
|
|
|
56
|
-
return self.
|
|
67
|
+
return self.log_file
|
|
57
68
|
|
|
58
69
|
def _monitor_file(self):
|
|
59
|
-
"""Monitor the
|
|
60
|
-
if not self.
|
|
70
|
+
"""Monitor the log file for new content."""
|
|
71
|
+
if not self.log_path:
|
|
61
72
|
return
|
|
62
73
|
|
|
63
|
-
|
|
74
|
+
# Start reading from current position (end of file before we started)
|
|
75
|
+
try:
|
|
76
|
+
self._last_read_pos = os.path.getsize(self.log_path)
|
|
77
|
+
except OSError:
|
|
78
|
+
self._last_read_pos = 0
|
|
79
|
+
|
|
64
80
|
while not self.stop_monitoring.is_set():
|
|
65
81
|
try:
|
|
66
|
-
with open(self.
|
|
67
|
-
f.seek(
|
|
82
|
+
with open(self.log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
83
|
+
f.seek(self._last_read_pos)
|
|
68
84
|
new_content = f.read()
|
|
69
85
|
if new_content:
|
|
70
|
-
|
|
86
|
+
self._last_read_pos = f.tell()
|
|
71
87
|
# Process new lines
|
|
72
88
|
for line in new_content.splitlines():
|
|
73
89
|
if line.strip():
|
|
74
90
|
self.captured_lines.append(line)
|
|
75
91
|
if self.emit_to_user:
|
|
76
92
|
emit_info(
|
|
77
|
-
f"
|
|
78
|
-
style="dim cyan",
|
|
93
|
+
f"MCP {self.server_name}: {line}",
|
|
79
94
|
message_group=self.message_group,
|
|
80
95
|
)
|
|
81
96
|
|
|
@@ -90,33 +105,37 @@ class StderrFileCapture:
|
|
|
90
105
|
if self.monitor_thread:
|
|
91
106
|
self.monitor_thread.join(timeout=1)
|
|
92
107
|
|
|
93
|
-
if self.
|
|
108
|
+
if self.log_file:
|
|
94
109
|
try:
|
|
95
|
-
self.
|
|
110
|
+
self.log_file.flush()
|
|
111
|
+
self.log_file.close()
|
|
96
112
|
except Exception:
|
|
97
113
|
pass
|
|
98
114
|
|
|
99
|
-
|
|
115
|
+
# Write shutdown marker
|
|
116
|
+
write_log(self.server_name, "--- Server stopped ---", "INFO")
|
|
117
|
+
|
|
118
|
+
# Read any remaining content for in-memory capture
|
|
119
|
+
if self.log_path and os.path.exists(self.log_path):
|
|
100
120
|
try:
|
|
101
|
-
|
|
102
|
-
|
|
121
|
+
with open(self.log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
122
|
+
f.seek(self._last_read_pos)
|
|
103
123
|
content = f.read()
|
|
104
124
|
for line in content.splitlines():
|
|
105
125
|
if line.strip() and line not in self.captured_lines:
|
|
106
126
|
self.captured_lines.append(line)
|
|
107
127
|
if self.emit_to_user:
|
|
108
128
|
emit_info(
|
|
109
|
-
f"
|
|
110
|
-
style="dim cyan",
|
|
129
|
+
f"MCP {self.server_name}: {line}",
|
|
111
130
|
message_group=self.message_group,
|
|
112
131
|
)
|
|
113
|
-
|
|
114
|
-
os.unlink(self.temp_path)
|
|
115
132
|
except Exception:
|
|
116
133
|
pass
|
|
117
134
|
|
|
135
|
+
# Note: We do NOT delete the log file - it's persistent now!
|
|
136
|
+
|
|
118
137
|
def get_captured_lines(self) -> List[str]:
|
|
119
|
-
"""Get all captured lines."""
|
|
138
|
+
"""Get all captured lines from this session."""
|
|
120
139
|
return self.captured_lines.copy()
|
|
121
140
|
|
|
122
141
|
|
|
@@ -203,15 +222,23 @@ class BlockingMCPServerStdio(SimpleCapturedMCPServerStdio):
|
|
|
203
222
|
|
|
204
223
|
return result
|
|
205
224
|
|
|
206
|
-
except
|
|
225
|
+
except BaseException as e:
|
|
207
226
|
# Store error and mark as initialized (with error)
|
|
208
|
-
|
|
227
|
+
# Unwrap ExceptionGroup if present (Python 3.11+)
|
|
228
|
+
if type(e).__name__ == "ExceptionGroup" and hasattr(e, "exceptions"):
|
|
229
|
+
# Use the first exception as the primary cause
|
|
230
|
+
self._init_error = e.exceptions[0]
|
|
231
|
+
error_details = f"{e.exceptions[0]}"
|
|
232
|
+
else:
|
|
233
|
+
self._init_error = e
|
|
234
|
+
error_details = str(e)
|
|
235
|
+
|
|
209
236
|
self._initialized.set()
|
|
210
237
|
|
|
211
238
|
# Emit error message
|
|
212
239
|
server_name = getattr(self, "tool_prefix", self.command)
|
|
213
240
|
emit_info(
|
|
214
|
-
f"❌ MCP Server '{server_name}' failed to initialize: {
|
|
241
|
+
f"❌ MCP Server '{server_name}' failed to initialize: {error_details}",
|
|
215
242
|
style="red",
|
|
216
243
|
message_group=self.message_group,
|
|
217
244
|
)
|
code_puppy/mcp_/config_wizard.py
CHANGED
|
@@ -9,7 +9,7 @@ import re
|
|
|
9
9
|
from typing import Dict, Optional
|
|
10
10
|
from urllib.parse import urlparse
|
|
11
11
|
|
|
12
|
-
from rich.
|
|
12
|
+
from rich.text import Text
|
|
13
13
|
|
|
14
14
|
from code_puppy.mcp_.manager import ServerConfig, get_mcp_manager
|
|
15
15
|
from code_puppy.messaging import (
|
|
@@ -20,8 +20,6 @@ from code_puppy.messaging import (
|
|
|
20
20
|
emit_warning,
|
|
21
21
|
)
|
|
22
22
|
|
|
23
|
-
console = Console()
|
|
24
|
-
|
|
25
23
|
|
|
26
24
|
def prompt_ask(
|
|
27
25
|
prompt_text: str, default: Optional[str] = None, choices: Optional[list] = None
|
|
@@ -491,7 +489,9 @@ def run_add_wizard(group_id: str = None) -> bool:
|
|
|
491
489
|
json.dump(data, f, indent=2)
|
|
492
490
|
|
|
493
491
|
emit_info(
|
|
494
|
-
|
|
492
|
+
Text.from_markup(
|
|
493
|
+
f"[dim]Configuration saved to {MCP_SERVERS_FILE}[/dim]"
|
|
494
|
+
),
|
|
495
495
|
message_group=group_id,
|
|
496
496
|
)
|
|
497
497
|
return True
|
code_puppy/mcp_/dashboard.py
CHANGED
|
@@ -16,11 +16,16 @@ from .status_tracker import ServerState
|
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class MCPDashboard:
|
|
19
|
-
"""Visual dashboard for MCP server status monitoring
|
|
19
|
+
"""Visual dashboard for MCP server status monitoring.
|
|
20
|
+
|
|
21
|
+
Note: This class uses Rich Console directly for rendering Rich tables.
|
|
22
|
+
This is intentional - Rich tables require Console for proper formatting.
|
|
23
|
+
"""
|
|
20
24
|
|
|
21
25
|
def __init__(self):
|
|
22
|
-
"""Initialize the MCP Dashboard"""
|
|
23
|
-
|
|
26
|
+
"""Initialize the MCP Dashboard."""
|
|
27
|
+
# Note: Console is used here specifically for Rich table rendering
|
|
28
|
+
self._console = Console()
|
|
24
29
|
|
|
25
30
|
def render_dashboard(self) -> Table:
|
|
26
31
|
"""
|
|
@@ -278,10 +283,14 @@ class MCPDashboard:
|
|
|
278
283
|
return "error"
|
|
279
284
|
|
|
280
285
|
def print_dashboard(self) -> None:
|
|
281
|
-
"""Print the dashboard to console
|
|
286
|
+
"""Print the dashboard to console.
|
|
287
|
+
|
|
288
|
+
Note: Uses Rich Console directly for table rendering - Rich tables
|
|
289
|
+
require Console for proper formatting with colors and borders.
|
|
290
|
+
"""
|
|
282
291
|
table = self.render_dashboard()
|
|
283
|
-
self.
|
|
284
|
-
self.
|
|
292
|
+
self._console.print(table)
|
|
293
|
+
self._console.print() # Add spacing
|
|
285
294
|
|
|
286
295
|
def get_dashboard_string(self) -> str:
|
|
287
296
|
"""
|
|
@@ -6,6 +6,7 @@ that adds management capabilities while maintaining 100% compatibility.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
9
10
|
import uuid
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
from datetime import datetime, timedelta
|
|
@@ -58,7 +59,7 @@ async def process_tool_call(
|
|
|
58
59
|
"""A tool call processor that passes along the deps."""
|
|
59
60
|
group_id = uuid.uuid4()
|
|
60
61
|
emit_info(
|
|
61
|
-
f"\
|
|
62
|
+
f"\nMCP Tool Call - {name}",
|
|
62
63
|
message_group=group_id,
|
|
63
64
|
)
|
|
64
65
|
emit_info("\nArgs:", message_group=group_id)
|
|
@@ -202,8 +203,8 @@ class ManagedMCPServer:
|
|
|
202
203
|
self._pydantic_server = BlockingMCPServerStdio(
|
|
203
204
|
**stdio_kwargs,
|
|
204
205
|
process_tool_call=process_tool_call,
|
|
205
|
-
tool_prefix=config
|
|
206
|
-
emit_stderr=
|
|
206
|
+
tool_prefix=self.config.name,
|
|
207
|
+
emit_stderr=False, # Logs go to file, not console (use /mcp logs to view)
|
|
207
208
|
message_group=message_group,
|
|
208
209
|
)
|
|
209
210
|
|
|
@@ -222,7 +223,16 @@ class ManagedMCPServer:
|
|
|
222
223
|
if "read_timeout" in config:
|
|
223
224
|
http_kwargs["read_timeout"] = config["read_timeout"]
|
|
224
225
|
if "headers" in config:
|
|
225
|
-
|
|
226
|
+
# Expand environment variables in headers
|
|
227
|
+
headers = config.get("headers")
|
|
228
|
+
resolved_headers = {}
|
|
229
|
+
if isinstance(headers, dict):
|
|
230
|
+
for k, v in headers.items():
|
|
231
|
+
if isinstance(v, str):
|
|
232
|
+
resolved_headers[k] = os.path.expandvars(v)
|
|
233
|
+
else:
|
|
234
|
+
resolved_headers[k] = v
|
|
235
|
+
http_kwargs["headers"] = resolved_headers
|
|
226
236
|
# Create HTTP client if headers are provided but no client specified
|
|
227
237
|
|
|
228
238
|
self._pydantic_server = MCPServerStreamableHTTP(
|
|
@@ -243,8 +253,18 @@ class ManagedMCPServer:
|
|
|
243
253
|
Configured async HTTP client with custom headers
|
|
244
254
|
"""
|
|
245
255
|
headers = self.config.config.get("headers", {})
|
|
256
|
+
|
|
257
|
+
# Expand environment variables in headers
|
|
258
|
+
resolved_headers = {}
|
|
259
|
+
if isinstance(headers, dict):
|
|
260
|
+
for k, v in headers.items():
|
|
261
|
+
if isinstance(v, str):
|
|
262
|
+
resolved_headers[k] = os.path.expandvars(v)
|
|
263
|
+
else:
|
|
264
|
+
resolved_headers[k] = v
|
|
265
|
+
|
|
246
266
|
timeout = self.config.config.get("timeout", 30)
|
|
247
|
-
client = create_async_client(headers=
|
|
267
|
+
client = create_async_client(headers=resolved_headers, timeout=timeout)
|
|
248
268
|
return client
|
|
249
269
|
|
|
250
270
|
def enable(self) -> None:
|
code_puppy/mcp_/manager.py
CHANGED
|
@@ -78,11 +78,76 @@ class MCPManager:
|
|
|
78
78
|
# Active managed servers (server_id -> ManagedMCPServer)
|
|
79
79
|
self._managed_servers: Dict[str, ManagedMCPServer] = {}
|
|
80
80
|
|
|
81
|
+
# Sync servers from mcp_servers.json into registry
|
|
82
|
+
self.sync_from_config()
|
|
83
|
+
|
|
81
84
|
# Load existing servers from registry
|
|
82
85
|
self._initialize_servers()
|
|
83
86
|
|
|
84
87
|
logger.info("MCPManager initialized with core components")
|
|
85
88
|
|
|
89
|
+
def sync_from_config(self) -> None:
|
|
90
|
+
"""Sync servers from mcp_servers.json into the registry.
|
|
91
|
+
|
|
92
|
+
This public method ensures that servers defined in the user's
|
|
93
|
+
configuration file are automatically registered with the manager.
|
|
94
|
+
It can be called during initialization or manually to reload
|
|
95
|
+
server configurations.
|
|
96
|
+
|
|
97
|
+
This is the single source of truth for syncing mcp_servers.json
|
|
98
|
+
into the registry, avoiding duplication with base_agent.py.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
from code_puppy.config import load_mcp_server_configs
|
|
102
|
+
|
|
103
|
+
configs = load_mcp_server_configs()
|
|
104
|
+
if not configs:
|
|
105
|
+
logger.debug("No servers found in mcp_servers.json")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
synced_count = 0
|
|
109
|
+
updated_count = 0
|
|
110
|
+
|
|
111
|
+
for name, conf in configs.items():
|
|
112
|
+
try:
|
|
113
|
+
# Create ServerConfig from the loaded configuration
|
|
114
|
+
server_config = ServerConfig(
|
|
115
|
+
id=conf.get("id", ""), # Empty ID will be auto-generated
|
|
116
|
+
name=name,
|
|
117
|
+
type=conf.get("type", "sse"),
|
|
118
|
+
enabled=conf.get("enabled", True),
|
|
119
|
+
config=conf,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Check if server already exists by name
|
|
123
|
+
existing = self.registry.get_by_name(name)
|
|
124
|
+
|
|
125
|
+
if not existing:
|
|
126
|
+
# Register new server
|
|
127
|
+
self.registry.register(server_config)
|
|
128
|
+
synced_count += 1
|
|
129
|
+
logger.debug(f"Synced new server from config: {name}")
|
|
130
|
+
else:
|
|
131
|
+
# Update existing server if config has changed
|
|
132
|
+
if existing.config != server_config.config:
|
|
133
|
+
server_config.id = existing.id # Keep existing ID
|
|
134
|
+
self.registry.update(existing.id, server_config)
|
|
135
|
+
updated_count += 1
|
|
136
|
+
logger.debug(f"Updated server from config: {name}")
|
|
137
|
+
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.warning(f"Failed to sync server '{name}' from config: {e}")
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if synced_count > 0 or updated_count > 0:
|
|
143
|
+
logger.info(
|
|
144
|
+
f"Synced {synced_count} new and updated {updated_count} servers from mcp_servers.json"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
except Exception as e:
|
|
148
|
+
logger.error(f"Failed to sync from mcp_servers.json: {e}")
|
|
149
|
+
# Don't fail initialization if sync fails
|
|
150
|
+
|
|
86
151
|
def _initialize_servers(self) -> None:
|
|
87
152
|
"""Initialize managed servers from registry configurations."""
|
|
88
153
|
configs = self.registry.list_all()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Server Log Management.
|
|
3
|
+
|
|
4
|
+
This module provides persistent log file management for MCP servers.
|
|
5
|
+
Logs are stored in STATE_DIR/mcp_logs/<server_name>.log
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from code_puppy.config import STATE_DIR
|
|
13
|
+
|
|
14
|
+
# Maximum log file size in bytes (5MB)
|
|
15
|
+
MAX_LOG_SIZE = 5 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
# Number of rotated logs to keep
|
|
18
|
+
MAX_ROTATED_LOGS = 3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_mcp_logs_dir() -> Path:
|
|
22
|
+
"""
|
|
23
|
+
Get the directory for MCP server logs.
|
|
24
|
+
|
|
25
|
+
Creates the directory if it doesn't exist.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Path to the MCP logs directory
|
|
29
|
+
"""
|
|
30
|
+
logs_dir = Path(STATE_DIR) / "mcp_logs"
|
|
31
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
return logs_dir
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_log_file_path(server_name: str) -> Path:
|
|
36
|
+
"""
|
|
37
|
+
Get the log file path for a specific server.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
server_name: Name of the MCP server
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Path to the server's log file
|
|
44
|
+
"""
|
|
45
|
+
# Sanitize server name for filesystem
|
|
46
|
+
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
|
|
47
|
+
return get_mcp_logs_dir() / f"{safe_name}.log"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def rotate_log_if_needed(server_name: str) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Rotate log file if it exceeds MAX_LOG_SIZE.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
server_name: Name of the MCP server
|
|
56
|
+
"""
|
|
57
|
+
log_path = get_log_file_path(server_name)
|
|
58
|
+
|
|
59
|
+
if not log_path.exists():
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
# Check if rotation is needed
|
|
63
|
+
if log_path.stat().st_size < MAX_LOG_SIZE:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
logs_dir = get_mcp_logs_dir()
|
|
67
|
+
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
|
|
68
|
+
|
|
69
|
+
# Remove oldest rotated log if we're at the limit
|
|
70
|
+
oldest = logs_dir / f"{safe_name}.log.{MAX_ROTATED_LOGS}"
|
|
71
|
+
if oldest.exists():
|
|
72
|
+
oldest.unlink()
|
|
73
|
+
|
|
74
|
+
# Shift existing rotated logs
|
|
75
|
+
for i in range(MAX_ROTATED_LOGS - 1, 0, -1):
|
|
76
|
+
old_path = logs_dir / f"{safe_name}.log.{i}"
|
|
77
|
+
new_path = logs_dir / f"{safe_name}.log.{i + 1}"
|
|
78
|
+
if old_path.exists():
|
|
79
|
+
old_path.rename(new_path)
|
|
80
|
+
|
|
81
|
+
# Rotate current log
|
|
82
|
+
rotated_path = logs_dir / f"{safe_name}.log.1"
|
|
83
|
+
log_path.rename(rotated_path)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write_log(server_name: str, message: str, level: str = "INFO") -> None:
|
|
87
|
+
"""
|
|
88
|
+
Write a log message for a server.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
server_name: Name of the MCP server
|
|
92
|
+
message: Log message to write
|
|
93
|
+
level: Log level (INFO, ERROR, WARN, DEBUG)
|
|
94
|
+
"""
|
|
95
|
+
rotate_log_if_needed(server_name)
|
|
96
|
+
|
|
97
|
+
log_path = get_log_file_path(server_name)
|
|
98
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
99
|
+
|
|
100
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
101
|
+
f.write(f"[{timestamp}] [{level}] {message}\n")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_logs(
|
|
105
|
+
server_name: str, lines: Optional[int] = None, include_rotated: bool = False
|
|
106
|
+
) -> List[str]:
|
|
107
|
+
"""
|
|
108
|
+
Read log lines for a server.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
server_name: Name of the MCP server
|
|
112
|
+
lines: Number of lines to return (from end). None means all lines.
|
|
113
|
+
include_rotated: Whether to include rotated log files
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of log lines (most recent last)
|
|
117
|
+
"""
|
|
118
|
+
all_lines = []
|
|
119
|
+
|
|
120
|
+
# Read rotated logs first (oldest to newest)
|
|
121
|
+
if include_rotated:
|
|
122
|
+
logs_dir = get_mcp_logs_dir()
|
|
123
|
+
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
|
|
124
|
+
|
|
125
|
+
for i in range(MAX_ROTATED_LOGS, 0, -1):
|
|
126
|
+
rotated_path = logs_dir / f"{safe_name}.log.{i}"
|
|
127
|
+
if rotated_path.exists():
|
|
128
|
+
with open(rotated_path, "r", encoding="utf-8", errors="replace") as f:
|
|
129
|
+
all_lines.extend(f.read().splitlines())
|
|
130
|
+
|
|
131
|
+
# Read current log
|
|
132
|
+
log_path = get_log_file_path(server_name)
|
|
133
|
+
if log_path.exists():
|
|
134
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
135
|
+
all_lines.extend(f.read().splitlines())
|
|
136
|
+
|
|
137
|
+
# Return requested number of lines
|
|
138
|
+
if lines is not None and lines > 0:
|
|
139
|
+
return all_lines[-lines:]
|
|
140
|
+
|
|
141
|
+
return all_lines
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def clear_logs(server_name: str, include_rotated: bool = True) -> None:
|
|
145
|
+
"""
|
|
146
|
+
Clear logs for a server.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
server_name: Name of the MCP server
|
|
150
|
+
include_rotated: Whether to also clear rotated log files
|
|
151
|
+
"""
|
|
152
|
+
log_path = get_log_file_path(server_name)
|
|
153
|
+
|
|
154
|
+
if log_path.exists():
|
|
155
|
+
log_path.unlink()
|
|
156
|
+
|
|
157
|
+
if include_rotated:
|
|
158
|
+
logs_dir = get_mcp_logs_dir()
|
|
159
|
+
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
|
|
160
|
+
|
|
161
|
+
for i in range(1, MAX_ROTATED_LOGS + 1):
|
|
162
|
+
rotated_path = logs_dir / f"{safe_name}.log.{i}"
|
|
163
|
+
if rotated_path.exists():
|
|
164
|
+
rotated_path.unlink()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def list_servers_with_logs() -> List[str]:
|
|
168
|
+
"""
|
|
169
|
+
List all servers that have log files.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of server names with log files
|
|
173
|
+
"""
|
|
174
|
+
logs_dir = get_mcp_logs_dir()
|
|
175
|
+
servers = set()
|
|
176
|
+
|
|
177
|
+
for path in logs_dir.glob("*.log*"):
|
|
178
|
+
# Extract server name from filename
|
|
179
|
+
name = path.stem
|
|
180
|
+
# Remove .log suffix and rotation numbers
|
|
181
|
+
name = name.replace(".log", "").rstrip(".0123456789")
|
|
182
|
+
if name:
|
|
183
|
+
servers.add(name)
|
|
184
|
+
|
|
185
|
+
return sorted(servers)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_log_stats(server_name: str) -> dict:
|
|
189
|
+
"""
|
|
190
|
+
Get statistics about a server's logs.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
server_name: Name of the MCP server
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Dictionary with log statistics
|
|
197
|
+
"""
|
|
198
|
+
log_path = get_log_file_path(server_name)
|
|
199
|
+
|
|
200
|
+
stats = {
|
|
201
|
+
"exists": log_path.exists(),
|
|
202
|
+
"size_bytes": 0,
|
|
203
|
+
"line_count": 0,
|
|
204
|
+
"rotated_count": 0,
|
|
205
|
+
"total_size_bytes": 0,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if log_path.exists():
|
|
209
|
+
stats["size_bytes"] = log_path.stat().st_size
|
|
210
|
+
stats["total_size_bytes"] = stats["size_bytes"]
|
|
211
|
+
with open(log_path, "r", encoding="utf-8", errors="replace") as f:
|
|
212
|
+
stats["line_count"] = sum(1 for _ in f)
|
|
213
|
+
|
|
214
|
+
# Count rotated logs
|
|
215
|
+
logs_dir = get_mcp_logs_dir()
|
|
216
|
+
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in server_name)
|
|
217
|
+
|
|
218
|
+
for i in range(1, MAX_ROTATED_LOGS + 1):
|
|
219
|
+
rotated_path = logs_dir / f"{safe_name}.log.{i}"
|
|
220
|
+
if rotated_path.exists():
|
|
221
|
+
stats["rotated_count"] += 1
|
|
222
|
+
stats["total_size_bytes"] += rotated_path.stat().st_size
|
|
223
|
+
|
|
224
|
+
return stats
|