Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions go/adk/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,10 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
})

case *adk.GeminiVertexAI:
// The Vertex AI client has no custom HTTP transport (same gap as defaultHeaders/TLS).
if len(m.PassthroughHeaders) > 0 {
log.Info("Warning: passthroughHeaders are not supported for GeminiVertexAI models and will be ignored")
}
project := os.Getenv("GOOGLE_CLOUD_PROJECT")
location := os.Getenv("GOOGLE_CLOUD_LOCATION")
if location == "" {
Expand Down Expand Up @@ -392,6 +396,10 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
return models.NewAnthropicVertexAIModelWithLogger(ctx, cfg, region, project, log)

case *adk.SAPAICore:
// SAP AI Core builds its own HTTP client without the shared transport.
if len(m.PassthroughHeaders) > 0 {
log.Info("Warning: passthroughHeaders are not supported for SAPAICore models and will be ignored")
}
cfg := models.SAPAICoreConfig{
Model: m.Model,
BaseUrl: m.BaseUrl,
Expand Down Expand Up @@ -431,6 +439,7 @@ func CreateLLM(ctx context.Context, m adk.Model, log logr.Logger) (adkmodel.LLM,
func transportConfigFromBase(b adk.BaseModel, timeout *int) models.TransportConfig {
return models.TransportConfig{
Headers: extractHeaders(b.Headers),
PassthroughHeaders: b.PassthroughHeaders,
TLSInsecureSkipVerify: b.TLSInsecureSkipVerify,
TLSCACertPath: b.TLSCACertPath,
TLSDisableSystemCAs: b.TLSDisableSystemCAs,
Expand Down
92 changes: 92 additions & 0 deletions go/adk/pkg/headers/headers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Package headers resolves per-request HTTP headers from the incoming A2A
// call context. It is shared by the MCP tool transport (allowedHeaders) and
// the model transport (passthroughHeaders) so both forward caller-supplied
// headers with identical semantics.
package headers

import (
"context"
"strings"

"github.com/a2aproject/a2a-go/v2/a2asrv"
)

// restrictedPassthroughHeaders are names that must never be forwarded from a
// caller onto another hop:
// - credential headers (Authorization, Proxy-Authorization, Cookie) would
// forward a caller credential onward or clobber the credential the
// receiving hop manages itself; apiKeyPassthrough is the supported
// mechanism for credential forwarding;
// - the rest are hop-by-hop or message-framing headers per RFC 9110, plus
// the non-standard Proxy-Connection.
//
// Must stay in sync with RESTRICTED_PASSTHROUGH_HEADERS in
// python/packages/kagent-adk/src/kagent/adk/_llm_header_passthrough_plugin.py.
var restrictedPassthroughHeaders = map[string]struct{}{
"authorization": {},
"connection": {},
"content-length": {},
"cookie": {},
"host": {},
"keep-alive": {},
"proxy-authenticate": {},
"proxy-authorization": {},
"proxy-connection": {},
"te": {},
"trailer": {},
"transfer-encoding": {},
"upgrade": {},
}

// IsRestricted reports whether a header name (case-insensitively) must never
// be forwarded from a caller onto another hop.
func IsRestricted(name string) bool {
_, restricted := restrictedPassthroughHeaders[strings.ToLower(name)]
return restricted
}

// FilterRestricted drops restricted names (case-insensitively) from a
// configured pass-through header list.
func FilterRestricted(names []string) []string {
var out []string
for _, n := range names {
if !IsRestricted(n) {
out = append(out, n)
}
}
return out
}

// AllowedRequestHeaders reads the incoming A2A request metadata from ctx and
// returns only the header key/value pairs whose names appear in allowed.
// It reads directly from the A2A CallContext that is already present in the Go
// context, avoiding a redundant copy.
//
// Lookup relies on ServiceParams.Get, which does a case-insensitive lookup
// (NewServiceParams lowercases keys at construction). Keys in the result
// preserve the casing from the allowed list so the receiving server sees the
// header names the operator configured. When a header has multiple values only
// the first one is forwarded; additional values are intentionally dropped.
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.ServiceParams()
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
}
37 changes: 2 additions & 35 deletions go/adk/pkg/mcp/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/a2aproject/a2a-go/v2/a2asrv"
"github.com/go-logr/logr"
"github.com/kagent-dev/kagent/go/adk/pkg/constants"
"github.com/kagent-dev/kagent/go/adk/pkg/headers"
"github.com/kagent-dev/kagent/go/api/adk"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"google.golang.org/adk/v2/tool"
Expand All @@ -29,40 +30,6 @@ const (
defaultTimeout = 30 * time.Minute
)

// allowedRequestHeaders reads the incoming A2A request metadata from ctx and
// returns only the header key/value pairs whose names appear in allowed.
// It reads directly from the A2A CallContext that is already present in the Go
// context, avoiding a redundant copy.
//
// Lookup relies on RequestMeta.Get which already does a case-insensitive O(1)
// lookup (NewRequestMeta lowercases keys at construction). Keys in the result
// preserve the casing from the allowed list so the MCP server sees the header
// names the operator configured. When a header has multiple values only the
// first one is forwarded; additional values are intentionally dropped.
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.ServiceParams()
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
}

// mcpServerParams groups connection parameters for an MCP server,
// reducing parameter sprawl across createTransport / initializeToolSet.
type mcpServerParams struct {
Expand Down Expand Up @@ -317,7 +284,7 @@ func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
}

// Forward explicitly allowed headers from the incoming A2A request.
for k, v := range allowedRequestHeaders(req.Context(), rt.allowedHeaders) {
for k, v := range headers.AllowedRequestHeaders(req.Context(), rt.allowedHeaders) {
req.Header.Set(k, v)
}

Expand Down
11 changes: 6 additions & 5 deletions go/adk/pkg/mcp/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"testing"

"github.com/a2aproject/a2a-go/v2/a2asrv"
"github.com/kagent-dev/kagent/go/adk/pkg/headers"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
adkagent "google.golang.org/adk/v2/agent"
"google.golang.org/adk/v2/session"
Expand Down Expand Up @@ -197,12 +198,12 @@ func TestAllowedRequestHeaders_EmptyAllowedList(t *testing.T) {
"Authorization": {"Bearer token"},
})

got := allowedRequestHeaders(ctx, nil)
got := headers.AllowedRequestHeaders(ctx, nil)
if got != nil {
t.Errorf("expected nil for empty allowed list, got %v", got)
}

got = allowedRequestHeaders(ctx, []string{})
got = headers.AllowedRequestHeaders(ctx, []string{})
if got != nil {
t.Errorf("expected nil for empty allowed list, got %v", got)
}
Expand Down Expand Up @@ -401,7 +402,7 @@ func TestAllowedRequestHeaders_CaseInsensitiveLookup(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctx := a2aCtx(tc.incoming)
got := allowedRequestHeaders(ctx, tc.allowed)
got := headers.AllowedRequestHeaders(ctx, tc.allowed)
if got[tc.wantKey] != tc.wantVal {
t.Errorf("got[%q] = %q, want %q (full map: %v)", tc.wantKey, got[tc.wantKey], tc.wantVal, got)
}
Expand All @@ -417,7 +418,7 @@ func TestAllowedRequestHeaders_MultiValueFirstWins(t *testing.T) {
ctx := a2aCtx(map[string][]string{
"X-Forwarded-For": {"1.2.3.4", "5.6.7.8", "9.10.11.12"},
})
got := allowedRequestHeaders(ctx, []string{"X-Forwarded-For"})
got := headers.AllowedRequestHeaders(ctx, []string{"X-Forwarded-For"})
if got["X-Forwarded-For"] != "1.2.3.4" {
t.Errorf("expected first value 1.2.3.4, got %q", got["X-Forwarded-For"])
}
Expand Down Expand Up @@ -498,7 +499,7 @@ func TestAllowedRequestHeaders_ReturnsNilWhenNoMatches(t *testing.T) {
ctx := a2aCtx(map[string][]string{
"X-Something-Else": {"value"},
})
got := allowedRequestHeaders(ctx, []string{"Authorization", "X-Trace-Id"})
got := headers.AllowedRequestHeaders(ctx, []string{"Authorization", "X-Trace-Id"})
if got != nil {
t.Errorf("expected nil when no allowed headers are present, got %v", got)
}
Expand Down
22 changes: 17 additions & 5 deletions go/adk/pkg/models/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"time"

"google.golang.org/genai"

"github.com/kagent-dev/kagent/go/adk/pkg/headers"
)

// defaultTimeout is the default execution timeout used by model implementations.
Expand All @@ -18,6 +20,7 @@ const defaultTimeout = 30 * time.Minute
// TransportConfig holds TLS, passthrough, and header settings shared by all model providers.
type TransportConfig struct {
Headers map[string]string
PassthroughHeaders []string // header names forwarded per request from the incoming A2A call context
TLSInsecureSkipVerify *bool
TLSCACertPath *string
TLSDisableSystemCAs *bool
Expand Down Expand Up @@ -49,8 +52,9 @@ func BuildHTTPClient(tc TransportConfig) (*http.Client, error) {
}
}

if len(tc.Headers) > 0 {
transport = &headerTransport{base: transport, headers: tc.Headers}
passthrough := headers.FilterRestricted(tc.PassthroughHeaders)
if len(tc.Headers) > 0 || len(passthrough) > 0 {
transport = &headerTransport{base: transport, headers: tc.Headers, passthrough: passthrough}
}

timeout := defaultTimeout
Expand Down Expand Up @@ -96,17 +100,25 @@ func PassthroughToken(ctx context.Context, apiKeyPassthrough bool) (token string

type contextKey struct{}

// headerTransport wraps an http.RoundTripper and adds custom headers to all requests
// headerTransport wraps an http.RoundTripper and adds custom headers to all
// requests: static headers first, then pass-through headers resolved per
// request from the incoming A2A call context, which therefore win on
// collision. Provider credentials are never affected — restricted names are
// stripped from the pass-through list at construction (headers.FilterRestricted).
type headerTransport struct {
base http.RoundTripper
headers map[string]string
base http.RoundTripper
headers map[string]string
passthrough []string
}

func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
for k, v := range t.headers {
req.Header.Set(k, v)
}
for k, v := range headers.AllowedRequestHeaders(req.Context(), t.passthrough) {
req.Header.Set(k, v)
}
return t.base.RoundTrip(req)
}

Expand Down
Loading