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,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")
|