What happened?
On macOS, running the menu bar app with MCPPROXY_TRAY_SKIP_CORE=1 (attaching to an externally-managed core instead of one the app spawns itself — documented in docs/tray-debug.md), once the external core stops there is no mechanism that ever detects it. The menu bar icon stays stuck showing the core as running/connected indefinitely — confirmed empirically over 5.5+ minutes, not just a slow timeout.
Root cause, traced through cmd/mcpproxy-tray/main.go:
-
handleWaitForCore() (~line 1305) is the only place HealthMonitor gets created and started:
if cpl.healthMonitor == nil {
cpl.healthMonitor = monitor.NewHealthMonitor(cpl.coreURL, cpl.logger, cpl.stateMachine)
cpl.healthMonitor.Start()
}
This handler is only reached in the normal self-launch flow (tray spawns its own core subprocess: LaunchingCore → WaitingForCore → ...).
-
handleConnectAPI() (~line 1330) is the attach-mode path (used when the app finds core already running, which is exactly what happens under SKIP_CORE). It never calls into handleWaitForCore(), so HealthMonitor is never created on this path.
-
monitorAPIConnection(ctx, alreadyConnected) (~line 1391) is started instead:
case tray.ConnectionStateReconnecting, tray.ConnectionStateDisconnected:
// Only send EventConnectionLost if we were relying on SSE for connection
// If we're already connected via HTTP, ignore SSE failures
if !alreadyConnected {
cpl.stateMachine.SendEvent(state.EventConnectionLost)
}
handleConnectAPI() calls this with alreadyConnected=true (comment: "Pass alreadyConnected=true since we verified API is ready via HTTP ... This tells the monitor to ignore SSE connection failures"). So even though the SSE client keeps retrying in the background (visible in Console.app as continuous Connection refused retries against 127.0.0.1:8080, indefinitely), those failures are explicitly discarded and never reach the state machine.
Net result: no health polling, no SSE-disconnect event — nothing left that can ever flip the state machine out of "connected" once attached this way. The icon stays frozen forever (confirmed empirically). By the same source-level guard (internal/tray/managers.go's SynchronizationManager.performSync() bails out entirely when isConnected() is false), the servers submenu should freeze too — this part is inferred from code, not independently re-confirmed via accessibility inspection in this test (individual server rows expose no AXMenuItemMarkChar/connected marker to script against, likely a colored-dot image instead), so a maintainer should double check the submenu specifically.
The one thing that does look live is the top menu summary line (e.g. "18/26 servers, 255 tools") — but that's because it's fetched fresh via a direct API call at the moment the menu is clicked open, bypassing the state machine entirely, not because anything is actually monitoring the connection.
Expected behavior
When attaching to an already-running core (alreadyConnected=true / SKIP_CORE=1 path), the app should still detect the core disappearing — either by also starting a HealthMonitor in handleConnectAPI(), or by not unconditionally suppressing EventConnectionLost for SSE failures once a reasonable retry/backoff budget is exhausted. Currently there is no code path in attach mode that can ever transition out of "connected," no matter how long the external core has been gone.
Steps to reproduce
- On macOS:
launchctl setenv MCPPROXY_TRAY_SKIP_CORE 1, then quit and reopen the app so it picks up the env var (attach mode, no self-spawned core).
- Manually start core:
mcpproxy serve &.
- Open the menu — confirm normal icon and "N/26 servers" summary.
- Stop core:
kill -9 $(pgrep -f "mcpproxy serve").
- Confirm core is actually gone:
mcpproxy status → State: Not running; socket removed.
- Watch the menu bar icon for several minutes — it never changes from the "connected" appearance, confirmed by scripting its title via Accessibility (
osascript/System Events) at 15-20s intervals for 5.5+ minutes. Opening the menu shows the summary line lose its server/tool counts (queried live at click time), but the icon itself never updates. (The "Servers (N)" submenu is expected to be similarly stuck per the isConnected() guard in performSync(), but that specific part wasn't independently reconfirmed with a visual marker in this test.)
MCPProxy version
v0.52.1 (personal) darwin/amd64
Operating system
macOS 15.7.7 (24G720)
Relevant logs
Core ran with --log-to-file --log-level=debug; behavior root-caused directly against cmd/mcpproxy-tray/main.go (handleConnectAPI, handleWaitForCore, monitorAPIConnection, lines ~1305-1420) and internal/tray/managers.go (SynchronizationManager.performSync, ~1211) on the main branch, matching the running binary version above. macOS unified log (log show --predicate 'process == "MCPProxy"') during the test shows continuous NSPOSIXErrorDomain Code=61 (Connection refused) retries against 127.0.0.1:8080 for the full duration with no resulting state change in the app.
Pre-submission checklist
What happened?
On macOS, running the menu bar app with
MCPPROXY_TRAY_SKIP_CORE=1(attaching to an externally-managed core instead of one the app spawns itself — documented indocs/tray-debug.md), once the external core stops there is no mechanism that ever detects it. The menu bar icon stays stuck showing the core as running/connected indefinitely — confirmed empirically over 5.5+ minutes, not just a slow timeout.Root cause, traced through
cmd/mcpproxy-tray/main.go:handleWaitForCore()(~line 1305) is the only placeHealthMonitorgets created and started:This handler is only reached in the normal self-launch flow (tray spawns its own core subprocess:
LaunchingCore→WaitingForCore→ ...).handleConnectAPI()(~line 1330) is the attach-mode path (used when the app finds core already running, which is exactly what happens underSKIP_CORE). It never calls intohandleWaitForCore(), soHealthMonitoris never created on this path.monitorAPIConnection(ctx, alreadyConnected)(~line 1391) is started instead:handleConnectAPI()calls this withalreadyConnected=true(comment: "Pass alreadyConnected=true since we verified API is ready via HTTP ... This tells the monitor to ignore SSE connection failures"). So even though the SSE client keeps retrying in the background (visible in Console.app as continuousConnection refusedretries against127.0.0.1:8080, indefinitely), those failures are explicitly discarded and never reach the state machine.Net result: no health polling, no SSE-disconnect event — nothing left that can ever flip the state machine out of "connected" once attached this way. The icon stays frozen forever (confirmed empirically). By the same source-level guard (
internal/tray/managers.go'sSynchronizationManager.performSync()bails out entirely whenisConnected()is false), the servers submenu should freeze too — this part is inferred from code, not independently re-confirmed via accessibility inspection in this test (individual server rows expose noAXMenuItemMarkChar/connected marker to script against, likely a colored-dot image instead), so a maintainer should double check the submenu specifically.The one thing that does look live is the top menu summary line (e.g. "18/26 servers, 255 tools") — but that's because it's fetched fresh via a direct API call at the moment the menu is clicked open, bypassing the state machine entirely, not because anything is actually monitoring the connection.
Expected behavior
When attaching to an already-running core (
alreadyConnected=true/SKIP_CORE=1path), the app should still detect the core disappearing — either by also starting aHealthMonitorinhandleConnectAPI(), or by not unconditionally suppressingEventConnectionLostfor SSE failures once a reasonable retry/backoff budget is exhausted. Currently there is no code path in attach mode that can ever transition out of "connected," no matter how long the external core has been gone.Steps to reproduce
launchctl setenv MCPPROXY_TRAY_SKIP_CORE 1, then quit and reopen the app so it picks up the env var (attach mode, no self-spawned core).mcpproxy serve &.kill -9 $(pgrep -f "mcpproxy serve").mcpproxy status→State: Not running; socket removed.osascript/System Events) at 15-20s intervals for 5.5+ minutes. Opening the menu shows the summary line lose its server/tool counts (queried live at click time), but the icon itself never updates. (The "Servers (N)" submenu is expected to be similarly stuck per theisConnected()guard inperformSync(), but that specific part wasn't independently reconfirmed with a visual marker in this test.)MCPProxy version
v0.52.1 (personal) darwin/amd64
Operating system
macOS 15.7.7 (24G720)
Relevant logs
Core ran with
--log-to-file --log-level=debug; behavior root-caused directly againstcmd/mcpproxy-tray/main.go(handleConnectAPI,handleWaitForCore,monitorAPIConnection, lines ~1305-1420) andinternal/tray/managers.go(SynchronizationManager.performSync, ~1211) on themainbranch, matching the running binary version above. macOS unified log (log show --predicate 'process == "MCPProxy"') during the test shows continuousNSPOSIXErrorDomain Code=61 (Connection refused)retries against127.0.0.1:8080for the full duration with no resulting state change in the app.Pre-submission checklist
mcpproxy doctorand reviewed the output (not applicable —doctorrequires a running daemon, and the bug is specifically about app state after the daemon stops)