Skip to content

Commit 7b06399

Browse files
drknowhowclaude
andcommitted
Fix port conflict: stdio mode evicts --ws-only server on startup
Root cause: when Claude Code spawns sentinel_mcp.py (stdio mode), it tries to start a new WebSocket server on 18925. If the extension's Start button already launched a --ws-only instance holding that port, the new server silently fails to bind. Claude's _ws_client stays None forever. Fix: - _main() calls _try_shutdown_existing() before starting the WS server It connects to ws://127.0.0.1:18925 and sends {"command":"SHUTDOWN"} - _ws_handler handles SHUTDOWN by calling sys.exit(0), releasing the port - _start_ws_server() retries up to 6 times (×0.5s) in case the port is slow to release — handles the race condition cleanly - Extension reconnects to the new server within ~3s (our WS_RECONNECT_MIN) - _send_command waits up to 35s for reconnect, so first tool call succeeds Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 60b9fc2 commit 7b06399

1 file changed

Lines changed: 32 additions & 4 deletions

File tree

mcp-server/sentinel_mcp.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ async def _ws_handler(websocket: WebSocketServerProtocol) -> None:
2727
try:
2828
async for raw in websocket:
2929
msg = json.loads(raw)
30+
# A new stdio-mode instance taking over the port sends this signal
31+
if msg.get("command") == "SHUTDOWN":
32+
print("[sentinel-mcp] Shutdown signal received — releasing port for new instance")
33+
sys.exit(0)
3034
msg_id = msg.get("id")
3135
if msg_id and msg_id in _pending:
3236
_pending[msg_id].set_result(msg)
@@ -38,10 +42,31 @@ async def _ws_handler(websocket: WebSocketServerProtocol) -> None:
3842
print("[sentinel-mcp] Extension disconnected")
3943

4044

45+
async def _try_shutdown_existing() -> None:
46+
"""If a --ws-only server is holding port 18925, tell it to exit so we can take over.
47+
The extension will reconnect to our new server within a few seconds."""
48+
try:
49+
ws = await asyncio.wait_for(websockets.connect("ws://127.0.0.1:18925"), timeout=1.5)
50+
await ws.send(json.dumps({"command": "SHUTDOWN"}))
51+
await ws.close()
52+
await asyncio.sleep(1.0) # wait for the port to be released
53+
print("[sentinel-mcp] Signalled existing server to stop")
54+
except Exception:
55+
pass # nothing running — fine
56+
57+
4158
async def _start_ws_server() -> None:
42-
server = await websockets.serve(_ws_handler, "127.0.0.1", 18925)
43-
print("[sentinel-mcp] WebSocket server listening on ws://127.0.0.1:18925")
44-
await server.wait_closed()
59+
# Retry a few times in case the previous server is still releasing the port
60+
for attempt in range(6):
61+
try:
62+
server = await websockets.serve(_ws_handler, "127.0.0.1", 18925)
63+
print("[sentinel-mcp] WebSocket server listening on ws://127.0.0.1:18925")
64+
await server.wait_closed()
65+
return
66+
except OSError:
67+
if attempt < 5:
68+
await asyncio.sleep(0.5)
69+
print("[sentinel-mcp] ERROR: Could not bind port 18925 after retries")
4570

4671

4772
async def _send_command(command: str, payload: dict | None = None, timeout: float = 30.0) -> dict:
@@ -280,7 +305,10 @@ async def sentinel_investigate(url: str, duration: int = 5) -> dict[str, Any]:
280305
# ── Entrypoint ──
281306

282307
async def _main() -> None:
283-
"""Normal mode: WebSocket server + MCP stdio transport (spawned by AI client)."""
308+
"""Normal mode: WebSocket server + MCP stdio transport (spawned by AI client).
309+
Sends a shutdown signal to any running --ws-only server first so it releases
310+
port 18925. The extension reconnects to our new server automatically."""
311+
await _try_shutdown_existing()
284312
asyncio.create_task(_start_ws_server())
285313
await mcp.run_stdio_async()
286314

0 commit comments

Comments
 (0)