Merge branch 'feat/6-log-viewer' into integration/v1.2.0

# Conflicts:
#	bcc_core.py
#	tests/test_core.py
This commit is contained in:
Cowork Supervisor
2026-07-07 22:12:17 -04:00
3 changed files with 195 additions and 0 deletions
+130
View File
@@ -374,6 +374,9 @@ class ServerEditor(QFrame):
self.spawn_btn.setToolTip("Spawn the server for 3 s and report whether it starts cleanly") 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.clicked.connect(self._test_spawn)
self.spawn_btn.setVisible(False) self.spawn_btn.setVisible(False)
self.logs_btn = QPushButton("View logs")
self.logs_btn.setToolTip("Open this server's MCP log in a read-only, auto-tailing viewer")
self.logs_btn.clicked.connect(self._view_logs)
self.details_btn = QPushButton("Details ▸") self.details_btn = QPushButton("Details ▸")
self.details_btn.setCheckable(True) self.details_btn.setCheckable(True)
self.details_btn.toggled.connect(self._toggle_diag) self.details_btn.toggled.connect(self._toggle_diag)
@@ -384,6 +387,7 @@ class ServerEditor(QFrame):
dep.addWidget(self.fix_btn) dep.addWidget(self.fix_btn)
dep.addWidget(self.test_btn) dep.addWidget(self.test_btn)
dep.addWidget(self.spawn_btn) dep.addWidget(self.spawn_btn)
dep.addWidget(self.logs_btn)
dep.addWidget(self.details_btn) dep.addWidget(self.details_btn)
dep.addWidget(recheck) dep.addWidget(recheck)
outer.addLayout(dep) outer.addLayout(dep)
@@ -728,6 +732,13 @@ class ServerEditor(QFrame):
elif self.diag_card.isVisible(): elif self.diag_card.isVisible():
self.diag_text.setPlainText(self._full_diag_text()) self.diag_text.setPlainText(self._full_diag_text())
def _view_logs(self):
name = self.current_name()
if not name:
return
dlg = LogViewerDialog(self.window(), name)
dlg.exec()
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Arguments editor: one line = one argument, with a numbered gutter so that # Arguments editor: one line = one argument, with a numbered gutter so that
@@ -1090,6 +1101,125 @@ class PasteDialog(QDialog):
self.err.setText(str(e)) self.err.setText(str(e))
# --------------------------------------------------------------------------- #
# Log viewer dialog (issue #6): a read-only, auto-tailing view of a single
# server's MCP log file. Polls on a QTimer instead of watching the filesystem
# so it works the same on every platform; never writes to the log.
# --------------------------------------------------------------------------- #
class LogViewerDialog(QDialog):
POLL_MS = 1500
MAX_TAIL_BYTES = 300_000
def __init__(self, parent, server_name: str):
super().__init__(parent)
self._name = server_name
self._last_path: Path | None = None
self._last_size: int | None = None
self.setWindowTitle(f"Logs — {server_name}")
self.resize(760, 520)
v = QVBoxLayout(self)
v.setSpacing(8)
self.path_label = QLabel("")
self.path_label.setObjectName("muted")
self.path_label.setWordWrap(True)
v.addWidget(self.path_label)
self.view = QPlainTextEdit()
self.view.setObjectName("diag")
self.view.setReadOnly(True)
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
v.addWidget(self.view, 1)
row = QHBoxLayout()
self.status_label = QLabel("")
self.status_label.setObjectName("muted")
row.addWidget(self.status_label, 1)
refresh_btn = QPushButton("Refresh now")
refresh_btn.clicked.connect(self._poll)
row.addWidget(refresh_btn)
close_btn = QPushButton("Close")
close_btn.setObjectName("primary")
close_btn.clicked.connect(self.accept)
row.addWidget(close_btn)
v.addLayout(row)
self._timer = QTimer(self)
self._timer.setInterval(self.POLL_MS)
self._timer.timeout.connect(self._poll)
self._timer.start()
self._poll()
def _poll(self):
path = core.server_log_path(self._name)
if path is None:
self._last_path = None
self._last_size = None
self.path_label.setText(f"No log file for '{self._name}' yet.")
self.view.setPlaceholderText(
"No log yet — this fills in once the server has run at least once "
"and produced output."
)
self.view.clear()
self.status_label.setText("Waiting…")
return
self.path_label.setText(str(path))
try:
size = path.stat().st_size
except OSError:
self.status_label.setText("Log file disappeared.")
return
# Nothing changed since the last poll -> skip the re-read/re-render.
if path == self._last_path and size == self._last_size:
return
was_at_bottom = self._is_scrolled_to_bottom()
text, truncated = self._read_tail(path)
self.view.setPlainText(text)
self.status_label.setText(
f"Showing last {self.MAX_TAIL_BYTES // 1000} KB of the log." if truncated else ""
)
if was_at_bottom:
self._scroll_to_bottom()
self._last_path = path
self._last_size = size
def _read_tail(self, path: Path) -> tuple[str, bool]:
try:
size = path.stat().st_size
with open(path, "rb") as f:
truncated = size > self.MAX_TAIL_BYTES
if truncated:
f.seek(size - self.MAX_TAIL_BYTES)
data = f.read()
except OSError as e:
return f"(could not read log: {e})", False
text = data.decode("utf-8", errors="replace")
if truncated:
nl = text.find("\n")
if nl != -1:
text = text[nl + 1 :]
text = "… (earlier lines truncated) …\n" + text
return text, truncated
def _is_scrolled_to_bottom(self) -> bool:
sb = self.view.verticalScrollBar()
return sb.value() >= sb.maximum() - 4
def _scroll_to_bottom(self):
sb = self.view.verticalScrollBar()
sb.setValue(sb.maximum())
def closeEvent(self, event):
self._timer.stop()
super().closeEvent(event)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Main window # Main window
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+19
View File
@@ -1314,6 +1314,25 @@ def diagnostics_text(name: str, data: dict) -> str:
return "\n".join(L) return "\n".join(L)
def server_log_path(name: str) -> Path | None:
"""
The platform-specific Claude Desktop MCP server log file for `name`, or
None if it doesn't exist yet (nothing has been logged for this server).
macOS : ~/Library/Logs/Claude/mcp-server-<name>.log (one file per server)
Windows: %APPDATA%\\Claude\\logs\\mcp.log (one shared file)
Other platforms: Claude Desktop doesn't ship a log in a known location -> None.
"""
if sys.platform == "darwin":
p = Path.home() / "Library" / "Logs" / "Claude" / f"mcp-server-{name}.log"
elif sys.platform.startswith("win"):
appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
p = appdata / "Claude" / "logs" / "mcp.log"
else:
return None
return p if p.is_file() else None
_STDERR_CAP = 4096 # bytes _STDERR_CAP = 4096 # bytes
+46
View File
@@ -708,3 +708,49 @@ def test_resolve_name_collision_repeated_calls_stay_unique():
second = c.resolve_name_collision("brave-search", existing) second = c.resolve_name_collision("brave-search", existing)
assert first != second assert first != second
assert {first, second} == {"brave-search-2", "brave-search-3"} assert {first, second} == {"brave-search-2", "brave-search-3"}
# --------------------------------------------------------------------------- #
# server_log_path (in-app log viewer — issue #6)
# --------------------------------------------------------------------------- #
def test_server_log_path_macos_when_file_exists(tmp_path, monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(c.Path, "home", lambda: tmp_path)
log_dir = tmp_path / "Library" / "Logs" / "Claude"
log_dir.mkdir(parents=True)
(log_dir / "mcp-server-brave-search.log").write_text("hello")
result = c.server_log_path("brave-search")
assert result == log_dir / "mcp-server-brave-search.log"
def test_server_log_path_macos_none_when_missing(tmp_path, monkeypatch):
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(c.Path, "home", lambda: tmp_path)
assert c.server_log_path("brave-search") is None
def test_server_log_path_windows_when_file_exists(tmp_path, monkeypatch):
# server_log_path branches on sys.platform only (never os.name), so the
# test doesn't have to touch os.name -- mutating that globally mid-test
# would also flip which concrete Path subclass pathlib hands out.
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setenv("APPDATA", str(tmp_path))
log_dir = tmp_path / "Claude" / "logs"
log_dir.mkdir(parents=True)
(log_dir / "mcp.log").write_text("hello")
result = c.server_log_path("brave-search")
assert result == log_dir / "mcp.log"
def test_server_log_path_windows_none_when_missing(tmp_path, monkeypatch):
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setenv("APPDATA", str(tmp_path))
assert c.server_log_path("brave-search") is None
def test_server_log_path_unsupported_platform_returns_none(tmp_path, monkeypatch):
monkeypatch.setattr(sys, "platform", "linux")
monkeypatch.setattr(c.Path, "home", lambda: tmp_path)
assert c.server_log_path("brave-search") is None