code-puppy 0.0.131__py3-none-any.whl → 0.0.133__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/command_line/mcp_commands.py +11 -78
- code_puppy/mcp/blocking_startup.py +404 -0
- code_puppy/mcp/captured_stdio_server.py +282 -0
- code_puppy/mcp/managed_server.py +55 -1
- code_puppy/tui/screens/mcp_install_wizard.py +186 -5
- {code_puppy-0.0.131.dist-info → code_puppy-0.0.133.dist-info}/METADATA +1 -1
- {code_puppy-0.0.131.dist-info → code_puppy-0.0.133.dist-info}/RECORD +11 -9
- {code_puppy-0.0.131.data → code_puppy-0.0.133.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.131.dist-info → code_puppy-0.0.133.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.131.dist-info → code_puppy-0.0.133.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.131.dist-info → code_puppy-0.0.133.dist-info}/licenses/LICENSE +0 -0
|
@@ -597,6 +597,12 @@ class MCPCommandHandler:
|
|
|
597
597
|
import uuid
|
|
598
598
|
group_id = str(uuid.uuid4())
|
|
599
599
|
|
|
600
|
+
# Check if in TUI mode and guide user to use Ctrl+T instead
|
|
601
|
+
if is_tui_mode() and not args:
|
|
602
|
+
emit_info("💡 In TUI mode, press Ctrl+T to open the MCP Install Wizard", message_group=group_id)
|
|
603
|
+
emit_info(" The wizard provides a better interface for browsing and installing MCP servers.", message_group=group_id)
|
|
604
|
+
return
|
|
605
|
+
|
|
600
606
|
try:
|
|
601
607
|
if args:
|
|
602
608
|
# Parse JSON from arguments
|
|
@@ -1567,93 +1573,20 @@ class MCPCommandHandler:
|
|
|
1567
1573
|
|
|
1568
1574
|
display, color = state_map.get(state, ("? Unk", "dim"))
|
|
1569
1575
|
return Text(display, style=color)
|
|
1570
|
-
|
|
1571
|
-
def _find_server_id_by_name(self, server_name: str) -> Optional[str]:
|
|
1572
|
-
"""
|
|
1573
|
-
Find a server ID by its name.
|
|
1574
|
-
|
|
1575
|
-
Args:
|
|
1576
|
-
server_name: Name of the server to find
|
|
1577
|
-
|
|
1578
|
-
Returns:
|
|
1579
|
-
Server ID if found, None otherwise
|
|
1580
|
-
"""
|
|
1581
|
-
try:
|
|
1582
|
-
servers = self.manager.list_servers()
|
|
1583
|
-
for server in servers:
|
|
1584
|
-
if server.name.lower() == server_name.lower():
|
|
1585
|
-
return server.id
|
|
1586
|
-
return None
|
|
1587
|
-
except Exception as e:
|
|
1588
|
-
logger.error(f"Error finding server by name '{server_name}': {e}")
|
|
1589
|
-
return None
|
|
1590
|
-
|
|
1591
|
-
def _suggest_similar_servers(self, server_name: str, group_id: str = None) -> None:
|
|
1592
|
-
"""
|
|
1593
|
-
Suggest similar server names when a server is not found.
|
|
1594
|
-
|
|
1595
|
-
Args:
|
|
1596
|
-
server_name: The server name that was not found
|
|
1597
|
-
group_id: Optional message group ID for grouping related messages
|
|
1598
|
-
"""
|
|
1599
|
-
try:
|
|
1600
|
-
servers = self.manager.list_servers()
|
|
1601
|
-
if not servers:
|
|
1602
|
-
emit_info("No servers are registered", message_group=group_id)
|
|
1603
|
-
return
|
|
1604
|
-
|
|
1605
|
-
# Simple suggestion based on partial matching
|
|
1606
|
-
suggestions = []
|
|
1607
|
-
server_name_lower = server_name.lower()
|
|
1608
|
-
|
|
1609
|
-
for server in servers:
|
|
1610
|
-
if server_name_lower in server.name.lower():
|
|
1611
|
-
suggestions.append(server.name)
|
|
1612
|
-
|
|
1613
|
-
if suggestions:
|
|
1614
|
-
emit_info(f"Did you mean: {', '.join(suggestions)}", message_group=group_id)
|
|
1615
|
-
else:
|
|
1616
|
-
server_names = [s.name for s in servers]
|
|
1617
|
-
emit_info(f"Available servers: {', '.join(server_names)}", message_group=group_id)
|
|
1618
|
-
|
|
1619
|
-
except Exception as e:
|
|
1620
|
-
logger.error(f"Error suggesting similar servers: {e}")
|
|
1621
|
-
|
|
1622
|
-
def _format_state_indicator(self, state: ServerState) -> Text:
|
|
1623
|
-
"""
|
|
1624
|
-
Format a server state with appropriate color and icon.
|
|
1625
|
-
|
|
1626
|
-
Args:
|
|
1627
|
-
state: Server state to format
|
|
1628
|
-
|
|
1629
|
-
Returns:
|
|
1630
|
-
Rich Text object with colored state indicator
|
|
1631
|
-
"""
|
|
1632
|
-
state_map = {
|
|
1633
|
-
ServerState.RUNNING: ("✓ Run", "green"),
|
|
1634
|
-
ServerState.STOPPED: ("✗ Stop", "red"),
|
|
1635
|
-
ServerState.STARTING: ("↗ Start", "yellow"),
|
|
1636
|
-
ServerState.STOPPING: ("↙ Stop", "yellow"),
|
|
1637
|
-
ServerState.ERROR: ("⚠ Err", "red"),
|
|
1638
|
-
ServerState.QUARANTINED: ("⏸ Quar", "yellow"),
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
display, color = state_map.get(state, ("? Unk", "dim"))
|
|
1642
|
-
return Text(display, style=color)
|
|
1643
|
-
|
|
1576
|
+
|
|
1644
1577
|
def _format_uptime(self, uptime_seconds: Optional[float]) -> str:
|
|
1645
1578
|
"""
|
|
1646
1579
|
Format uptime in a human-readable format.
|
|
1647
|
-
|
|
1580
|
+
|
|
1648
1581
|
Args:
|
|
1649
1582
|
uptime_seconds: Uptime in seconds, or None
|
|
1650
|
-
|
|
1583
|
+
|
|
1651
1584
|
Returns:
|
|
1652
1585
|
Formatted uptime string
|
|
1653
1586
|
"""
|
|
1654
1587
|
if uptime_seconds is None or uptime_seconds <= 0:
|
|
1655
1588
|
return "-"
|
|
1656
|
-
|
|
1589
|
+
|
|
1657
1590
|
# Convert to readable format
|
|
1658
1591
|
if uptime_seconds < 60:
|
|
1659
1592
|
return f"{int(uptime_seconds)}s"
|
|
@@ -1665,7 +1598,7 @@ class MCPCommandHandler:
|
|
|
1665
1598
|
hours = int(uptime_seconds // 3600)
|
|
1666
1599
|
minutes = int((uptime_seconds % 3600) // 60)
|
|
1667
1600
|
return f"{hours}h {minutes}m"
|
|
1668
|
-
|
|
1601
|
+
|
|
1669
1602
|
def _show_detailed_server_status(self, server_id: str, server_name: str, group_id: str = None) -> None:
|
|
1670
1603
|
"""
|
|
1671
1604
|
Show comprehensive status information for a specific server.
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Server with blocking startup capability and stderr capture.
|
|
3
|
+
|
|
4
|
+
This module provides MCP servers that:
|
|
5
|
+
1. Capture stderr output from stdio servers
|
|
6
|
+
2. Block until fully initialized before allowing operations
|
|
7
|
+
3. Emit stderr to users via emit_info with message groups
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import uuid
|
|
15
|
+
from typing import Optional, Callable, List
|
|
16
|
+
from contextlib import asynccontextmanager
|
|
17
|
+
from pydantic_ai.mcp import MCPServerStdio
|
|
18
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
19
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
20
|
+
from mcp.shared.session import SessionMessage
|
|
21
|
+
from code_puppy.messaging import emit_info
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StderrFileCapture:
|
|
25
|
+
"""Captures stderr to a file and monitors it in a background thread."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, server_name: str, emit_to_user: bool = True, message_group: Optional[uuid.UUID] = None):
|
|
28
|
+
self.server_name = server_name
|
|
29
|
+
self.emit_to_user = emit_to_user
|
|
30
|
+
self.message_group = message_group or uuid.uuid4()
|
|
31
|
+
self.temp_file = None
|
|
32
|
+
self.temp_path = None
|
|
33
|
+
self.monitor_thread = None
|
|
34
|
+
self.stop_monitoring = threading.Event()
|
|
35
|
+
self.captured_lines = []
|
|
36
|
+
|
|
37
|
+
def start(self):
|
|
38
|
+
"""Start capture by creating temp file and monitor thread."""
|
|
39
|
+
# Create temp file
|
|
40
|
+
self.temp_file = tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.err')
|
|
41
|
+
self.temp_path = self.temp_file.name
|
|
42
|
+
|
|
43
|
+
# Start monitoring thread
|
|
44
|
+
self.stop_monitoring.clear()
|
|
45
|
+
self.monitor_thread = threading.Thread(target=self._monitor_file)
|
|
46
|
+
self.monitor_thread.daemon = True
|
|
47
|
+
self.monitor_thread.start()
|
|
48
|
+
|
|
49
|
+
return self.temp_file
|
|
50
|
+
|
|
51
|
+
def _monitor_file(self):
|
|
52
|
+
"""Monitor the temp file for new content."""
|
|
53
|
+
if not self.temp_path:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
last_pos = 0
|
|
57
|
+
while not self.stop_monitoring.is_set():
|
|
58
|
+
try:
|
|
59
|
+
with open(self.temp_path, 'r') as f:
|
|
60
|
+
f.seek(last_pos)
|
|
61
|
+
new_content = f.read()
|
|
62
|
+
if new_content:
|
|
63
|
+
last_pos = f.tell()
|
|
64
|
+
# Process new lines
|
|
65
|
+
for line in new_content.splitlines():
|
|
66
|
+
if line.strip():
|
|
67
|
+
self.captured_lines.append(line)
|
|
68
|
+
if self.emit_to_user:
|
|
69
|
+
emit_info(
|
|
70
|
+
f"[bold white on blue] MCP {self.server_name} [/bold white on blue] {line}",
|
|
71
|
+
style="dim cyan",
|
|
72
|
+
message_group=self.message_group
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
except Exception:
|
|
76
|
+
pass # File might not exist yet or be deleted
|
|
77
|
+
|
|
78
|
+
self.stop_monitoring.wait(0.1) # Check every 100ms
|
|
79
|
+
|
|
80
|
+
def stop(self):
|
|
81
|
+
"""Stop monitoring and clean up."""
|
|
82
|
+
self.stop_monitoring.set()
|
|
83
|
+
if self.monitor_thread:
|
|
84
|
+
self.monitor_thread.join(timeout=1)
|
|
85
|
+
|
|
86
|
+
if self.temp_file:
|
|
87
|
+
try:
|
|
88
|
+
self.temp_file.close()
|
|
89
|
+
except:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
if self.temp_path and os.path.exists(self.temp_path):
|
|
93
|
+
try:
|
|
94
|
+
# Read any remaining content
|
|
95
|
+
with open(self.temp_path, 'r') as f:
|
|
96
|
+
content = f.read()
|
|
97
|
+
for line in content.splitlines():
|
|
98
|
+
if line.strip() and line not in self.captured_lines:
|
|
99
|
+
self.captured_lines.append(line)
|
|
100
|
+
if self.emit_to_user:
|
|
101
|
+
emit_info(
|
|
102
|
+
f"[bold white on blue] MCP {self.server_name} [/bold white on blue] {line}",
|
|
103
|
+
style="dim cyan",
|
|
104
|
+
message_group=self.message_group
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
os.unlink(self.temp_path)
|
|
108
|
+
except:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def get_captured_lines(self) -> List[str]:
|
|
112
|
+
"""Get all captured lines."""
|
|
113
|
+
return self.captured_lines.copy()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SimpleCapturedMCPServerStdio(MCPServerStdio):
|
|
117
|
+
"""
|
|
118
|
+
MCPServerStdio that captures stderr to a file and optionally emits to user.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
command: str,
|
|
124
|
+
args=(),
|
|
125
|
+
env=None,
|
|
126
|
+
cwd=None,
|
|
127
|
+
emit_stderr: bool = True,
|
|
128
|
+
message_group: Optional[uuid.UUID] = None,
|
|
129
|
+
**kwargs
|
|
130
|
+
):
|
|
131
|
+
super().__init__(command=command, args=args, env=env, cwd=cwd, **kwargs)
|
|
132
|
+
self.emit_stderr = emit_stderr
|
|
133
|
+
self.message_group = message_group or uuid.uuid4()
|
|
134
|
+
self._stderr_capture = None
|
|
135
|
+
|
|
136
|
+
@asynccontextmanager
|
|
137
|
+
async def client_streams(self):
|
|
138
|
+
"""Create streams with stderr capture."""
|
|
139
|
+
server = StdioServerParameters(
|
|
140
|
+
command=self.command,
|
|
141
|
+
args=list(self.args),
|
|
142
|
+
env=self.env,
|
|
143
|
+
cwd=self.cwd
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Create stderr capture
|
|
147
|
+
server_name = getattr(self, 'tool_prefix', self.command)
|
|
148
|
+
self._stderr_capture = StderrFileCapture(server_name, self.emit_stderr, self.message_group)
|
|
149
|
+
stderr_file = self._stderr_capture.start()
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
async with stdio_client(server=server, errlog=stderr_file) as (read_stream, write_stream):
|
|
153
|
+
yield read_stream, write_stream
|
|
154
|
+
finally:
|
|
155
|
+
self._stderr_capture.stop()
|
|
156
|
+
|
|
157
|
+
def get_captured_stderr(self) -> List[str]:
|
|
158
|
+
"""Get captured stderr lines."""
|
|
159
|
+
if self._stderr_capture:
|
|
160
|
+
return self._stderr_capture.get_captured_lines()
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class BlockingMCPServerStdio(SimpleCapturedMCPServerStdio):
|
|
165
|
+
"""
|
|
166
|
+
MCP Server that blocks until fully initialized.
|
|
167
|
+
|
|
168
|
+
This server ensures that initialization is complete before
|
|
169
|
+
allowing any operations, preventing race conditions.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def __init__(self, *args, **kwargs):
|
|
173
|
+
super().__init__(*args, **kwargs)
|
|
174
|
+
self._initialized = asyncio.Event()
|
|
175
|
+
self._init_error: Optional[Exception] = None
|
|
176
|
+
self._initialization_task = None
|
|
177
|
+
|
|
178
|
+
async def __aenter__(self):
|
|
179
|
+
"""Enter context and track initialization."""
|
|
180
|
+
try:
|
|
181
|
+
# Start initialization
|
|
182
|
+
result = await super().__aenter__()
|
|
183
|
+
|
|
184
|
+
# Mark as initialized
|
|
185
|
+
self._initialized.set()
|
|
186
|
+
|
|
187
|
+
# Emit success message
|
|
188
|
+
server_name = getattr(self, 'tool_prefix', self.command)
|
|
189
|
+
emit_info(
|
|
190
|
+
f"✅ MCP Server '{server_name}' initialized successfully",
|
|
191
|
+
style="green",
|
|
192
|
+
message_group=self.message_group
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
except Exception as e:
|
|
198
|
+
# Store error and mark as initialized (with error)
|
|
199
|
+
self._init_error = e
|
|
200
|
+
self._initialized.set()
|
|
201
|
+
|
|
202
|
+
# Emit error message
|
|
203
|
+
server_name = getattr(self, 'tool_prefix', self.command)
|
|
204
|
+
emit_info(
|
|
205
|
+
f"❌ MCP Server '{server_name}' failed to initialize: {e}",
|
|
206
|
+
style="red",
|
|
207
|
+
message_group=self.message_group
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
raise
|
|
211
|
+
|
|
212
|
+
async def wait_until_ready(self, timeout: float = 30.0) -> bool:
|
|
213
|
+
"""
|
|
214
|
+
Wait until the server is ready.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
timeout: Maximum time to wait in seconds
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
True if server is ready, False if timeout or error
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
TimeoutError: If server doesn't initialize within timeout
|
|
224
|
+
Exception: If server initialization failed
|
|
225
|
+
"""
|
|
226
|
+
try:
|
|
227
|
+
await asyncio.wait_for(self._initialized.wait(), timeout=timeout)
|
|
228
|
+
|
|
229
|
+
# Check if there was an initialization error
|
|
230
|
+
if self._init_error:
|
|
231
|
+
raise self._init_error
|
|
232
|
+
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
except asyncio.TimeoutError:
|
|
236
|
+
server_name = getattr(self, 'tool_prefix', self.command)
|
|
237
|
+
raise TimeoutError(f"Server '{server_name}' initialization timeout after {timeout}s")
|
|
238
|
+
|
|
239
|
+
async def ensure_ready(self, timeout: float = 30.0):
|
|
240
|
+
"""
|
|
241
|
+
Ensure server is ready before proceeding.
|
|
242
|
+
|
|
243
|
+
This is a convenience method that raises if not ready.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
timeout: Maximum time to wait in seconds
|
|
247
|
+
|
|
248
|
+
Raises:
|
|
249
|
+
TimeoutError: If server doesn't initialize within timeout
|
|
250
|
+
Exception: If server initialization failed
|
|
251
|
+
"""
|
|
252
|
+
await self.wait_until_ready(timeout)
|
|
253
|
+
|
|
254
|
+
def is_ready(self) -> bool:
|
|
255
|
+
"""
|
|
256
|
+
Check if server is ready without blocking.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
True if server is initialized and ready
|
|
260
|
+
"""
|
|
261
|
+
return self._initialized.is_set() and self._init_error is None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class StartupMonitor:
|
|
265
|
+
"""
|
|
266
|
+
Monitor for tracking multiple server startups.
|
|
267
|
+
|
|
268
|
+
This class helps coordinate startup of multiple MCP servers
|
|
269
|
+
and ensures all are ready before proceeding.
|
|
270
|
+
"""
|
|
271
|
+
|
|
272
|
+
def __init__(self, message_group: Optional[uuid.UUID] = None):
|
|
273
|
+
self.servers = {}
|
|
274
|
+
self.startup_times = {}
|
|
275
|
+
self.message_group = message_group or uuid.uuid4()
|
|
276
|
+
|
|
277
|
+
def add_server(self, name: str, server: BlockingMCPServerStdio):
|
|
278
|
+
"""Add a server to monitor."""
|
|
279
|
+
self.servers[name] = server
|
|
280
|
+
|
|
281
|
+
async def wait_all_ready(self, timeout: float = 30.0) -> dict:
|
|
282
|
+
"""
|
|
283
|
+
Wait for all servers to be ready.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
timeout: Maximum time to wait for all servers
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Dictionary of server names to ready status
|
|
290
|
+
"""
|
|
291
|
+
import time
|
|
292
|
+
results = {}
|
|
293
|
+
|
|
294
|
+
# Create tasks for all servers
|
|
295
|
+
async def wait_server(name: str, server: BlockingMCPServerStdio):
|
|
296
|
+
start = time.time()
|
|
297
|
+
try:
|
|
298
|
+
await server.wait_until_ready(timeout)
|
|
299
|
+
self.startup_times[name] = time.time() - start
|
|
300
|
+
results[name] = True
|
|
301
|
+
emit_info(
|
|
302
|
+
f" {name}: Ready in {self.startup_times[name]:.2f}s",
|
|
303
|
+
style="dim green",
|
|
304
|
+
message_group=self.message_group
|
|
305
|
+
)
|
|
306
|
+
except Exception as e:
|
|
307
|
+
self.startup_times[name] = time.time() - start
|
|
308
|
+
results[name] = False
|
|
309
|
+
emit_info(
|
|
310
|
+
f" {name}: Failed after {self.startup_times[name]:.2f}s - {e}",
|
|
311
|
+
style="dim red",
|
|
312
|
+
message_group=self.message_group
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Wait for all servers in parallel
|
|
316
|
+
emit_info(
|
|
317
|
+
f"⏳ Waiting for {len(self.servers)} MCP servers to initialize...",
|
|
318
|
+
style="cyan",
|
|
319
|
+
message_group=self.message_group
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
tasks = [
|
|
323
|
+
asyncio.create_task(wait_server(name, server))
|
|
324
|
+
for name, server in self.servers.items()
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
328
|
+
|
|
329
|
+
# Report summary
|
|
330
|
+
ready_count = sum(1 for r in results.values() if r)
|
|
331
|
+
total_count = len(results)
|
|
332
|
+
|
|
333
|
+
if ready_count == total_count:
|
|
334
|
+
emit_info(
|
|
335
|
+
f"✅ All {total_count} servers ready!",
|
|
336
|
+
style="green bold",
|
|
337
|
+
message_group=self.message_group
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
emit_info(
|
|
341
|
+
f"⚠️ {ready_count}/{total_count} servers ready",
|
|
342
|
+
style="yellow",
|
|
343
|
+
message_group=self.message_group
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return results
|
|
347
|
+
|
|
348
|
+
def get_startup_report(self) -> str:
|
|
349
|
+
"""Get a report of startup times."""
|
|
350
|
+
lines = ["Server Startup Times:"]
|
|
351
|
+
for name, time_taken in self.startup_times.items():
|
|
352
|
+
status = "✅" if self.servers[name].is_ready() else "❌"
|
|
353
|
+
lines.append(f" {status} {name}: {time_taken:.2f}s")
|
|
354
|
+
return "\n".join(lines)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
async def start_servers_with_blocking(*servers: BlockingMCPServerStdio, timeout: float = 30.0, message_group: Optional[uuid.UUID] = None):
|
|
358
|
+
"""
|
|
359
|
+
Start multiple servers and wait for all to be ready.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
*servers: Variable number of BlockingMCPServerStdio instances
|
|
363
|
+
timeout: Maximum time to wait for all servers
|
|
364
|
+
message_group: Optional UUID for grouping log messages
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
List of ready servers
|
|
368
|
+
|
|
369
|
+
Example:
|
|
370
|
+
server1 = BlockingMCPServerStdio(...)
|
|
371
|
+
server2 = BlockingMCPServerStdio(...)
|
|
372
|
+
ready = await start_servers_with_blocking(server1, server2)
|
|
373
|
+
"""
|
|
374
|
+
monitor = StartupMonitor(message_group=message_group)
|
|
375
|
+
|
|
376
|
+
for i, server in enumerate(servers):
|
|
377
|
+
name = getattr(server, 'tool_prefix', f"server-{i}")
|
|
378
|
+
monitor.add_server(name, server)
|
|
379
|
+
|
|
380
|
+
# Start all servers
|
|
381
|
+
async def start_server(server):
|
|
382
|
+
async with server:
|
|
383
|
+
await asyncio.sleep(0.1) # Keep context alive briefly
|
|
384
|
+
return server
|
|
385
|
+
|
|
386
|
+
# Start servers in parallel
|
|
387
|
+
server_tasks = [
|
|
388
|
+
asyncio.create_task(start_server(server))
|
|
389
|
+
for server in servers
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
# Wait for all to be ready
|
|
393
|
+
results = await monitor.wait_all_ready(timeout)
|
|
394
|
+
|
|
395
|
+
# Get the report
|
|
396
|
+
emit_info(monitor.get_startup_report(), message_group=monitor.message_group)
|
|
397
|
+
|
|
398
|
+
# Return ready servers
|
|
399
|
+
ready_servers = [
|
|
400
|
+
server for name, server in monitor.servers.items()
|
|
401
|
+
if results.get(name, False)
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
return ready_servers
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom MCPServerStdio that captures stderr output properly.
|
|
3
|
+
|
|
4
|
+
This module provides a version of MCPServerStdio that captures subprocess
|
|
5
|
+
stderr output and makes it available through proper logging channels.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import io
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
from contextlib import asynccontextmanager
|
|
15
|
+
from typing import AsyncIterator, Sequence, Optional, Any
|
|
16
|
+
from threading import Thread
|
|
17
|
+
from queue import Queue, Empty
|
|
18
|
+
|
|
19
|
+
from pydantic_ai.mcp import MCPServerStdio
|
|
20
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
21
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
22
|
+
from mcp.shared.session import SessionMessage
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StderrCapture:
|
|
28
|
+
"""
|
|
29
|
+
Captures stderr output using a pipe and background reader.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, name: str, handler: Optional[callable] = None):
|
|
33
|
+
"""
|
|
34
|
+
Initialize stderr capture.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
name: Name for this capture stream
|
|
38
|
+
handler: Optional function to call with captured lines
|
|
39
|
+
"""
|
|
40
|
+
self.name = name
|
|
41
|
+
self.handler = handler or self._default_handler
|
|
42
|
+
self._captured_lines = []
|
|
43
|
+
self._reader_task = None
|
|
44
|
+
self._pipe_r = None
|
|
45
|
+
self._pipe_w = None
|
|
46
|
+
|
|
47
|
+
def _default_handler(self, line: str):
|
|
48
|
+
"""Default handler that logs to Python logging."""
|
|
49
|
+
if line.strip():
|
|
50
|
+
logger.debug(f"[MCP {self.name}] {line.rstrip()}")
|
|
51
|
+
|
|
52
|
+
async def start_capture(self):
|
|
53
|
+
"""Start capturing stderr by creating a pipe and reader task."""
|
|
54
|
+
# Create a pipe for capturing stderr
|
|
55
|
+
self._pipe_r, self._pipe_w = os.pipe()
|
|
56
|
+
|
|
57
|
+
# Make the read end non-blocking
|
|
58
|
+
os.set_blocking(self._pipe_r, False)
|
|
59
|
+
|
|
60
|
+
# Start background task to read from pipe
|
|
61
|
+
self._reader_task = asyncio.create_task(self._read_pipe())
|
|
62
|
+
|
|
63
|
+
# Return the write end as the file descriptor for stderr
|
|
64
|
+
return self._pipe_w
|
|
65
|
+
|
|
66
|
+
async def _read_pipe(self):
|
|
67
|
+
"""Background task to read from the pipe."""
|
|
68
|
+
loop = asyncio.get_event_loop()
|
|
69
|
+
buffer = b''
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
while True:
|
|
73
|
+
# Use asyncio's add_reader for efficient async reading
|
|
74
|
+
future = asyncio.Future()
|
|
75
|
+
|
|
76
|
+
def read_callback():
|
|
77
|
+
try:
|
|
78
|
+
data = os.read(self._pipe_r, 4096)
|
|
79
|
+
future.set_result(data)
|
|
80
|
+
except BlockingIOError:
|
|
81
|
+
future.set_result(b'')
|
|
82
|
+
except Exception as e:
|
|
83
|
+
future.set_exception(e)
|
|
84
|
+
|
|
85
|
+
loop.add_reader(self._pipe_r, read_callback)
|
|
86
|
+
try:
|
|
87
|
+
data = await future
|
|
88
|
+
finally:
|
|
89
|
+
loop.remove_reader(self._pipe_r)
|
|
90
|
+
|
|
91
|
+
if not data:
|
|
92
|
+
await asyncio.sleep(0.1)
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# Process the data
|
|
96
|
+
buffer += data
|
|
97
|
+
|
|
98
|
+
# Look for complete lines
|
|
99
|
+
while b'\n' in buffer:
|
|
100
|
+
line, buffer = buffer.split(b'\n', 1)
|
|
101
|
+
line_str = line.decode('utf-8', errors='replace')
|
|
102
|
+
if line_str:
|
|
103
|
+
self._captured_lines.append(line_str)
|
|
104
|
+
self.handler(line_str)
|
|
105
|
+
|
|
106
|
+
except asyncio.CancelledError:
|
|
107
|
+
# Process any remaining buffer
|
|
108
|
+
if buffer:
|
|
109
|
+
line_str = buffer.decode('utf-8', errors='replace')
|
|
110
|
+
if line_str:
|
|
111
|
+
self._captured_lines.append(line_str)
|
|
112
|
+
self.handler(line_str)
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
async def stop_capture(self):
|
|
116
|
+
"""Stop capturing and clean up."""
|
|
117
|
+
if self._reader_task:
|
|
118
|
+
self._reader_task.cancel()
|
|
119
|
+
try:
|
|
120
|
+
await self._reader_task
|
|
121
|
+
except asyncio.CancelledError:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
if self._pipe_r is not None:
|
|
125
|
+
os.close(self._pipe_r)
|
|
126
|
+
if self._pipe_w is not None:
|
|
127
|
+
os.close(self._pipe_w)
|
|
128
|
+
|
|
129
|
+
def get_captured_lines(self) -> list[str]:
|
|
130
|
+
"""Get all captured lines."""
|
|
131
|
+
return self._captured_lines.copy()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class CapturedMCPServerStdio(MCPServerStdio):
|
|
135
|
+
"""
|
|
136
|
+
Extended MCPServerStdio that captures and handles stderr output.
|
|
137
|
+
|
|
138
|
+
This class captures stderr from the subprocess and makes it available
|
|
139
|
+
through proper logging channels instead of letting it pollute the console.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
command: str,
|
|
145
|
+
args: Sequence[str] = (),
|
|
146
|
+
env: dict[str, str] | None = None,
|
|
147
|
+
cwd: str | None = None,
|
|
148
|
+
stderr_handler: Optional[callable] = None,
|
|
149
|
+
**kwargs
|
|
150
|
+
):
|
|
151
|
+
"""
|
|
152
|
+
Initialize captured stdio server.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
command: The command to run
|
|
156
|
+
args: Arguments for the command
|
|
157
|
+
env: Environment variables
|
|
158
|
+
cwd: Working directory
|
|
159
|
+
stderr_handler: Optional function to handle stderr lines
|
|
160
|
+
**kwargs: Additional arguments for MCPServerStdio
|
|
161
|
+
"""
|
|
162
|
+
super().__init__(command=command, args=args, env=env, cwd=cwd, **kwargs)
|
|
163
|
+
self.stderr_handler = stderr_handler
|
|
164
|
+
self._stderr_capture = None
|
|
165
|
+
self._captured_lines = []
|
|
166
|
+
|
|
167
|
+
@asynccontextmanager
|
|
168
|
+
async def client_streams(
|
|
169
|
+
self,
|
|
170
|
+
) -> AsyncIterator[
|
|
171
|
+
tuple[
|
|
172
|
+
MemoryObjectReceiveStream[SessionMessage | Exception],
|
|
173
|
+
MemoryObjectSendStream[SessionMessage],
|
|
174
|
+
]
|
|
175
|
+
]:
|
|
176
|
+
"""Create the streams for the MCP server with stderr capture."""
|
|
177
|
+
server = StdioServerParameters(
|
|
178
|
+
command=self.command,
|
|
179
|
+
args=list(self.args),
|
|
180
|
+
env=self.env,
|
|
181
|
+
cwd=self.cwd
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Create stderr capture
|
|
185
|
+
def stderr_line_handler(line: str):
|
|
186
|
+
"""Handle captured stderr lines."""
|
|
187
|
+
self._captured_lines.append(line)
|
|
188
|
+
|
|
189
|
+
if self.stderr_handler:
|
|
190
|
+
self.stderr_handler(line)
|
|
191
|
+
else:
|
|
192
|
+
# Default: log at DEBUG level to avoid console spam
|
|
193
|
+
logger.debug(f"[MCP Server {self.command}] {line}")
|
|
194
|
+
|
|
195
|
+
self._stderr_capture = StderrCapture(self.command, stderr_line_handler)
|
|
196
|
+
|
|
197
|
+
# For now, use devnull for stderr to suppress output
|
|
198
|
+
# We'll capture it through other means if needed
|
|
199
|
+
with open(os.devnull, 'w') as devnull:
|
|
200
|
+
async with stdio_client(server=server, errlog=devnull) as (read_stream, write_stream):
|
|
201
|
+
yield read_stream, write_stream
|
|
202
|
+
|
|
203
|
+
def get_captured_stderr(self) -> list[str]:
|
|
204
|
+
"""
|
|
205
|
+
Get all captured stderr lines.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of captured stderr lines
|
|
209
|
+
"""
|
|
210
|
+
return self._captured_lines.copy()
|
|
211
|
+
|
|
212
|
+
def clear_captured_stderr(self):
|
|
213
|
+
"""Clear the captured stderr buffer."""
|
|
214
|
+
self._captured_lines.clear()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class StderrCollector:
|
|
218
|
+
"""
|
|
219
|
+
A centralized collector for stderr from multiple MCP servers.
|
|
220
|
+
|
|
221
|
+
This can be used to aggregate stderr from all MCP servers in one place.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(self):
|
|
225
|
+
"""Initialize the collector."""
|
|
226
|
+
self.servers = {}
|
|
227
|
+
self.all_lines = []
|
|
228
|
+
|
|
229
|
+
def create_handler(self, server_name: str, emit_to_user: bool = False):
|
|
230
|
+
"""
|
|
231
|
+
Create a handler function for a specific server.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
server_name: Name to identify this server
|
|
235
|
+
emit_to_user: If True, emit stderr lines to user via emit_info
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
Handler function that can be passed to CapturedMCPServerStdio
|
|
239
|
+
"""
|
|
240
|
+
def handler(line: str):
|
|
241
|
+
# Store with server identification
|
|
242
|
+
import time
|
|
243
|
+
entry = {
|
|
244
|
+
'server': server_name,
|
|
245
|
+
'line': line,
|
|
246
|
+
'timestamp': time.time()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if server_name not in self.servers:
|
|
250
|
+
self.servers[server_name] = []
|
|
251
|
+
|
|
252
|
+
self.servers[server_name].append(line)
|
|
253
|
+
self.all_lines.append(entry)
|
|
254
|
+
|
|
255
|
+
# Emit to user if requested
|
|
256
|
+
if emit_to_user:
|
|
257
|
+
from code_puppy.messaging import emit_info
|
|
258
|
+
emit_info(f"[MCP {server_name}] {line}", style="dim cyan")
|
|
259
|
+
|
|
260
|
+
return handler
|
|
261
|
+
|
|
262
|
+
def get_server_output(self, server_name: str) -> list[str]:
|
|
263
|
+
"""Get all output from a specific server."""
|
|
264
|
+
return self.servers.get(server_name, []).copy()
|
|
265
|
+
|
|
266
|
+
def get_all_output(self) -> list[dict]:
|
|
267
|
+
"""Get all output from all servers with metadata."""
|
|
268
|
+
return self.all_lines.copy()
|
|
269
|
+
|
|
270
|
+
def clear(self, server_name: Optional[str] = None):
|
|
271
|
+
"""Clear captured output."""
|
|
272
|
+
if server_name:
|
|
273
|
+
if server_name in self.servers:
|
|
274
|
+
self.servers[server_name].clear()
|
|
275
|
+
# Also clear from all_lines
|
|
276
|
+
self.all_lines = [
|
|
277
|
+
entry for entry in self.all_lines
|
|
278
|
+
if entry['server'] != server_name
|
|
279
|
+
]
|
|
280
|
+
else:
|
|
281
|
+
self.servers.clear()
|
|
282
|
+
self.all_lines.clear()
|
code_puppy/mcp/managed_server.py
CHANGED
|
@@ -19,6 +19,7 @@ from pydantic_ai import RunContext
|
|
|
19
19
|
from pydantic_ai.mcp import MCPServerSSE, MCPServerStdio, MCPServerStreamableHTTP, CallToolFunc, ToolResult
|
|
20
20
|
|
|
21
21
|
from code_puppy.messaging import emit_info
|
|
22
|
+
from code_puppy.mcp.blocking_startup import BlockingMCPServerStdio
|
|
22
23
|
|
|
23
24
|
# Configure logging
|
|
24
25
|
logger = logging.getLogger(__name__)
|
|
@@ -197,7 +198,16 @@ class ManagedMCPServer:
|
|
|
197
198
|
if "read_timeout" in config:
|
|
198
199
|
stdio_kwargs["read_timeout"] = config["read_timeout"]
|
|
199
200
|
|
|
200
|
-
|
|
201
|
+
# Use BlockingMCPServerStdio for proper initialization blocking and stderr capture
|
|
202
|
+
# Create a unique message group for this server
|
|
203
|
+
message_group = uuid.uuid4()
|
|
204
|
+
self._pydantic_server = BlockingMCPServerStdio(
|
|
205
|
+
**stdio_kwargs,
|
|
206
|
+
process_tool_call=process_tool_call,
|
|
207
|
+
tool_prefix=config["name"],
|
|
208
|
+
emit_stderr=True, # Always emit stderr for now
|
|
209
|
+
message_group=message_group
|
|
210
|
+
)
|
|
201
211
|
|
|
202
212
|
elif server_type == "http":
|
|
203
213
|
if "url" not in config:
|
|
@@ -308,6 +318,50 @@ class ManagedMCPServer:
|
|
|
308
318
|
|
|
309
319
|
return True
|
|
310
320
|
|
|
321
|
+
def get_captured_stderr(self) -> list[str]:
|
|
322
|
+
"""
|
|
323
|
+
Get captured stderr output if this is a stdio server.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
List of captured stderr lines, or empty list if not applicable
|
|
327
|
+
"""
|
|
328
|
+
if isinstance(self._pydantic_server, BlockingMCPServerStdio):
|
|
329
|
+
return self._pydantic_server.get_captured_stderr()
|
|
330
|
+
return []
|
|
331
|
+
|
|
332
|
+
async def wait_until_ready(self, timeout: float = 30.0) -> bool:
|
|
333
|
+
"""
|
|
334
|
+
Wait until the server is ready.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
timeout: Maximum time to wait in seconds
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
True if server is ready, False otherwise
|
|
341
|
+
"""
|
|
342
|
+
if isinstance(self._pydantic_server, BlockingMCPServerStdio):
|
|
343
|
+
try:
|
|
344
|
+
await self._pydantic_server.wait_until_ready(timeout)
|
|
345
|
+
return True
|
|
346
|
+
except Exception:
|
|
347
|
+
return False
|
|
348
|
+
# Non-stdio servers are considered ready immediately
|
|
349
|
+
return True
|
|
350
|
+
|
|
351
|
+
async def ensure_ready(self, timeout: float = 30.0):
|
|
352
|
+
"""
|
|
353
|
+
Ensure server is ready, raising exception if not.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
timeout: Maximum time to wait in seconds
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
TimeoutError: If server doesn't initialize within timeout
|
|
360
|
+
Exception: If server initialization failed
|
|
361
|
+
"""
|
|
362
|
+
if isinstance(self._pydantic_server, BlockingMCPServerStdio):
|
|
363
|
+
await self._pydantic_server.ensure_ready(timeout)
|
|
364
|
+
|
|
311
365
|
def get_status(self) -> Dict[str, Any]:
|
|
312
366
|
"""
|
|
313
367
|
Return current status information.
|
|
@@ -31,8 +31,9 @@ class MCPInstallWizardScreen(ModalScreen):
|
|
|
31
31
|
super().__init__(**kwargs)
|
|
32
32
|
self.selected_server = None
|
|
33
33
|
self.env_vars = {}
|
|
34
|
-
self.step = "search" # search -> configure -> install
|
|
34
|
+
self.step = "search" # search -> configure -> install -> custom_json
|
|
35
35
|
self.search_counter = 0 # Counter to ensure unique IDs
|
|
36
|
+
self.custom_json_mode = False # Track if we're in custom JSON mode
|
|
36
37
|
|
|
37
38
|
DEFAULT_CSS = """
|
|
38
39
|
MCPInstallWizardScreen {
|
|
@@ -139,6 +140,43 @@ class MCPInstallWizardScreen(ModalScreen):
|
|
|
139
140
|
width: 2fr;
|
|
140
141
|
border: solid $primary;
|
|
141
142
|
}
|
|
143
|
+
|
|
144
|
+
#custom-json-container {
|
|
145
|
+
width: 100%;
|
|
146
|
+
height: 1fr;
|
|
147
|
+
layout: vertical;
|
|
148
|
+
display: none;
|
|
149
|
+
padding: 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#custom-json-header {
|
|
153
|
+
width: 100%;
|
|
154
|
+
height: 2;
|
|
155
|
+
text-align: left;
|
|
156
|
+
color: $warning;
|
|
157
|
+
margin-bottom: 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#custom-name-input {
|
|
161
|
+
width: 100%;
|
|
162
|
+
margin-bottom: 1;
|
|
163
|
+
border: solid $primary;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#custom-json-input {
|
|
167
|
+
width: 100%;
|
|
168
|
+
height: 1fr;
|
|
169
|
+
border: solid $primary;
|
|
170
|
+
margin-bottom: 1;
|
|
171
|
+
background: $surface-darken-1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#custom-json-button {
|
|
175
|
+
width: auto;
|
|
176
|
+
height: 3;
|
|
177
|
+
margin: 0 1;
|
|
178
|
+
min-width: 14;
|
|
179
|
+
}
|
|
142
180
|
"""
|
|
143
181
|
|
|
144
182
|
def compose(self) -> ComposeResult:
|
|
@@ -157,10 +195,17 @@ class MCPInstallWizardScreen(ModalScreen):
|
|
|
157
195
|
yield Container(id="server-info")
|
|
158
196
|
yield Container(id="env-vars-container")
|
|
159
197
|
|
|
198
|
+
# Step 3: Custom JSON configuration (hidden initially)
|
|
199
|
+
with Container(id="custom-json-container"):
|
|
200
|
+
yield Static("📝 Custom JSON Configuration", id="custom-json-header")
|
|
201
|
+
yield Input(placeholder="Server name (e.g. 'my-sqlite-db')", id="custom-name-input")
|
|
202
|
+
yield TextArea(id="custom-json-input")
|
|
203
|
+
|
|
160
204
|
# Navigation buttons
|
|
161
205
|
with Horizontal(id="button-container"):
|
|
162
206
|
yield Button("Cancel", id="cancel-button", variant="default")
|
|
163
207
|
yield Button("Back", id="back-button", variant="default")
|
|
208
|
+
yield Button("Custom JSON", id="custom-json-button", variant="warning")
|
|
164
209
|
yield Button("Next", id="next-button", variant="primary")
|
|
165
210
|
yield Button("Install", id="install-button", variant="success")
|
|
166
211
|
|
|
@@ -176,40 +221,78 @@ class MCPInstallWizardScreen(ModalScreen):
|
|
|
176
221
|
def _show_search_step(self) -> None:
|
|
177
222
|
"""Show the search step."""
|
|
178
223
|
self.step = "search"
|
|
224
|
+
self.custom_json_mode = False
|
|
179
225
|
self.query_one("#search-container").display = True
|
|
180
226
|
self.query_one("#config-container").display = False
|
|
227
|
+
self.query_one("#custom-json-container").display = False
|
|
181
228
|
|
|
182
229
|
self.query_one("#back-button").display = False
|
|
230
|
+
self.query_one("#custom-json-button").display = True
|
|
183
231
|
self.query_one("#next-button").display = True
|
|
184
232
|
self.query_one("#install-button").display = False
|
|
185
233
|
|
|
186
234
|
def _show_config_step(self) -> None:
|
|
187
235
|
"""Show the configuration step."""
|
|
188
236
|
self.step = "configure"
|
|
237
|
+
self.custom_json_mode = False
|
|
189
238
|
self.query_one("#search-container").display = False
|
|
190
239
|
self.query_one("#config-container").display = True
|
|
240
|
+
self.query_one("#custom-json-container").display = False
|
|
191
241
|
|
|
192
242
|
self.query_one("#back-button").display = True
|
|
243
|
+
self.query_one("#custom-json-button").display = False
|
|
193
244
|
self.query_one("#next-button").display = False
|
|
194
245
|
self.query_one("#install-button").display = True
|
|
195
246
|
|
|
196
247
|
self._setup_server_config()
|
|
248
|
+
|
|
249
|
+
def _show_custom_json_step(self) -> None:
|
|
250
|
+
"""Show the custom JSON configuration step."""
|
|
251
|
+
self.step = "custom_json"
|
|
252
|
+
self.custom_json_mode = True
|
|
253
|
+
self.query_one("#search-container").display = False
|
|
254
|
+
self.query_one("#config-container").display = False
|
|
255
|
+
self.query_one("#custom-json-container").display = True
|
|
256
|
+
|
|
257
|
+
self.query_one("#back-button").display = True
|
|
258
|
+
self.query_one("#custom-json-button").display = False
|
|
259
|
+
self.query_one("#next-button").display = False
|
|
260
|
+
self.query_one("#install-button").display = True
|
|
261
|
+
|
|
262
|
+
# Pre-populate with SQLite example
|
|
263
|
+
name_input = self.query_one("#custom-name-input", Input)
|
|
264
|
+
name_input.value = "my-sqlite-db"
|
|
265
|
+
|
|
266
|
+
json_input = self.query_one("#custom-json-input", TextArea)
|
|
267
|
+
json_input.text = """{
|
|
268
|
+
"type": "stdio",
|
|
269
|
+
"command": "npx",
|
|
270
|
+
"args": ["-y", "@modelcontextprotocol/server-sqlite", "./database.db"],
|
|
271
|
+
"timeout": 30
|
|
272
|
+
}"""
|
|
273
|
+
|
|
274
|
+
# Focus the name input
|
|
275
|
+
name_input.focus()
|
|
197
276
|
|
|
198
277
|
def _load_popular_servers(self) -> None:
|
|
199
|
-
"""Load
|
|
278
|
+
"""Load all available servers into the list."""
|
|
200
279
|
self.search_counter += 1
|
|
201
280
|
counter = self.search_counter
|
|
202
281
|
|
|
203
282
|
try:
|
|
204
283
|
from code_puppy.mcp.server_registry_catalog import catalog
|
|
205
|
-
servers
|
|
284
|
+
# Load ALL servers instead of just popular ones
|
|
285
|
+
servers = catalog.servers
|
|
206
286
|
|
|
207
287
|
results_list = self.query_one("#results-list", ListView)
|
|
208
288
|
# Force clear by removing all children
|
|
209
289
|
results_list.remove_children()
|
|
210
290
|
|
|
211
291
|
if servers:
|
|
212
|
-
|
|
292
|
+
# Sort servers to show popular and verified first
|
|
293
|
+
sorted_servers = sorted(servers, key=lambda s: (not s.popular, not s.verified, s.display_name))
|
|
294
|
+
|
|
295
|
+
for i, server in enumerate(sorted_servers):
|
|
213
296
|
indicators = []
|
|
214
297
|
if server.verified:
|
|
215
298
|
indicators.append("✓")
|
|
@@ -240,7 +323,7 @@ class MCPInstallWizardScreen(ModalScreen):
|
|
|
240
323
|
query = event.value.strip()
|
|
241
324
|
|
|
242
325
|
if not query:
|
|
243
|
-
self._load_popular_servers()
|
|
326
|
+
self._load_popular_servers() # This now loads all servers
|
|
244
327
|
return
|
|
245
328
|
|
|
246
329
|
self.search_counter += 1
|
|
@@ -301,12 +384,21 @@ class MCPInstallWizardScreen(ModalScreen):
|
|
|
301
384
|
"""Handle back button click."""
|
|
302
385
|
if self.step == "configure":
|
|
303
386
|
self._show_search_step()
|
|
387
|
+
elif self.step == "custom_json":
|
|
388
|
+
self._show_search_step()
|
|
304
389
|
|
|
390
|
+
@on(Button.Pressed, "#custom-json-button")
|
|
391
|
+
def on_custom_json_clicked(self) -> None:
|
|
392
|
+
"""Handle custom JSON button click."""
|
|
393
|
+
self._show_custom_json_step()
|
|
394
|
+
|
|
305
395
|
@on(Button.Pressed, "#install-button")
|
|
306
396
|
def on_install_clicked(self) -> None:
|
|
307
397
|
"""Handle install button click."""
|
|
308
398
|
if self.step == "configure" and self.selected_server:
|
|
309
399
|
self._install_server()
|
|
400
|
+
elif self.step == "custom_json":
|
|
401
|
+
self._install_custom_json()
|
|
310
402
|
|
|
311
403
|
@on(Button.Pressed, "#cancel-button")
|
|
312
404
|
def on_cancel_clicked(self) -> None:
|
|
@@ -587,6 +679,95 @@ class MCPInstallWizardScreen(ModalScreen):
|
|
|
587
679
|
"message": f"Installation failed: {str(e)}"
|
|
588
680
|
})
|
|
589
681
|
|
|
682
|
+
def _install_custom_json(self) -> None:
|
|
683
|
+
"""Install server from custom JSON configuration."""
|
|
684
|
+
try:
|
|
685
|
+
name_input = self.query_one("#custom-name-input", Input)
|
|
686
|
+
json_input = self.query_one("#custom-json-input", TextArea)
|
|
687
|
+
|
|
688
|
+
server_name = name_input.value.strip()
|
|
689
|
+
json_text = json_input.text.strip()
|
|
690
|
+
|
|
691
|
+
if not server_name:
|
|
692
|
+
# Show error - need a name
|
|
693
|
+
return
|
|
694
|
+
|
|
695
|
+
if not json_text:
|
|
696
|
+
# Show error - need JSON config
|
|
697
|
+
return
|
|
698
|
+
|
|
699
|
+
# Parse JSON
|
|
700
|
+
try:
|
|
701
|
+
config_dict = json.loads(json_text)
|
|
702
|
+
except json.JSONDecodeError as e:
|
|
703
|
+
# Show error - invalid JSON
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
# Validate required fields
|
|
707
|
+
if 'type' not in config_dict:
|
|
708
|
+
# Show error - missing type
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
# Extract type and create server config
|
|
712
|
+
server_type = config_dict.pop('type')
|
|
713
|
+
|
|
714
|
+
# Create and register the server
|
|
715
|
+
from code_puppy.mcp import ServerConfig
|
|
716
|
+
from code_puppy.mcp.manager import get_mcp_manager
|
|
717
|
+
|
|
718
|
+
server_config = ServerConfig(
|
|
719
|
+
id=server_name,
|
|
720
|
+
name=server_name,
|
|
721
|
+
type=server_type,
|
|
722
|
+
enabled=True,
|
|
723
|
+
config=config_dict
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
manager = get_mcp_manager()
|
|
727
|
+
server_id = manager.register_server(server_config)
|
|
728
|
+
|
|
729
|
+
if server_id:
|
|
730
|
+
# Save to mcp_servers.json
|
|
731
|
+
from code_puppy.config import MCP_SERVERS_FILE
|
|
732
|
+
|
|
733
|
+
if os.path.exists(MCP_SERVERS_FILE):
|
|
734
|
+
with open(MCP_SERVERS_FILE, 'r') as f:
|
|
735
|
+
data = json.load(f)
|
|
736
|
+
servers = data.get("mcp_servers", {})
|
|
737
|
+
else:
|
|
738
|
+
servers = {}
|
|
739
|
+
data = {"mcp_servers": servers}
|
|
740
|
+
|
|
741
|
+
# Add the full config including type
|
|
742
|
+
full_config = config_dict.copy()
|
|
743
|
+
full_config['type'] = server_type
|
|
744
|
+
servers[server_name] = full_config
|
|
745
|
+
|
|
746
|
+
os.makedirs(os.path.dirname(MCP_SERVERS_FILE), exist_ok=True)
|
|
747
|
+
with open(MCP_SERVERS_FILE, 'w') as f:
|
|
748
|
+
json.dump(data, f, indent=2)
|
|
749
|
+
|
|
750
|
+
# Reload MCP servers
|
|
751
|
+
from code_puppy.agent import reload_mcp_servers
|
|
752
|
+
reload_mcp_servers()
|
|
753
|
+
|
|
754
|
+
self.dismiss({
|
|
755
|
+
"success": True,
|
|
756
|
+
"message": f"Successfully installed custom server '{server_name}'",
|
|
757
|
+
"server_name": server_name
|
|
758
|
+
})
|
|
759
|
+
else:
|
|
760
|
+
self.dismiss({
|
|
761
|
+
"success": False,
|
|
762
|
+
"message": "Failed to register custom server"
|
|
763
|
+
})
|
|
764
|
+
|
|
765
|
+
except Exception as e:
|
|
766
|
+
self.dismiss({
|
|
767
|
+
"success": False,
|
|
768
|
+
"message": f"Installation failed: {str(e)}"
|
|
769
|
+
})
|
|
770
|
+
|
|
590
771
|
def on_key(self, event) -> None:
|
|
591
772
|
"""Handle key events."""
|
|
592
773
|
if event.key == "escape":
|
|
@@ -25,7 +25,7 @@ code_puppy/command_line/__init__.py,sha256=y7WeRemfYppk8KVbCGeAIiTuiOszIURCDjOMZ
|
|
|
25
25
|
code_puppy/command_line/command_handler.py,sha256=IhDaDa0GmpW0BCFfmkgpL_6iQBJlXEKQC9SDmpj_XeI,20730
|
|
26
26
|
code_puppy/command_line/file_path_completion.py,sha256=gw8NpIxa6GOpczUJRyh7VNZwoXKKn-yvCqit7h2y6Gg,2931
|
|
27
27
|
code_puppy/command_line/load_context_completion.py,sha256=6eZxV6Bs-EFwZjN93V8ZDZUC-6RaWxvtZk-04Wtikyw,2240
|
|
28
|
-
code_puppy/command_line/mcp_commands.py,sha256=
|
|
28
|
+
code_puppy/command_line/mcp_commands.py,sha256=v9faF0G3Ux_JwiQRUPEfvvy1DRDylHyrhCPnrBuwngM,78784
|
|
29
29
|
code_puppy/command_line/meta_command_handler.py,sha256=02NU4Lspf5qRMPTsrGiMRLSUshZhdmS0XQA26k8vUjw,5665
|
|
30
30
|
code_puppy/command_line/model_picker_completion.py,sha256=xvwgthVmLRA9a8RJG6iFImxR2yD6rJYPJJav0YJoVCc,3599
|
|
31
31
|
code_puppy/command_line/motd.py,sha256=PEdkp3ZnydVfvd7mNJylm8YyFNUKg9jmY6uwkA1em8c,2152
|
|
@@ -33,12 +33,14 @@ code_puppy/command_line/prompt_toolkit_completion.py,sha256=BKNw-DwacZPNTKjjXlxn
|
|
|
33
33
|
code_puppy/command_line/utils.py,sha256=7eyxDHjPjPB9wGDJQQcXV_zOsGdYsFgI0SGCetVmTqE,1251
|
|
34
34
|
code_puppy/mcp/__init__.py,sha256=LJd9mGStskhXYBEp1UhtHlrAQ3rCHnfTa7KSmqtZe34,1143
|
|
35
35
|
code_puppy/mcp/async_lifecycle.py,sha256=45tw7ZcDV6LVBrTvvNkMCDhnTapgQCYcc01W8Gp9c5A,8064
|
|
36
|
+
code_puppy/mcp/blocking_startup.py,sha256=R5XjDeIORd_jY5G0OF7GPFzSiDhjFDxMRcJJ-MCTFO8,14140
|
|
37
|
+
code_puppy/mcp/captured_stdio_server.py,sha256=DHeLYCbOWwM_a47xEsacS8yz0eCQhGsUTK9JIpHhX78,9504
|
|
36
38
|
code_puppy/mcp/circuit_breaker.py,sha256=TF0ukl_d4EithPQbiq1yDfFPkCZ7bRBe0jiEx_tnMR4,8630
|
|
37
39
|
code_puppy/mcp/config_wizard.py,sha256=d0o29ZcnGLdpV778nwTUAPH7B8lHAC2XggU-ql37Fzo,16777
|
|
38
40
|
code_puppy/mcp/dashboard.py,sha256=fHBdAVidQUWuhjMjF7hIy0SjrQlrz31zSqzqJv8H2eI,9396
|
|
39
41
|
code_puppy/mcp/error_isolation.py,sha256=xw8DGTItT8-RY5TVYzORtrtfdI1s1ZfUUT88Pr7h_pI,12270
|
|
40
42
|
code_puppy/mcp/health_monitor.py,sha256=oNgPyEwzkF61gNc7nk-FJU_yO2MERjASn4g5shcabic,20428
|
|
41
|
-
code_puppy/mcp/managed_server.py,sha256=
|
|
43
|
+
code_puppy/mcp/managed_server.py,sha256=z0uA9JFtThVxno0TZpa5nlsvRVe2tNnZrMJYymj600E,14932
|
|
42
44
|
code_puppy/mcp/manager.py,sha256=ZfYoz3nmQqrkSnDzgr9s_QvGKFCthA1bvt3gQm0W6VI,27280
|
|
43
45
|
code_puppy/mcp/registry.py,sha256=YJ-VPFjk1ZFrSftbu9bKVIWvGYX4zIk2TnrM_h_us1M,15841
|
|
44
46
|
code_puppy/mcp/retry_manager.py,sha256=B4q1MyzZQ9RZRW3FKhyqhq-ebSG8Do01j4A70zHTxgA,10988
|
|
@@ -80,7 +82,7 @@ code_puppy/tui/models/command_history.py,sha256=bPWr_xnyQvjG5tPg_5pwqlEzn2fR170H
|
|
|
80
82
|
code_puppy/tui/models/enums.py,sha256=1ulsei95Gxy4r1sk-m-Sm5rdmejYCGRI-YtUwJmKFfM,501
|
|
81
83
|
code_puppy/tui/screens/__init__.py,sha256=Sa_R_caykfsa7D55Zuc9VYpFfmQZAYxBFxfn_7Qe41M,287
|
|
82
84
|
code_puppy/tui/screens/help.py,sha256=eJuPaOOCp7ZSUlecearqsuX6caxWv7NQszUh0tZJjBM,3232
|
|
83
|
-
code_puppy/tui/screens/mcp_install_wizard.py,sha256=
|
|
85
|
+
code_puppy/tui/screens/mcp_install_wizard.py,sha256=sUaoJa03xqpK5CUCOYbnMffD8IrLRzPid82o2SupVF4,28219
|
|
84
86
|
code_puppy/tui/screens/settings.py,sha256=GMpv-qa08rorAE9mj3AjmqjZFPhmeJ_GWd-DBHG6iAA,10671
|
|
85
87
|
code_puppy/tui/screens/tools.py,sha256=3pr2Xkpa9Js6Yhf1A3_wQVRzFOui-KDB82LwrsdBtyk,1715
|
|
86
88
|
code_puppy/tui/tests/__init__.py,sha256=Fzb4un4eeKfaKsIa5tqI5pTuwfpS8qD7Z6W7KeqWe84,23
|
|
@@ -103,9 +105,9 @@ code_puppy/tui/tests/test_sidebar_history_navigation.py,sha256=JGiyua8A2B8dLfwiE
|
|
|
103
105
|
code_puppy/tui/tests/test_status_bar.py,sha256=nYT_FZGdmqnnbn6o0ZuOkLtNUtJzLSmtX8P72liQ5Vo,1797
|
|
104
106
|
code_puppy/tui/tests/test_timestamped_history.py,sha256=nVXt9hExZZ_8MFP-AZj4L4bB_1Eo_mc-ZhVICzTuw3I,1799
|
|
105
107
|
code_puppy/tui/tests/test_tools.py,sha256=kgzzAkK4r0DPzQwHHD4cePpVNgrHor6cFr05Pg6DBWg,2687
|
|
106
|
-
code_puppy-0.0.
|
|
107
|
-
code_puppy-0.0.
|
|
108
|
-
code_puppy-0.0.
|
|
109
|
-
code_puppy-0.0.
|
|
110
|
-
code_puppy-0.0.
|
|
111
|
-
code_puppy-0.0.
|
|
108
|
+
code_puppy-0.0.133.data/data/code_puppy/models.json,sha256=GpvtWnBKERm6T7HCZJQUIVAS5256-tZ_bFuRtnKXEsY,3128
|
|
109
|
+
code_puppy-0.0.133.dist-info/METADATA,sha256=wjIRB-TqWvvIwXBfOlfmIzqM-4oONVgTIyqM1fP8OIw,19873
|
|
110
|
+
code_puppy-0.0.133.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
111
|
+
code_puppy-0.0.133.dist-info/entry_points.txt,sha256=d8YkBvIUxF-dHNJAj-x4fPEqizbY5d_TwvYpc01U5kw,58
|
|
112
|
+
code_puppy-0.0.133.dist-info/licenses/LICENSE,sha256=31u8x0SPgdOq3izJX41kgFazWsM43zPEF9eskzqbJMY,1075
|
|
113
|
+
code_puppy-0.0.133.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|