code-puppy 0.0.126__py3-none-any.whl → 0.0.128__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_puppy/__init__.py +1 -0
- code_puppy/agent.py +65 -69
- code_puppy/agents/agent_code_puppy.py +0 -3
- code_puppy/agents/runtime_manager.py +212 -0
- code_puppy/command_line/command_handler.py +56 -25
- code_puppy/command_line/mcp_commands.py +1298 -0
- code_puppy/command_line/meta_command_handler.py +3 -2
- code_puppy/command_line/model_picker_completion.py +21 -8
- code_puppy/main.py +52 -157
- code_puppy/mcp/__init__.py +23 -0
- code_puppy/mcp/async_lifecycle.py +237 -0
- code_puppy/mcp/circuit_breaker.py +218 -0
- code_puppy/mcp/config_wizard.py +437 -0
- code_puppy/mcp/dashboard.py +291 -0
- code_puppy/mcp/error_isolation.py +360 -0
- code_puppy/mcp/examples/retry_example.py +208 -0
- code_puppy/mcp/health_monitor.py +549 -0
- code_puppy/mcp/managed_server.py +346 -0
- code_puppy/mcp/manager.py +701 -0
- code_puppy/mcp/registry.py +412 -0
- code_puppy/mcp/retry_manager.py +321 -0
- code_puppy/mcp/server_registry_catalog.py +751 -0
- code_puppy/mcp/status_tracker.py +355 -0
- code_puppy/messaging/spinner/textual_spinner.py +6 -2
- code_puppy/model_factory.py +19 -4
- code_puppy/models.json +22 -4
- code_puppy/tui/app.py +19 -27
- code_puppy/tui/tests/test_agent_command.py +22 -15
- {code_puppy-0.0.126.data → code_puppy-0.0.128.data}/data/code_puppy/models.json +22 -4
- {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/METADATA +2 -3
- {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/RECORD +34 -18
- {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,218 @@
|
|
|
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 time
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from typing import Any, Callable
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CircuitState(Enum):
|
|
21
|
+
"""Circuit breaker states."""
|
|
22
|
+
CLOSED = "closed" # Normal operation
|
|
23
|
+
OPEN = "open" # Blocking calls
|
|
24
|
+
HALF_OPEN = "half_open" # Testing recovery
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CircuitOpenError(Exception):
|
|
28
|
+
"""Raised when circuit breaker is in OPEN state."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CircuitBreaker:
|
|
33
|
+
"""
|
|
34
|
+
Circuit breaker to prevent cascading failures in MCP servers.
|
|
35
|
+
|
|
36
|
+
The circuit breaker monitors the success/failure rate of operations and
|
|
37
|
+
transitions between states to protect the system from unhealthy dependencies.
|
|
38
|
+
|
|
39
|
+
States:
|
|
40
|
+
- CLOSED: Normal operation, all calls allowed
|
|
41
|
+
- OPEN: Circuit is open, all calls fail fast with CircuitOpenError
|
|
42
|
+
- HALF_OPEN: Testing recovery, limited calls allowed
|
|
43
|
+
|
|
44
|
+
State Transitions:
|
|
45
|
+
- CLOSED → OPEN: After failure_threshold consecutive failures
|
|
46
|
+
- OPEN → HALF_OPEN: After timeout seconds
|
|
47
|
+
- HALF_OPEN → CLOSED: After success_threshold consecutive successes
|
|
48
|
+
- HALF_OPEN → OPEN: After any failure
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, failure_threshold: int = 5, success_threshold: int = 2, timeout: int = 60):
|
|
52
|
+
"""
|
|
53
|
+
Initialize circuit breaker.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
failure_threshold: Number of consecutive failures before opening circuit
|
|
57
|
+
success_threshold: Number of consecutive successes needed to close circuit from half-open
|
|
58
|
+
timeout: Seconds to wait before transitioning from OPEN to HALF_OPEN
|
|
59
|
+
"""
|
|
60
|
+
self.failure_threshold = failure_threshold
|
|
61
|
+
self.success_threshold = success_threshold
|
|
62
|
+
self.timeout = timeout
|
|
63
|
+
|
|
64
|
+
self._state = CircuitState.CLOSED
|
|
65
|
+
self._failure_count = 0
|
|
66
|
+
self._success_count = 0
|
|
67
|
+
self._last_failure_time = None
|
|
68
|
+
self._lock = asyncio.Lock()
|
|
69
|
+
|
|
70
|
+
logger.info(
|
|
71
|
+
f"Circuit breaker initialized: failure_threshold={failure_threshold}, "
|
|
72
|
+
f"success_threshold={success_threshold}, timeout={timeout}s"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def call(self, func: Callable, *args, **kwargs) -> Any:
|
|
76
|
+
"""
|
|
77
|
+
Execute a function through the circuit breaker.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
func: Function to execute
|
|
81
|
+
*args: Positional arguments for the function
|
|
82
|
+
**kwargs: Keyword arguments for the function
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Result of the function call
|
|
86
|
+
|
|
87
|
+
Raises:
|
|
88
|
+
CircuitOpenError: If circuit is in OPEN state
|
|
89
|
+
Exception: Any exception raised by the wrapped function
|
|
90
|
+
"""
|
|
91
|
+
async with self._lock:
|
|
92
|
+
current_state = self._get_current_state()
|
|
93
|
+
|
|
94
|
+
if current_state == CircuitState.OPEN:
|
|
95
|
+
logger.warning("Circuit breaker is OPEN, failing fast")
|
|
96
|
+
raise CircuitOpenError("Circuit breaker is open")
|
|
97
|
+
|
|
98
|
+
if current_state == CircuitState.HALF_OPEN:
|
|
99
|
+
# In half-open state, we're testing recovery
|
|
100
|
+
logger.info("Circuit breaker is HALF_OPEN, allowing test call")
|
|
101
|
+
|
|
102
|
+
# Execute the function outside the lock to avoid blocking other calls
|
|
103
|
+
try:
|
|
104
|
+
result = await func(*args, **kwargs) if asyncio.iscoroutinefunction(func) else func(*args, **kwargs)
|
|
105
|
+
await self._on_success()
|
|
106
|
+
return result
|
|
107
|
+
except Exception as e:
|
|
108
|
+
await self._on_failure()
|
|
109
|
+
raise e
|
|
110
|
+
|
|
111
|
+
def record_success(self) -> None:
|
|
112
|
+
"""Record a successful operation."""
|
|
113
|
+
asyncio.create_task(self._on_success())
|
|
114
|
+
|
|
115
|
+
def record_failure(self) -> None:
|
|
116
|
+
"""Record a failed operation."""
|
|
117
|
+
asyncio.create_task(self._on_failure())
|
|
118
|
+
|
|
119
|
+
def get_state(self) -> CircuitState:
|
|
120
|
+
"""Get current circuit breaker state."""
|
|
121
|
+
return self._get_current_state()
|
|
122
|
+
|
|
123
|
+
def is_open(self) -> bool:
|
|
124
|
+
"""Check if circuit breaker is in OPEN state."""
|
|
125
|
+
return self._get_current_state() == CircuitState.OPEN
|
|
126
|
+
|
|
127
|
+
def is_half_open(self) -> bool:
|
|
128
|
+
"""Check if circuit breaker is in HALF_OPEN state."""
|
|
129
|
+
return self._get_current_state() == CircuitState.HALF_OPEN
|
|
130
|
+
|
|
131
|
+
def is_closed(self) -> bool:
|
|
132
|
+
"""Check if circuit breaker is in CLOSED state."""
|
|
133
|
+
return self._get_current_state() == CircuitState.CLOSED
|
|
134
|
+
|
|
135
|
+
def reset(self) -> None:
|
|
136
|
+
"""Reset circuit breaker to CLOSED state and clear counters."""
|
|
137
|
+
logger.info("Resetting circuit breaker to CLOSED state")
|
|
138
|
+
self._state = CircuitState.CLOSED
|
|
139
|
+
self._failure_count = 0
|
|
140
|
+
self._success_count = 0
|
|
141
|
+
self._last_failure_time = None
|
|
142
|
+
|
|
143
|
+
def force_open(self) -> None:
|
|
144
|
+
"""Force circuit breaker to OPEN state."""
|
|
145
|
+
logger.warning("Forcing circuit breaker to OPEN state")
|
|
146
|
+
self._state = CircuitState.OPEN
|
|
147
|
+
self._last_failure_time = time.time()
|
|
148
|
+
|
|
149
|
+
def force_close(self) -> None:
|
|
150
|
+
"""Force circuit breaker to CLOSED state and reset counters."""
|
|
151
|
+
logger.info("Forcing circuit breaker to CLOSED state")
|
|
152
|
+
self._state = CircuitState.CLOSED
|
|
153
|
+
self._failure_count = 0
|
|
154
|
+
self._success_count = 0
|
|
155
|
+
self._last_failure_time = None
|
|
156
|
+
|
|
157
|
+
def _get_current_state(self) -> CircuitState:
|
|
158
|
+
"""
|
|
159
|
+
Get the current state, handling automatic transitions.
|
|
160
|
+
|
|
161
|
+
This method handles the automatic transition from OPEN to HALF_OPEN
|
|
162
|
+
after the timeout period has elapsed.
|
|
163
|
+
"""
|
|
164
|
+
if self._state == CircuitState.OPEN and self._should_attempt_reset():
|
|
165
|
+
logger.info("Timeout reached, transitioning from OPEN to HALF_OPEN")
|
|
166
|
+
self._state = CircuitState.HALF_OPEN
|
|
167
|
+
self._success_count = 0 # Reset success counter for half-open testing
|
|
168
|
+
|
|
169
|
+
return self._state
|
|
170
|
+
|
|
171
|
+
def _should_attempt_reset(self) -> bool:
|
|
172
|
+
"""Check if enough time has passed to attempt reset from OPEN to HALF_OPEN."""
|
|
173
|
+
if self._last_failure_time is None:
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
return time.time() - self._last_failure_time >= self.timeout
|
|
177
|
+
|
|
178
|
+
async def _on_success(self) -> None:
|
|
179
|
+
"""Handle successful operation."""
|
|
180
|
+
async with self._lock:
|
|
181
|
+
current_state = self._get_current_state()
|
|
182
|
+
|
|
183
|
+
if current_state == CircuitState.CLOSED:
|
|
184
|
+
# Reset failure count on success in closed state
|
|
185
|
+
if self._failure_count > 0:
|
|
186
|
+
logger.debug("Resetting failure count after success")
|
|
187
|
+
self._failure_count = 0
|
|
188
|
+
|
|
189
|
+
elif current_state == CircuitState.HALF_OPEN:
|
|
190
|
+
self._success_count += 1
|
|
191
|
+
logger.debug(f"Success in HALF_OPEN state: {self._success_count}/{self.success_threshold}")
|
|
192
|
+
|
|
193
|
+
if self._success_count >= self.success_threshold:
|
|
194
|
+
logger.info("Success threshold reached, transitioning from HALF_OPEN to CLOSED")
|
|
195
|
+
self._state = CircuitState.CLOSED
|
|
196
|
+
self._failure_count = 0
|
|
197
|
+
self._success_count = 0
|
|
198
|
+
self._last_failure_time = None
|
|
199
|
+
|
|
200
|
+
async def _on_failure(self) -> None:
|
|
201
|
+
"""Handle failed operation."""
|
|
202
|
+
async with self._lock:
|
|
203
|
+
current_state = self._get_current_state()
|
|
204
|
+
|
|
205
|
+
if current_state == CircuitState.CLOSED:
|
|
206
|
+
self._failure_count += 1
|
|
207
|
+
logger.debug(f"Failure in CLOSED state: {self._failure_count}/{self.failure_threshold}")
|
|
208
|
+
|
|
209
|
+
if self._failure_count >= self.failure_threshold:
|
|
210
|
+
logger.warning("Failure threshold reached, transitioning from CLOSED to OPEN")
|
|
211
|
+
self._state = CircuitState.OPEN
|
|
212
|
+
self._last_failure_time = time.time()
|
|
213
|
+
|
|
214
|
+
elif current_state == CircuitState.HALF_OPEN:
|
|
215
|
+
logger.warning("Failure in HALF_OPEN state, transitioning back to OPEN")
|
|
216
|
+
self._state = CircuitState.OPEN
|
|
217
|
+
self._success_count = 0
|
|
218
|
+
self._last_failure_time = time.time()
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Configuration Wizard - Interactive setup for MCP servers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Dict, Optional
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
|
|
9
|
+
from code_puppy.mcp import ServerConfig, get_mcp_manager
|
|
10
|
+
from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
|
|
11
|
+
from rich.prompt import Prompt, Confirm
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MCPConfigWizard:
|
|
18
|
+
"""Interactive wizard for configuring MCP servers."""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self.manager = get_mcp_manager()
|
|
22
|
+
|
|
23
|
+
def run_wizard(self) -> Optional[ServerConfig]:
|
|
24
|
+
"""
|
|
25
|
+
Run the interactive configuration wizard.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
ServerConfig if successful, None if cancelled
|
|
29
|
+
"""
|
|
30
|
+
console.print("\n[bold cyan]🧙 MCP Server Configuration Wizard[/bold cyan]\n")
|
|
31
|
+
|
|
32
|
+
# Step 1: Server name
|
|
33
|
+
name = self.prompt_server_name()
|
|
34
|
+
if not name:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
# Step 2: Server type
|
|
38
|
+
server_type = self.prompt_server_type()
|
|
39
|
+
if not server_type:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
# Step 3: Type-specific configuration
|
|
43
|
+
config = {}
|
|
44
|
+
if server_type == "sse":
|
|
45
|
+
config = self.prompt_sse_config()
|
|
46
|
+
elif server_type == "http":
|
|
47
|
+
config = self.prompt_http_config()
|
|
48
|
+
elif server_type == "stdio":
|
|
49
|
+
config = self.prompt_stdio_config()
|
|
50
|
+
|
|
51
|
+
if not config:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
# Step 4: Create ServerConfig
|
|
55
|
+
server_config = ServerConfig(
|
|
56
|
+
id=f"{name}_{hash(name)}",
|
|
57
|
+
name=name,
|
|
58
|
+
type=server_type,
|
|
59
|
+
enabled=True,
|
|
60
|
+
config=config
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Step 5: Show summary and confirm
|
|
64
|
+
if self.prompt_confirmation(server_config):
|
|
65
|
+
return server_config
|
|
66
|
+
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def prompt_server_name(self) -> Optional[str]:
|
|
70
|
+
"""Prompt for server name with validation."""
|
|
71
|
+
while True:
|
|
72
|
+
name = Prompt.ask(
|
|
73
|
+
"[yellow]Enter server name[/yellow]",
|
|
74
|
+
default=None
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if not name:
|
|
78
|
+
if not Confirm.ask("Cancel configuration?", default=False):
|
|
79
|
+
continue
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
# Validate name
|
|
83
|
+
if not self.validate_name(name):
|
|
84
|
+
emit_error("Name must be alphanumeric with hyphens/underscores only")
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
# Check uniqueness
|
|
88
|
+
existing = self.manager.registry.get_by_name(name)
|
|
89
|
+
if existing:
|
|
90
|
+
emit_error(f"Server '{name}' already exists")
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
return name
|
|
94
|
+
|
|
95
|
+
def prompt_server_type(self) -> Optional[str]:
|
|
96
|
+
"""Prompt for server type."""
|
|
97
|
+
console.print("\n[cyan]Server types:[/cyan]")
|
|
98
|
+
console.print(" [bold]sse[/bold] - Server-Sent Events (HTTP streaming)")
|
|
99
|
+
console.print(" [bold]http[/bold] - HTTP/REST API")
|
|
100
|
+
console.print(" [bold]stdio[/bold] - Local command (subprocess)")
|
|
101
|
+
|
|
102
|
+
while True:
|
|
103
|
+
server_type = Prompt.ask(
|
|
104
|
+
"\n[yellow]Select server type[/yellow]",
|
|
105
|
+
choices=["sse", "http", "stdio"],
|
|
106
|
+
default="stdio"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if server_type in ["sse", "http", "stdio"]:
|
|
110
|
+
return server_type
|
|
111
|
+
|
|
112
|
+
emit_error("Invalid type. Choose: sse, http, or stdio")
|
|
113
|
+
|
|
114
|
+
def prompt_sse_config(self) -> Optional[Dict]:
|
|
115
|
+
"""Prompt for SSE server configuration."""
|
|
116
|
+
console.print("\n[cyan]Configuring SSE server[/cyan]")
|
|
117
|
+
|
|
118
|
+
# URL
|
|
119
|
+
url = self.prompt_url("SSE")
|
|
120
|
+
if not url:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
config = {
|
|
124
|
+
"type": "sse",
|
|
125
|
+
"url": url,
|
|
126
|
+
"timeout": 30
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# Headers (optional)
|
|
130
|
+
if Confirm.ask("Add custom headers?", default=False):
|
|
131
|
+
headers = self.prompt_headers()
|
|
132
|
+
if headers:
|
|
133
|
+
config["headers"] = headers
|
|
134
|
+
|
|
135
|
+
# Timeout
|
|
136
|
+
timeout_str = Prompt.ask(
|
|
137
|
+
"Connection timeout (seconds)",
|
|
138
|
+
default="30"
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
config["timeout"] = int(timeout_str)
|
|
142
|
+
except ValueError:
|
|
143
|
+
config["timeout"] = 30
|
|
144
|
+
|
|
145
|
+
return config
|
|
146
|
+
|
|
147
|
+
def prompt_http_config(self) -> Optional[Dict]:
|
|
148
|
+
"""Prompt for HTTP server configuration."""
|
|
149
|
+
console.print("\n[cyan]Configuring HTTP server[/cyan]")
|
|
150
|
+
|
|
151
|
+
# URL
|
|
152
|
+
url = self.prompt_url("HTTP")
|
|
153
|
+
if not url:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
config = {
|
|
157
|
+
"type": "http",
|
|
158
|
+
"url": url,
|
|
159
|
+
"timeout": 30
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# Headers (optional)
|
|
163
|
+
if Confirm.ask("Add custom headers?", default=False):
|
|
164
|
+
headers = self.prompt_headers()
|
|
165
|
+
if headers:
|
|
166
|
+
config["headers"] = headers
|
|
167
|
+
|
|
168
|
+
# Timeout
|
|
169
|
+
timeout_str = Prompt.ask(
|
|
170
|
+
"Request timeout (seconds)",
|
|
171
|
+
default="30"
|
|
172
|
+
)
|
|
173
|
+
try:
|
|
174
|
+
config["timeout"] = int(timeout_str)
|
|
175
|
+
except ValueError:
|
|
176
|
+
config["timeout"] = 30
|
|
177
|
+
|
|
178
|
+
return config
|
|
179
|
+
|
|
180
|
+
def prompt_stdio_config(self) -> Optional[Dict]:
|
|
181
|
+
"""Prompt for Stdio server configuration."""
|
|
182
|
+
console.print("\n[cyan]Configuring Stdio server[/cyan]")
|
|
183
|
+
console.print("[dim]Examples:[/dim]")
|
|
184
|
+
console.print("[dim] • npx -y @modelcontextprotocol/server-filesystem /path[/dim]")
|
|
185
|
+
console.print("[dim] • python mcp_server.py[/dim]")
|
|
186
|
+
console.print("[dim] • node server.js[/dim]")
|
|
187
|
+
|
|
188
|
+
# Command
|
|
189
|
+
command = Prompt.ask(
|
|
190
|
+
"\n[yellow]Enter command[/yellow]",
|
|
191
|
+
default=None
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if not command:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
config = {
|
|
198
|
+
"type": "stdio",
|
|
199
|
+
"command": command,
|
|
200
|
+
"args": [],
|
|
201
|
+
"timeout": 30
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# Arguments
|
|
205
|
+
args_str = Prompt.ask(
|
|
206
|
+
"Enter arguments (space-separated)",
|
|
207
|
+
default=""
|
|
208
|
+
)
|
|
209
|
+
if args_str:
|
|
210
|
+
# Simple argument parsing (handles quoted strings)
|
|
211
|
+
import shlex
|
|
212
|
+
try:
|
|
213
|
+
config["args"] = shlex.split(args_str)
|
|
214
|
+
except ValueError:
|
|
215
|
+
config["args"] = args_str.split()
|
|
216
|
+
|
|
217
|
+
# Working directory (optional)
|
|
218
|
+
cwd = Prompt.ask(
|
|
219
|
+
"Working directory (optional)",
|
|
220
|
+
default=""
|
|
221
|
+
)
|
|
222
|
+
if cwd:
|
|
223
|
+
import os
|
|
224
|
+
if os.path.isdir(os.path.expanduser(cwd)):
|
|
225
|
+
config["cwd"] = os.path.expanduser(cwd)
|
|
226
|
+
else:
|
|
227
|
+
emit_warning(f"Directory '{cwd}' not found, ignoring")
|
|
228
|
+
|
|
229
|
+
# Environment variables (optional)
|
|
230
|
+
if Confirm.ask("Add environment variables?", default=False):
|
|
231
|
+
env = self.prompt_env_vars()
|
|
232
|
+
if env:
|
|
233
|
+
config["env"] = env
|
|
234
|
+
|
|
235
|
+
# Timeout
|
|
236
|
+
timeout_str = Prompt.ask(
|
|
237
|
+
"Startup timeout (seconds)",
|
|
238
|
+
default="30"
|
|
239
|
+
)
|
|
240
|
+
try:
|
|
241
|
+
config["timeout"] = int(timeout_str)
|
|
242
|
+
except ValueError:
|
|
243
|
+
config["timeout"] = 30
|
|
244
|
+
|
|
245
|
+
return config
|
|
246
|
+
|
|
247
|
+
def prompt_url(self, server_type: str) -> Optional[str]:
|
|
248
|
+
"""Prompt for and validate URL."""
|
|
249
|
+
while True:
|
|
250
|
+
url = Prompt.ask(
|
|
251
|
+
f"[yellow]Enter {server_type} server URL[/yellow]",
|
|
252
|
+
default=None
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
if not url:
|
|
256
|
+
if Confirm.ask("Cancel configuration?", default=False):
|
|
257
|
+
return None
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
if self.validate_url(url):
|
|
261
|
+
return url
|
|
262
|
+
|
|
263
|
+
emit_error("Invalid URL. Must be http:// or https://")
|
|
264
|
+
|
|
265
|
+
def prompt_headers(self) -> Dict[str, str]:
|
|
266
|
+
"""Prompt for HTTP headers."""
|
|
267
|
+
headers = {}
|
|
268
|
+
console.print("[dim]Enter headers (format: Name: Value)[/dim]")
|
|
269
|
+
console.print("[dim]Press Enter with empty name to finish[/dim]")
|
|
270
|
+
|
|
271
|
+
while True:
|
|
272
|
+
name = Prompt.ask("Header name", default="")
|
|
273
|
+
if not name:
|
|
274
|
+
break
|
|
275
|
+
|
|
276
|
+
value = Prompt.ask(f"Value for '{name}'", default="")
|
|
277
|
+
headers[name] = value
|
|
278
|
+
|
|
279
|
+
if not Confirm.ask("Add another header?", default=True):
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
return headers
|
|
283
|
+
|
|
284
|
+
def prompt_env_vars(self) -> Dict[str, str]:
|
|
285
|
+
"""Prompt for environment variables."""
|
|
286
|
+
env = {}
|
|
287
|
+
console.print("[dim]Enter environment variables[/dim]")
|
|
288
|
+
console.print("[dim]Press Enter with empty name to finish[/dim]")
|
|
289
|
+
|
|
290
|
+
while True:
|
|
291
|
+
name = Prompt.ask("Variable name", default="")
|
|
292
|
+
if not name:
|
|
293
|
+
break
|
|
294
|
+
|
|
295
|
+
value = Prompt.ask(f"Value for '{name}'", default="")
|
|
296
|
+
env[name] = value
|
|
297
|
+
|
|
298
|
+
if not Confirm.ask("Add another variable?", default=True):
|
|
299
|
+
break
|
|
300
|
+
|
|
301
|
+
return env
|
|
302
|
+
|
|
303
|
+
def validate_name(self, name: str) -> bool:
|
|
304
|
+
"""Validate server name."""
|
|
305
|
+
# Allow alphanumeric, hyphens, and underscores
|
|
306
|
+
return bool(re.match(r'^[a-zA-Z0-9_-]+$', name))
|
|
307
|
+
|
|
308
|
+
def validate_url(self, url: str) -> bool:
|
|
309
|
+
"""Validate URL format."""
|
|
310
|
+
try:
|
|
311
|
+
result = urlparse(url)
|
|
312
|
+
return result.scheme in ('http', 'https') and bool(result.netloc)
|
|
313
|
+
except Exception:
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
def validate_command(self, command: str) -> bool:
|
|
317
|
+
"""Check if command exists (basic check)."""
|
|
318
|
+
import shutil
|
|
319
|
+
import os
|
|
320
|
+
|
|
321
|
+
# If it's a path, check if file exists
|
|
322
|
+
if '/' in command or '\\' in command:
|
|
323
|
+
return os.path.isfile(command)
|
|
324
|
+
|
|
325
|
+
# Otherwise check if it's in PATH
|
|
326
|
+
return shutil.which(command) is not None
|
|
327
|
+
|
|
328
|
+
def test_connection(self, config: ServerConfig) -> bool:
|
|
329
|
+
"""
|
|
330
|
+
Test connection to the configured server.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
config: Server configuration to test
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
True if connection successful, False otherwise
|
|
337
|
+
"""
|
|
338
|
+
emit_info("Testing connection...")
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
# Try to create the server instance
|
|
342
|
+
managed = self.manager.get_server(config.id)
|
|
343
|
+
if not managed:
|
|
344
|
+
# Temporarily register to test
|
|
345
|
+
self.manager.register_server(config)
|
|
346
|
+
managed = self.manager.get_server(config.id)
|
|
347
|
+
|
|
348
|
+
if managed:
|
|
349
|
+
# Try to get the pydantic server (this validates config)
|
|
350
|
+
server = managed.get_pydantic_server()
|
|
351
|
+
if server:
|
|
352
|
+
emit_success("✓ Configuration valid")
|
|
353
|
+
return True
|
|
354
|
+
|
|
355
|
+
emit_error("✗ Failed to create server instance")
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
except Exception as e:
|
|
359
|
+
emit_error(f"✗ Configuration error: {e}")
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
def prompt_confirmation(self, config: ServerConfig) -> bool:
|
|
363
|
+
"""Show summary and ask for confirmation."""
|
|
364
|
+
console.print("\n[bold cyan]Configuration Summary:[/bold cyan]")
|
|
365
|
+
console.print(f" [bold]Name:[/bold] {config.name}")
|
|
366
|
+
console.print(f" [bold]Type:[/bold] {config.type}")
|
|
367
|
+
|
|
368
|
+
if config.type in ["sse", "http"]:
|
|
369
|
+
console.print(f" [bold]URL:[/bold] {config.config.get('url')}")
|
|
370
|
+
elif config.type == "stdio":
|
|
371
|
+
console.print(f" [bold]Command:[/bold] {config.config.get('command')}")
|
|
372
|
+
args = config.config.get('args', [])
|
|
373
|
+
if args:
|
|
374
|
+
console.print(f" [bold]Arguments:[/bold] {' '.join(args)}")
|
|
375
|
+
|
|
376
|
+
console.print(f" [bold]Timeout:[/bold] {config.config.get('timeout', 30)}s")
|
|
377
|
+
|
|
378
|
+
# Test connection if requested
|
|
379
|
+
if Confirm.ask("\n[yellow]Test connection?[/yellow]", default=True):
|
|
380
|
+
if not self.test_connection(config):
|
|
381
|
+
if not Confirm.ask("Continue anyway?", default=False):
|
|
382
|
+
return False
|
|
383
|
+
|
|
384
|
+
return Confirm.ask("\n[bold green]Save this configuration?[/bold green]", default=True)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def run_add_wizard() -> bool:
|
|
388
|
+
"""
|
|
389
|
+
Run the MCP add wizard and register the server.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
True if server was added, False otherwise
|
|
393
|
+
"""
|
|
394
|
+
wizard = MCPConfigWizard()
|
|
395
|
+
config = wizard.run_wizard()
|
|
396
|
+
|
|
397
|
+
if config:
|
|
398
|
+
try:
|
|
399
|
+
manager = get_mcp_manager()
|
|
400
|
+
server_id = manager.register_server(config)
|
|
401
|
+
|
|
402
|
+
emit_success(f"\n✅ Server '{config.name}' added successfully!")
|
|
403
|
+
emit_info(f"Server ID: {server_id}")
|
|
404
|
+
emit_info("Use '/mcp list' to see all servers")
|
|
405
|
+
emit_info(f"Use '/mcp start {config.name}' to start the server")
|
|
406
|
+
|
|
407
|
+
# Also save to mcp_servers.json for persistence
|
|
408
|
+
from code_puppy.config import MCP_SERVERS_FILE, load_mcp_server_configs
|
|
409
|
+
import json
|
|
410
|
+
import os
|
|
411
|
+
|
|
412
|
+
# Load existing configs
|
|
413
|
+
if os.path.exists(MCP_SERVERS_FILE):
|
|
414
|
+
with open(MCP_SERVERS_FILE, 'r') as f:
|
|
415
|
+
data = json.load(f)
|
|
416
|
+
servers = data.get("mcp_servers", {})
|
|
417
|
+
else:
|
|
418
|
+
servers = {}
|
|
419
|
+
data = {"mcp_servers": servers}
|
|
420
|
+
|
|
421
|
+
# Add new server
|
|
422
|
+
servers[config.name] = config.config
|
|
423
|
+
|
|
424
|
+
# Save back
|
|
425
|
+
os.makedirs(os.path.dirname(MCP_SERVERS_FILE), exist_ok=True)
|
|
426
|
+
with open(MCP_SERVERS_FILE, 'w') as f:
|
|
427
|
+
json.dump(data, f, indent=2)
|
|
428
|
+
|
|
429
|
+
emit_info(f"[dim]Configuration saved to {MCP_SERVERS_FILE}[/dim]")
|
|
430
|
+
return True
|
|
431
|
+
|
|
432
|
+
except Exception as e:
|
|
433
|
+
emit_error(f"Failed to add server: {e}")
|
|
434
|
+
return False
|
|
435
|
+
else:
|
|
436
|
+
emit_warning("Configuration cancelled")
|
|
437
|
+
return False
|