stacklok / stacklok/toolhive

vMCP: backend health/set changes are not propagated to live sessions (stale tool catalog until reconnect; no tools/list_changed)

Open
#5,786 6 comments 0 reactions 1 assignee View on GitHub

@premctl is already working on this.

Since Jul 17, 2026.

enhancement health-checks p2 vmcp
Dominant language
Go
Stars
2.2k
Forks
300
Avg merge
1d 15h
Merged PRs (30d)
184

Description

vMCP: backend health/set changes are not propagated to live sessions (stale tool catalog until client reconnect; no tools/list_changed)

Type: bug / enhancement · Area: vMCP (aggregator / server / health) · Version: v0.34.0 (latest release; also on main)
Related: companion to the SSRF-allowlist request (co-located backends) — same deployment.

Summary

When a vMCP backend's availability changes at runtime — a backend recovers (unhealthy→healthy), or is added/removed from the group — already-connected client sessions do not see the change. Their tool catalog stays as it was when the session was created, and the vMCP never emits notifications/tools/list_changed. Clients must reconnect (re-initialize the session) to pick up a newly-healthy backend's tools. New sessions are fine; existing ones are stuck.

Repro

  1. Run a vMCP fronting ≥2 backends; connect a client (session A).
  2. A backend that was unhealthy at A's connect time becomes healthy (fix its endpoint, restart it, etc.). The vMCP re-aggregates — logs capability aggregation complete backends:N tools:M with the new backend included.
  3. From session A: the new backend's tools are not callable (call_tooltool not found) and don't appear in discovery. A freshly connected session B sees them.
  4. Only reconnecting session A surfaces them.

(In optimizer mode this is extra-confusing: the client's advertised tools are the stable find_tool/call_tool meta-tools, so even a client that honors tools/list_changed has nothing new to re-fetch — the staleness lives inside the optimizer catalog that find_tool/call_tool resolve against.)

Root cause (verified, v0.34.0)

  • Per-session snapshot, taken once at registration. pkg/vmcp/server/serve_handlers.goinjectCoreSessionCapabilitiescoreSessionTools calls s.core.ListTools(...) only when the session is registered, and installs the result via setSessionToolsDirect. In optimizer mode serveSessionTools builds the find_tool/call_tool meta-tools over that same one-time snapshot.
  • Aggregation has no downstream notification. pkg/vmcp/aggregator/default_aggregator.go (AggregateCapabilities) and pkg/vmcp/aggregator/caching_aggregator.go compute/refresh the merged set, but neither notifies sessions nor the server of a change.
  • Health monitor detects transitions but doesn't fan out. pkg/vmcp/health/monitor.go tracks status (statusTracker, UpdateBackends, StatusProvider/Reporter interfaces) and logs starting health monitoring for new backend, but there is no callback/subscription that re-injects live sessions or triggers a client notification on a state change.
  • No tools/list_changed is ever emitted on the vMCP path. The method is parsed (pkg/mcp/parser.go) and the primitive exists (pkg/transport/bridge.goSendNotificationToAllClients), but it is only used by the single-server proxy bridge, not wired into the vMCP aggregation/session layer.

Net: health monitor → re-aggregate is a dead end w.r.t. connected sessions.

Proposal

On a backend-set change (health transition, or group add/remove), propagate to live sessions:

  1. Passthrough mode (optimizer off): update each connected session's advertised tools (the go-sdk supports per-session AddSessionTools/SetSessionTools/DeleteSessionTools) and emit notifications/tools/list_changed to all clients via the MCPServer (the same primitive the bridge already uses). Requires the server to advertise tools.listChanged: true.
  2. Optimizer mode (optimizer on): rebuild the optimizer catalog/index that find_tool/call_tool resolve against so existing sessions resolve the new backend without reconnect (either refresh the per-session optimizer state, or have the optimizer resolve against the live core catalog on each call / on a change signal).
  3. Wiring: give health.Monitor a change-callback / subscription (it already centralizes status via statusTracker); the core/server subscribes, re-aggregates, and performs (1)/(2). Debounce to the existing statusReportingInterval so a flapping backend doesn't storm sessions.

Notes

  • Gate strictly on the server advertising tools.listChanged; no behavior change otherwise.
  • The debounce already present for health (healthCheckInterval × unhealthyThreshold) keeps this from firing on transient blips.
  • Happy to help / open a PR — a sketch of the integration points is written up alongside this.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.