feat(target-health): track MCP and agent target health

Add a per-replica, in-memory health tracker for MCP servers and A2A
agents (target_health.go). The tracker records observations from real
proxied traffic (handleTargetProxy, mcp_a2a.go) and federated calls
(mcpFederatedToolsList/mcpFederatedToolsCall, mcp_federation.go), and
derives a three-state readout (unknown, healthy, unhealthy) from a
configurable consecutive-failure threshold. Add TargetHealthConfig to
Config, validated at construction. The tracker never gates routing:
federation and the target proxy keep contacting every configured
server regardless of its recorded health.

Extend proxyResult (routes_passthrough.go) with status and
clientCanceled so handleTargetProxy can classify a proxied response
without proxyUpstream knowing anything about target health.

Add maybeSweepTargetHealth, an opt-in, lazy, single-flight active
probe sweep with no timer and no long-lived goroutine, matching this
plugin's lack of a Close hook under Traefik hot reload. A probe uses
the initialize/close handshake for a Streamable HTTP MCP server, a
plain unread GET for a legacy /sse server, and a GET of the agent card
path for an A2A agent. handleMCPServers and handleAgents now trigger a
sweep at the top of each call.

Claude-Session: https://claude.ai/code/session_01EebV5jyWvYocByNQ3oss1w
This commit is contained in:
2026-09-03 01:42:21 +01:00
parent 4bb49b4fe9
commit d6eebd5a84
8 changed files with 1413 additions and 11 deletions
+11 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"sort"
"time"
)
// Target kinds handleTargetProxy and resolveTarget dispatch on, selecting
@@ -63,6 +64,7 @@ type agentListing struct {
// servers, filtered by group.allowsMCP and sorted by name for a
// deterministic response.
func (g *Gateway) handleMCPServers(w http.ResponseWriter, grp *group) {
g.maybeSweepTargetHealth()
names := make([]string, 0, len(g.cfg.MCPServers))
for name := range g.cfg.MCPServers {
if grp.allowsMCP(name) {
@@ -86,6 +88,7 @@ func (g *Gateway) handleMCPServers(w http.ResponseWriter, grp *group) {
// proxy URL plus its configured card path, defaulting to
// defaultAgentCardPath when AgentConfig.Card is empty.
func (g *Gateway) handleAgents(w http.ResponseWriter, grp *group) {
g.maybeSweepTargetHealth()
names := make([]string, 0, len(g.cfg.Agents))
for name := range g.cfg.Agents {
if grp.allowsAgent(name) {
@@ -216,7 +219,14 @@ func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *u
upstreamURL += "?" + r.URL.RawQuery
}
g.proxyUpstream(w, r, upstreamURL, g.targetClient, nil, nil, false, kind+" target (name "+name+")", g.targetTimeout)
// feat/target-health: passive recording (target_health.go) — latency
// measured around the whole proxyUpstream call, matching what an
// active probe of the same target measures. See
// recordTargetProxyHealth's own doc comment for the exact
// success/failure rule.
start := time.Now()
result, ok := g.proxyUpstream(w, r, upstreamURL, g.targetClient, nil, nil, false, kind+" target (name "+name+")", g.targetTimeout)
g.recordTargetProxyHealth(kind, name, result, ok, time.Since(start))
}
// validateTargetURLs checks every configured MCP-server and agent entry at