code-puppy 0.0.134__py3-none-any.whl → 0.0.136__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 (60) 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/main.py +23 -17
  28. code_puppy/mcp/__init__.py +42 -16
  29. code_puppy/mcp/async_lifecycle.py +51 -49
  30. code_puppy/mcp/blocking_startup.py +125 -113
  31. code_puppy/mcp/captured_stdio_server.py +63 -70
  32. code_puppy/mcp/circuit_breaker.py +63 -47
  33. code_puppy/mcp/config_wizard.py +169 -136
  34. code_puppy/mcp/dashboard.py +79 -71
  35. code_puppy/mcp/error_isolation.py +147 -100
  36. code_puppy/mcp/examples/retry_example.py +55 -42
  37. code_puppy/mcp/health_monitor.py +152 -141
  38. code_puppy/mcp/managed_server.py +100 -97
  39. code_puppy/mcp/manager.py +168 -156
  40. code_puppy/mcp/registry.py +148 -110
  41. code_puppy/mcp/retry_manager.py +63 -61
  42. code_puppy/mcp/server_registry_catalog.py +271 -225
  43. code_puppy/mcp/status_tracker.py +80 -80
  44. code_puppy/mcp/system_tools.py +47 -52
  45. code_puppy/messaging/message_queue.py +20 -13
  46. code_puppy/messaging/renderers.py +30 -15
  47. code_puppy/state_management.py +103 -0
  48. code_puppy/tui/app.py +64 -7
  49. code_puppy/tui/components/chat_view.py +3 -3
  50. code_puppy/tui/components/human_input_modal.py +12 -8
  51. code_puppy/tui/screens/__init__.py +2 -2
  52. code_puppy/tui/screens/mcp_install_wizard.py +208 -179
  53. code_puppy/tui/tests/test_agent_command.py +3 -3
  54. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/METADATA +1 -1
  55. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/RECORD +59 -41
  56. code_puppy/command_line/mcp_commands.py +0 -1789
  57. {code_puppy-0.0.134.data → code_puppy-0.0.136.data}/data/code_puppy/models.json +0 -0
  58. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/WHEEL +0 -0
  59. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/entry_points.txt +0 -0
  60. {code_puppy-0.0.134.dist-info → code_puppy-0.0.136.dist-info}/licenses/LICENSE +0 -0
@@ -5,22 +5,27 @@ This module provides a managed wrapper around pydantic-ai MCP server classes
5
5
  that adds management capabilities while maintaining 100% compatibility.
6
6
  """
7
7
 
8
- import asyncio
9
8
  import json
10
9
  import logging
11
10
  import uuid
12
11
  from dataclasses import dataclass, field
13
12
  from datetime import datetime, timedelta
14
13
  from enum import Enum
15
- from typing import Dict, Union, Optional, Any
14
+ from typing import Any, Dict, Optional, Union
15
+
16
16
  import httpx
17
17
  from pydantic_ai import RunContext
18
-
19
- from pydantic_ai.mcp import MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP, CallToolFunc, ToolResult
18
+ from pydantic_ai.mcp import (
19
+ CallToolFunc,
20
+ MCPServerSSE,
21
+ MCPServerStdio,
22
+ MCPServerStreamableHTTP,
23
+ ToolResult,
24
+ )
20
25
 
21
26
  from code_puppy.http_utils import create_async_client
22
- from code_puppy.messaging import emit_info
23
27
  from code_puppy.mcp.blocking_startup import BlockingMCPServerStdio
28
+ from code_puppy.messaging import emit_info
24
29
 
25
30
  # Configure logging
26
31
  logger = logging.getLogger(__name__)
@@ -28,6 +33,7 @@ logger = logging.getLogger(__name__)
28
33
 
29
34
  class ServerState(Enum):
30
35
  """Enumeration of possible server states."""
36
+
31
37
  STOPPED = "stopped"
32
38
  STARTING = "starting"
33
39
  RUNNING = "running"
@@ -39,6 +45,7 @@ class ServerState(Enum):
39
45
  @dataclass
40
46
  class ServerConfig:
41
47
  """Configuration for an MCP server."""
48
+
42
49
  id: str
43
50
  name: str
44
51
  type: str # "sse", "stdio", or "http"
@@ -58,45 +65,41 @@ async def process_tool_call(
58
65
  f"\n[bold white on purple] MCP Tool Call - {name}[/bold white on purple]",
59
66
  message_group=group_id,
60
67
  )
61
- emit_info(
62
- "\nArgs:",
63
- message_group=group_id
64
- )
65
- emit_info(
66
- json.dumps(tool_args, indent=2),
67
- message_group=group_id
68
- )
69
- return await call_tool(name, tool_args, {'deps': ctx.deps})
68
+ emit_info("\nArgs:", message_group=group_id)
69
+ emit_info(json.dumps(tool_args, indent=2), message_group=group_id)
70
+ return await call_tool(name, tool_args, {"deps": ctx.deps})
70
71
 
71
72
 
72
73
  class ManagedMCPServer:
73
74
  """
