diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index db203c88..ede13a96 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,11 +1,12 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-03-01 (P1-T8 archived) +**Last Updated:** 2026-03-01 (P3-T11 archived) ## Archived Tasks | Task ID | Folder | Archived | Verdict | |---------|--------|----------|---------| +| P3-T11 | [P3-T11_Add_Stop_broker_service_control_button_to_Web_UI/](P3-T11_Add_Stop_broker_service_control_button_to_Web_UI/) | 2026-03-01 | PASS | | P1-T8 | [P1-T8_Update_config_examples_for_broker_setup_first/](P1-T8_Update_config_examples_for_broker_setup_first/) | 2026-03-01 | PASS | | P2-T6 | [P2-T6_Remove_legacy_broker_connect_and_broker_spawn_flags/](P2-T6_Remove_legacy_broker_connect_and_broker_spawn_flags/) | 2026-03-01 | PASS | | P1-T7 | [P1-T7_Hide_README_version_badge_maintenance_note/](P1-T7_Hide_README_version_badge_maintenance_note/) | 2026-03-01 | PASS | @@ -181,6 +182,7 @@ | File | Description | |------|-------------| +| [REVIEW_p3_t11_webui_stop_control.md](_Historical/REVIEW_p3_t11_webui_stop_control.md) | Review report for P3-T11 | | [REVIEW_p1_t8_config_broker_setup_first.md](_Historical/REVIEW_p1_t8_config_broker_setup_first.md) | Review report for P1-T8 | | [REVIEW_P2-T5_webui_mismatch_warning.md](_Historical/REVIEW_P2-T5_webui_mismatch_warning.md) | Review report for P2-T5 | | [REVIEW_P2-T4_broker_unavailable_error.md](_Historical/REVIEW_P2-T4_broker_unavailable_error.md) | Review report for P2-T4 | @@ -548,3 +550,5 @@ | 2026-02-28 | P1-T1 | Archived REVIEW_p1_t1_version_badge_script_tests report | | 2026-02-28 | P1-T2 | Archived Add_Xcode_26_4_known_issue_release_notes_link_to_README (PASS) | | 2026-02-28 | P1-T2 | Archived REVIEW_p1_t2_xcode_26_4_known_issue_link report | +| 2026-03-01 | P3-T11 | Archived Add_Stop_broker_service_control_button_to_Web_UI (PASS) | +| 2026-03-01 | P3-T11 | Archived REVIEW_p3_t11_webui_stop_control report | diff --git a/SPECS/ARCHIVE/P3-T11_Add_Stop_broker_service_control_button_to_Web_UI/P3-T11_Add_Stop_broker_service_control_button_to_Web_UI.md b/SPECS/ARCHIVE/P3-T11_Add_Stop_broker_service_control_button_to_Web_UI/P3-T11_Add_Stop_broker_service_control_button_to_Web_UI.md new file mode 100644 index 00000000..c9ea37fb --- /dev/null +++ b/SPECS/ARCHIVE/P3-T11_Add_Stop_broker_service_control_button_to_Web_UI/P3-T11_Add_Stop_broker_service_control_button_to_Web_UI.md @@ -0,0 +1,68 @@ +# P3-T11 - Add Stop broker/service control button to Web UI + +**Task ID:** P3-T11 +**Priority:** P1 +**Dependencies:** P2-T6 +**Status:** Planned + +## Goal + +Add a dashboard control that allows users to request graceful shutdown of the running broker/service process from the Web UI when supported by the runtime mode. + +## Problem Statement + +The current Web UI is observability-only. Users can inspect health/metrics but cannot stop a long-running broker/service process from the dashboard. They must switch to terminal-based process management, which is slower and less discoverable. + +## Deliverables + +- `src/mcpbridge_wrapper/webui/server.py` + - Add control capability endpoint (`GET /api/control`). + - Add stop endpoint (`POST /api/control/stop`) guarded by auth. + - Support optional shutdown callback wiring and graceful deferred trigger. +- `src/mcpbridge_wrapper/__main__.py` + - In broker-daemon mode, wire Web UI stop callback to graceful process shutdown signaling. +- `src/mcpbridge_wrapper/webui/static/index.html` + - Add Stop button in header controls (hidden/disabled until capability confirms support). +- `src/mcpbridge_wrapper/webui/static/dashboard.js` + - Load control capability at startup. + - Show/hide/label Stop button based on capability. + - Add confirmation + stop request flow with UX state updates. +- `tests/unit/webui/test_server.py` + - Add endpoint tests for supported/unsupported stop-control paths. + - Verify auth still applies to control endpoints. + +## Acceptance Criteria + +- Dashboard exposes a Stop control only when backend reports stop capability. +- `POST /api/control/stop` returns accepted and triggers graceful broker shutdown in broker-daemon mode. +- Unsupported runtime modes return a clear non-2xx response for stop requests. +- Unit tests cover supported and unsupported stop-control behavior. +- Existing quality gates pass: + - `pytest` + - `ruff check src/` + - `mypy src/` + - `pytest --cov` (>=90%) + +## Implementation Notes + +- Keep control endpoints auth-protected via existing `_check_auth` path. +- Prefer deferred shutdown trigger (small async delay) so HTTP response can return before process begins teardown. +- Keep behavior explicit in API payloads (`can_stop`, `service_name`, accepted/rejected state). +- In non-broker-daemon flows, advertise no stop capability and reject stop requests with HTTP 409. + +## Validation Plan + +1. Add/adjust unit tests for Web UI control endpoints. +2. Run full quality gates listed above. +3. Create `SPECS/INPROGRESS/P3-T11_Validation_Report.md` with command outputs and verdict. + +## Risks + +- If shutdown is triggered synchronously, response delivery may race process termination. +- Non-daemon modes must not be accidentally terminated by dashboard controls. + +## Out of Scope + +- Start/Restart controls. +- Per-client session stop management. +- Remote process control beyond local wrapper process scope. diff --git a/SPECS/ARCHIVE/P3-T11_Add_Stop_broker_service_control_button_to_Web_UI/P3-T11_Validation_Report.md b/SPECS/ARCHIVE/P3-T11_Add_Stop_broker_service_control_button_to_Web_UI/P3-T11_Validation_Report.md new file mode 100644 index 00000000..9c9f01ae --- /dev/null +++ b/SPECS/ARCHIVE/P3-T11_Add_Stop_broker_service_control_button_to_Web_UI/P3-T11_Validation_Report.md @@ -0,0 +1,56 @@ +# Validation Report: P3-T11 — Add Stop broker/service control button to Web UI + +**Date:** 2026-03-01 +**Verdict:** PASS + +## Summary + +Implemented a Web UI stop control path for broker-daemon runtime with explicit capability discovery: +- Backend now exposes control capability and stop endpoints. +- Dashboard now renders a Stop button only when stop is supported. +- Broker-daemon mode wires stop requests to graceful self-termination signaling. + +## Delivered Changes + +- Added control API and stop callback plumbing: + - `src/mcpbridge_wrapper/webui/server.py` +- Wired broker-daemon stop callback into dashboard startup: + - `src/mcpbridge_wrapper/__main__.py` +- Added dashboard control button and action handler: + - `src/mcpbridge_wrapper/webui/static/index.html` + - `src/mcpbridge_wrapper/webui/static/dashboard.js` + - `src/mcpbridge_wrapper/webui/static/dashboard.css` +- Added/updated tests for control endpoints and broker-daemon wiring: + - `tests/unit/webui/test_server.py` + - `tests/unit/test_main.py` + +## Acceptance Criteria Check + +- [x] Dashboard exposes a Stop control only when backend reports stop capability. +- [x] `POST /api/control/stop` returns accepted and triggers graceful broker shutdown in broker-daemon mode. +- [x] Unsupported runtime modes return a clear non-2xx response for stop requests. +- [x] Unit tests cover supported and unsupported stop-control behavior. +- [x] Quality gates pass: `pytest`, `ruff check src/`, `mypy src/`, `pytest --cov` (coverage >= 90%). + +## Quality Gates + +1. `pytest` +- Result: PASS +- Evidence: `740 passed, 5 skipped` + +2. `ruff check src/` +- Result: PASS +- Evidence: `All checks passed!` + +3. `mypy src/` +- Result: PASS +- Evidence: `Success: no issues found in 18 source files` + +4. `pytest --cov` +- Result: PASS +- Evidence: `Required test coverage of 90.0% reached. Total coverage: 91.01%` + +## Notes + +- Stop capability is intentionally advertised only when `request_stop` callback is wired (broker-daemon mode). +- In unsupported modes, stop requests return HTTP 409 with actionable detail. diff --git a/SPECS/ARCHIVE/_Historical/REVIEW_p3_t11_webui_stop_control.md b/SPECS/ARCHIVE/_Historical/REVIEW_p3_t11_webui_stop_control.md new file mode 100644 index 00000000..a93d7ab1 --- /dev/null +++ b/SPECS/ARCHIVE/_Historical/REVIEW_p3_t11_webui_stop_control.md @@ -0,0 +1,34 @@ +## REVIEW REPORT — P3-T11 Web UI Stop Control + +**Scope:** origin/main..HEAD +**Files:** 12 + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +### Critical Issues + +- None. + +### Secondary Issues + +- None. + +### Architectural Notes + +- Control-plane API (`/api/control`, `/api/control/stop`) is capability-driven and opt-in via callback wiring, which avoids unsafe stop behavior in unsupported runtime modes. +- Broker-daemon stop is routed through delayed self-SIGTERM from a helper thread so HTTP response can complete before shutdown starts. + +### Tests + +- `pytest` passed (`740 passed, 5 skipped`) +- `ruff check src/` passed +- `mypy src/` passed +- `pytest --cov` passed (`91.01%`, threshold >= 90%) + +### Next Steps + +- FOLLOW-UP skipped: no actionable findings. diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index 6d9a0541..680b67ec 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -1,9 +1,10 @@ # No Active Task -**Status:** Idle — P1-T8 archived. Select the next task from `SPECS/Workplan.md`. +**Status:** Idle — P3-T11 archived. Select the next task from `SPECS/Workplan.md`. ## Recently Archived +- **P3-T11** — Add Stop broker/service control button to Web UI (2026-03-01, PASS) - **P1-T8** — Update /config examples for broker setup first (2026-03-01, PASS) - **P2-T6** — Remove legacy --broker-connect and --broker-spawn flags (2026-03-01, PASS) - **P1-T7** — Hide README version badge maintenance note (2026-03-01, PASS) diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index b269a953..b33312e9 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -214,6 +214,26 @@ Add new tasks using the canonical template in [TASK_TEMPLATE.md](TASK_TEMPLATE.m - [x] Broker mode guidance remains clear with `--broker` (proxy) and `--broker-daemon` (host) - [x] Required quality gates pass (`pytest`, `ruff check src/`, `mypy src/`, `pytest --cov` with coverage >=90%) +### Phase 3: Web UI Controls + +#### ✅ P3-T11: Add Stop broker/service control button to Web UI +- **Status:** ✅ Completed (2026-03-01) +- **Description:** Add a Web UI control that lets users request graceful shutdown of the running broker/service process directly from the dashboard, with clear availability rules and safe behavior in unsupported modes. +- **Priority:** P1 +- **Dependencies:** P2-T6 +- **Parallelizable:** yes +- **Outputs/Artifacts:** + - `src/mcpbridge_wrapper/webui/server.py` — control capability + stop endpoint + - `src/mcpbridge_wrapper/webui/static/index.html` — Stop control button + - `src/mcpbridge_wrapper/webui/static/dashboard.js` — control discovery + stop action handler + - `src/mcpbridge_wrapper/__main__.py` — broker-daemon shutdown wiring for Web UI control + - `tests/unit/webui/test_server.py` — endpoint and capability tests +- **Acceptance Criteria:** + - [x] Dashboard exposes a Stop control only when backend reports stop capability + - [x] `POST /api/control/stop` returns accepted and triggers graceful broker shutdown in broker-daemon mode + - [x] Unsupported runtime modes return a clear non-2xx response for stop requests + - [x] Unit tests cover both supported and unsupported stop-control paths + ### Bug Fixes #### ✅ BUG-T8: Fix broker proxy bridge exits after first write due to BaseProtocol missing _drain_helper diff --git a/src/mcpbridge_wrapper/__main__.py b/src/mcpbridge_wrapper/__main__.py index 685f8e8e..45bcea41 100644 --- a/src/mcpbridge_wrapper/__main__.py +++ b/src/mcpbridge_wrapper/__main__.py @@ -505,6 +505,8 @@ def main() -> int: from mcpbridge_wrapper.broker.transport import UnixSocketServer from mcpbridge_wrapper.broker.types import BrokerConfig + stop_requested = threading.Event() + daemon: Optional[BrokerDaemon] = None config = None metrics = None audit = None @@ -534,7 +536,20 @@ def main() -> int: file=sys.stderr, ) else: - _ = run_server_in_thread(config, metrics, audit) + + def request_broker_shutdown() -> None: + """Request broker daemon shutdown after replying to HTTP control call.""" + stop_requested.set() + if daemon is not None: + daemon.request_shutdown() + + _ = run_server_in_thread( + config, + metrics, + audit, + service_name="broker-daemon", + request_stop=request_broker_shutdown, + ) print( f"Web UI dashboard started at http://{config.host}:{config.port}", file=sys.stderr, @@ -542,6 +557,9 @@ def main() -> int: broker_config = BrokerConfig.default() daemon = BrokerDaemon(broker_config) + if stop_requested.is_set(): + daemon.request_shutdown() + transport = UnixSocketServer( broker_config, daemon, diff --git a/src/mcpbridge_wrapper/broker/daemon.py b/src/mcpbridge_wrapper/broker/daemon.py index fa367120..7632c5ba 100644 --- a/src/mcpbridge_wrapper/broker/daemon.py +++ b/src/mcpbridge_wrapper/broker/daemon.py @@ -26,6 +26,7 @@ import os import signal import sys +import threading from asyncio.subprocess import PIPE from typing import TYPE_CHECKING, Any @@ -73,6 +74,11 @@ def __init__( self._reconnect_attempt: int = 0 self._stop_event: asyncio.Event = asyncio.Event() self._stopped_event: asyncio.Event = asyncio.Event() + # When set, run_forever should stop as soon as startup reaches READY. + self._shutdown_requested: bool = False + # Event loop running run_forever; used for thread-safe stop scheduling. + self._loop: asyncio.AbstractEventLoop | None = None + self._shutdown_lock = threading.Lock() # ------------------------------------------------------------------ # Public API @@ -95,6 +101,28 @@ def status(self) -> dict[str, Any]: "upstream_pid": upstream_pid, } + def request_shutdown(self) -> None: + """Request graceful daemon shutdown from any thread/context. + + This method is safe to call before :meth:`run_forever` starts. In that + case the request is recorded and applied immediately after startup. + """ + with self._shutdown_lock: + self._shutdown_requested = True + + loop = self._loop + if loop is None or not loop.is_running(): + return + + def _schedule_stop() -> None: + # During startup (INIT), defer actual stop to run_forever() post-start check. + if self._state == BrokerState.INIT: + return + asyncio.ensure_future(self.stop()) + + with contextlib.suppress(RuntimeError): + loop.call_soon_threadsafe(_schedule_stop) + async def start(self) -> None: """Start the broker: validate lock, launch upstream, then write PID file. @@ -201,28 +229,28 @@ async def stop(self) -> None: async def run_forever(self) -> None: """Start and block until a shutdown signal is received.""" - await self.start() - loop = asyncio.get_running_loop() - - shutdown_called = False - - async def _handle_signal() -> None: - nonlocal shutdown_called - if not shutdown_called: - shutdown_called = True - await self.stop() + self._loop = loop def _sync_signal_handler() -> None: - asyncio.ensure_future(_handle_signal()) + self.request_shutdown() for sig in (signal.SIGTERM, signal.SIGINT): with contextlib.suppress(NotImplementedError, RuntimeError): loop.add_signal_handler(sig, _sync_signal_handler) - # Wait for shutdown to be requested and fully completed. - await self._stop_event.wait() - await self._stopped_event.wait() + try: + await self.start() + + # Handle stop requests that happened before or during startup. + if self._shutdown_requested: + await self.stop() + + # Wait for shutdown to be requested and fully completed. + await self._stop_event.wait() + await self._stopped_event.wait() + finally: + self._loop = None # ------------------------------------------------------------------ # Internal helpers diff --git a/src/mcpbridge_wrapper/webui/server.py b/src/mcpbridge_wrapper/webui/server.py index a56a5538..d87d5d55 100644 --- a/src/mcpbridge_wrapper/webui/server.py +++ b/src/mcpbridge_wrapper/webui/server.py @@ -163,6 +163,8 @@ def create_app( config: WebUIConfig, metrics: MetricsCollector, audit: AuditLogger, + service_name: str = "mcpbridge-wrapper", + request_stop: Callable[[], None] | None = None, ) -> FastAPI: """Create and configure the FastAPI application. @@ -170,6 +172,8 @@ def create_app( config: Web UI configuration. metrics: Metrics collector instance. audit: Audit logger instance. + service_name: Runtime service label exposed by control API. + request_stop: Optional callback used by control API to request shutdown. Returns: Configured FastAPI application. @@ -185,6 +189,8 @@ def create_app( app.state.config = config app.state.metrics = metrics app.state.audit = audit + app.state.service_name = service_name + app.state.request_stop = request_stop ws_clients: list[WebSocket] = [] app.state.ws_clients = ws_clients @@ -335,6 +341,33 @@ async def get_config(request: Request) -> dict[str, Any]: _check_auth(request, config) return config.to_dict() + # --- API: Control --- + + @app.get("/api/control") + async def get_control(request: Request) -> dict[str, Any]: + """Get available control operations for the running service.""" + _check_auth(request, config) + return { + "service_name": service_name, + "can_stop": request_stop is not None, + } + + @app.post("/api/control/stop") + async def stop_service(request: Request) -> dict[str, str]: + """Request graceful shutdown when stop control is enabled.""" + _check_auth(request, config) + if request_stop is None: + raise HTTPException( + status_code=409, + detail="Stop control is not available in this runtime mode.", + ) + + request_stop() + return { + "status": "accepted", + "message": f"Shutdown requested for {service_name}.", + } + # --- API: Health --- @app.get("/api/health") @@ -384,6 +417,8 @@ def run_server( metrics: MetricsCollector, audit: AuditLogger, on_started: Callable[[], None] | None = None, + service_name: str = "mcpbridge-wrapper", + request_stop: Callable[[], None] | None = None, ) -> None: """Start the web UI server (blocking). @@ -392,10 +427,18 @@ def run_server( metrics: Metrics collector instance. audit: Audit logger instance. on_started: Optional callback invoked after server starts. + service_name: Runtime service label exposed by control API. + request_stop: Optional callback used by control API to request shutdown. """ _require_webui_deps() assert uvicorn is not None - app = create_app(config, metrics, audit) + app = create_app( + config, + metrics, + audit, + service_name=service_name, + request_stop=request_stop, + ) server_config = uvicorn.Config( app, @@ -441,6 +484,8 @@ def run_server_in_thread( config: WebUIConfig, metrics: MetricsCollector, audit: AuditLogger, + service_name: str = "mcpbridge-wrapper", + request_stop: Callable[[], None] | None = None, ) -> threading.Thread: """Start the web UI server in a daemon thread. @@ -448,6 +493,8 @@ def run_server_in_thread( config: Web UI configuration. metrics: Metrics collector instance. audit: Audit logger instance. + service_name: Runtime service label exposed by control API. + request_stop: Optional callback used by control API to request shutdown. Returns: The daemon thread running the server. @@ -455,7 +502,13 @@ def run_server_in_thread( _require_webui_deps() thread = threading.Thread( target=run_server, - args=(config, metrics, audit), + kwargs={ + "config": config, + "metrics": metrics, + "audit": audit, + "service_name": service_name, + "request_stop": request_stop, + }, daemon=True, name="webui-server", ) diff --git a/src/mcpbridge_wrapper/webui/static/dashboard.css b/src/mcpbridge_wrapper/webui/static/dashboard.css index ee67d4ea..0345e706 100644 --- a/src/mcpbridge_wrapper/webui/static/dashboard.css +++ b/src/mcpbridge_wrapper/webui/static/dashboard.css @@ -299,6 +299,15 @@ tbody tr:hover { background: rgba(210, 153, 34, 0.15); } +.btn-danger { + border-color: var(--accent-red); + color: var(--accent-red); +} + +.btn-danger:hover { + background: var(--accent-red-bg); +} + .btn-theme-toggle { border-color: var(--border-color); color: var(--text-secondary); diff --git a/src/mcpbridge_wrapper/webui/static/dashboard.js b/src/mcpbridge_wrapper/webui/static/dashboard.js index 2229e06f..5fad97a9 100644 --- a/src/mcpbridge_wrapper/webui/static/dashboard.js +++ b/src/mcpbridge_wrapper/webui/static/dashboard.js @@ -15,6 +15,10 @@ var lastSeenTotalRequests = null; var captureParamsEnabled = null; var latestToolLatencySummary = Object.create(null); + var controlState = { + canStop: false, + serviceName: "service", + }; // --- Theme --- var THEME_COLORS = { @@ -275,6 +279,44 @@ }); } + function configureStopButton() { + var btn = el("btn-stop-service"); + if (!btn) return; + + if (!controlState.canStop) { + btn.style.display = "none"; + btn.disabled = true; + btn.textContent = "Stop Service"; + return; + } + + btn.style.display = ""; + btn.disabled = false; + btn.textContent = "Stop " + controlState.serviceName; + } + + function loadControlCapabilities() { + fetch("/api/control") + .then(function (r) { + if (!r.ok) throw new Error("control_fetch_failed"); + return r.json(); + }) + .then(function (data) { + controlState.canStop = !!(data && data.can_stop); + if (data && typeof data.service_name === "string" && data.service_name.trim()) { + controlState.serviceName = data.service_name.trim(); + } else { + controlState.serviceName = "service"; + } + configureStopButton(); + }) + .catch(function () { + controlState.canStop = false; + controlState.serviceName = "service"; + configureStopButton(); + }); + } + // --- Chart Initialization --- function initCharts() { // Tool usage bar chart @@ -905,6 +947,27 @@ } }); + var stopBtn = el("btn-stop-service"); + if (stopBtn) { + stopBtn.addEventListener("click", function () { + var targetName = controlState.serviceName || "service"; + if (!confirm("Request graceful stop for " + targetName + "?")) return; + + stopBtn.disabled = true; + fetch("/api/control/stop", { method: "POST" }) + .then(function (r) { + if (!r.ok) throw new Error("stop_failed"); + return r.json(); + }) + .then(function () { + stopBtn.textContent = "Stopping " + targetName + "..."; + }) + .catch(function () { + stopBtn.disabled = false; + }); + }); + } + el("btn-export-json").addEventListener("click", function () { window.location.href = "/api/audit/export/json"; }); @@ -1130,6 +1193,7 @@ setupEventHandlers(); initKeyboardShortcuts(); loadDashboardConfig(); + loadControlCapabilities(); connectWebSocket(); startPolling(); loadAuditLogs(); diff --git a/src/mcpbridge_wrapper/webui/static/index.html b/src/mcpbridge_wrapper/webui/static/index.html index f7a6d32f..125ed12f 100644 --- a/src/mcpbridge_wrapper/webui/static/index.html +++ b/src/mcpbridge_wrapper/webui/static/index.html @@ -13,6 +13,7 @@

