feat: MCP/A2A registry and authed streaming proxy

Add GET /v1/mcp/servers and GET /v1/agents: group-filtered,
name-sorted registry listings of configured MCP servers and A2A
agents, with their gateway-relative proxy (and, for agents, card)
URLs.

Add handleTargetProxy for /mcp/{name}/{rest...} and
/a2a/{name}/{rest...}: reverse-proxies to the target's configured
URL. Checks run unknown name (404), group deny via allowsMCP/
allowsAgent (403), request-rate limits (429/503), then Upgrade
(501) and path traversal (400) around the proxy call itself. No
upstream credential is injected — MCP servers and A2A agents are
in-cluster targets, unlike native provider passthrough.

Extract proxyUpstream from routes_passthrough.go's handlePassthrough
into a shared reverse-proxy core (header stripping, SSE-safe
flushWriter streaming, dead-upstream 502, canceled-client log-only,
optional JSON usage teeing) used by both native passthrough and the
new target proxy. Behavior-preserving for Task 13: its tests pass
unmodified.

Validate every configured MCP-server and agent target URL at
construction (url.Parse, http/https scheme only) — a malformed or
non-HTTP target is a constructor error, not a per-request 502.
This commit is contained in:
2026-08-20 12:02:06 +01:00
parent 3ce3715c79
commit b879b21975
4 changed files with 931 additions and 42 deletions
+43 -1
View File
@@ -107,7 +107,12 @@ type Gateway struct {
limiter *limiter
registry *modelRegistry
adapters map[string]providerAdapter
name string
// targetClient is the shared, connection-pooled *http.Client the
// MCP/A2A target proxy (mcp_a2a.go) issues every upstream request
// through — built once via newAdapterHTTPClient, the same constructor
// each provider adapter uses for its own client.
targetClient *http.Client
name string
}
// New creates the middleware. NOTE: no tail call — Yaegi zeroes
@@ -124,6 +129,9 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
if config == nil || len(config.Providers) == 0 {
return nil, errors.New("llmgateway: at least one provider must be configured")
}
if err := validateTargetURLs(config); err != nil {
return nil, err
}
auth, err := newAuthStore(config)
if err != nil {
return nil, err
@@ -148,6 +156,7 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str
return nil, err
}
g.adapters = adapters
g.targetClient = newAdapterHTTPClient()
registry, err := newModelRegistry(adapters, config, g.errorf)
if err != nil {
@@ -268,6 +277,39 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
if r.Method == http.MethodGet && (r.URL.Path == "/v1/mcp/servers" || r.URL.Path == "/v1/agents") {
_, grp, ok := g.auth.identify(r)
if !ok {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
if r.URL.Path == "/v1/mcp/servers" {
g.handleMCPServers(sw, grp)
} else {
g.handleAgents(sw, grp)
}
return
}
if name, rest, ok := targetRoute(r.URL.EscapedPath(), targetKindMCP); ok {
u, grp, authOK := g.auth.identify(r)
if !authOK {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
g.handleTargetProxy(sw, r, u, grp, targetKindMCP, name, rest)
return
}
if name, rest, ok := targetRoute(r.URL.EscapedPath(), targetKindAgent); ok {
u, grp, authOK := g.auth.identify(r)
if !authOK {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
g.handleTargetProxy(sw, r, u, grp, targetKindAgent, name, rest)
return
}
if providerName, rest, ok := passthroughRoute(r.URL.EscapedPath()); ok {
if _, known := g.adapters[providerName]; known {
u, grp, ok := g.auth.identify(r)