74
75
  Managed wrapper around pydantic-ai MCP server classes.
75
-
76
+
76
77
  This class provides management capabilities like enable/disable,
77
78
  quarantine, and status tracking while maintaining 100% compatibility
78
79
  with the existing Agent interface through get_pydantic_server().
79
-
80
+
80
81
  Example usage:
81
82
  config = ServerConfig(
82
- id="123",
83
- name="test",
84
- type="sse",
83
+ id="123",
84
+ name="test",
85
+ type="sse",
85
86
  config={"url": "http://localhost:8080"}
86
87
  )
87
88
  managed = ManagedMCPServer(config)
88
89
  pydantic_server = managed.get_pydantic_server() # Returns actual MCPServerSSE
89
90
  """
90
-
91
+
91
92
  def __init__(self, server_config: ServerConfig):
92
93
  """
93
94
  Initialize managed server with configuration.
94
-
95
+
95
96
  Args:
96
97
  server_config: Server configuration containing type, connection details, etc.
97
98
  """
98
99
  self.config = server_config
99
- self._pydantic_server: Optional[Union[MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP]] = None
100
+ self._pydantic_server: Optional[
101
+ Union[MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP]
102
+ ] = None
100
103
  self._state = ServerState.STOPPED
101
104
  # Always start disabled - servers must be explicitly started with /mcp start
102
105
  self._enabled = False
@@ -104,7 +107,7 @@ class ManagedMCPServer:
104
107
  self._start_time: Optional[datetime] = None
105
108
  self._stop_time: Optional[datetime] = None
106
109
  self._error_message: Optional[str] = None
107
-
110
+
108
111
  # Initialize the pydantic server
109
112
  try:
110
113
  self._create_server()
@@ -114,52 +117,52 @@ class ManagedMCPServer:
114
117
  logger.error(f"Failed to create server {self.config.name}: {e}")
115
118
  self._state = ServerState.ERROR
116
119
  self._error_message = str(e)
117
-
118
- def get_pydantic_server(self) -> Union[MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP]:
120
+
121
+ def get_pydantic_server(
122
+ self,
123
+ ) -> Union[MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP]:
119
124
  """
120
125
  Get the actual pydantic-ai server instance.
121
-
126
+
122
127
  This method returns the real pydantic-ai MCP server objects for 100% compatibility
123
128
  with the existing Agent interface. Do not return custom classes or proxies.
124
-
129
+
125
130
  Returns:
126
131
  Actual pydantic-ai MCP server instance (MCPServerSSE, MCPServerStdio, or MCPServerStreamableHTTP)
127
-
132
+
128
133
  Raises:
129
134
  RuntimeError: If server creation failed or server is not available
130
135
  """
131
136
  if self._pydantic_server is None:
132
137
  raise RuntimeError(f"Server {self.config.name} is not available")
133
-
138
+
134
139
  if not self.is_enabled() or self.is_quarantined():
135
140
  raise RuntimeError(f"Server {self.config.name} is disabled or quarantined")
136
-
141
+
137
142
  return self._pydantic_server
138
-
143
+
139
144
  def _create_server(self) -> None:
140
145
  """
141
146
  Create appropriate pydantic-ai server based on config type.
142
-
147
+
143
148
  Raises:
144
149
  ValueError: If server type is unsupported or config is invalid
145
150
  Exception: If server creation fails
