Skip to content

fix(go-adk): forward allowedHeaders from incoming request to MCP tool calls#1733

Merged
EItanya merged 8 commits intokagent-dev:mainfrom
mesutoezdil:fix/go-allowed-headers-mcp
May 1, 2026
Merged

fix(go-adk): forward allowedHeaders from incoming request to MCP tool calls#1733
EItanya merged 8 commits intokagent-dev:mainfrom
mesutoezdil:fix/go-allowed-headers-mcp

Conversation

@mesutoezdil
Copy link
Copy Markdown
Contributor

@mesutoezdil mesutoezdil commented Apr 23, 2026

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:

  • allowedRequestHeaders helper added to registry.go. It reads the a2asrv.CallContext already present in the Go context and returns only the header values whose names appear in the configured allowedHeaders list (case-insensitive). No intermediate context key, no extra copy.
  • headerRoundTripper gets an allowedHeaders field. On each outbound MCP HTTP request the transport calls allowedRequestHeaders and sets the matching headers. Static headers from the server spec are applied last so they always take precedence, mirroring the Python runtime behaviour.
  • CreateToolsets now passes AllowedHeaders from each MCP server config into the transport params.
  • executor.go no longer needs to touch headers at all. The A2A server already stores the CallContext in the Go context before Execute is called, so the round tripper picks it up automatically.

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

Copilot AI review requested due to automatic review settings April 23, 2026 11:46
@github-actions github-actions Bot added the bug Something isn't working label Apr 23, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Copy link
Copy Markdown
Contributor

@jmhbh jmhbh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread go/adk/pkg/mcp/registry.go Outdated
Comment thread go/adk/pkg/agent/agent.go Outdated
@mesutoezdil mesutoezdil force-pushed the fix/go-allowed-headers-mcp branch from 5c977ea to f82c5a0 Compare April 23, 2026 21:58
… 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>
@mesutoezdil mesutoezdil force-pushed the fix/go-allowed-headers-mcp branch from f82c5a0 to c39896f Compare April 23, 2026 21:59
@mesutoezdil
Copy link
Copy Markdown
Contributor Author

mesutoezdil commented Apr 23, 2026

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 withRequestHeaders wrapper and inlined the header extraction directly in Execute alongside withBearerToken. The exported mcp.WithRequestHeaders is kept since registry_test.go needs it to set up context state for the round-tripper tests, but the extra layer in executor.go is gone.

Comment thread go/adk/pkg/mcp/registry.go Outdated
Comment thread go/adk/pkg/a2a/executor.go Outdated
Comment on lines +121 to +133
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)
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Apr 23, 2026
@mesutoezdil
Copy link
Copy Markdown
Contributor Author

I've now updated the top section as well. @EItanya @jmhbh

Copy link
Copy Markdown
Contributor

@jsonmp-k8 jsonmp-k8 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@mesutoezdil
Copy link
Copy Markdown
Contributor Author

mesutoezdil commented Apr 24, 2026

Thanks for the thorough review @jsonmp-k8. Applied the fixes in 76e7c1a:

  • allowedRequestHeaders now uses RequestMeta.Get for an O(1) case-insensitive lookup instead of iterating meta.List. Result keys preserve the casing from the allowedHeaders config rather than the lowercased form from List. Returns nil instead of an empty map when nothing matches.
  • strings import removed since it is no longer needed.
  • New test TestAllowedRequestHeaders_CaseInsensitiveLookup exercises both directions (config lowercase vs incoming capitalized, and the reverse). Multi-value test locks in the first-wins behaviour. Empty-match test verifies the nil return.
  • t.Parallel added to every test function and subtest.

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.

EItanya
EItanya previously approved these changes Apr 29, 2026
Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
@EItanya EItanya merged commit d7e23d4 into kagent-dev:main May 1, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] [Go Runtime] AllowedHeaders not implemented — JWT/headers not forwarded to MCP tool calls

6 participants