feat(mcp): federated /mcp aggregation endpoint

New POST /mcp: a single JSON-RPC 2.0 endpoint aggregating every MCP
server the caller's group can reach, for clients built against the
earlier pre-plugin gateway's one aggregated endpoint (pugbot's
mcpclient, agentkit) rather than this plugin's per-server
/mcp/{name}/... proxy.

Unlike the per-server proxy, this cannot be a byte-level reverse
proxy: aggregating N servers' tools/list results, or routing one
tools/call to the single right server, both require actually parsing
the JSON-RPC envelope. The gateway answers initialize/ping locally
and, for tools/list/tools/call, issues its own outbound JSON-RPC
call(s) to the relevant backend server(s) per incoming request — no
session state persisted across separate client requests, matching the
per-server proxy's own complete absence of gateway-side session
tracking. A backend may answer bare JSON or an SSE stream; both are
accepted (parseBackendJSONRPC).

- tools/list fans out concurrently to every allowed server and merges
  results, prefixing each tool "<serverName>_<toolName>" — the
  underscore convention the earlier agentgateway used and that
  pugbot/agentkit already persist in their own tool-id records (e.g.
  brave-search_brave_web_search, verified live). A failing/unreachable
  server is skipped, not a whole-call failure.
- tools/call resolves the target by LONGEST matching
  "<serverName>_" prefix against the caller's allowed servers
  (disambiguates a server name that prefixes another), strips it,
  forwards, and relays the result/error under the CLIENT's own id. An
  unresolvable prefix (unconfigured, or authz-excluded) is a JSON-RPC
  invalid-params error, not a 404.
- Reuses Feature B's countTargetRequest for per-target attribution —
  every backend server actually contacted increments its own scope.
- notifications/* -> 202 no body; unknown method -> JSON-RPC
  method-not-found; malformed body -> JSON-RPC parse error with
  id:null (spec-required, not omitted).

No new limits config surface. Tests cover prefix resolution
(table-driven, incl. the ambiguous-prefix case), SSE/JSON backend
parsing, group-restricted aggregation (a denied server is never even
contacted), call routing/prefix-stripping, backend error relay,
unreachable-server handling, unknown-prefix errors, and per-target
counter attribution. README's MCP and A2A section documents the
federation contract and naming convention; the architecture diagram
and the total-scope/rejected-before-quota notes are updated to
include the new route.
This commit is contained in:
2026-08-21 18:38:28 +01:00
parent 94d8f90805
commit d565c1d4ea
4 changed files with 1292 additions and 14 deletions
+17
View File
@@ -449,6 +449,23 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Federated /mcp (Feature C, v0.21) is matched ahead of the per-server
// "/mcp/{name}/..." target proxy below: targetRoute's own splitting
// already rejects a bare "/mcp" (no name segment to route to), so
// there is no ambiguity between the two, but this ordering keeps every
// exact-path route grouped together (mirroring "/v1/mcp/servers" and
// "/v1/agents" immediately above) ahead of the prefix-matched one.
if r.Method == http.MethodPost && r.URL.Path == federatedMCPPath {
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)
if !ok {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
g.handleMCPFederated(sw, r, u, grp)
return
}
if name, rest, ok := targetRoute(r.URL.EscapedPath(), targetKindMCP); ok {
u, grp, authOK := g.auth.identify(r)
g.logAuthEvent(authOK, authEventUserName(u), r)