146
151
  """
147
152
  server_type = self.config.type.lower()
148
153
  config = self.config.config
149
-
154
+
150
155
  try:
151
156
  if server_type == "sse":
152
157
  if "url" not in config:
153
158
  raise ValueError("SSE server requires 'url' in config")
154
-
159
+
155
160
  # Prepare arguments for MCPServerSSE
156
161
  sse_kwargs = {
157
162
  "url": config["url"],
158
163
  }
159
-
164
+
160
165
  # Add optional parameters if provided
161
- if "headers" in config:
162
- sse_kwargs["headers"] = config["headers"]
163
166
  if "timeout" in config:
164
167
  sse_kwargs["timeout"] = config["timeout"]
165
168
  if "read_timeout" in config:
@@ -169,26 +172,25 @@ class ManagedMCPServer:
169
172
  elif config.get("headers"):
170
173
  # Create HTTP client if headers are provided but no client specified
171
174
  sse_kwargs["http_client"] = self._get_http_client()
172
-
173
- self._pydantic_server = MCPServerSSE(**sse_kwargs, process_tool_call=process_tool_call)
174
-
175
+
176
+ self._pydantic_server = MCPServerSSE(
177
+ **sse_kwargs, process_tool_call=process_tool_call
178
+ )
179
+
175
180
  elif server_type == "stdio":
176
181
  if "command" not in config:
177
182
  raise ValueError("Stdio server requires 'command' in config")
178
-
183
+
179
184
  # Handle command and arguments
180
185
  command = config["command"]
181
186
  args = config.get("args", [])
182
187
  if isinstance(args, str):
183
188
  # If args is a string, split it
184
189
  args = args.split()
185
-
190
+
186
191
  # Prepare arguments for MCPServerStdio
187
- stdio_kwargs = {
188
- "command": command,
189
- "args": list(args) if args else []
190
- }
191
-
192
+ stdio_kwargs = {"command": command, "args": list(args) if args else []}
193
+
192
194
  # Add optional parameters if provided
193
195
  if "env" in config:
194
196
  stdio_kwargs["env"] = config["env"]
@@ -198,30 +200,28 @@ class ManagedMCPServer:
198
200
  stdio_kwargs["timeout"] = config["timeout"]
199
201
  if "read_timeout" in config:
200
202
  stdio_kwargs["read_timeout"] = config["read_timeout"]
201
-
203
+
202
204
  # Use BlockingMCPServerStdio for proper initialization blocking and stderr capture
203
205
  # Create a unique message group for this server
204
206
  message_group = uuid.uuid4()
205
207
  self._pydantic_server = BlockingMCPServerStdio(
206
- **stdio_kwargs,
207
- process_tool_call=process_tool_call,
208
+ **stdio_kwargs,
209
+ process_tool_call=process_tool_call,
208
210
  tool_prefix=config["name"],
209
211
  emit_stderr=True, # Always emit stderr for now
210
- message_group=message_group
212
+ message_group=message_group,
211
213
  )
212
-
214
+
213
215
  elif server_type == "http":
214
216
  if "url" not in config:
215
217
  raise ValueError("HTTP server requires 'url' in config")
216
-
218
+
217
219
  # Prepare arguments for MCPServerStreamableHTTP
218
220
  http_kwargs = {
219
221
  "url": config["url"],
220
222
  }
221
-
223
+
222
224
  # Add optional parameters if provided
223
- if "headers" in config:
224
- http_kwargs["headers"] = config["headers"]
225
225
  if "timeout" in config:
226
226
  http_kwargs["timeout"] = config["timeout"]
227
227
  if "read_timeout" in config:
@@ -231,33 +231,34 @@ class ManagedMCPServer:
231
231
  elif config.get("headers"):
232
232
  # Create HTTP client if headers are provided but no client specified
233
233
  http_kwargs["http_client"] = self._get_http_client()
234
-
235
- self._pydantic_server = MCPServerStreamableHTTP(**http_kwargs, process_tool_call=process_tool_call)
236
-
234
+
235
+ self._pydantic_server = MCPServerStreamableHTTP(
236
+ **http_kwargs, process_tool_call=process_tool_call
237
+ )
238
+
237
239
  else:
238
240
  raise ValueError(f"Unsupported server type: {server_type}")
239
-
241
+
240
242
  logger.info(f"Created {server_type} server: {self.config.name}")
241
-
243
+
242
244
  except Exception as e:
243
- logger.error(f"Failed to create {server_type} server {self.config.name}: {e}")
245
+ logger.error(
246
+ f"Failed to create {server_type} server {self.config.name}: {e}"
247
+ )
244
248
  raise
245
-
249
+
246
250
  def _get_http_client(self) -> httpx.AsyncClient:
247
251
  """
248
252
  Create httpx.AsyncClient with headers from config.
249
-
253
+
250
254
  Returns:
251
255
  Configured async HTTP client with custom headers
252
256
  """
253
257
  headers = self.config.config.get("headers", {})
254
258
  timeout = self.config.config.get("timeout", 30)
255
- client = create_async_client(
256
- headers=headers,
257
- timeout=timeout
258
- )
259
+ client = create_async_client(headers=headers, timeout=timeout)
259
260
  return client
260
-
261
+
261
262
  def enable(self) -> None:
262
263
  """Enable server availability."""
263
264
  self._enabled = True
@@ -265,7 +266,7 @@ class ManagedMCPServer:
265
266
  self._state = ServerState.RUNNING
266
267
  self._start_time = datetime.now()
267
268
  logger.info(f"Enabled server: {self.config.name}")
268
-
269
+
269
270
  def disable(self) -> None:
270
271
  """Disable server availability."""
271
272
  self._enabled = False
@@ -273,20 +274,20 @@ class ManagedMCPServer:
273
274
  self._state = ServerState.STOPPED
274
275
  self._stop_time = datetime.now()
275
276
  logger.info(f"Disabled server: {self.config.name}")
276
-
277
+
277
278
  def is_enabled(self) -> bool:
278
279
  """
