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.
Files changed (34) hide show
  1. code_puppy/__init__.py +1 -0
  2. code_puppy/agent.py +65 -69
  3. code_puppy/agents/agent_code_puppy.py +0 -3
  4. code_puppy/agents/runtime_manager.py +212 -0
  5. code_puppy/command_line/command_handler.py +56 -25
  6. code_puppy/command_line/mcp_commands.py +1298 -0
  7. code_puppy/command_line/meta_command_handler.py +3 -2
  8. code_puppy/command_line/model_picker_completion.py +21 -8
  9. code_puppy/main.py +52 -157
  10. code_puppy/mcp/__init__.py +23 -0
  11. code_puppy/mcp/async_lifecycle.py +237 -0
  12. code_puppy/mcp/circuit_breaker.py +218 -0
  13. code_puppy/mcp/config_wizard.py +437 -0
  14. code_puppy/mcp/dashboard.py +291 -0
  15. code_puppy/mcp/error_isolation.py +360 -0
  16. code_puppy/mcp/examples/retry_example.py +208 -0
  17. code_puppy/mcp/health_monitor.py +549 -0
  18. code_puppy/mcp/managed_server.py +346 -0
  19. code_puppy/mcp/manager.py +701 -0
  20. code_puppy/mcp/registry.py +412 -0
  21. code_puppy/mcp/retry_manager.py +321 -0
  22. code_puppy/mcp/server_registry_catalog.py +751 -0
  23. code_puppy/mcp/status_tracker.py +355 -0
  24. code_puppy/messaging/spinner/textual_spinner.py +6 -2
  25. code_puppy/model_factory.py +19 -4
  26. code_puppy/models.json +22 -4
  27. code_puppy/tui/app.py +19 -27
  28. code_puppy/tui/tests/test_agent_command.py +22 -15
  29. {code_puppy-0.0.126.data → code_puppy-0.0.128.data}/data/code_puppy/models.json +22 -4
  30. {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/METADATA +2 -3
  31. {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/RECORD +34 -18
  32. {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/WHEEL +0 -0
  33. {code_puppy-0.0.126.dist-info → code_puppy-0.0.128.dist-info}/entry_points.txt +0 -0
  34. {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