code-puppy 0.0.341__py3-none-any.whl → 0.0.348__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/agents/base_agent.py +17 -248
- code_puppy/agents/event_stream_handler.py +257 -0
- code_puppy/cli_runner.py +4 -3
- code_puppy/command_line/add_model_menu.py +8 -9
- code_puppy/command_line/mcp/catalog_server_installer.py +5 -6
- code_puppy/command_line/mcp/custom_server_form.py +54 -19
- code_puppy/command_line/mcp/custom_server_installer.py +8 -9
- code_puppy/command_line/mcp/handler.py +0 -2
- code_puppy/command_line/mcp/help_command.py +1 -5
- code_puppy/command_line/mcp/start_command.py +36 -18
- code_puppy/command_line/onboarding_slides.py +0 -1
- code_puppy/command_line/prompt_toolkit_completion.py +16 -10
- code_puppy/command_line/utils.py +54 -0
- code_puppy/mcp_/async_lifecycle.py +35 -4
- code_puppy/mcp_/managed_server.py +49 -20
- code_puppy/mcp_/manager.py +81 -52
- code_puppy/messaging/message_queue.py +11 -23
- code_puppy/tools/agent_tools.py +66 -13
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/METADATA +1 -1
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/RECORD +25 -25
- code_puppy/command_line/mcp/add_command.py +0 -170
- {code_puppy-0.0.341.data → code_puppy-0.0.348.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.341.data → code_puppy-0.0.348.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/licenses/LICENSE +0 -0
|
@@ -7,6 +7,7 @@ MCP servers from the catalog.
|
|
|
7
7
|
import os
|
|
8
8
|
from typing import Dict, Optional
|
|
9
9
|
|
|
10
|
+
from code_puppy.command_line.utils import safe_input
|
|
10
11
|
from code_puppy.messaging import emit_info, emit_success, emit_warning
|
|
11
12
|
|
|
12
13
|
# Helpful hints for common environment variables
|
|
@@ -52,7 +53,7 @@ def prompt_for_server_config(manager, server) -> Optional[Dict]:
|
|
|
52
53
|
# Get custom name
|
|
53
54
|
default_name = server.name
|
|
54
55
|
try:
|
|
55
|
-
name_input =
|
|
56
|
+
name_input = safe_input(f" Server name [{default_name}]: ")
|
|
56
57
|
server_name = name_input if name_input else default_name
|
|
57
58
|
except (KeyboardInterrupt, EOFError):
|
|
58
59
|
emit_info("")
|
|
@@ -63,9 +64,7 @@ def prompt_for_server_config(manager, server) -> Optional[Dict]:
|
|
|
63
64
|
existing = find_server_id_by_name(manager, server_name)
|
|
64
65
|
if existing:
|
|
65
66
|
try:
|
|
66
|
-
override =
|
|
67
|
-
f" Server '{server_name}' exists. Override? [y/N]: "
|
|
68
|
-
).strip()
|
|
67
|
+
override = safe_input(f" Server '{server_name}' exists. Override? [y/N]: ")
|
|
69
68
|
if not override.lower().startswith("y"):
|
|
70
69
|
emit_warning("Installation cancelled")
|
|
71
70
|
return None
|
|
@@ -91,7 +90,7 @@ def prompt_for_server_config(manager, server) -> Optional[Dict]:
|
|
|
91
90
|
hint = get_env_var_hint(var)
|
|
92
91
|
if hint:
|
|
93
92
|
emit_info(f" {hint}")
|
|
94
|
-
value =
|
|
93
|
+
value = safe_input(f" Enter {var}: ")
|
|
95
94
|
if value:
|
|
96
95
|
env_vars[var] = value
|
|
97
96
|
# Save to config for future use
|
|
@@ -119,7 +118,7 @@ def prompt_for_server_config(manager, server) -> Optional[Dict]:
|
|
|
119
118
|
prompt_str += " (optional)"
|
|
120
119
|
|
|
121
120
|
try:
|
|
122
|
-
value =
|
|
121
|
+
value = safe_input(f"{prompt_str}: ")
|
|
123
122
|
if value:
|
|
124
123
|
cmd_args[name] = value
|
|
125
124
|
elif default:
|
|
@@ -43,7 +43,7 @@ CUSTOM_SERVER_EXAMPLES = {
|
|
|
43
43
|
"type": "http",
|
|
44
44
|
"url": "http://localhost:8080/mcp",
|
|
45
45
|
"headers": {
|
|
46
|
-
"Authorization": "Bearer
|
|
46
|
+
"Authorization": "Bearer $MY_API_KEY",
|
|
47
47
|
"Content-Type": "application/json"
|
|
48
48
|
},
|
|
49
49
|
"timeout": 30
|
|
@@ -52,7 +52,7 @@ CUSTOM_SERVER_EXAMPLES = {
|
|
|
52
52
|
"type": "sse",
|
|
53
53
|
"url": "http://localhost:8080/sse",
|
|
54
54
|
"headers": {
|
|
55
|
-
"Authorization": "Bearer
|
|
55
|
+
"Authorization": "Bearer $MY_API_KEY"
|
|
56
56
|
}
|
|
57
57
|
}""",
|
|
58
58
|
}
|
|
@@ -367,24 +367,59 @@ class CustomServerForm:
|
|
|
367
367
|
config_dict = json.loads(self.json_config)
|
|
368
368
|
|
|
369
369
|
try:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
370
|
+
# In edit mode, find the existing server and update it
|
|
371
|
+
if self.edit_mode and self.original_name:
|
|
372
|
+
existing_config = self.manager.get_server_by_name(self.original_name)
|
|
373
|
+
if existing_config:
|
|
374
|
+
# Use the existing server's ID for the update
|
|
375
|
+
server_config = ServerConfig(
|
|
376
|
+
id=existing_config.id,
|
|
377
|
+
name=server_name,
|
|
378
|
+
type=server_type,
|
|
379
|
+
enabled=True,
|
|
380
|
+
config=config_dict,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Update the server in the manager
|
|
384
|
+
success = self.manager.update_server(
|
|
385
|
+
existing_config.id, server_config
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
if not success:
|
|
389
|
+
self.validation_error = "Failed to update server"
|
|
390
|
+
self.status_message = "Save failed: Could not update server"
|
|
391
|
+
self.status_is_error = True
|
|
392
|
+
return False
|
|
393
|
+
|
|
394
|
+
server_id = existing_config.id
|
|
395
|
+
else:
|
|
396
|
+
# Original server not found, treat as new registration
|
|
397
|
+
server_config = ServerConfig(
|
|
398
|
+
id=server_name,
|
|
399
|
+
name=server_name,
|
|
400
|
+
type=server_type,
|
|
401
|
+
enabled=True,
|
|
402
|
+
config=config_dict,
|
|
403
|
+
)
|
|
404
|
+
server_id = self.manager.register_server(server_config)
|
|
405
|
+
else:
|
|
406
|
+
# New server - register it
|
|
407
|
+
server_config = ServerConfig(
|
|
408
|
+
id=server_name,
|
|
409
|
+
name=server_name,
|
|
410
|
+
type=server_type,
|
|
411
|
+
enabled=True,
|
|
412
|
+
config=config_dict,
|
|
385
413
|
)
|
|
386
|
-
|
|
387
|
-
|
|
414
|
+
|
|
415
|
+
# Register with manager
|
|
416
|
+
server_id = self.manager.register_server(server_config)
|
|
417
|
+
|
|
418
|
+
if not server_id:
|
|
419
|
+
self.validation_error = "Failed to register server"
|
|
420
|
+
self.status_message = "Save failed: Could not register server (name may already exist)"
|
|
421
|
+
self.status_is_error = True
|
|
422
|
+
return False
|
|
388
423
|
|
|
389
424
|
# Save to mcp_servers.json for persistence
|
|
390
425
|
if os.path.exists(MCP_SERVERS_FILE):
|
|
@@ -7,6 +7,7 @@ custom MCP servers with JSON configuration.
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
|
|
10
|
+
from code_puppy.command_line.utils import safe_input
|
|
10
11
|
from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
|
|
11
12
|
|
|
12
13
|
# Example configurations for each server type
|
|
@@ -24,7 +25,7 @@ CUSTOM_SERVER_EXAMPLES = {
|
|
|
24
25
|
"type": "http",
|
|
25
26
|
"url": "http://localhost:8080/mcp",
|
|
26
27
|
"headers": {
|
|
27
|
-
"Authorization": "Bearer
|
|
28
|
+
"Authorization": "Bearer $MY_API_KEY",
|
|
28
29
|
"Content-Type": "application/json"
|
|
29
30
|
},
|
|
30
31
|
"timeout": 30
|
|
@@ -33,7 +34,7 @@ CUSTOM_SERVER_EXAMPLES = {
|
|
|
33
34
|
"type": "sse",
|
|
34
35
|
"url": "http://localhost:8080/sse",
|
|
35
36
|
"headers": {
|
|
36
|
-
"Authorization": "Bearer
|
|
37
|
+
"Authorization": "Bearer $MY_API_KEY"
|
|
37
38
|
}
|
|
38
39
|
}""",
|
|
39
40
|
}
|
|
@@ -58,7 +59,7 @@ def prompt_and_install_custom_server(manager) -> bool:
|
|
|
58
59
|
|
|
59
60
|
# Get server name
|
|
60
61
|
try:
|
|
61
|
-
server_name =
|
|
62
|
+
server_name = safe_input(" Server name: ")
|
|
62
63
|
if not server_name:
|
|
63
64
|
emit_warning("Server name is required")
|
|
64
65
|
return False
|
|
@@ -71,9 +72,7 @@ def prompt_and_install_custom_server(manager) -> bool:
|
|
|
71
72
|
existing = find_server_id_by_name(manager, server_name)
|
|
72
73
|
if existing:
|
|
73
74
|
try:
|
|
74
|
-
override =
|
|
75
|
-
f" Server '{server_name}' exists. Override? [y/N]: "
|
|
76
|
-
).strip()
|
|
75
|
+
override = safe_input(f" Server '{server_name}' exists. Override? [y/N]: ")
|
|
77
76
|
if not override.lower().startswith("y"):
|
|
78
77
|
emit_warning("Cancelled")
|
|
79
78
|
return False
|
|
@@ -89,7 +88,7 @@ def prompt_and_install_custom_server(manager) -> bool:
|
|
|
89
88
|
emit_info(" 3. 📡 sse - Server-Sent Events\n")
|
|
90
89
|
|
|
91
90
|
try:
|
|
92
|
-
type_choice =
|
|
91
|
+
type_choice = safe_input(" Enter choice [1-3]: ")
|
|
93
92
|
except (KeyboardInterrupt, EOFError):
|
|
94
93
|
emit_info("")
|
|
95
94
|
emit_warning("Cancelled")
|
|
@@ -115,8 +114,8 @@ def prompt_and_install_custom_server(manager) -> bool:
|
|
|
115
114
|
empty_count = 0
|
|
116
115
|
try:
|
|
117
116
|
while True:
|
|
118
|
-
line =
|
|
119
|
-
if line
|
|
117
|
+
line = safe_input("")
|
|
118
|
+
if line == "":
|
|
120
119
|
empty_count += 1
|
|
121
120
|
if empty_count >= 2:
|
|
122
121
|
break
|
|
@@ -12,7 +12,6 @@ from rich.text import Text
|
|
|
12
12
|
|
|
13
13
|
from code_puppy.messaging import emit_info
|
|
14
14
|
|
|
15
|
-
from .add_command import AddCommand
|
|
16
15
|
from .base import MCPCommandBase
|
|
17
16
|
from .edit_command import EditCommand
|
|
18
17
|
from .help_command import HelpCommand
|
|
@@ -63,7 +62,6 @@ class MCPCommandHandler(MCPCommandBase):
|
|
|
63
62
|
"restart": RestartCommand(),
|
|
64
63
|
"status": StatusCommand(),
|
|
65
64
|
"test": TestCommand(),
|
|
66
|
-
"add": AddCommand(),
|
|
67
65
|
"edit": EditCommand(),
|
|
68
66
|
"remove": RemoveCommand(),
|
|
69
67
|
"logs": LogsCommand(),
|
|
@@ -101,10 +101,6 @@ class HelpCommand(MCPCommandBase):
|
|
|
101
101
|
Text("/mcp logs", style="cyan")
|
|
102
102
|
+ Text(" <name> [limit] Show recent events (default limit: 10)")
|
|
103
103
|
)
|
|
104
|
-
help_lines.append(
|
|
105
|
-
Text("/mcp add", style="cyan")
|
|
106
|
-
+ Text(" [json] Add new server (JSON or wizard)")
|
|
107
|
-
)
|
|
108
104
|
help_lines.append(
|
|
109
105
|
Text("/mcp edit", style="cyan")
|
|
110
106
|
+ Text(" <name> Edit existing server config")
|
|
@@ -134,7 +130,7 @@ class HelpCommand(MCPCommandBase):
|
|
|
134
130
|
/mcp start-all # Start all servers at once
|
|
135
131
|
/mcp stop-all # Stop all running servers
|
|
136
132
|
/mcp edit filesystem # Edit an existing server config
|
|
137
|
-
/mcp
|
|
133
|
+
/mcp remove filesystem # Remove a server"""
|
|
138
134
|
help_lines.append(Text(examples_text, style="dim"))
|
|
139
135
|
|
|
140
136
|
# Combine all lines
|
|
@@ -3,7 +3,6 @@ MCP Start Command - Starts a specific MCP server.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import logging
|
|
6
|
-
import time
|
|
7
6
|
from typing import List, Optional
|
|
8
7
|
|
|
9
8
|
from rich.text import Text
|
|
@@ -23,6 +22,7 @@ class StartCommand(MCPCommandBase):
|
|
|
23
22
|
Command handler for starting MCP servers.
|
|
24
23
|
|
|
25
24
|
Starts a specific MCP server by name and reloads the agent.
|
|
25
|
+
The server subprocess starts asynchronously in the background.
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
def execute(self, args: List[str], group_id: Optional[str] = None) -> None:
|
|
@@ -56,31 +56,49 @@ class StartCommand(MCPCommandBase):
|
|
|
56
56
|
suggest_similar_servers(self.manager, server_name, group_id=group_id)
|
|
57
57
|
return
|
|
58
58
|
|
|
59
|
-
#
|
|
59
|
+
# Get server info for better messaging (safely handle missing method)
|
|
60
|
+
server_type = "unknown"
|
|
61
|
+
try:
|
|
62
|
+
if hasattr(self.manager, "get_server_by_name"):
|
|
63
|
+
server_config = self.manager.get_server_by_name(server_name)
|
|
64
|
+
server_type = (
|
|
65
|
+
getattr(server_config, "type", "unknown")
|
|
66
|
+
if server_config
|
|
67
|
+
else "unknown"
|
|
68
|
+
)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass # Default to unknown type if we can't determine it
|
|
71
|
+
|
|
72
|
+
# Start the server (schedules async start in background)
|
|
60
73
|
success = self.manager.start_server_sync(server_id)
|
|
61
74
|
|
|
62
75
|
if success:
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
if server_type == "stdio":
|
|
77
|
+
# Stdio servers start subprocess asynchronously
|
|
78
|
+
emit_success(
|
|
79
|
+
f"🚀 Starting server: {server_name} (subprocess starting in background)",
|
|
80
|
+
message_group=group_id,
|
|
81
|
+
)
|
|
82
|
+
emit_info(
|
|
83
|
+
Text.from_markup(
|
|
84
|
+
"[dim]Tip: Use /mcp status to check if the server is fully initialized[/dim]"
|
|
85
|
+
),
|
|
86
|
+
message_group=group_id,
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
# SSE/HTTP servers connect on first use
|
|
90
|
+
emit_success(
|
|
91
|
+
f"✅ Enabled server: {server_name}",
|
|
92
|
+
message_group=group_id,
|
|
93
|
+
)
|
|
78
94
|
|
|
79
95
|
# Reload the agent to pick up the newly enabled server
|
|
96
|
+
# NOTE: We don't block or wait - the server will be ready
|
|
97
|
+
# when the next prompt runs (pydantic-ai handles connection)
|
|
80
98
|
try:
|
|
81
99
|
agent = get_current_agent()
|
|
82
100
|
agent.reload_code_generation_agent()
|
|
83
|
-
#
|
|
101
|
+
# Clear MCP tool cache - it will be repopulated on next run
|
|
84
102
|
agent.update_mcp_tool_cache_sync()
|
|
85
103
|
emit_info(
|
|
86
104
|
"Agent reloaded with updated servers",
|
|
@@ -122,7 +122,6 @@ def slide_mcp() -> str:
|
|
|
122
122
|
content += "[white]Supercharge with external tools![/white]\n\n"
|
|
123
123
|
content += "[green]Commands:[/green]\n"
|
|
124
124
|
content += " [cyan]/mcp install[/cyan] Browse catalog\n"
|
|
125
|
-
content += " [cyan]/mcp add[/cyan] Add custom server\n"
|
|
126
125
|
content += " [cyan]/mcp list[/cyan] See your servers\n\n"
|
|
127
126
|
content += "[yellow]🌟 Popular picks:[/yellow]\n"
|
|
128
127
|
content += " • GitHub integration\n"
|
|
@@ -648,30 +648,36 @@ async def get_input_with_combined_completion(
|
|
|
648
648
|
else:
|
|
649
649
|
event.current_buffer.validate_and_handle()
|
|
650
650
|
|
|
651
|
-
# Handle bracketed paste
|
|
652
|
-
#
|
|
651
|
+
# Handle bracketed paste - smart detection for text vs images.
|
|
652
|
+
# Most terminals (Windows included!) send Ctrl+V through bracketed paste.
|
|
653
|
+
# - If there's meaningful text content → paste as text (drag-and-drop file paths, copied text)
|
|
654
|
+
# - If text is empty/whitespace → check for clipboard image (image paste on Windows)
|
|
653
655
|
@bindings.add(Keys.BracketedPaste)
|
|
654
656
|
def handle_bracketed_paste(event):
|
|
655
|
-
"""Handle bracketed paste -
|
|
656
|
-
# The pasted data is in event.data
|
|
657
|
+
"""Handle bracketed paste - smart text vs image detection."""
|
|
657
658
|
pasted_data = event.data
|
|
658
659
|
|
|
659
|
-
#
|
|
660
|
+
# If we have meaningful text content, paste it (don't check for images)
|
|
661
|
+
# This handles drag-and-drop file paths and normal text paste
|
|
662
|
+
if pasted_data and pasted_data.strip():
|
|
663
|
+
# Normalize Windows line endings to Unix style
|
|
664
|
+
sanitized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
|
|
665
|
+
event.app.current_buffer.insert_text(sanitized_data)
|
|
666
|
+
return
|
|
667
|
+
|
|
668
|
+
# No meaningful text - check if clipboard has an image (Windows image paste!)
|
|
660
669
|
try:
|
|
661
670
|
if has_image_in_clipboard():
|
|
662
671
|
placeholder = capture_clipboard_image_to_pending()
|
|
663
672
|
if placeholder:
|
|
664
673
|
event.app.current_buffer.insert_text(placeholder + " ")
|
|
665
|
-
# The placeholder itself is visible feedback - no need for extra output
|
|
666
|
-
# Use bell for audible feedback (works in most terminals)
|
|
667
674
|
event.app.output.bell()
|
|
668
|
-
return
|
|
675
|
+
return
|
|
669
676
|
except Exception:
|
|
670
677
|
pass
|
|
671
678
|
|
|
672
|
-
#
|
|
679
|
+
# Fallback: if there was whitespace-only data, paste it
|
|
673
680
|
if pasted_data:
|
|
674
|
-
# Normalize Windows line endings to Unix style
|
|
675
681
|
sanitized_data = pasted_data.replace("\r\n", "\n").replace("\r", "\n")
|
|
676
682
|
event.app.current_buffer.insert_text(sanitized_data)
|
|
677
683
|
|
code_puppy/command_line/utils.py
CHANGED
|
@@ -37,3 +37,57 @@ def make_directory_table(path: str = None) -> Table:
|
|
|
37
37
|
for f in sorted(files):
|
|
38
38
|
table.add_row("[yellow]file[/yellow]", f"{f}")
|
|
39
39
|
return table
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _reset_windows_console() -> None:
|
|
43
|
+
"""Reset Windows console to normal input mode.
|
|
44
|
+
|
|
45
|
+
After a prompt_toolkit Application exits on Windows, the console can be
|
|
46
|
+
left in a weird state where Enter doesn't work properly. This resets it.
|
|
47
|
+
"""
|
|
48
|
+
import sys
|
|
49
|
+
|
|
50
|
+
if sys.platform != "win32":
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
import ctypes
|
|
55
|
+
|
|
56
|
+
kernel32 = ctypes.windll.kernel32
|
|
57
|
+
# Get handle to stdin
|
|
58
|
+
STD_INPUT_HANDLE = -10
|
|
59
|
+
handle = kernel32.GetStdHandle(STD_INPUT_HANDLE)
|
|
60
|
+
|
|
61
|
+
# Enable line input and echo (normal console mode)
|
|
62
|
+
# ENABLE_LINE_INPUT = 0x0002
|
|
63
|
+
# ENABLE_ECHO_INPUT = 0x0004
|
|
64
|
+
# ENABLE_PROCESSED_INPUT = 0x0001
|
|
65
|
+
NORMAL_MODE = 0x0007 # Line input + echo + processed
|
|
66
|
+
kernel32.SetConsoleMode(handle, NORMAL_MODE)
|
|
67
|
+
except Exception:
|
|
68
|
+
pass # Silently ignore errors - this is best-effort
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def safe_input(prompt_text: str = "") -> str:
|
|
72
|
+
"""Cross-platform safe input that works after prompt_toolkit Applications.
|
|
73
|
+
|
|
74
|
+
On Windows, raw input() can fail after a prompt_toolkit Application exits
|
|
75
|
+
because the terminal can be left in a weird state. This function resets
|
|
76
|
+
the Windows console mode before calling input().
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
prompt_text: The prompt to display to the user
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The user's input string (stripped)
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
KeyboardInterrupt: If user presses Ctrl+C
|
|
86
|
+
EOFError: If user presses Ctrl+D/Ctrl+Z
|
|
87
|
+
"""
|
|
88
|
+
# Reset Windows console to normal mode before reading input
|
|
89
|
+
_reset_windows_console()
|
|
90
|
+
|
|
91
|
+
# Use standard input() - now that console is reset, it should work
|
|
92
|
+
result = input(prompt_text)
|
|
93
|
+
return result.strip() if result else ""
|
|
@@ -108,10 +108,17 @@ class AsyncServerLifecycleManager:
|
|
|
108
108
|
|
|
109
109
|
try:
|
|
110
110
|
logger.info(f"Starting server lifecycle for {server_id}")
|
|
111
|
+
logger.info(
|
|
112
|
+
f"Server {server_id} _running_count before enter: {getattr(server, '_running_count', 'N/A')}"
|
|
113
|
+
)
|
|
111
114
|
|
|
112
115
|
# Enter the server's context
|
|
113
116
|
await exit_stack.enter_async_context(server)
|
|
114
117
|
|
|
118
|
+
logger.info(
|
|
119
|
+
f"Server {server_id} _running_count after enter: {getattr(server, '_running_count', 'N/A')}"
|
|
120
|
+
)
|
|
121
|
+
|
|
115
122
|
# Store the managed context
|
|
116
123
|
async with self._lock:
|
|
117
124
|
self._servers[server_id] = ManagedServerContext(
|
|
@@ -122,26 +129,50 @@ class AsyncServerLifecycleManager:
|
|
|
122
129
|
task=asyncio.current_task(),
|
|
123
130
|
)
|
|
124
131
|
|
|
125
|
-
logger.info(
|
|
132
|
+
logger.info(
|
|
133
|
+
f"Server {server_id} started successfully and stored in _servers"
|
|
134
|
+
)
|
|
126
135
|
|
|
127
136
|
# Keep the task alive until cancelled
|
|
137
|
+
loop_count = 0
|
|
128
138
|
while True:
|
|
129
139
|
await asyncio.sleep(1)
|
|
140
|
+
loop_count += 1
|
|
130
141
|
|
|
131
142
|
# Check if server is still running
|
|
132
|
-
|
|
133
|
-
|
|
143
|
+
running_count = getattr(server, "_running_count", "N/A")
|
|
144
|
+
is_running = server.is_running
|
|
145
|
+
logger.debug(
|
|
146
|
+
f"Server {server_id} heartbeat #{loop_count}: "
|
|
147
|
+
f"is_running={is_running}, _running_count={running_count}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if not is_running:
|
|
151
|
+
logger.warning(
|
|
152
|
+
f"Server {server_id} stopped unexpectedly! "
|
|
153
|
+
f"_running_count={running_count}"
|
|
154
|
+
)
|
|
134
155
|
break
|
|
135
156
|
|
|
136
157
|
except asyncio.CancelledError:
|
|
137
158
|
logger.info(f"Server {server_id} lifecycle task cancelled")
|
|
138
159
|
raise
|
|
139
160
|
except Exception as e:
|
|
140
|
-
logger.error(f"Error in server {server_id} lifecycle: {e}")
|
|
161
|
+
logger.error(f"Error in server {server_id} lifecycle: {e}", exc_info=True)
|
|
141
162
|
finally:
|
|
163
|
+
running_count = getattr(server, "_running_count", "N/A")
|
|
164
|
+
logger.info(
|
|
165
|
+
f"Server {server_id} lifecycle ending, _running_count={running_count}"
|
|
166
|
+
)
|
|
167
|
+
|
|
142
168
|
# Clean up the context
|
|
143
169
|
await exit_stack.aclose()
|
|
144
170
|
|
|
171
|
+
running_count_after = getattr(server, "_running_count", "N/A")
|
|
172
|
+
logger.info(
|
|
173
|
+
f"Server {server_id} context closed, _running_count={running_count_after}"
|
|
174
|
+
)
|
|
175
|
+
|
|
145
176
|
# Remove from managed servers
|
|
146
177
|
async with self._lock:
|
|
147
178
|
if server_id in self._servers:
|
|
@@ -28,6 +28,31 @@ from code_puppy.mcp_.blocking_startup import BlockingMCPServerStdio
|
|
|
28
28
|
from code_puppy.messaging import emit_info
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def _expand_env_vars(value: Any) -> Any:
|
|
32
|
+
"""
|
|
33
|
+
Recursively expand environment variables in config values.
|
|
34
|
+
|
|
35
|
+
Supports $VAR and ${VAR} syntax. Works with:
|
|
36
|
+
- Strings: expands env vars
|
|
37
|
+
- Dicts: recursively expands all string values
|
|
38
|
+
- Lists: recursively expands all string elements
|
|
39
|
+
- Other types: returned as-is
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
value: The value to expand env vars in
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
The value with env vars expanded
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
return os.path.expandvars(value)
|
|
49
|
+
elif isinstance(value, dict):
|
|
50
|
+
return {k: _expand_env_vars(v) for k, v in value.items()}
|
|
51
|
+
elif isinstance(value, list):
|
|
52
|
+
return [_expand_env_vars(item) for item in value]
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
31
56
|
class ServerState(Enum):
|
|
32
57
|
"""Enumeration of possible server states."""
|
|
33
58
|
|
|
@@ -153,9 +178,9 @@ class ManagedMCPServer:
|
|
|
153
178
|
if "url" not in config:
|
|
154
179
|
raise ValueError("SSE server requires 'url' in config")
|
|
155
180
|
|
|
156
|
-
# Prepare arguments for MCPServerSSE
|
|
181
|
+
# Prepare arguments for MCPServerSSE (expand env vars in URL)
|
|
157
182
|
sse_kwargs = {
|
|
158
|
-
"url": config["url"],
|
|
183
|
+
"url": _expand_env_vars(config["url"]),
|
|
159
184
|
}
|
|
160
185
|
|
|
161
186
|
# Add optional parameters if provided
|
|
@@ -177,23 +202,26 @@ class ManagedMCPServer:
|
|
|
177
202
|
if "command" not in config:
|
|
178
203
|
raise ValueError("Stdio server requires 'command' in config")
|
|
179
204
|
|
|
180
|
-
# Handle command and arguments
|
|
181
|
-
command = config["command"]
|
|
205
|
+
# Handle command and arguments (expand env vars)
|
|
206
|
+
command = _expand_env_vars(config["command"])
|
|
182
207
|
args = config.get("args", [])
|
|
183
208
|
if isinstance(args, str):
|
|
184
|
-
# If args is a string, split it
|
|
185
|
-
args = args.split()
|
|
209
|
+
# If args is a string, split it then expand
|
|
210
|
+
args = [_expand_env_vars(a) for a in args.split()]
|
|
211
|
+
else:
|
|
212
|
+
args = _expand_env_vars(args)
|
|
186
213
|
|
|
187
214
|
# Prepare arguments for MCPServerStdio
|
|
188
215
|
stdio_kwargs = {"command": command, "args": list(args) if args else []}
|
|
189
216
|
|
|
190
|
-
# Add optional parameters if provided
|
|
217
|
+
# Add optional parameters if provided (expand env vars in env and cwd)
|
|
191
218
|
if "env" in config:
|
|
192
|
-
stdio_kwargs["env"] = config["env"]
|
|
219
|
+
stdio_kwargs["env"] = _expand_env_vars(config["env"])
|
|
193
220
|
if "cwd" in config:
|
|
194
|
-
stdio_kwargs["cwd"] = config["cwd"]
|
|
195
|
-
|
|
196
|
-
|
|
221
|
+
stdio_kwargs["cwd"] = _expand_env_vars(config["cwd"])
|
|
222
|
+
# Default timeout of 60s for stdio servers - some servers like Serena take a while to start
|
|
223
|
+
# Users can override this in their config
|
|
224
|
+
stdio_kwargs["timeout"] = config.get("timeout", 60)
|
|
197
225
|
if "read_timeout" in config:
|
|
198
226
|
stdio_kwargs["read_timeout"] = config["read_timeout"]
|
|
199
227
|
|
|
@@ -212,9 +240,9 @@ class ManagedMCPServer:
|
|
|
212
240
|
if "url" not in config:
|
|
213
241
|
raise ValueError("HTTP server requires 'url' in config")
|
|
214
242
|
|
|
215
|
-
# Prepare arguments for MCPServerStreamableHTTP
|
|
243
|
+
# Prepare arguments for MCPServerStreamableHTTP (expand env vars in URL)
|
|
216
244
|
http_kwargs = {
|
|
217
|
-
"url": config["url"],
|
|
245
|
+
"url": _expand_env_vars(config["url"]),
|
|
218
246
|
}
|
|
219
247
|
|
|
220
248
|
# Add optional parameters if provided
|
|
@@ -223,13 +251,14 @@ class ManagedMCPServer:
|
|
|
223
251
|
if "read_timeout" in config:
|
|
224
252
|
http_kwargs["read_timeout"] = config["read_timeout"]
|
|
225
253
|
|
|
226
|
-
#
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
254
|
+
# Pass headers directly instead of creating http_client
|
|
255
|
+
# Note: There's a bug in MCP 1.25.0 where passing http_client
|
|
256
|
+
# causes "'_AsyncGeneratorContextManager' object has no attribute 'stream'"
|
|
257
|
+
# The workaround is to pass headers directly and let pydantic-ai
|
|
258
|
+
# create the http_client internally.
|
|
259
|
+
if config.get("headers"):
|
|
260
|
+
# Expand environment variables in headers
|
|
261
|
+
http_kwargs["headers"] = _expand_env_vars(config["headers"])
|
|
233
262
|
|
|
234
263
|
self._pydantic_server = MCPServerStreamableHTTP(
|
|
235
264
|
**http_kwargs, process_tool_call=process_tool_call
|