279
280
  Check if server is enabled.
280
-
281
+
281
282
  Returns:
282
283
  True if server is enabled, False otherwise
283
284
  """
284
285
  return self._enabled
285
-
286
+
286
287
  def quarantine(self, duration: int) -> None:
287
288
  """
288
289
  Temporarily disable server for specified duration.
289
-
290
+
290
291
  Args:
291
292
  duration: Quarantine duration in seconds
292
293
  """
@@ -297,46 +298,48 @@ class ManagedMCPServer:
297
298
  f"Quarantined server {self.config.name} for {duration} seconds "
298
299
  f"(was {previous_state.value})"
299
300
  )
300
-
301
+
301
302
  def is_quarantined(self) -> bool:
302
303
  """
303
304
  Check if server is currently quarantined.
304
-
305
+
305
306
  Returns:
306
307
  True if server is quarantined, False otherwise
307
308
  """
308
309
  if self._quarantine_until is None:
309
310
  return False
310
-
311
+
311
312
  if datetime.now() >= self._quarantine_until:
312
313
  # Quarantine period has expired
313
314
  self._quarantine_until = None
314
315
  if self._state == ServerState.QUARANTINED:
315
316
  # Restore to running state if enabled
316
- self._state = ServerState.RUNNING if self._enabled else ServerState.STOPPED
317
+ self._state = (
318
+ ServerState.RUNNING if self._enabled else ServerState.STOPPED
319
+ )
317
320
  logger.info(f"Released quarantine for server: {self.config.name}")
318
321
  return False
319
-
322
+
320
323
  return True
321
-
324
+
322
325
  def get_captured_stderr(self) -> list[str]:
323
326
  """
324
327
  Get captured stderr output if this is a stdio server.
325
-
328
+
326
329
  Returns:
327
330
  List of captured stderr lines, or empty list if not applicable
328
331
  """
329
332
  if isinstance(self._pydantic_server, BlockingMCPServerStdio):
330
333
  return self._pydantic_server.get_captured_stderr()
331
334
  return []
332
-
335
+
333
336
  async def wait_until_ready(self, timeout: float = 30.0) -> bool:
334
337
  """
335
338
  Wait until the server is ready.
336
-
339
+
337
340
  Args:
338
341
  timeout: Maximum time to wait in seconds
339
-
342
+
340
343
  Returns:
341
344
  True if server is ready, False otherwise
342
345
  """
@@ -348,25 +351,25 @@ class ManagedMCPServer:
348
351
  return False
349
352
  # Non-stdio servers are considered ready immediately
350
353
  return True
351
-
354
+
352
355
  async def ensure_ready(self, timeout: float = 30.0):
353
356
  """
354
357
  Ensure server is ready, raising exception if not.
355
-
358
+
356
359
  Args:
357
360
  timeout: Maximum time to wait in seconds
358
-
361
+
359
362
  Raises:
360
363
  TimeoutError: If server doesn't initialize within timeout
361
364
  Exception: If server initialization failed
362
365
  """
363
366
  if isinstance(self._pydantic_server, BlockingMCPServerStdio):
364
367
  await self._pydantic_server.ensure_ready(timeout)
365
-
368
+
366
369
  def get_status(self) -> Dict[str, Any]:
367
370
  """
368
371
  Return current status information.
369
-
372
+
370
373
  Returns:
371
374
  Dictionary containing comprehensive status information
372
375
  """
@@ -374,11 +377,11 @@ class ManagedMCPServer:
374
377
  uptime = None
375
378
  if self._start_time and self._state == ServerState.RUNNING:
376
379
  uptime = (now - self._start_time).total_seconds()
377
-
380
+
378
381
  quarantine_remaining = None
379
382
  if self.is_quarantined():
380
383
  quarantine_remaining = (self._quarantine_until - now).total_seconds()
381
-
384
+
382
385
  return {
383
386
  "id": self.config.id,
384
387
  "name": self.config.name,
@@ -393,9 +396,9 @@ class ManagedMCPServer:
393
396
  "error_message": self._error_message,
394
397
  "config": self.config.config.copy(), # Copy to prevent modification
395
398
  "server_available": (
396
- self._pydantic_server is not None and
397
- self._enabled and
398
- not self.is_quarantined() and
399
- self._state == ServerState.RUNNING
400
- )
401
- }
399
+ self._pydantic_server is not None
400
+ and self._enabled
401
+ and not self.is_quarantined()
402
+ and self._state == ServerState.RUNNING
403
+ ),
404
+ }