diff --git a/bcc.py b/bcc.py index 4b902b2..a7fde1c 100644 --- a/bcc.py +++ b/bcc.py @@ -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.clicked.connect(self._test_spawn) 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.setCheckable(True) self.details_btn.toggled.connect(self._toggle_diag) @@ -384,6 +387,7 @@ class ServerEditor(QFrame): dep.addWidget(self.fix_btn) dep.addWidget(self.test_btn) dep.addWidget(self.spawn_btn) + dep.addWidget(self.logs_btn) dep.addWidget(self.details_btn) dep.addWidget(recheck) outer.addLayout(dep) @@ -728,6 +732,13 @@ class ServerEditor(QFrame): elif self.diag_card.isVisible(): 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 @@ -1090,6 +1101,125 @@ class PasteDialog(QDialog): 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 # --------------------------------------------------------------------------- # diff --git a/bcc_core.py b/bcc_core.py index 129eb80..509efa5 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -1314,6 +1314,25 @@ def diagnostics_text(name: str, data: dict) -> str: 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-.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 diff --git a/tests/test_core.py b/tests/test_core.py index eb830c2..89cada9 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -708,3 +708,49 @@ def test_resolve_name_collision_repeated_calls_stay_unique(): second = c.resolve_name_collision("brave-search", existing) assert first != second 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