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,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
|
+
}
|