fix(mcp): stop duplicate tool calls and keep full tool metadata

Federated tools/call re-initializes a session only when the backend
signals a missing session: HTTP 400 or 404, or a JSON-RPC error that
names a session. A failing tool is no longer called twice.

tools/list now:
- passes each tool object through and rewrites only its name
- follows nextCursor up to a page cap and keeps the earlier pages if a
  later page fails
- skips tools with no name
- resolves name collisions to the server that tools/call routes to

The handshake sends notifications/initialized. tools/call forwards
_meta. Multi-line SSE data is joined. A2A target URLs are joined with
url.JoinPath and keep their query string. agents[].card must start with
"/". Logged target URLs and errors are scrubbed of credentials.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 09:44:32 +01:00
parent 4c01c75276
commit 4a70daa8b8
5 changed files with 1338 additions and 110 deletions
+84 -6
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"sort"
"strings"
"time"
)
@@ -211,12 +212,14 @@ func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *u
}
g.limiter.countTargetRequest(targetScopeKind(kind), name)
upstreamURL := targetURL
if rest != "" {
upstreamURL += "/" + rest
}
if r.URL.RawQuery != "" {
upstreamURL += "?" + r.URL.RawQuery
upstreamURL, err := buildUpstreamTargetURL(targetURL, rest, r.URL.RawQuery)
if err != nil {
// targetURL is operator-configured and already validated at
// construction (validateTargetURLs) — unreachable in practice,
// kept as an explicit guard rather than trusting that invariant
// silently.
writeOAIError(w, http.StatusInternalServerError, "invalid_request_error", "invalid target configuration")
return
}
// feat/target-health: passive recording (target_health.go) — latency
@@ -229,6 +232,70 @@ func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *u
g.recordTargetProxyHealth(kind, name, targetURL, result, ok, time.Since(start))
}
// buildUpstreamTargetURL joins targetURL (an MCP server or A2A agent's own
// configured base, already validated at construction by
// validateTargetURL) with rest (handleTargetProxy's own path remainder,
// already checked for a traversal segment by its caller) using
// url.URL.JoinPath rather than plain string concatenation (F7, review
// round 3, 2026-09): the prior "+ \"/\" + rest" produced a doubled slash
// whenever targetURL's own path already ended in "/" (e.g. configured as
// "http://x/mcp/"), and, worse, a targetURL carrying its own query string
// — the only way TargetConfig/AgentConfig can pass a credential to an
// upstream today, since neither carries a header field — got that query
// glued onto the wrong side of "?" + the request's own RawQuery,
// producing something like "...?token=abc/foo" or two "?" characters and
// corrupting the token federation was relying on. The target's own query
// string is preserved (JoinPath never touches RawQuery) and merged with
// the incoming request's own query (mergeQueryStrings, below) — neither
// silently overwrites the other.
func buildUpstreamTargetURL(targetURL, rest, requestRawQuery string) (string, error) {
base, err := url.Parse(targetURL)
if err != nil {
return "", err
}
joined := base
if rest != "" {
joined = base.JoinPath(rest)
}
joined.RawQuery = mergeQueryStrings(joined.RawQuery, requestRawQuery)
return joined.String(), nil
}
// mergeQueryStrings combines targetQuery (the configured target URL's own
// query string, returned verbatim when it stands alone) with
// requestQuery (the incoming client request's own), so neither silently
// discards the other (F7). A request query that fails to parse is
// dropped, not treated as a whole-request failure: the request's own
// method/body may still be perfectly valid, and the target's own query —
// a credential, per this file's own TargetConfig/AgentConfig doc
// comments — is the one half of this that must never be lost silently.
func mergeQueryStrings(targetQuery, requestQuery string) string {
if targetQuery == "" {
return requestQuery
}
if requestQuery == "" {
return targetQuery
}
merged, err := url.ParseQuery(targetQuery)
if err != nil {
// targetQuery came from an already-parsed url.URL's own RawQuery,
// so this is unreachable in practice — falls back to the
// request's own query alone rather than panicking or silently
// dropping both.
return requestQuery
}
reqValues, err := url.ParseQuery(requestQuery)
if err != nil {
return targetQuery
}
for k, vs := range reqValues {
for _, v := range vs {
merged.Add(k, v)
}
}
return merged.Encode()
}
// validateTargetURLs checks every configured MCP-server and agent entry at
// construction: the map value must be non-nil, the name must pass
// validateConfigName (the same character-set, dot-only, and reserved-name
@@ -261,6 +328,17 @@ func validateTargetURLs(cfg *Config) error {
if err := validateTargetURL(ac.URL); err != nil {
return fmt.Errorf("llmgateway: agents %q: %w", name, err)
}
// F8, review round 3, 2026-09: handleAgents builds the card URL as
// "/a2a/" + name + cardPath (its own doc comment) with no
// separator of its own — a Card that does not start with "/"
// (e.g. "agent.json") concatenates straight onto the agent name
// instead of starting a new path segment, producing something
// like "/a2a/myagentagent.json": a 404, or a completely different
// route, never the card the operator configured. Empty (the
// default, defaultAgentCardPath applies) is always valid.
if ac.Card != "" && !strings.HasPrefix(ac.Card, "/") {
return fmt.Errorf("llmgateway: agents %q: card %q must start with \"/\"", name, ac.Card)
}
}
return nil
}