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,412 @@
1
+ """
2
+ ServerRegistry implementation for managing MCP server configurations.
3
+
4
+ This module provides a registry that tracks all MCP server configurations
5
+ and provides thread-safe CRUD operations with JSON persistence.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import os
11
+ import threading
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional, Any
15
+
16
+ from .managed_server import ServerConfig
17
+
18
+ # Configure logging
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class ServerRegistry:
23
+ """
24
+ Registry for managing MCP server configurations.
25
+
26
+ Provides CRUD operations for server configurations with thread-safe access,
27
+ validation, and persistent storage to ~/.code_puppy/mcp_registry.json.
28
+
29
+ All operations are thread-safe and use JSON serialization for ServerConfig objects.
30
+ Handles file not existing gracefully and validates configurations according to
31
+ server type requirements.
32
+ """
33
+
34
+ def __init__(self, storage_path: Optional[str] = None):
35
+ """
36
+ Initialize the server registry.
37
+
38
+ Args:
39
+ storage_path: Optional custom path for registry storage.
40
+ Defaults to ~/.code_puppy/mcp_registry.json
41
+ """
42
+ if storage_path is None:
43
+ home_dir = Path.home()
44
+ code_puppy_dir = home_dir / ".code_puppy"
45
+ code_puppy_dir.mkdir(exist_ok=True)
46
+ self._storage_path = code_puppy_dir / "mcp_registry.json"
47
+ else:
48
+ self._storage_path = Path(storage_path)
49
+
50
+ # Thread safety lock (reentrant)
51
+ self._lock = threading.RLock()
52
+
53
+ # In-memory storage: server_id -> ServerConfig
54
+ self._servers: Dict[str, ServerConfig] = {}
55
+
56
+ # Load existing configurations
57
+ self._load()
58
+
59
+ logger.info(f"Initialized ServerRegistry with storage at {self._storage_path}")
60
+
61
+ def register(self, config: ServerConfig) -> str:
62
+ """
63
+ Add new server configuration.
64
+
65
+ Args:
66
+ config: Server configuration to register
67
+
68
+ Returns:
69
+ Server ID of the registered server
70
+
71
+ Raises:
72
+ ValueError: If validation fails or server already exists
73
+ """
74
+ with self._lock:
75
+ # Validate configuration
76
+ validation_errors = self.validate_config(config)
77
+ if validation_errors:
78
+ raise ValueError(f"Validation failed: {'; '.join(validation_errors)}")
79
+
80
+ # Generate ID if not provided or ensure uniqueness
81
+ if not config.id:
82
+ config.id = str(uuid.uuid4())
83
+ elif config.id in self._servers:
84
+ raise ValueError(f"Server with ID {config.id} already exists")
85
+
86
+ # Check name uniqueness
87
+ existing_config = self.get_by_name(config.name)
88
+ if existing_config and existing_config.id != config.id:
89
+ raise ValueError(f"Server with name '{config.name}' already exists")
90
+
91
+ # Store configuration
92
+ self._servers[config.id] = config
93
+
94
+ # Persist to disk
95
+ self._persist()
96
+
97
+ logger.info(f"Registered server: {config.name} (ID: {config.id})")
98
+ return config.id
99
+
100
+ def unregister(self, server_id: str) -> bool:
101
+ """
102
+ Remove server configuration.
103
+
104
+ Args:
105
+ server_id: ID of server to remove
106
+
107
+ Returns:
108
+ True if server was removed, False if not found
109
+ """
110
+ with self._lock:
111
+ if server_id not in self._servers:
112
+ logger.warning(f"Attempted to unregister non-existent server: {server_id}")
113
+ return False
114
+
115
+ server_name = self._servers[server_id].name
116
+ del self._servers[server_id]
117
+
118
+ # Persist to disk
119
+ self._persist()
120
+
121
+ logger.info(f"Unregistered server: {server_name} (ID: {server_id})")
122
+ return True
123
+
124
+ def get(self, server_id: str) -> Optional[ServerConfig]:
125
+ """
126
+ Get server configuration by ID.
127
+
128
+ Args:
129
+ server_id: ID of server to retrieve
130
+
131
+ Returns:
132
+ ServerConfig if found, None otherwise
133
+ """
134
+ with self._lock:
135
+ return self._servers.get(server_id)
136
+
137
+ def get_by_name(self, name: str) -> Optional[ServerConfig]:
138
+ """
139
+ Get server configuration by name.
140
+
141
+ Args:
142
+ name: Name of server to retrieve
143
+
144
+ Returns:
145
+ ServerConfig if found, None otherwise
146
+ """
147
+ with self._lock:
148
+ for config in self._servers.values():
149
+ if config.name == name:
150
+ return config
151
+ return None
152
+
153
+ def list_all(self) -> List[ServerConfig]:
154
+ """
155
+ Get all server configurations.
156
+
157
+ Returns:
158
+ List of all ServerConfig objects
159
+ """
160
+ with self._lock:
161
+ return list(self._servers.values())
162
+
163
+ def update(self, server_id: str, config: ServerConfig) -> bool:
164
+ """
165
+ Update existing server configuration.
166
+
167
+ Args:
168
+ server_id: ID of server to update
169
+ config: New configuration
170
+
171
+ Returns:
172
+ True if update succeeded, False if server not found
173
+
174
+ Raises:
175
+ ValueError: If validation fails
176
+ """
177
+ with self._lock:
178
+ if server_id not in self._servers:
179
+ logger.warning(f"Attempted to update non-existent server: {server_id}")
180
+ return False
181
+
182
+ # Ensure the ID matches
183
+ config.id = server_id
184
+
185
+ # Validate configuration
186
+ validation_errors = self.validate_config(config)
187
+ if validation_errors:
188
+ raise ValueError(f"Validation failed: {'; '.join(validation_errors)}")
189
+
190
+ # Check name uniqueness (excluding current server)
191
+ existing_config = self.get_by_name(config.name)
192
+ if existing_config and existing_config.id != server_id:
193
+ raise ValueError(f"Server with name '{config.name}' already exists")
194
+
195
+ # Update configuration
196
+ old_name = self._servers[server_id].name
197
+ self._servers[server_id] = config
198
+
199
+ # Persist to disk
200
+ self._persist()
201
+
202
+ logger.info(f"Updated server: {old_name} -> {config.name} (ID: {server_id})")
203
+ return True
204
+
205
+ def exists(self, server_id: str) -> bool:
206
+ """
207
+ Check if server exists.
208
+
209
+ Args:
210
+ server_id: ID of server to check
211
+
212
+ Returns:
213
+ True if server exists, False otherwise
214
+ """
215
+ with self._lock:
216
+ return server_id in self._servers
217
+
218
+ def validate_config(self, config: ServerConfig) -> List[str]:
219
+ """
220
+ Validate server configuration.
221
+
222
+ Args:
223
+ config: Configuration to validate
224
+
225
+ Returns:
226
+ List of validation error messages (empty if valid)
227
+ """
228
+ errors = []
229
+
230
+ # Basic validation
231
+ if not config.name or not config.name.strip():
232
+ errors.append("Server name is required")
233
+ elif not config.name.replace('-', '').replace('_', '').isalnum():
234
+ errors.append("Server name must be alphanumeric (hyphens and underscores allowed)")
235
+
236
+ if not config.type:
237
+ errors.append("Server type is required")
238
+ elif config.type.lower() not in ["sse", "stdio", "http"]:
239
+ errors.append("Server type must be one of: sse, stdio, http")
240
+
241
+ if not isinstance(config.config, dict):
242
+ errors.append("Server config must be a dictionary")
243
+ return errors # Can't validate further without valid config dict
244
+
245
+ # Type-specific validation
246
+ server_type = config.type.lower()
247
+ server_config = config.config
248
+
249
+ if server_type in ["sse", "http"]:
250
+ if "url" not in server_config:
251
+ errors.append(f"{server_type.upper()} server requires 'url' in config")
252
+ elif not isinstance(server_config["url"], str) or not server_config["url"].strip():
253
+ errors.append(f"{server_type.upper()} server URL must be a non-empty string")
254
+ elif not (server_config["url"].startswith("http://") or server_config["url"].startswith("https://")):
255
+ errors.append(f"{server_type.upper()} server URL must start with http:// or https://")
256
+
257
+ # Optional parameter validation
258
+ if "timeout" in server_config:
259
+ try:
260
+ timeout = float(server_config["timeout"])
261
+ if timeout <= 0:
262
+ errors.append("Timeout must be positive")
263
+ except (ValueError, TypeError):
264
+ errors.append("Timeout must be a number")
265
+
266
+ if "read_timeout" in server_config:
267
+ try:
268
+ read_timeout = float(server_config["read_timeout"])
269
+ if read_timeout <= 0:
270
+ errors.append("Read timeout must be positive")
271
+ except (ValueError, TypeError):
272
+ errors.append("Read timeout must be a number")
273
+
274
+ if "headers" in server_config:
275
+ if not isinstance(server_config["headers"], dict):
276
+ errors.append("Headers must be a dictionary")
277
+
278
+ elif server_type == "stdio":
279
+ if "command" not in server_config:
280
+ errors.append("Stdio server requires 'command' in config")
281
+ elif not isinstance(server_config["command"], str) or not server_config["command"].strip():
282
+ errors.append("Stdio server command must be a non-empty string")
283
+
284
+ # Optional parameter validation
285
+ if "args" in server_config:
286
+ args = server_config["args"]
287
+ if not isinstance(args, (list, str)):
288
+ errors.append("Args must be a list or string")
289
+ elif isinstance(args, list):
290
+ if not all(isinstance(arg, str) for arg in args):
291
+ errors.append("All args must be strings")
292
+
293
+ if "env" in server_config:
294
+ if not isinstance(server_config["env"], dict):
295
+ errors.append("Environment variables must be a dictionary")
296
+ elif not all(isinstance(k, str) and isinstance(v, str)
297
+ for k, v in server_config["env"].items()):
298
+ errors.append("All environment variables must be strings")
299
+
300
+ if "cwd" in server_config:
301
+ if not isinstance(server_config["cwd"], str):
302
+ errors.append("Working directory must be a string")
303
+
304
+ return errors
305
+
306
+ def _persist(self) -> None:
307
+ """
308
+ Save registry to disk.
309
+
310
+ This method assumes it's called within a lock context.
311
+
312
+ Raises:
313
+ Exception: If unable to write to storage file
314
+ """
315
+ try:
316
+ # Convert ServerConfig objects to dictionaries for JSON serialization
317
+ data = {}
318
+ for server_id, config in self._servers.items():
319
+ data[server_id] = {
320
+ "id": config.id,
321
+ "name": config.name,
322
+ "type": config.type,
323
+ "enabled": config.enabled,
324
+ "config": config.config
325
+ }
326
+
327
+ # Ensure directory exists
328
+ self._storage_path.parent.mkdir(parents=True, exist_ok=True)
329
+
330
+ # Write to temporary file first, then rename (atomic operation)
331
+ temp_path = self._storage_path.with_suffix('.tmp')
332
+ with open(temp_path, 'w', encoding='utf-8') as f:
333
+ json.dump(data, f, indent=2, ensure_ascii=False)
334
+
335
+ # Atomic rename
336
+ temp_path.replace(self._storage_path)
337
+
338
+ logger.debug(f"Persisted {len(self._servers)} server configurations to {self._storage_path}")
339
+
340
+ except Exception as e:
341
+ logger.error(f"Failed to persist server registry: {e}")
342
+ raise
343
+
344
+ def _load(self) -> None:
345
+ """
346
+ Load registry from disk.
347
+
348
+ Handles file not existing gracefully by starting with empty registry.
349
+ Invalid entries are logged and skipped.
350
+ """
351
+ try:
352
+ if not self._storage_path.exists():
353
+ logger.info(f"Registry file {self._storage_path} does not exist, starting with empty registry")
354
+ return
355
+
356
+ # Check if file is empty
357
+ if self._storage_path.stat().st_size == 0:
358
+ logger.info(f"Registry file {self._storage_path} is empty, starting with empty registry")
359
+ return
360
+
361
+ with open(self._storage_path, 'r', encoding='utf-8') as f:
362
+ data = json.load(f)
363
+
364
+ if not isinstance(data, dict):
365
+ logger.warning(f"Invalid registry format in {self._storage_path}, starting with empty registry")
366
+ return
367
+
368
+ # Load server configurations
369
+ loaded_count = 0
370
+ for server_id, config_data in data.items():
371
+ try:
372
+ # Validate the structure
373
+ if not isinstance(config_data, dict):
374
+ logger.warning(f"Skipping invalid config for server {server_id}: not a dictionary")
375
+ continue
376
+
377
+ required_fields = ["id", "name", "type", "config"]
378
+ if not all(field in config_data for field in required_fields):
379
+ logger.warning(f"Skipping incomplete config for server {server_id}: missing required fields")
380
+ continue
381
+
382
+ # Create ServerConfig object
383
+ config = ServerConfig(
384
+ id=config_data["id"],
385
+ name=config_data["name"],
386
+ type=config_data["type"],
387
+ enabled=config_data.get("enabled", True),
388
+ config=config_data["config"]
389
+ )
390
+
391
+ # Basic validation
392
+ validation_errors = self.validate_config(config)
393
+ if validation_errors:
394
+ logger.warning(f"Skipping invalid config for server {server_id}: {'; '.join(validation_errors)}")
395
+ continue
396
+
397
+ # Store configuration
398
+ self._servers[server_id] = config
399
+ loaded_count += 1
400
+
401
+ except Exception as e:
402
+ logger.warning(f"Skipping invalid config for server {server_id}: {e}")
403
+ continue
404
+
405
+ logger.info(f"Loaded {loaded_count} server configurations from {self._storage_path}")
406
+
407
+ except json.JSONDecodeError as e:
408
+ logger.error(f"Invalid JSON in registry file {self._storage_path}: {e}")
409
+ logger.info("Starting with empty registry")
410
+ except Exception as e:
411
+ logger.error(f"Failed to load server registry: {e}")
412
+ logger.info("Starting with empty registry")