fix(go-adk): forward allowedHeaders from incoming request to MCP tool calls#1733
Conversation
jmhbh
left a comment
There was a problem hiding this comment.
header forwarding changes lgtm just curious about the bedrock changes in this PR since they aren't explicitly tied to the issue at hand, do we have a separate issue for that?
5c977ea to
f82c5a0
Compare
… calls The AllowedHeaders field on McpServer was populated by the translator and stored in HttpMcpServerConfig/SseMcpServerConfig but never used by the Go runtime. Headers from the incoming A2A request (e.g. Authorization: Bearer) were therefore never forwarded to downstream MCP server calls, even when the operator explicitly listed them in allowedHeaders. Fix: - Add WithRequestHeaders/requestHeadersFromContext helpers to the mcp package so the executor can store incoming request headers in the Go context without the packages being coupled. - Extend headerRoundTripper with an allowedHeaders field. On each outbound MCP request the transport reads the context for incoming headers and adds only those whose names appear in allowedHeaders. Static headers from the server spec are applied last so they always take precedence (mirrors the Python runtime's security model where STS tokens override propagated headers). - CreateToolsets passes AllowedHeaders from each McpServer config into the transport params. - executor.Execute extracts incoming headers from the A2A request metadata inline and stores them via mcp.WithRequestHeaders before the agent loop runs. Closes kagent-dev#1679 Signed-off-by: mesutoezdil <mesudozdil@gmail.com>
f82c5a0 to
c39896f
Compare
|
Good catch on both counts. @jmhbh The Bedrock changes crept in because this branch was cut from the wrong base. Rebased onto main so only the allowedHeaders fix is here now, single clean commit. On the nit: removed the private |
| if callCtx, ok := a2asrv.CallContextFrom(ctx); ok { | ||
| if meta := callCtx.RequestMeta(); meta != nil { | ||
| headers := make(map[string]string) | ||
| for key, vals := range meta.List() { | ||
| if len(vals) > 0 && vals[0] != "" { | ||
| headers[key] = vals[0] | ||
| } | ||
| } | ||
| if len(headers) > 0 { | ||
| ctx = mcp.WithRequestHeaders(ctx, headers) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Why are we moving these values from the original context value into a custom one, what value does that bring us? I'd rather just create a helper function in registry.go that pulls these values, what do you think?
There was a problem hiding this comment.
Done. Replaced the WithRequestHeaders/requestHeadersFromContext approach with a single allowedRequestHeaders helper in registry.go that reads straight from the a2asrv.CallContext already present in the Go context. No intermediate copy, no exported helper needed. Tests use a2asrv.NewRequestMeta + a2asrv.WithCallContext to build the test context, matching what the real server does.
Remove the intermediate WithRequestHeaders/requestHeadersFromContext context-key approach. Instead, a new helper allowedRequestHeaders reads the allowed headers straight from the a2asrv.CallContext that the A2A server already stores in the request context, eliminating the extra copy. Tests are updated to build the A2A context with a2asrv.WithCallContext and a2asrv.NewRequestMeta, matching real server behaviour. Signed-off-by: mesutoezdil <mesudozdil@gmail.com>
jsonmp-k8
left a comment
There was a problem hiding this comment.
Bug: allowedRequestHeaders reinvents RequestMeta.Get() poorly
The entire inner loop is unnecessary and introduces bugs:
for _, name := range allowed {
lower := strings.ToLower(name)
for key, vals := range meta.List() {
if strings.ToLower(key) == lower && len(vals) > 0 && vals[0] != "" {
result[key] = vals[0]
break
}
}
}RequestMeta.Get(key) already does case-insensitive O(1) lookup (it lowercases keys at construction in NewRequestMeta, and Get lowercases the lookup key). This loop is O(n*m) for no reason. Worse, since List() yields already-lowercased keys, result[key] stores the lowercased form — so req.Header.Set("authorization", v) is what actually happens. Go's Header.Set canonicalizes it so it works by accident, but it's still wrong in intent.
Replace the entire function body with:
func allowedRequestHeaders(ctx context.Context, allowed []string) map[string]string {
if len(allowed) == 0 {
return nil
}
callCtx, ok := a2asrv.CallContextFrom(ctx)
if !ok {
return nil
}
meta := callCtx.RequestMeta()
if meta == nil {
return nil
}
result := make(map[string]string)
for _, name := range allowed {
if vals, ok := meta.Get(name); ok && len(vals) > 0 && vals[0] != "" {
result[name] = vals[0]
}
}
if len(result) == 0 {
return nil
}
return result
}This preserves the original casing from the allowedHeaders config, does O(n) instead of O(n*m), and avoids returning an empty map (the callers range over it, so nil is cleaner).
Missing: no test for case mismatch between config and request
The PR claims case-insensitive matching but none of the tests exercise it. Add a test where allowedHeaders says "authorization" but the A2A request sends "Authorization" (or vice versa). Without this, the case-insensitivity claim is untested.
Missing: no test for multi-value headers
RequestMeta stores []string per key. The PR takes vals[0] unconditionally, silently dropping all subsequent values. This is probably fine for headers like Authorization, but the PR should document this choice (first value wins) and test that a multi-value header only forwards the first value. If a header like X-Forwarded-For gets multiple values, dropping all but the first is data loss.
Concern: context propagation is assumed, not verified
The entire approach relies on the A2A CallContext surviving from the executor through the Google ADK runner, through the agent invocation, through the MCP tool call, all the way down to headerRoundTripper.RoundTrip(req). The PR doesn't verify this end-to-end. The unit tests construct the context manually with a2asrv.WithCallContext — they don't prove the real request path propagates it.
This is the kind of thing that works in tests and breaks in production when some middleware swaps the context. The PR description says "The A2A server already stores the CallContext in the Go context before Execute is called, so the round tripper picks it up automatically" — but "automatically" is doing a lot of work there. An integration test that actually sends an A2A request and verifies the MCP server receives the forwarded header would catch this.
Nit: empty map allocation when no headers match
If all allowed header names are present in the config but none appear in the request metadata, the function allocates a map[string]string and returns it empty. Callers range over it harmlessly, but nil is more idiomatic and avoids the allocation.
Tests: no t.Parallel()
All tests spin up real HTTP servers. They should call t.Parallel() to avoid unnecessarily serializing the test suite.
Use RequestMeta.Get for the lookup instead of iterating meta.List with manual case comparison. Get already does a case-insensitive O(1) lookup since NewRequestMeta lowercases keys at construction. Three behavioural fixes fall out of the rewrite: - Result map keys now preserve the casing from the operator-configured allowedHeaders list instead of the lowercased form produced by meta.List. Consumers see the header name they asked for. - The function returns nil rather than an empty map when nothing matches, matching the early-return paths. - Multi-value headers are documented: only the first value is forwarded (tested via TestAllowedRequestHeaders_MultiValueFirstWins). Tests gain case-insensitive matching coverage across both directions, multi-value forwarding, the no-match nil return, and t.Parallel on all test functions and subtests. Signed-off-by: mesutoezdil <mesudozdil@gmail.com>
|
Thanks for the thorough review @jsonmp-k8. Applied the fixes in 76e7c1a:
On the end-to-end context propagation concern: agreed that the unit tests do not prove the CallContext survives from executor through the ADK runner down to the round tripper. The current e2e suite does not have a hook that sends an A2A request and asserts headers on the MCP server side, so I did not add one here. Happy to follow up with an e2e test in a separate PR if the maintainers think the scope fits, otherwise this relies on the a2asrv runtime contract that the CallContext is stored in ctx before Execute is called. |
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
Closes #1679
The allowedHeaders field on McpServer was being populated by the translator and stored in HttpMcpServerConfig/SseMcpServerConfig, but the Go runtime never used it. Headers from the incoming A2A request were silently dropped even when the operator explicitly listed them in allowedHeaders. The Python runtime handled this correctly already.
What changed:
The change is fully backwards compatible. If allowedHeaders is empty, no extra headers are forwarded.
Tests use a2asrv.NewRequestMeta and a2asrv.WithCallContext to build the test context, matching what the real A2A server does. Coverage includes: normal forwarding, static-header-wins override, no A2A context, non-allowed header filter, and empty allowed list.
Signed-off-by: mesutoezdil mesudozdil@gmail.com