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:
@@ -132,6 +132,19 @@ class ConnTester(QThread):
|
||||
self.done.emit(ok, detail)
|
||||
|
||||
|
||||
class SpawnTester(QThread):
|
||||
done = Signal(dict)
|
||||
|
||||
def __init__(self, data, timeout=3.0):
|
||||
super().__init__()
|
||||
self._data = data
|
||||
self._timeout = timeout
|
||||
|
||||
def run(self):
|
||||
result = core.spawn_test(self._data, self._timeout)
|
||||
self.done.emit(result)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Small reusable: key/value editor (for env and headers)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -354,6 +367,10 @@ class ServerEditor(QFrame):
|
||||
self.test_btn = QPushButton("Test connection")
|
||||
self.test_btn.clicked.connect(self._test_remote)
|
||||
self.test_btn.setVisible(False)
|
||||
self.spawn_btn = QPushButton("Test launch")
|
||||
self.spawn_btn.setToolTip("Spawn the server for 3 s and report whether it starts cleanly")
|
||||
self.spawn_btn.clicked.connect(self._test_spawn)
|
||||
self.spawn_btn.setVisible(False)
|
||||
self.details_btn = QPushButton("Details ▸")
|
||||
self.details_btn.setCheckable(True)
|
||||
self.details_btn.toggled.connect(self._toggle_diag)
|
||||
@@ -363,6 +380,7 @@ class ServerEditor(QFrame):
|
||||
dep.addWidget(self.dep_label, 1)
|
||||
dep.addWidget(self.fix_btn)
|
||||
dep.addWidget(self.test_btn)
|
||||
dep.addWidget(self.spawn_btn)
|
||||
dep.addWidget(self.details_btn)
|
||||
dep.addWidget(recheck)
|
||||
outer.addLayout(dep)
|
||||
@@ -585,7 +603,9 @@ class ServerEditor(QFrame):
|
||||
res = core.check_dependency(data)
|
||||
self._set_dep(res)
|
||||
self.fix_btn.setVisible(res["status"] == "warn")
|
||||
self.test_btn.setVisible(self.type.currentIndex() == 1)
|
||||
is_remote = self.type.currentIndex() == 1
|
||||
self.test_btn.setVisible(is_remote)
|
||||
self.spawn_btn.setVisible(not is_remote and res["status"] in ("ok", "warn"))
|
||||
problem = res["status"] in ("missing", "warn", "unknown")
|
||||
if auto_open and problem and not self.details_btn.isChecked():
|
||||
self.details_btn.setChecked(True) # opens panel (fills text via _toggle_diag)
|
||||
@@ -650,6 +670,40 @@ class ServerEditor(QFrame):
|
||||
self.dep_label.setText(f"reachable · {detail}" if ok else f"unreachable · {detail}")
|
||||
self.dep_label.setStyleSheet(f"color: {color};")
|
||||
|
||||
def _test_spawn(self):
|
||||
self.spawn_btn.setEnabled(False)
|
||||
self.spawn_btn.setText("Launching…")
|
||||
self.dep_dot.setText("◆")
|
||||
self.dep_dot.setStyleSheet(f"color: {MUTED}; font-size: 14px;")
|
||||
self.dep_label.setText("spawning server…")
|
||||
self.dep_label.setStyleSheet(f"color: {MUTED};")
|
||||
self._spawner = SpawnTester(self.dump_data(), timeout=3.0)
|
||||
self._spawner.done.connect(self._on_spawn_done)
|
||||
self._spawner.start()
|
||||
|
||||
def _on_spawn_done(self, result: dict):
|
||||
self.spawn_btn.setEnabled(True)
|
||||
self.spawn_btn.setText("Test launch")
|
||||
outcome = result.get("outcome", "")
|
||||
detail = result.get("detail", "")
|
||||
stderr = result.get("stderr", "").strip()
|
||||
if outcome == "ok":
|
||||
color = GOOD
|
||||
label = f"started · {detail}"
|
||||
elif outcome == "exited":
|
||||
color = WARN
|
||||
label = f"exited cleanly · {detail}"
|
||||
else:
|
||||
color = BAD
|
||||
label = f"{outcome} · {detail}"
|
||||
self.dep_dot.setText("●")
|
||||
self.dep_dot.setStyleSheet(f"color: {color}; font-size: 14px;")
|
||||
self.dep_label.setText(label)
|
||||
self.dep_label.setStyleSheet(f"color: {color};")
|
||||
if stderr and self.diag_card.isVisible():
|
||||
current = self.diag_text.toPlainText()
|
||||
self.diag_text.setPlainText(current + f"\n\n── stderr from Test launch ──\n{stderr}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Arguments editor: one line = one argument, with a numbered gutter so that
|
||||
|
||||
Reference in New Issue
Block a user