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,346 @@
1
+ """
2
+ ManagedMCPServer wrapper class implementation.
3
+
4
+ This module provides a managed wrapper around pydantic-ai MCP server classes
5
+ that adds management capabilities while maintaining 100% compatibility.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime, timedelta
14
+ from enum import Enum
15
+ from typing import Dict, Union, Optional, Any
16
+ import httpx
17
+ from pydantic_ai import RunContext
18
+
19
+ from pydantic_ai.mcp import MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP, CallToolFunc, ToolResult
20
+
21
+ from code_puppy.messaging import emit_info
22
+
23
+ # Configure logging
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class ServerState(Enum):
28
+ """Enumeration of possible server states."""
29
+ STOPPED = "stopped"
30
+ STARTING = "starting"
31
+ RUNNING = "running"
32
+ STOPPING = "stopping"
33
+ ERROR = "error"
34
+ QUARANTINED = "quarantined"
35
+
36
+
37
+ @dataclass
38
+ class ServerConfig:
39
+ """Configuration for an MCP server."""
40
+ id: str
41
+ name: str
42
+ type: str # "sse", "stdio", or "http"
43
+ enabled: bool = True
44
+ config: Dict = field(default_factory=dict) # Raw config from JSON
45
+
46
+
47
+ async def process_tool_call(
48
+ ctx: RunContext[Any],
49
+ call_tool: CallToolFunc,
50
+ name: str,
51
+ tool_args: dict[str, Any],
52
+ ) -> ToolResult:
53
+ """A tool call processor that passes along the deps."""
54
+ group_id = uuid.uuid4()
55
+ emit_info(
56
+ f"\n[bold white on purple] MCP Tool Call - {name}[/bold white on purple]",
57
+ message_group=group_id,
58
+ )
59
+ emit_info(
60
+ "\nArgs:",
61
+ message_group=group_id
62
+ )
63
+ emit_info(
64
+ json.dumps(tool_args, indent=2),
65
+ message_group=group_id
66
+ )
67
+ return await call_tool(name, tool_args, {'deps': ctx.deps})
68
+
69
+
70
+ class ManagedMCPServer:
71
+ """
72
+ Managed wrapper around pydantic-ai MCP server classes.
73
+
74
+ This class provides management capabilities like enable/disable,
75
+ quarantine, and status tracking while maintaining 100% compatibility
76
+ with the existing Agent interface through get_pydantic_server().
77
+
78
+ Example usage:
79
+ config = ServerConfig(
80
+ id="123",
81
+ name="test",
82
+ type="sse",
83
+ config={"url": "http://localhost:8080"}
84
+ )
85
+ managed = ManagedMCPServer(config)
86
+ pydantic_server = managed.get_pydantic_server() # Returns actual MCPServerSSE
87
+ """
88
+
89
+ def __init__(self, server_config: ServerConfig):
90
+ """
91
+ Initialize managed server with configuration.
92
+
93
+ Args:
94
+ server_config: Server configuration containing type, connection details, etc.
95
+ """
96
+ self.config = server_config
97
+ self._pydantic_server: Optional[Union[MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP]] = None
98
+ self._state = ServerState.STOPPED
99
+ # Always start disabled - servers must be explicitly started with /mcp start
100
+ self._enabled = False
101
+ self._quarantine_until: Optional[datetime] = None
102
+ self._start_time: Optional[datetime] = None
103
+ self._stop_time: Optional[datetime] = None
104
+ self._error_message: Optional[str] = None
105
+
106
+ # Initialize the pydantic server
107
+ try:
108
+ self._create_server()
109
+ # Always start as STOPPED - servers must be explicitly started
110
+ self._state = ServerState.STOPPED
111
+ except Exception as e:
112
+ logger.error(f"Failed to create server {self.config.name}: {e}")
113
+ self._state = ServerState.ERROR
114
+ self._error_message = str(e)
115
+
116
+ def get_pydantic_server(self) -> Union[MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP]:
117
+ """
118
+ Get the actual pydantic-ai server instance.
119
+
120
+ This method returns the real pydantic-ai MCP server objects for 100% compatibility
121
+ with the existing Agent interface. Do not return custom classes or proxies.
122
+
123
+ Returns:
124
+ Actual pydantic-ai MCP server instance (MCPServerSSE, MCPServerStdio, or MCPServerStreamableHTTP)
125
+
126
+ Raises:
127
+ RuntimeError: If server creation failed or server is not available
128
+ """
129
+ if self._pydantic_server is None:
130
+ raise RuntimeError(f"Server {self.config.name} is not available")
131
+
132
+ if not self.is_enabled() or self.is_quarantined():
133
+ raise RuntimeError(f"Server {self.config.name} is disabled or quarantined")
134
+
135
+ return self._pydantic_server
136
+
137
+ def _create_server(self) -> None:
138
+ """
139
+ Create appropriate pydantic-ai server based on config type.
140
+
141
+ Raises:
142
+ ValueError: If server type is unsupported or config is invalid
143
+ Exception: If server creation fails
144
+ """
145
+ server_type = self.config.type.lower()
146
+ config = self.config.config
147
+
148
+ try:
149
+ if server_type == "sse":
150
+ if "url" not in config:
151
+ raise ValueError("SSE server requires 'url' in config")
152
+
153
+ # Prepare arguments for MCPServerSSE
154
+ sse_kwargs = {
155
+ "url": config["url"],
156
+ }
157
+
158
+ # Add optional parameters if provided
159
+ if "headers" in config:
160
+ sse_kwargs["headers"] = config["headers"]
161
+ if "timeout" in config:
162
+ sse_kwargs["timeout"] = config["timeout"]
163
+ if "read_timeout" in config:
164
+ sse_kwargs["read_timeout"] = config["read_timeout"]
165
+ if "http_client" in config:
166
+ sse_kwargs["http_client"] = config["http_client"]
167
+ elif config.get("headers"):
168
+ # Create HTTP client if headers are provided but no client specified
169
+ sse_kwargs["http_client"] = self._get_http_client()
170
+
171
+ self._pydantic_server = MCPServerSSE(**sse_kwargs, process_tool_call=process_tool_call)
172
+
173
+ elif server_type == "stdio":
174
+ if "command" not in config:
175
+ raise ValueError("Stdio server requires 'command' in config")
176
+
177
+ # Handle command and arguments
178
+ command = config["command"]
179
+ args = config.get("args", [])
180
+ if isinstance(args, str):
181
+ # If args is a string, split it
182
+ args = args.split()
183
+
184
+ # Prepare arguments for MCPServerStdio
185
+ stdio_kwargs = {
186
+ "command": command,
187
+ "args": list(args) if args else []
188
+ }
189
+
190
+ # Add optional parameters if provided
191
+ if "env" in config:
192
+ stdio_kwargs["env"] = config["env"]
193
+ if "cwd" in config:
194
+ stdio_kwargs["cwd"] = config["cwd"]
195
+ if "timeout" in config:
196
+ stdio_kwargs["timeout"] = config["timeout"]
197
+ if "read_timeout" in config:
198
+ stdio_kwargs["read_timeout"] = config["read_timeout"]
199
+
200
+ self._pydantic_server = MCPServerStdio(**stdio_kwargs, process_tool_call=process_tool_call)
201
+
202
+ elif server_type == "http":
203
+ if "url" not in config:
204
+ raise ValueError("HTTP server requires 'url' in config")
205
+
206
+ # Prepare arguments for MCPServerStreamableHTTP
207
+ http_kwargs = {
208
+ "url": config["url"],
209
+ }
210
+
211
+ # Add optional parameters if provided
212
+ if "headers" in config:
213
+ http_kwargs["headers"] = config["headers"]
214
+ if "timeout" in config:
215
+ http_kwargs["timeout"] = config["timeout"]
216
+ if "read_timeout" in config:
217
+ http_kwargs["read_timeout"] = config["read_timeout"]
218
+ if "http_client" in config:
219
+ http_kwargs["http_client"] = config["http_client"]
220
+ elif config.get("headers"):
221
+ # Create HTTP client if headers are provided but no client specified
222
+ http_kwargs["http_client"] = self._get_http_client()
223
+
224
+ self._pydantic_server = MCPServerStreamableHTTP(**http_kwargs, process_tool_call=process_tool_call)
225
+
226
+ else:
227
+ raise ValueError(f"Unsupported server type: {server_type}")
228
+
229
+ logger.info(f"Created {server_type} server: {self.config.name}")
230
+
231
+ except Exception as e:
232
+ logger.error(f"Failed to create {server_type} server {self.config.name}: {e}")
233
+ raise
234
+
235
+ def _get_http_client(self) -> httpx.AsyncClient:
236
+ """
237
+ Create httpx.AsyncClient with headers from config.
238
+
239
+ Returns:
240
+ Configured async HTTP client with custom headers
241
+ """
242
+ headers = self.config.config.get("headers", {})
243
+ timeout = self.config.config.get("timeout", 30)
244
+
245
+ return httpx.AsyncClient(
246
+ headers=headers,
247
+ timeout=timeout
248
+ )
249
+
250
+ def enable(self) -> None:
251
+ """Enable server availability."""
252
+ self._enabled = True
253
+ if self._state == ServerState.STOPPED and self._pydantic_server is not None:
254
+ self._state = ServerState.RUNNING
255
+ self._start_time = datetime.now()
256
+ logger.info(f"Enabled server: {self.config.name}")
257
+
258
+ def disable(self) -> None:
259
+ """Disable server availability."""
260
+ self._enabled = False
261
+ if self._state == ServerState.RUNNING:
262
+ self._state = ServerState.STOPPED
263
+ self._stop_time = datetime.now()
264
+ logger.info(f"Disabled server: {self.config.name}")
265
+
266
+ def is_enabled(self) -> bool:
267
+ """
268
+ Check if server is enabled.
269
+
270
+ Returns:
271
+ True if server is enabled, False otherwise
272
+ """
273
+ return self._enabled
274
+
275
+ def quarantine(self, duration: int) -> None:
276
+ """
277
+ Temporarily disable server for specified duration.
278
+
279
+ Args:
280
+ duration: Quarantine duration in seconds
281
+ """
282
+ self._quarantine_until = datetime.now() + timedelta(seconds=duration)
283
+ previous_state = self._state
284
+ self._state = ServerState.QUARANTINED
285
+ logger.warning(
286
+ f"Quarantined server {self.config.name} for {duration} seconds "
287
+ f"(was {previous_state.value})"
288
+ )
289
+
290
+ def is_quarantined(self) -> bool:
291
+ """
292
+ Check if server is currently quarantined.
293
+
294
+ Returns:
295
+ True if server is quarantined, False otherwise
296
+ """
297
+ if self._quarantine_until is None:
298
+ return False
299
+
300
+ if datetime.now() >= self._quarantine_until:
301
+ # Quarantine period has expired
302
+ self._quarantine_until = None
303
+ if self._state == ServerState.QUARANTINED:
304
+ # Restore to running state if enabled
305
+ self._state = ServerState.RUNNING if self._enabled else ServerState.STOPPED
306
+ logger.info(f"Released quarantine for server: {self.config.name}")
307
+ return False
308
+
309
+ return True
310
+
311
+ def get_status(self) -> Dict[str, Any]:
312
+ """
313
+ Return current status information.
314
+
315
+ Returns:
316
+ Dictionary containing comprehensive status information
317
+ """
318
+ now = datetime.now()
319
+ uptime = None
320
+ if self._start_time and self._state == ServerState.RUNNING:
321
+ uptime = (now - self._start_time).total_seconds()
322
+
323
+ quarantine_remaining = None
324
+ if self.is_quarantined():
325
+ quarantine_remaining = (self._quarantine_until - now).total_seconds()
326
+
327
+ return {
328
+ "id": self.config.id,
329
+ "name": self.config.name,
330
+ "type": self.config.type,
331
+ "state": self._state.value,
332
+ "enabled": self._enabled,
333
+ "quarantined": self.is_quarantined(),
334
+ "quarantine_remaining_seconds": quarantine_remaining,
335
+ "uptime_seconds": uptime,
336
+ "start_time": self._start_time.isoformat() if self._start_time else None,
337
+ "stop_time": self._stop_time.isoformat() if self._stop_time else None,
338
+ "error_message": self._error_message,
339
+ "config": self.config.config.copy(), # Copy to prevent modification
340
+ "server_available": (
341
+ self._pydantic_server is not None and
342
+ self._enabled and
343
+ not self.is_quarantined() and
344
+ self._state == ServerState.RUNNING
345
+ )
346
+ }