From 1a24062366b43d9e417e8b2876a7d2a2635c785f Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 28 Feb 2026 14:22:37 +0300 Subject: [PATCH 1/9] Branch for FU-P11-T2-4: webui restart workflow From a08354e414668e51b77a6d2830b9f63ce1ab72fe Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 28 Feb 2026 14:22:45 +0300 Subject: [PATCH 2/9] Select task FU-P11-T2-4: Add one-command Web UI restart workflow --- SPECS/INPROGRESS/next.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index 0f9c23a9..61238f43 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -1,11 +1,15 @@ -# No Active Task +# Next Task: FU-P11-T2-4 — Add one-command Web UI restart workflow -## Recently Archived +**Priority:** P2 +**Phase:** Phase 11 +**Effort:** 2-4 hours +**Dependencies:** P11-T2 +**Status:** Selected -- **FU-P11-T2-3** — Reorder sessions from the last to the first (2026-02-28, PASS) -- **BUG-T9** — Orphaned Web UI server process blocks port after MCP client disconnect or config change (2026-02-25, PASS) -- **BUG-T18** — Error Breakdown widget must be full width streatched (2026-02-26, PASS) +## Description -## Suggested Next Tasks +Add a simple restart workflow for developers and users that reliably frees the configured Web UI port and starts a fresh dashboard process after updates. -- None (all workplan tasks are complete) +## Next Step + +Run the PLAN command to generate the implementation-ready PRD. From 21d451caac31e969082c076898ee9038ee9d7ada Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 28 Feb 2026 14:22:59 +0300 Subject: [PATCH 3/9] Plan task FU-P11-T2-4: Add one-command Web UI restart workflow --- ...Add_one-command_Web_UI_restart_workflow.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 SPECS/INPROGRESS/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md diff --git a/SPECS/INPROGRESS/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md b/SPECS/INPROGRESS/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md new file mode 100644 index 00000000..841ec58c --- /dev/null +++ b/SPECS/INPROGRESS/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md @@ -0,0 +1,70 @@ +# PRD: FU-P11-T2-4 — Add one-command Web UI restart workflow + +## Objective + +Provide a single, reliable restart command for the Web UI server that works in local development and packaged/uvx usage. The restart flow must reclaim the selected port even when a stale listener exists, preferring graceful shutdown and using force-kill only when necessary. + +## Deliverables + +- CLI restart support in `src/mcpbridge_wrapper/__main__.py` (or delegated helper) that: + - Detects listeners on the target Web UI port + - Attempts graceful termination first + - Falls back to forceful termination when required + - Starts a fresh Web UI process using the existing startup path +- `Makefile` target `webui-restart` for one-command local restart +- Troubleshooting docs updates in: + - `docs/troubleshooting.md` + - `Sources/XcodeMCPWrapper/Documentation.docc/Troubleshooting.md` +- Automated tests for restart behavior and occupied-port edge cases + +## Acceptance Criteria + +- One documented command restarts Web UI on a chosen port without manual PID lookup. +- Restart flow performs graceful stop first and force-kills only if needed. +- Workflow works for both local/dev install and uvx usage. +- Tests cover restart behavior and practical port-occupied edge cases. + +## Dependencies and Constraints + +- Depends on `P11-T2` Web UI infrastructure. +- Keep compatibility with current CLI flags and startup flow. +- Avoid introducing platform-only behavior beyond existing macOS assumptions in this repo. + +## Test-First Plan + +1. Add/extend unit tests for restart helpers: + - Process lookup by port + - Graceful->forceful termination sequence + - No-op when no listener exists +2. Add CLI-level tests for restart command wiring. +3. Implement minimal code to satisfy tests. +4. Add/adjust docs and ensure examples are executable. + +## Implementation Phases + +### Phase 1: Restart primitives +- Inputs: target port, timeout, startup arguments +- Outputs: deterministic restart routine with logging/messages +- Verification: unit tests for success/fallback/failure handling + +### Phase 2: CLI and Makefile integration +- Inputs: CLI argument parsing and existing web-ui commands +- Outputs: command/flag to restart + `make webui-restart` +- Verification: CLI tests and manual command dry-check + +### Phase 3: Documentation and validation +- Inputs: final command syntax and expected behavior +- Outputs: troubleshooting docs updated in both markdown/docc +- Verification: command examples align across docs + +## Validation Commands + +- `pytest` +- `ruff check src/` +- `mypy src/` +- `pytest --cov=src/mcpbridge_wrapper --cov-report=term-missing` + +## Notes + +- Keep restart idempotent so repeated calls do not fail when no process is running. +- Reuse existing process management helpers if present instead of duplicating logic. From c08791d951c26643f3f5e600f3a06d0745c7935b Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 28 Feb 2026 14:26:05 +0300 Subject: [PATCH 4/9] Implement FU-P11-T2-4: add one-command Web UI restart workflow --- Makefile | 8 +- .../FU-P11-T2-4_Validation_Report.md | 37 +++++ .../Documentation.docc/Troubleshooting.md | 19 +-- docs/troubleshooting.md | 19 +-- src/mcpbridge_wrapper/__main__.py | 109 +++++++++++++- tests/unit/test_main.py | 134 ++++++++++++++++++ tests/unit/test_main_webui.py | 38 +++-- 7 files changed, 332 insertions(+), 32 deletions(-) create mode 100644 SPECS/INPROGRESS/FU-P11-T2-4_Validation_Report.md diff --git a/Makefile b/Makefile index 812de39b..509d1c2d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for mcpbridge-wrapper -.PHONY: help install install-webui test test-webui lint format format-check typecheck doccheck doccheck-staged doccheck-branch doccheck-all doccheck-all-strict package-assets-check bump-version clean webui webui-health check +.PHONY: help install install-webui test test-webui lint format format-check typecheck doccheck doccheck-staged doccheck-branch doccheck-all doccheck-all-strict package-assets-check bump-version clean webui webui-restart webui-health check help: @echo "Available targets:" @@ -20,6 +20,7 @@ help: @echo " package-assets-check - Build artifacts and verify required packaged assets" @echo " bump-version - Update pyproject.toml and server.json versions (VERSION=x.y.z, add DRY_RUN=1 to preview)" @echo " webui - Start wrapper with Web UI dashboard (port 8080)" + @echo " webui-restart - Restart Web UI dashboard on chosen port (PORT=8080 by default)" @echo " webui-health - Check Web UI health status" @echo " clean - Clean build artifacts" @echo " check - Run all quality gates (test, lint, format, typecheck, doccheck-all, package-assets-check)" @@ -100,6 +101,11 @@ webui: @echo "Press Ctrl+C to stop" python -m mcpbridge_wrapper --web-ui --web-ui-port 8080 +webui-restart: + @PORT_VALUE=$${PORT:-8080}; \ + echo "Restarting Web UI on http://127.0.0.1:$$PORT_VALUE"; \ + python -m mcpbridge_wrapper --web-ui-only --web-ui-restart --web-ui-port "$$PORT_VALUE" + webui-health: @echo "Checking Web UI health..." @curl -s http://localhost:8080/api/health | python -m json.tool 2>/dev/null || echo "Web UI not accessible at http://localhost:8080" diff --git a/SPECS/INPROGRESS/FU-P11-T2-4_Validation_Report.md b/SPECS/INPROGRESS/FU-P11-T2-4_Validation_Report.md new file mode 100644 index 00000000..d6c82a54 --- /dev/null +++ b/SPECS/INPROGRESS/FU-P11-T2-4_Validation_Report.md @@ -0,0 +1,37 @@ +# Validation Report: FU-P11-T2-4 — Add one-command Web UI restart workflow + +**Date:** 2026-02-28 +**Task ID:** FU-P11-T2-4 +**Verdict:** PASS + +## Scope Implemented + +- Added `--web-ui-restart` CLI flag parsing. +- Implemented restart helpers to reclaim occupied Web UI ports: + - listener PID discovery via `lsof` + - graceful shutdown (`SIGTERM`) first + - force kill (`SIGKILL`) fallback when needed +- Wired restart behavior into Web UI startup flow in `main()`. +- Added `Makefile` target `webui-restart` with configurable `PORT`. +- Updated troubleshooting docs (markdown + DocC) with one-command restart workflow for local and uvx usage. +- Added/updated unit tests for parser changes, restart helpers, and main restart wiring. + +## Quality Gates + +- `PYTHONPATH=src pytest` → PASS (`668 passed, 5 skipped`) +- `PYTHONPATH=src ruff check src/` → PASS +- `PYTHONPATH=src mypy src/` → PASS +- `PYTHONPATH=src pytest --cov=src/mcpbridge_wrapper --cov-report=term-missing` → PASS + - Total coverage: **90.89%** (required: >= 90%) + +## Acceptance Criteria Check + +- [x] A single documented command restarts Web UI on a chosen port without manual PID hunting. +- [x] Restart flow attempts graceful stop first, then force-kill only if needed. +- [x] Works for both local/dev install and uvx usage. +- [x] Tests cover restart behavior and port-occupied edge case(s). + +## Notes + +- Port listener discovery relies on `lsof` availability (standard on macOS). +- Existing non-restart Web UI startup behavior is preserved. diff --git a/Sources/XcodeMCPWrapper/Documentation.docc/Troubleshooting.md b/Sources/XcodeMCPWrapper/Documentation.docc/Troubleshooting.md index 8dba3eca..7c7e3074 100644 --- a/Sources/XcodeMCPWrapper/Documentation.docc/Troubleshooting.md +++ b/Sources/XcodeMCPWrapper/Documentation.docc/Troubleshooting.md @@ -225,20 +225,21 @@ Error: Web UI port 8080 is already in use. Stop the existing process and retry. **Cause:** A stale wrapper process from a previous run (or a crashed client restart) is still occupying the port. Multiple processes can exist simultaneously — for example after a Cursor restart — because the old process is never explicitly stopped. -**Diagnosis:** +**One-command restart (recommended):** ```bash -# Find the PID of the process listening on the Web UI port (default 8080) -PORT=8080 -lsof -i TCP:$PORT -sTCP:LISTEN +# Local development (from repo root) +make webui-restart PORT=8080 +``` -# Alternatively, search by process name -ps aux | grep mcpbridge +```bash +# uvx usage +uvx --from 'mcpbridge-wrapper[webui]' mcpbridge-wrapper --web-ui-only --web-ui-restart --web-ui-port 8080 ``` -Both commands show the PID in the second column (`PID`). +The restart flow attempts graceful shutdown first (`SIGTERM`) and only uses force-kill (`SIGKILL`) for listeners that do not exit in time. -**Recovery:** +**Manual fallback (if needed):** ```bash # Kill the listener bound to the port @@ -257,7 +258,7 @@ pkill -f -i "mcpbridge_wrapper --web-ui" || true lsof -nP -iTCP:$PORT -sTCP:LISTEN ``` -After stopping the stale process, restart your MCP client (Cursor / Zed / Claude Code) or re-run the `--web-ui-only` command and the port should now be free. +After stopping the stale process, restart your MCP client (Cursor / Zed / Claude Code) or rerun one of the restart commands above. Prefer `kill` (`SIGTERM`) first; use `kill -9` only when the process does not exit. **Note:** Multiple wrapper processes can run simultaneously on *different* ports. Make sure you identify the PID bound specifically to the port you want, not just any `mcpbridge` process. If the port is immediately re-occupied, close/restart MCP clients (Cursor/Zed/Claude) that may auto-spawn a new wrapper process. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d1f7a765..8cdf4846 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -259,20 +259,21 @@ Error: Web UI port 8080 is already in use. Stop the existing process and retry. **Cause:** A stale wrapper process from a previous run (or a crashed client restart) is still occupying the port. Multiple processes can exist simultaneously — for example after a Cursor restart — because the old process is never explicitly stopped. -**Diagnosis:** +**One-command restart (recommended):** ```bash -# Find the PID of the process listening on the Web UI port (default 8080) -PORT=8080 -lsof -i TCP:$PORT -sTCP:LISTEN +# Local development (from repo root) +make webui-restart PORT=8080 +``` -# Alternatively, search by process name -ps aux | grep mcpbridge +```bash +# uvx usage +uvx --from 'mcpbridge-wrapper[webui]' mcpbridge-wrapper --web-ui-only --web-ui-restart --web-ui-port 8080 ``` -Both commands show the PID in the second column (`PID`). +The restart flow attempts graceful shutdown first (`SIGTERM`) and only uses force-kill (`SIGKILL`) for listeners that do not exit in time. -**Recovery:** +**Manual fallback (if needed):** ```bash # Kill the listener bound to the port @@ -291,7 +292,7 @@ pkill -f -i "mcpbridge_wrapper --web-ui" || true lsof -nP -iTCP:$PORT -sTCP:LISTEN ``` -After stopping the stale process, restart your MCP client (Cursor / Zed / Claude Code) or re-run the `--web-ui-only` command and the port should now be free. +After stopping the stale process, restart your MCP client (Cursor / Zed / Claude Code) or rerun one of the restart commands above. Prefer `kill` (`SIGTERM`) first; use `kill -9` only when the process does not exit. **Note:** Multiple wrapper processes can run simultaneously on *different* ports. Make sure you identify the PID bound specifically to the port you want, not just any `mcpbridge` process. If the port is immediately re-occupied, close/restart MCP clients (Cursor/Zed/Claude) that may auto-spawn a new wrapper process. diff --git a/src/mcpbridge_wrapper/__main__.py b/src/mcpbridge_wrapper/__main__.py index b98fd303..64675ae0 100644 --- a/src/mcpbridge_wrapper/__main__.py +++ b/src/mcpbridge_wrapper/__main__.py @@ -1,11 +1,13 @@ """Entry point for mcpbridge-wrapper.""" import contextlib +import os import signal +import subprocess import sys import threading import time -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Set, Tuple from mcpbridge_wrapper.bridge import ( cleanup_bridge, @@ -63,7 +65,7 @@ def _parse_webui_port(raw_value: str) -> int: def _parse_webui_args( args: list, -) -> Tuple[bool, bool, Optional[int], Optional[str], list]: +) -> Tuple[bool, bool, bool, Optional[int], Optional[str], list]: """Parse web UI arguments from command-line args. Extracts --web-ui, --web-ui-only, --web-ui-port, and --web-ui-config flags and @@ -76,6 +78,7 @@ def _parse_webui_args( Tuple of ( web_ui_enabled, web_ui_only_mode, + web_ui_restart_mode, port_or_none, config_path_or_none, remaining_args, @@ -86,6 +89,7 @@ def _parse_webui_args( """ web_ui = False web_ui_only = False + web_ui_restart = False port: Optional[int] = None config_path: Optional[str] = None remaining = [] @@ -100,6 +104,11 @@ def _parse_webui_args( web_ui = True web_ui_only = True i += 1 + elif args[i] == "--web-ui-restart": + # Restart mode is meaningful only when Web UI is enabled. + web_ui = True + web_ui_restart = True + i += 1 elif args[i] == "--web-ui-port" and i + 1 < len(args): port = _parse_webui_port(args[i + 1]) i += 2 @@ -116,7 +125,79 @@ def _parse_webui_args( remaining.append(args[i]) i += 1 - return web_ui, web_ui_only, port, config_path, remaining + return web_ui, web_ui_only, web_ui_restart, port, config_path, remaining + + +def _find_listener_pids_for_port(port: int) -> Set[int]: + """Return listener PIDs bound to TCP port, or empty set when none found.""" + try: + result = subprocess.run( + ["lsof", f"-tiTCP:{port}", "-sTCP:LISTEN"], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return set() + + pids: Set[int] = set() + for raw in result.stdout.splitlines(): + raw = raw.strip() + if not raw: + continue + with contextlib.suppress(ValueError): + pids.add(int(raw)) + return pids + + +def _pid_exists(pid: int) -> bool: + """Return True when process exists and caller has permission to probe it.""" + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _terminate_pids_gracefully_then_force( + pids: Set[int], + grace_timeout_seconds: float = 1.5, + poll_interval_seconds: float = 0.05, +) -> bool: + """Terminate PIDs with SIGTERM, then SIGKILL remaining after timeout.""" + if not pids: + return True + + for pid in pids: + with contextlib.suppress(ProcessLookupError, PermissionError): + os.kill(pid, signal.SIGTERM) + + deadline = time.monotonic() + grace_timeout_seconds + remaining = set(pids) + while remaining and time.monotonic() < deadline: + remaining = {pid for pid in remaining if _pid_exists(pid)} + if not remaining: + return True + time.sleep(poll_interval_seconds) + + for pid in remaining: + with contextlib.suppress(ProcessLookupError, PermissionError): + os.kill(pid, signal.SIGKILL) + + remaining = {pid for pid in remaining if _pid_exists(pid)} + return not remaining + + +def _restart_webui_listener(host: str, port: int) -> bool: + """Try to free Web UI port by terminating stale listeners.""" + del host # Reserved for future host-specific diagnostics. + + stale_pids = _find_listener_pids_for_port(port) + if not stale_pids: + return True + return _terminate_pids_gracefully_then_force(stale_pids) def _extract_tool_name(line: str) -> Optional[str]: @@ -274,7 +355,14 @@ def main() -> int: # Parse web UI args from command line all_args = sys.argv[1:] if len(sys.argv) > 1 else [] try: - web_ui_enabled, web_ui_only, web_ui_port, web_ui_config, after_webui_args = ( + ( + web_ui_enabled, + web_ui_only, + web_ui_restart, + web_ui_port, + web_ui_config, + after_webui_args, + ) = ( _parse_webui_args(all_args) ) except ValueError as exc: @@ -359,6 +447,19 @@ def main() -> int: ) config._data["port"] = web_ui_port + if web_ui_restart: + if _restart_webui_listener(config.host, config.port): + print( + f"Web UI restart prepared on port {config.port}.", + file=sys.stderr, + ) + else: + print( + f"Error: Unable to free Web UI port {config.port} during restart.", + file=sys.stderr, + ) + return 1 + # Use shared metrics store for multi-process support from mcpbridge_wrapper.webui.shared_metrics import SharedMetricsStore diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index cfa0ba33..311c14cb 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -1,6 +1,7 @@ """Unit tests for the __main__ module.""" import queue +import signal from subprocess import Popen from unittest.mock import MagicMock, patch @@ -1117,3 +1118,136 @@ def capture_run(coro): main() assert wired_transport is mock_transport + + +class TestParseWebUIArgs: + """Tests for _parse_webui_args helper.""" + + def test_parse_webui_restart_sets_flags_and_keeps_remaining(self): + from mcpbridge_wrapper.__main__ import _parse_webui_args + + enabled, only, restart, port, config_path, remaining = _parse_webui_args( + ["--web-ui-restart", "--web-ui-port", "9090", "--foo"] + ) + assert enabled is True + assert only is False + assert restart is True + assert port == 9090 + assert config_path is None + assert remaining == ["--foo"] + + +class TestWebUIRestartHelpers: + """Tests for Web UI restart port recovery helpers.""" + + @patch("mcpbridge_wrapper.__main__.subprocess.run") + def test_find_listener_pids_for_port_parses_numeric_lines(self, mock_run): + from mcpbridge_wrapper.__main__ import _find_listener_pids_for_port + + mock_run.return_value = MagicMock(stdout="123\nabc\n456\n") + assert _find_listener_pids_for_port(8080) == {123, 456} + + @patch("mcpbridge_wrapper.__main__.time.sleep") + @patch("mcpbridge_wrapper.__main__.time.monotonic") + @patch("mcpbridge_wrapper.__main__._pid_exists") + @patch("mcpbridge_wrapper.__main__.os.kill") + def test_terminate_pids_gracefully_then_force_sends_sigkill_after_timeout( + self, + mock_kill, + mock_pid_exists, + mock_monotonic, + _mock_sleep, + ): + from mcpbridge_wrapper.__main__ import _terminate_pids_gracefully_then_force + + # First loop check before deadline sees process alive; second check after + # SIGKILL sees process gone. + mock_monotonic.side_effect = [0.0, 0.2, 2.0] + mock_pid_exists.side_effect = [True, False] + + ok = _terminate_pids_gracefully_then_force({999}, grace_timeout_seconds=1.0) + + assert ok is True + assert mock_kill.call_args_list[0].args[1] == signal.SIGTERM + assert mock_kill.call_args_list[1].args[1] == signal.SIGKILL + + @patch("mcpbridge_wrapper.__main__._find_listener_pids_for_port", return_value={1111}) + @patch("mcpbridge_wrapper.__main__._terminate_pids_gracefully_then_force", return_value=True) + def test_restart_webui_listener_uses_termination_flow(self, mock_terminate, mock_find): + from mcpbridge_wrapper.__main__ import _restart_webui_listener + + assert _restart_webui_listener("127.0.0.1", 8080) is True + mock_find.assert_called_once_with(8080) + mock_terminate.assert_called_once_with({1111}) + + +class TestMainWebUIRestartMode: + """Tests for main() behavior with --web-ui-restart.""" + + @patch("mcpbridge_wrapper.__main__.run_stdin_forwarder") + @patch("mcpbridge_wrapper.__main__.run_stdout_reader") + @patch("mcpbridge_wrapper.__main__.create_bridge") + @patch("mcpbridge_wrapper.__main__.cleanup_bridge") + @patch("mcpbridge_wrapper.__main__._restart_webui_listener", return_value=True) + def test_main_webui_restart_calls_restart_helper( + self, + mock_restart, + mock_cleanup, + mock_create, + mock_stdout_reader, + mock_stdin_forwarder, + ): + mock_bridge = MagicMock(spec=Popen) + mock_bridge.poll.return_value = None + mock_create.return_value = mock_bridge + mock_cleanup.return_value = 0 + + fake_webui_config = MagicMock(spec=WebUIConfig) + fake_webui_config.host = "127.0.0.1" + fake_webui_config.port = 8080 + fake_webui_config.audit_log_dir = "/tmp" + fake_webui_config.audit_max_file_size_mb = 1 + fake_webui_config.audit_max_files = 1 + fake_webui_config.audit_enabled = False + fake_webui_config.audit_capture_payload = False + + mock_queue = queue.Queue() + mock_queue.put(None) + mock_stdout_reader.return_value = (MagicMock(), mock_queue) + + with patch("mcpbridge_wrapper.webui.config.WebUIConfig", return_value=fake_webui_config), patch( + "mcpbridge_wrapper.webui.shared_metrics.SharedMetricsStore", + return_value=MagicMock(), + ), patch("mcpbridge_wrapper.webui.audit.AuditLogger", return_value=MagicMock()), patch( + "mcpbridge_wrapper.webui.server.is_port_available", return_value=True + ), patch("mcpbridge_wrapper.webui.server.run_server_in_thread", return_value=MagicMock()), patch( + "mcpbridge_wrapper.__main__.sys.argv", + ["mcpbridge-wrapper", "--web-ui", "--web-ui-restart"], + ): + result = main() + + assert result == 0 + mock_restart.assert_called_once_with("127.0.0.1", 8080) + + @patch("mcpbridge_wrapper.__main__._restart_webui_listener", return_value=False) + @patch("mcpbridge_wrapper.__main__.create_bridge") + def test_main_webui_restart_returns_1_when_port_cannot_be_freed( + self, mock_create, _mock_restart + ): + fake_webui_config = MagicMock(spec=WebUIConfig) + fake_webui_config.host = "127.0.0.1" + fake_webui_config.port = 8080 + fake_webui_config.audit_log_dir = "/tmp" + fake_webui_config.audit_max_file_size_mb = 1 + fake_webui_config.audit_max_files = 1 + fake_webui_config.audit_enabled = False + fake_webui_config.audit_capture_payload = False + + with patch("mcpbridge_wrapper.webui.config.WebUIConfig", return_value=fake_webui_config), patch( + "mcpbridge_wrapper.__main__.sys.argv", + ["mcpbridge-wrapper", "--web-ui-only", "--web-ui-restart"], + ): + result = main() + + assert result == 1 + mock_create.assert_not_called() diff --git a/tests/unit/test_main_webui.py b/tests/unit/test_main_webui.py index e56c6ea0..402076c3 100644 --- a/tests/unit/test_main_webui.py +++ b/tests/unit/test_main_webui.py @@ -20,9 +20,10 @@ class TestParseWebUIArgs: def test_no_webui_args(self): """Test parsing with no web UI args.""" args = ["--some-other-arg"] - web_ui, web_ui_only, port, config_path, remaining = _parse_webui_args(args) + web_ui, web_ui_only, web_ui_restart, port, config_path, remaining = _parse_webui_args(args) assert web_ui is False assert web_ui_only is False + assert web_ui_restart is False assert port is None assert config_path is None assert remaining == ["--some-other-arg"] @@ -30,9 +31,10 @@ def test_no_webui_args(self): def test_webui_flag(self): """Test parsing --web-ui flag.""" args = ["--web-ui"] - web_ui, web_ui_only, port, config_path, remaining = _parse_webui_args(args) + web_ui, web_ui_only, web_ui_restart, port, config_path, remaining = _parse_webui_args(args) assert web_ui is True assert web_ui_only is False + assert web_ui_restart is False assert port is None assert config_path is None assert remaining == [] @@ -40,9 +42,10 @@ def test_webui_flag(self): def test_webui_port(self): """Test parsing --web-ui-port.""" args = ["--web-ui", "--web-ui-port", "9090"] - web_ui, web_ui_only, port, config_path, remaining = _parse_webui_args(args) + web_ui, web_ui_only, web_ui_restart, port, config_path, remaining = _parse_webui_args(args) assert web_ui is True assert web_ui_only is False + assert web_ui_restart is False assert port == 9090 assert config_path is None assert remaining == [] @@ -50,17 +53,19 @@ def test_webui_port(self): def test_webui_port_equals(self): """Test parsing --web-ui-port=9090.""" args = ["--web-ui", "--web-ui-port=9090"] - web_ui, web_ui_only, port, config_path, remaining = _parse_webui_args(args) + web_ui, web_ui_only, web_ui_restart, port, config_path, remaining = _parse_webui_args(args) assert web_ui is True assert web_ui_only is False + assert web_ui_restart is False assert port == 9090 def test_webui_config(self): """Test parsing --web-ui-config.""" args = ["--web-ui", "--web-ui-config", "/path/to/config.json"] - web_ui, web_ui_only, port, config_path, remaining = _parse_webui_args(args) + web_ui, web_ui_only, web_ui_restart, port, config_path, remaining = _parse_webui_args(args) assert web_ui is True assert web_ui_only is False + assert web_ui_restart is False assert port is None assert config_path == "/path/to/config.json" assert remaining == [] @@ -68,16 +73,18 @@ def test_webui_config(self): def test_webui_config_equals(self): """Test parsing --web-ui-config=/path.""" args = ["--web-ui", "--web-ui-config=/path/to/config.json"] - web_ui, web_ui_only, port, config_path, remaining = _parse_webui_args(args) + web_ui, web_ui_only, web_ui_restart, port, config_path, remaining = _parse_webui_args(args) assert web_ui_only is False + assert web_ui_restart is False assert config_path == "/path/to/config.json" def test_bridge_args_preserved(self): """Test that bridge args are preserved.""" args = ["--web-ui", "--web-ui-port", "9090", "--", "--bridge-arg"] - web_ui, web_ui_only, port, config_path, remaining = _parse_webui_args(args) + web_ui, web_ui_only, web_ui_restart, port, config_path, remaining = _parse_webui_args(args) assert web_ui is True assert web_ui_only is False + assert web_ui_restart is False assert port == 9090 assert remaining == ["--", "--bridge-arg"] @@ -91,9 +98,10 @@ def test_all_flags_together(self): "/config.json", "--bridge-arg", ] - web_ui, web_ui_only, port, config_path, remaining = _parse_webui_args(args) + web_ui, web_ui_only, web_ui_restart, port, config_path, remaining = _parse_webui_args(args) assert web_ui is True assert web_ui_only is False + assert web_ui_restart is False assert port == 9090 assert config_path == "/config.json" assert remaining == ["--bridge-arg"] @@ -101,13 +109,25 @@ def test_all_flags_together(self): def test_webui_only_enables_webui(self): """Test parsing --web-ui-only standalone mode.""" args = ["--web-ui-only"] - web_ui, web_ui_only, port, config_path, remaining = _parse_webui_args(args) + web_ui, web_ui_only, web_ui_restart, port, config_path, remaining = _parse_webui_args(args) assert web_ui is True assert web_ui_only is True + assert web_ui_restart is False assert port is None assert config_path is None assert remaining == [] + def test_webui_restart_enables_webui(self): + """Test parsing --web-ui-restart enables restart mode.""" + args = ["--web-ui-restart", "--bridge-arg"] + web_ui, web_ui_only, web_ui_restart, port, config_path, remaining = _parse_webui_args(args) + assert web_ui is True + assert web_ui_only is False + assert web_ui_restart is True + assert port is None + assert config_path is None + assert remaining == ["--bridge-arg"] + def test_webui_port_non_numeric_raises(self): """Test invalid non-numeric web UI port raises ValueError.""" with pytest.raises(ValueError, match="Invalid --web-ui-port value"): From 1165111def4721ea6ef204afb94f216c92de0fbf Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 28 Feb 2026 14:26:31 +0300 Subject: [PATCH 5/9] Archive task FU-P11-T2-4: Add one-command Web UI restart workflow (PASS) --- ..._Add_one-command_Web_UI_restart_workflow.md | 4 ++++ .../FU-P11-T2-4_Validation_Report.md | 0 SPECS/ARCHIVE/INDEX.md | 3 ++- SPECS/INPROGRESS/next.md | 18 +++++++----------- SPECS/Workplan.md | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) rename SPECS/{INPROGRESS => ARCHIVE/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow}/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md (98%) rename SPECS/{INPROGRESS => ARCHIVE/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow}/FU-P11-T2-4_Validation_Report.md (100%) diff --git a/SPECS/INPROGRESS/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md b/SPECS/ARCHIVE/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md similarity index 98% rename from SPECS/INPROGRESS/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md rename to SPECS/ARCHIVE/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md index 841ec58c..0addc617 100644 --- a/SPECS/INPROGRESS/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md +++ b/SPECS/ARCHIVE/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow.md @@ -68,3 +68,7 @@ Provide a single, reliable restart command for the Web UI server that works in l - Keep restart idempotent so repeated calls do not fail when no process is running. - Reuse existing process management helpers if present instead of duplicating logic. + +--- +**Archived:** 2026-02-28 +**Verdict:** PASS diff --git a/SPECS/INPROGRESS/FU-P11-T2-4_Validation_Report.md b/SPECS/ARCHIVE/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow/FU-P11-T2-4_Validation_Report.md similarity index 100% rename from SPECS/INPROGRESS/FU-P11-T2-4_Validation_Report.md rename to SPECS/ARCHIVE/FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow/FU-P11-T2-4_Validation_Report.md diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index 5a157911..7cf046f1 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,11 +1,12 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-02-28 (FU-P11-T2-3_Reorder_sessions_from_the_last_to_the_first) +**Last Updated:** 2026-02-28 (FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow) ## Archived Tasks | Task ID | Folder | Archived | Verdict | |---------|--------|----------|---------| +| FU-P11-T2-4 | [FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow/](FU-P11-T2-4_Add_one-command_Web_UI_restart_workflow/) | 2026-02-28 | PASS | | FU-P11-T2-3 | [FU-P11-T2-3_Reorder_sessions_from_the_last_to_the_first/](FU-P11-T2-3_Reorder_sessions_from_the_last_to_the_first/) | 2026-02-28 | PASS | | BUG-T18 | [BUG-T18_Error_Breakdown_full_width_layout_fix/](BUG-T18_Error_Breakdown_full_width_layout_fix/) | 2026-02-26 | PASS | | BUG-T9 | [BUG-T9_Orphaned_Web_UI_server_process_blocks_port_after_MCP_client_disconnect_or_config_change/](BUG-T9_Orphaned_Web_UI_server_process_blocks_port_after_MCP_client_disconnect_or_config_change/) | 2026-02-25 | PASS | diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index 61238f43..cd136102 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -1,15 +1,11 @@ -# Next Task: FU-P11-T2-4 — Add one-command Web UI restart workflow +# No Active Task -**Priority:** P2 -**Phase:** Phase 11 -**Effort:** 2-4 hours -**Dependencies:** P11-T2 -**Status:** Selected +## Recently Archived -## Description +- **FU-P11-T2-4** — Add one-command Web UI restart workflow (2026-02-28, PASS) +- **FU-P11-T2-3** — Reorder sessions from the last to the first (2026-02-28, PASS) +- **BUG-T9** — Orphaned Web UI server process blocks port after MCP client disconnect or config change (2026-02-25, PASS) -Add a simple restart workflow for developers and users that reliably frees the configured Web UI port and starts a fresh dashboard process after updates. +## Suggested Next Tasks -## Next Step - -Run the PLAN command to generate the implementation-ready PRD. +- None (all workplan tasks are complete) diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index 276dda53..27f37731 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -2097,7 +2097,7 @@ Phase 9 Follow-up Backlog --- -#### ⬜️ FU-P11-T2-4: Add one-command Web UI restart workflow +#### ✅ FU-P11-T2-4: Add one-command Web UI restart workflow - **Description:** Add a simple restart workflow for developers and users that reliably frees the configured Web UI port and starts a fresh dashboard process after updates. - **Priority:** P2 - **Dependencies:** P11-T2 From 10e065b59842a46567607d7198410ceddd8294ca Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 28 Feb 2026 14:26:41 +0300 Subject: [PATCH 6/9] Review FU-P11-T2-4: restart workflow --- .../REVIEW_fu-p11-t2-4-restart-workflow.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 SPECS/INPROGRESS/REVIEW_fu-p11-t2-4-restart-workflow.md diff --git a/SPECS/INPROGRESS/REVIEW_fu-p11-t2-4-restart-workflow.md b/SPECS/INPROGRESS/REVIEW_fu-p11-t2-4-restart-workflow.md new file mode 100644 index 00000000..3464aa89 --- /dev/null +++ b/SPECS/INPROGRESS/REVIEW_fu-p11-t2-4-restart-workflow.md @@ -0,0 +1,32 @@ +## REVIEW REPORT — FU-P11-T2-4 restart workflow + +**Scope:** origin/main..HEAD +**Files:** 10 + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +### Critical Issues +- None. + +### Secondary Issues +- [Low] `lsof` dependency is macOS-standard in this repository context; behavior remains deterministic when unavailable (restart helper no-ops), and tests cover fallback paths. + +### Architectural Notes +- Restart logic is isolated into helpers (`_find_listener_pids_for_port`, `_terminate_pids_gracefully_then_force`, `_restart_webui_listener`) to keep `main()` readable and testable. +- Existing non-restart startup behavior remains unchanged. + +### Tests +- Quality gates executed and passing: + - `PYTHONPATH=src pytest` + - `PYTHONPATH=src ruff check src/` + - `PYTHONPATH=src mypy src/` + - `PYTHONPATH=src pytest --cov=src/mcpbridge_wrapper --cov-report=term-missing` +- Coverage remains >= 90%: 90.89%. + +### Next Steps +- No actionable follow-up tasks identified. +- FOLLOW-UP step can be skipped for this task. From d48e1d4ea28e4857c67ea7d701529105bfe854ef Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 28 Feb 2026 14:26:51 +0300 Subject: [PATCH 7/9] Archive REVIEW_fu-p11-t2-4-restart-workflow report --- SPECS/ARCHIVE/INDEX.md | 1 + .../_Historical}/REVIEW_fu-p11-t2-4-restart-workflow.md | 0 2 files changed, 1 insertion(+) rename SPECS/{INPROGRESS => ARCHIVE/_Historical}/REVIEW_fu-p11-t2-4-restart-workflow.md (100%) diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index 7cf046f1..4a518ada 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -213,6 +213,7 @@ | [REVIEW_P12-T3_error_classification_categorization.md](P12-T3_Add_Error_Classification_and_Categorization/REVIEW_P12-T3_error_classification_categorization.md) | Review report for P12-T3 | | [REVIEW_P12-T4_data_storage_documentation.md](P12-T4_Add_documentation_about_data_storage/REVIEW_P12-T4_data_storage_documentation.md) | Review report for P12-T4 | | [REVIEW_P12-T2_param_frequency_analysis.md](P12-T2_Add_Tool_Parameter_Frequency_Analysis/REVIEW_P12-T2_param_frequency_analysis.md) | Review report for P12-T2 | +| [REVIEW_fu-p11-t2-4-restart-workflow.md](_Historical/REVIEW_fu-p11-t2-4-restart-workflow.md) | Review report for FU-P11-T2-4 | | [REVIEW_fu_p11_t2_3_session_ordering.md](_Historical/REVIEW_fu_p11_t2_3_session_ordering.md) | Review report for FU-P11-T2-3 | | [REVIEW_FU-P11-T2-2_limit_query_param.md](_Historical/REVIEW_FU-P11-T2-2_limit_query_param.md) | Review report for FU-P11-T2-2 | | [REVIEW_FU-P11-T1-1_fake_webuiconfig_refactor.md](_Historical/REVIEW_FU-P11-T1-1_fake_webuiconfig_refactor.md) | Review report for FU-P11-T1-1 | diff --git a/SPECS/INPROGRESS/REVIEW_fu-p11-t2-4-restart-workflow.md b/SPECS/ARCHIVE/_Historical/REVIEW_fu-p11-t2-4-restart-workflow.md similarity index 100% rename from SPECS/INPROGRESS/REVIEW_fu-p11-t2-4-restart-workflow.md rename to SPECS/ARCHIVE/_Historical/REVIEW_fu-p11-t2-4-restart-workflow.md From 99e9e57e68a1fb71deecb1b73c9e487b5baccb09 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 28 Feb 2026 14:30:39 +0300 Subject: [PATCH 8/9] Implement FU-P11-T2-4: fix lint and coverage for CI profile --- tests/unit/test_main.py | 52 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 311c14cb..b5275fdc 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -1215,12 +1215,22 @@ def test_main_webui_restart_calls_restart_helper( mock_queue.put(None) mock_stdout_reader.return_value = (MagicMock(), mock_queue) - with patch("mcpbridge_wrapper.webui.config.WebUIConfig", return_value=fake_webui_config), patch( + with patch( + "mcpbridge_wrapper.webui.config.WebUIConfig", + return_value=fake_webui_config, + ), patch( "mcpbridge_wrapper.webui.shared_metrics.SharedMetricsStore", return_value=MagicMock(), - ), patch("mcpbridge_wrapper.webui.audit.AuditLogger", return_value=MagicMock()), patch( - "mcpbridge_wrapper.webui.server.is_port_available", return_value=True - ), patch("mcpbridge_wrapper.webui.server.run_server_in_thread", return_value=MagicMock()), patch( + ), patch( + "mcpbridge_wrapper.webui.audit.AuditLogger", + return_value=MagicMock(), + ), patch( + "mcpbridge_wrapper.webui.server.is_port_available", + return_value=True, + ), patch( + "mcpbridge_wrapper.webui.server.run_server_in_thread", + return_value=MagicMock(), + ), patch( "mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--web-ui", "--web-ui-restart"], ): @@ -1243,7 +1253,10 @@ def test_main_webui_restart_returns_1_when_port_cannot_be_freed( fake_webui_config.audit_enabled = False fake_webui_config.audit_capture_payload = False - with patch("mcpbridge_wrapper.webui.config.WebUIConfig", return_value=fake_webui_config), patch( + with patch( + "mcpbridge_wrapper.webui.config.WebUIConfig", + return_value=fake_webui_config, + ), patch( "mcpbridge_wrapper.__main__.sys.argv", ["mcpbridge-wrapper", "--web-ui-only", "--web-ui-restart"], ): @@ -1251,3 +1264,32 @@ def test_main_webui_restart_returns_1_when_port_cannot_be_freed( assert result == 1 mock_create.assert_not_called() + + +class TestMainWebUIRestartCoverageHelpers: + """Additional helper coverage for restart primitives in __main__.""" + + @patch("mcpbridge_wrapper.__main__.subprocess.run", side_effect=OSError("missing lsof")) + def test_find_listener_pids_handles_oserror(self, _mock_run): + from mcpbridge_wrapper.__main__ import _find_listener_pids_for_port + + assert _find_listener_pids_for_port(8080) == set() + + @patch("mcpbridge_wrapper.__main__.subprocess.run") + def test_find_listener_pids_skips_blank_lines(self, mock_run): + from mcpbridge_wrapper.__main__ import _find_listener_pids_for_port + + mock_run.return_value = MagicMock(stdout="\n123\n\n") + assert _find_listener_pids_for_port(8080) == {123} + + @patch("mcpbridge_wrapper.__main__.os.kill", side_effect=ProcessLookupError()) + def test_pid_exists_false_when_process_missing(self, _mock_kill): + from mcpbridge_wrapper.__main__ import _pid_exists + + assert _pid_exists(1) is False + + @patch("mcpbridge_wrapper.__main__.os.kill", side_effect=PermissionError()) + def test_pid_exists_true_on_permission_error(self, _mock_kill): + from mcpbridge_wrapper.__main__ import _pid_exists + + assert _pid_exists(1) is True From b3174113128952d751b42c0dffdaf4ceaea2d0d7 Mon Sep 17 00:00:00 2001 From: Egor Merkushev Date: Sat, 28 Feb 2026 14:32:09 +0300 Subject: [PATCH 9/9] Implement FU-P11-T2-4: apply formatter for CI --- src/mcpbridge_wrapper/__main__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/mcpbridge_wrapper/__main__.py b/src/mcpbridge_wrapper/__main__.py index 64675ae0..01fb3ca9 100644 --- a/src/mcpbridge_wrapper/__main__.py +++ b/src/mcpbridge_wrapper/__main__.py @@ -362,9 +362,7 @@ def main() -> int: web_ui_port, web_ui_config, after_webui_args, - ) = ( - _parse_webui_args(all_args) - ) + ) = _parse_webui_args(all_args) except ValueError as exc: print(f"Error: {exc}", file=sys.stderr) return 2