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