feat: stdio server spawn-test (issue #2)
Add spawn_test() to bcc_core — spawns a stdio server for up to 3 s, captures stderr, and reports ok/exited/crashed/not_found. Key design decisions driven by real MCP server behaviour: - stdin=PIPE (never written): servers block on JSON-RPC input and stay alive, so "still running after timeout" reliably signals a healthy start. stdin=DEVNULL would send EOF, causing well-behaved servers to exit 0 and be misclassified as "exited". - Command resolved via shutil.which(augmented_path()) before Popen so subprocess PATH resolution is unambiguous across platforms. - start_new_session=True on POSIX + os.killpg on timeout: kills the whole process group, not just the launcher (npx, uvx), which would otherwise orphan the actual node/python grandchild process. - stdout=DEVNULL: draining a PIPE we don't read would deadlock at ~64 KB. - stderr drained in a daemon thread, capped at 4 KB. GUI: SpawnTester(QThread) wraps spawn_test; "Test launch" button in ServerEditor dep row (stdio only, visible when command resolves ok/warn). Result colours match the existing dep-status palette (green/amber/red). Stderr appended to the diagnostics panel if it is open. 7 new unit tests cover all outcomes and the stdin-open regression guard. Ran python bcc.py locally: button appears for stdio servers whose command resolves, is hidden for remote servers and missing-command servers. Closes #2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+127
@@ -20,8 +20,11 @@ import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal as _signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -1057,6 +1060,130 @@ def diagnostics_text(name: str, data: dict) -> str:
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
_STDERR_CAP = 4096 # bytes
|
||||
|
||||
|
||||
def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
||||
"""
|
||||
Attempt to start a stdio server and observe it for `timeout` seconds.
|
||||
|
||||
Returns a dict:
|
||||
outcome: "ok" — still running after timeout; server started correctly
|
||||
"exited" — exited with code 0 before timeout; unusual for a server
|
||||
"crashed" — exited with a non-zero code before timeout
|
||||
"not_found" — command could not be resolved to an executable
|
||||
"not_applicable" — remote server or no command; nothing to spawn
|
||||
returncode: int | None
|
||||
stderr: str (first ~4 KB)
|
||||
detail: str
|
||||
|
||||
Run this off the UI thread — it blocks for up to `timeout` seconds.
|
||||
"""
|
||||
if "url" in data and "command" not in data:
|
||||
return {
|
||||
"outcome": "not_applicable",
|
||||
"returncode": None,
|
||||
"stderr": "",
|
||||
"detail": "remote server",
|
||||
}
|
||||
|
||||
cmd = (data.get("command") or "").strip()
|
||||
if not cmd:
|
||||
return {
|
||||
"outcome": "not_applicable",
|
||||
"returncode": None,
|
||||
"stderr": "",
|
||||
"detail": "no command set",
|
||||
}
|
||||
|
||||
# Resolve to absolute path so subprocess doesn't fight with PATH in env.
|
||||
resolved_cmd = shutil.which(cmd, path=augmented_path())
|
||||
if resolved_cmd is None:
|
||||
return {
|
||||
"outcome": "not_found",
|
||||
"returncode": None,
|
||||
"stderr": "",
|
||||
"detail": f"command not found: {cmd}",
|
||||
}
|
||||
|
||||
args_list = [resolved_cmd] + [str(a) for a in (data.get("args") or [])]
|
||||
|
||||
merged_env = {**os.environ, "PATH": augmented_path()}
|
||||
merged_env.update(data.get("env") or {})
|
||||
|
||||
stderr_chunks: list[bytes] = []
|
||||
|
||||
def _drain(pipe) -> None:
|
||||
try:
|
||||
while sum(len(c) for c in stderr_chunks) < _STDERR_CAP:
|
||||
chunk = pipe.read(1024)
|
||||
if not chunk:
|
||||
break
|
||||
stderr_chunks.append(chunk)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
popen_kwargs: dict = dict(
|
||||
stdin=subprocess.PIPE, # keep open so servers block on read rather than seeing EOF
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
env=merged_env,
|
||||
)
|
||||
if os.name != "nt":
|
||||
popen_kwargs["start_new_session"] = True # own process group → clean kill
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(args_list, **popen_kwargs)
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
return {
|
||||
"outcome": "not_found",
|
||||
"returncode": None,
|
||||
"stderr": "",
|
||||
"detail": str(e),
|
||||
}
|
||||
|
||||
drain_thread = threading.Thread(target=_drain, args=(proc.stderr,), daemon=True)
|
||||
drain_thread.start()
|
||||
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
try:
|
||||
if os.name != "nt":
|
||||
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
||||
else:
|
||||
proc.kill() # best-effort on Windows
|
||||
except OSError:
|
||||
pass
|
||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||
proc.wait(timeout=1.0)
|
||||
drain_thread.join(0.5)
|
||||
stderr = b"".join(stderr_chunks).decode(errors="replace")
|
||||
return {
|
||||
"outcome": "ok",
|
||||
"returncode": None,
|
||||
"stderr": stderr,
|
||||
"detail": f"still running after {timeout:.0f}s — server started successfully",
|
||||
}
|
||||
|
||||
drain_thread.join(0.5)
|
||||
stderr = b"".join(stderr_chunks).decode(errors="replace")
|
||||
rc = proc.returncode
|
||||
if rc == 0:
|
||||
return {
|
||||
"outcome": "exited",
|
||||
"returncode": rc,
|
||||
"stderr": stderr,
|
||||
"detail": "process exited cleanly (code 0) — unusual; a healthy server should keep running",
|
||||
}
|
||||
return {
|
||||
"outcome": "crashed",
|
||||
"returncode": rc,
|
||||
"stderr": stderr,
|
||||
"detail": f"process exited with code {rc}",
|
||||
}
|
||||
|
||||
|
||||
def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
|
||||
"""
|
||||
Reachability check for a url-based MCP server. ANY HTTP response (even 4xx/5xx)
|
||||
|
||||
Reference in New Issue
Block a user