XcodeMCPWrapper Dashboard

Disconnected +
diff --git a/tests/unit/test_broker_daemon.py b/tests/unit/test_broker_daemon.py index 4952723f..cc76c1cf 100644 --- a/tests/unit/test_broker_daemon.py +++ b/tests/unit/test_broker_daemon.py @@ -591,6 +591,30 @@ async def _do_stop() -> None: assert all(delay != 0.1 for delay in sleep_calls) + @pytest.mark.asyncio + async def test_run_forever_honors_prestart_shutdown_request(self, tmp_path: Path) -> None: + """A shutdown request before run_forever starts still stops cleanly.""" + cfg = _make_config(tmp_path) + daemon = BrokerDaemon(cfg) + + async def _block(*a, **kw) -> bytes: # type: ignore[no-untyped-def] + await daemon._stop_event.wait() + return b"" + + proc = _make_mock_process() + proc.stdout.readline = _block + + # Simulate stop requested before asyncio.run()/run_forever startup. + daemon.request_shutdown() + + with patch( + "mcpbridge_wrapper.broker.daemon.asyncio.create_subprocess_exec", + new=AsyncMock(return_value=proc), + ): + await daemon.run_forever() + + assert daemon.state == BrokerState.STOPPED + # --------------------------------------------------------------------------- # _check_and_clear_stale_lock — edge cases diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 872581ee..e7525428 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -3,7 +3,7 @@ import queue import signal from subprocess import Popen -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch from mcpbridge_wrapper.__main__ import main from mcpbridge_wrapper.webui.config import WebUIConfig @@ -1207,7 +1207,16 @@ def test_main_broker_daemon_webui_wires_metrics_and_audit_into_transport(self): result = main() assert result == 0 - run_server_in_thread.assert_called_once_with(webui_config, metrics, audit) + run_server_in_thread.assert_called_once_with( + webui_config, + metrics, + audit, + service_name="broker-daemon", + request_stop=ANY, + ) + request_stop = run_server_in_thread.call_args.kwargs["request_stop"] + request_stop() + daemon.request_shutdown.assert_called_once_with() mock_transport_cls.assert_called_once_with( broker_cfg, daemon, diff --git a/tests/unit/webui/test_server.py b/tests/unit/webui/test_server.py index 4df3e89a..ca1ba89b 100644 --- a/tests/unit/webui/test_server.py +++ b/tests/unit/webui/test_server.py @@ -3,6 +3,7 @@ import base64 import json import tempfile +from unittest.mock import MagicMock import pytest @@ -256,6 +257,43 @@ def test_get_config(self, client): # Password should be masked assert data["auth"]["password"] == "********" + def test_control_capability_default_disables_stop(self, client): + """Control API reports stop is unavailable by default.""" + response = client.get("/api/control") + assert response.status_code == 200 + data = response.json() + assert data["service_name"] == "mcpbridge-wrapper" + assert data["can_stop"] is False + + def test_control_stop_unsupported_returns_conflict(self, client): + """Stop requests return 409 when shutdown callback is not configured.""" + response = client.post("/api/control/stop") + assert response.status_code == 409 + assert "not available" in response.json()["detail"] + + def test_control_stop_supported_invokes_callback(self, config, metrics, audit): + """Stop requests invoke configured stop callback and return accepted status.""" + stop_cb = MagicMock() + app = create_app( + config, + metrics, + audit, + service_name="broker-daemon", + request_stop=stop_cb, + ) + client = TestClient(app) + + control = client.get("/api/control") + assert control.status_code == 200 + assert control.json() == {"service_name": "broker-daemon", "can_stop": True} + + response = client.post("/api/control/stop") + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "accepted" + assert "broker-daemon" in payload["message"] + stop_cb.assert_called_once_with() + def test_dashboard_served(self, client): """Test that dashboard is served.""" response = client.get("/") @@ -265,6 +303,12 @@ def test_dashboard_served(self, client): assert "/static/dashboard.css" in response.text assert "/static/dashboard.js" in response.text + def test_dashboard_includes_stop_service_button(self, client): + """Dashboard markup includes Stop Service control button shell.""" + response = client.get("/") + assert response.status_code == 200 + assert 'id="btn-stop-service"' in response.text + def test_dashboard_error_breakdown_widget_is_full_width(self, client): """Error Breakdown chart container spans full width in charts layout.""" response = client.get("/") @@ -446,6 +490,13 @@ def test_auth_required(self, client_with_auth): response = client_with_auth.get("/api/metrics") assert response.status_code == 401 + def test_control_endpoints_require_auth(self, client_with_auth): + """Control endpoints enforce auth when dashboard auth is enabled.""" + response = client_with_auth.get("/api/control") + assert response.status_code == 401 + response = client_with_auth.post("/api/control/stop") + assert response.status_code == 401 + def test_auth_with_valid_credentials(self, client_with_auth): """Test auth with valid credentials.""" import base64