Repository navigation
Description
Parent Issue
#478 - Capability Freshness Architecture
Objective
Add a background capability poller that periodically re-fetches tool/resource/prompt lists from downstream MCP servers and updates the registry/store if anything changed. This catches scenario 2c from the epic: silent server redeployments where no CR change occurs and no MCP notifications/tools/list_changed notification is sent.
Problem
Layer 2 (push-based notification refresh) handles scenario 2d -- servers that actively send capability-change notifications. However, when a downstream server is silently redeployed (e.g., a Kubernetes pod restart that doesn't trigger a CR update), its tool list may change without any notification being sent. The aggregator's cached tools become stale until the next full reconnection.
Terminology
This issue distinguishes two server categories based on their connection model, not their authentication mechanism:
- Non-authenticated servers (
StatusConnected): Have a persistent globalServerInfo.Client. Tools stored inServerInfo.Tools. One client serves all sessions. - Authenticated servers (
StatusAuthRequired): Have per-session clients pooled inSessionConnectionPool. Tools stored per-session inCapabilityStore. Includes manual OAuth login, token forwarding, and token exchange -- the auth mechanism is irrelevant to the polling design.
Design
New file: internal/aggregator/capability_poller.go
A CapabilityPoller struct that runs a background goroutine on a configurable interval:
type CapabilityPoller struct {
aggregator *AggregatorServer
interval time.Duration
sfGroup *singleflight.Group // shared with notification subscriber
}Polling logic by server type
Non-authenticated servers (StatusConnected with persistent ServerInfo.Client):
- Call
refreshNonOAuthCapabilities(serverName)-- this method already exists innotification_subscriber.goand does exactly the right thing: re-fetches tools/resources/prompts via the persistent client, deep-compares withServerInfo.Tools/Resources/PromptsusingtoolListsEqual/resourceListsEqual/promptListsEqual, and updates + notifies only if something changed. - Singleflight-deduplicated via the existing
notifRefreshGroupwith keynotif-caps/<serverName>, so a poll that overlaps with a notification-triggered refresh is coalesced. - A failed
ListToolsis a natural health signal for Layer 4. If the persistent client cannot list tools, log at warn and skip the update (don't clear the existing tools). Optionally callclient.Ping()first as a cheap connectivity check.
Authenticated servers (StatusAuthRequired with per-session pooled clients):
-
Need a new method on
SessionConnectionPoolto iterate active sessions for a given server:func (p *SessionConnectionPool) GetSessionsForServer(serverName string) []PooledSession type PooledSession struct { SessionID string Client MCPClient }
This returns a snapshot of currently pooled sessions for the server. No lock is held after return; the caller must handle the case where a session was evicted between snapshot and use.
-
For each active session, call
refreshSessionCapabilities(ctx, serverName, sessionID, client)-- this method already exists innotification_subscriber.goand correctly: re-fetches tools/resources/prompts via the session's own client, deep-compares withCapabilityStore.Get(), and updatesCapabilityStore.Set()only if something changed. Auth state inSessionAuthStoreis never affected. -
Singleflight-deduplicated via the existing
notifRefreshGroupwith key<sessionID>/<serverName>, coalescing with notification-triggered refreshes. -
Authenticated servers with no active pooled clients are skipped entirely (no clients to poll with).
Lifecycle
- Start: Add a
wg.Add(1); go a.runCapabilityPoller()inAggregatorServer.Start(), similar to the existingmonitorRegistryUpdatesandrunSSOTrackerCleanupgoroutines. - Stop: The poller respects
a.ctx.Done()and exits, counted bya.wg. - Context: Use
a.refreshContext()(same as notification subscriber) for the refresh operations.
Poll interval
- Default: 5 minutes (
DefaultCapabilityPollInterval = 5 * time.Minute) - The poller iterates all registered servers each tick. A full pass re-fetches capabilities from every connected server.
- Singleflight deduplication ensures that if a notification-triggered refresh just ran for a server, the poll's attempt for that server is a no-op.
Shared singleflight group with Layer 2
The notification subscriber already uses notifRefreshGroup for deduplication. The poller should use the same singleflight group so that:
- A notification-triggered refresh that just completed prevents the poll from redundantly re-fetching the same server.
- A poll in progress prevents a concurrent notification from triggering a duplicate re-fetch.
- The dedup keys are already well-defined:
notif-caps/<serverName>for non-authenticated,<sessionID>/<serverName>for authenticated.
Code reuse from Layer 2
The poller reuses existing methods with zero duplication:
refreshNonOAuthCapabilities(serverName)for non-authenticated serversrefreshSessionCapabilities(ctx, serverName, sessionID, client)for authenticated serverstoolListsEqual,resourceListsEqual,promptListsEqualfor deep comparison (used internally by the refresh methods)
Interaction with Layer 4 (health tracking)
The poll results are natural health observation points. If Layer 4 is implemented (or when it is):
- Successful
ListTools-> mark server healthy - Failed
ListTools(network error) -> increment failure counter - The poller doubles as a health probe, eliminating the need for a separate health check loop
This is explicitly not in scope for this issue but is a documented extension point.
Implementation plan
- Add
GetSessionsForServer(serverName string) []PooledSessiontoSessionConnectionPool - Create
capability_poller.gowithrunCapabilityPoller()loop - Wire into
AggregatorServer.Start()/Stop()lifecycle - Add unit tests for the poller (mock server, verify polling triggers refresh)
- Add BDD test scenario: non-authenticated server tool change detected by polling (no notification sent by the mock server, change detected by periodic poll)
Acceptance criteria
- Background poller runs every 5 minutes (configurable default) and re-fetches capabilities from all connected servers
- Non-authenticated servers: stale tools are detected and updated via
refreshNonOAuthCapabilities - Authenticated servers: active pooled sessions are iterated, stale tools detected and updated via
refreshSessionCapabilities - Authenticated servers with no active pooled clients are skipped (no-op)
- Singleflight deduplication is shared with the notification subscriber to avoid redundant fetches
- No auth state is affected by capability polling (AuthStore is never touched)
- Poller respects aggregator lifecycle (starts with
Start(), stops withStop()) - Network errors during polling are logged but do not clear existing cached capabilities
- Unit tests cover poller lifecycle, non-authenticated refresh, authenticated session iteration, and singleflight dedup
- BDD scenario validates silent redeployment detection (tool change without notification)
- Min 80% test coverage for new code
Files to create/modify
- Create:
internal/aggregator/capability_poller.go-- poller loop and lifecycle - Create:
internal/aggregator/capability_poller_test.go-- unit tests - Modify:
internal/aggregator/session_connection_pool.go-- addGetSessionsForServer - Modify:
internal/aggregator/session_connection_pool_test.go-- testGetSessionsForServer - Modify:
internal/aggregator/server.go-- wire poller intoStart()/Stop()lifecycle - Create:
internal/testing/scenarios/poll-capability-refresh-nonoauth.yaml-- BDD test scenario
Dependencies
- No dependency on Layer 4 (health tracking). Can be implemented in parallel.
- Uses Layer 2 infrastructure (comparison helpers, refresh methods, singleflight group).
- Uses Layer 6 infrastructure (SessionConnectionPool for authenticated server iteration).
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsDone ✅
Activity
Feat: Periodic capability polling for silent redeployment detection (c…
fix(deps): repin mcp-oauth to obo/collapse-exchange rebased past v0.18.9
feat(oauth): forward the validated token to backends, remove localMin…
teemow commentedon Sep 5, 2026
Concrete repro of the stale-connection half of this, found while fixing #1162 (#1164):
With two SSO (token-forwarding) family members, stop the mock backend of one member,
core_service_restartit (probe fails → serviceFailed), start the mock again on the same port and restart the service (probe succeeds →Auth Required). The session's cached capabilities count again, but its pooled connection to that member is the one from before the outage: the restarted backend answers the nexttools/callwithand nothing evicts the pooled client or re-initializes it;
core_auth_logoutis refused for SSO servers, so the person cannot force it either. The scenariomcpserver-family-member-relists-subsetin #1164 stops at the listing level after the recovery for that reason (thetest_stop_mock_server/test_start_mock_servertools it adds make the redeployment easy to stage). Evicting the pooled entry on asession terminatedresponse and retrying once with a fresh client, the way the 401 path re-exchanges, would close it independently of the poller.feat(aggregator): poll downstream capabilities for silent redeploys
teemow commentedon Sep 22, 2026
Shipped in v5.29.0 through #496, rewritten against today's aggregator: every
MUSTER_AGGREGATOR_CAPABILITY_POLL_INTERVAL(default 5m) the aggregator re-lists every connected server -- a shared client once, a per-session server through each live pooled connection -- and updates what changed through the notification subscriber's refresh paths and singleflight keys. The stale pooled connection described above is healed on the way: the poll's listing meets the 404, the client re-initializes (#1186) and the fresh list is compared. Scenariosmcpserver-capability-poll-after-silent-redeployand-ssoroll a mock backend to another tool set without a notification and see the poll re-list it on the production schedule; both fail on the aggregator before this release.