diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index 78def5dc58..6f647c3c01 100644 --- a/docs/architecture/credential-injection.md +++ b/docs/architecture/credential-injection.md @@ -80,10 +80,97 @@ ModelConfig, RemoteMCPServer, or [EgressPolicy](egress-policies.md) for gateway AWS IAM signing keys, Google service-account keys, and OAuth client credentials require mechanisms beyond static header injection and are rejected rather than serialized into runtimes. -Caller-token passthrough retains its existing behavior. A passthrough model +`ModelConfig.spec.tokenPassthrough: true` enables caller-token passthrough +instead of a static model API key. For example: + +```yaml +spec: + provider: OpenAI + model: gpt-4.1 + tokenPassthrough: true +``` + +A passthrough model cannot share an origin with static gateway credentials, which would override the caller's authentication. +## Remote MCP caller bearer tokens + +`RemoteMCPServer.spec.tokenPassthrough` is optional and defaults to +`false`. When enabled, the server receives `Authorization: Bearer ` +from the **current authenticated A2A invocation**. Go ADK reads only +`models.PassthroughToken` (the restricted `BearerTokenKey`); Python reads the +dedicated `bearer_token` ContextVar. Neither path falls back to general request +metadata, an STS token, a runtime service credential, or a previous invocation. +A missing token fails before an MCP connection starts. +A resumed/approved call requires the new invocation's token. + +```yaml +apiVersion: api.kagent.dev/v1alpha3 +kind: RemoteMCPServer +metadata: + name: caller-tools + namespace: kagent + labels: + kagent.dev/discovery: disabled +spec: + description: Tools authorized by the invoking caller + url: https://tools.example.com/mcp + tokenPassthrough: true +``` + +Controller discovery has no caller identity: + +- The existing `kagent.dev/discovery: disabled` label takes precedence. It + retains `Accepted=True`, with no discovered tools or catalog connectivity. +- Without that label, passthrough requires an `Authorization` entry in + `headersFrom` (case-insensitive) for discovery. Without one, the controller + does not dial, clears stale discovered tools and catalog connectivity, and + reports `Accepted=False`, reason `DiscoveryCredentialRequired`. It uses the + ordinary discovery refresh interval. +- When supplied, that Authorization is **discovery-only**. All case variants + are removed before runtime header resolution, including ConfigMap lookup, + Secret environment placeholders, and gateway credential compilation. Other + configured headers remain unchanged. Discovery failures do not publish + upstream errors that could echo the credential. + +Model and MCP passthrough destinations cannot share an origin with **any** +static gateway credential, including an MCP server's own Secret-backed +non-Authorization header. Matching lowercases DNS names and removes a trailing +dot and normalizes default ports, just as egress credential canonicalization +does. Different schemes or ports are separate origins; paths are not. +Multiple passthrough users of one origin are +allowed. Discovery-only Authorization creates no gateway binding. + +The ADK protobuf carries only the flag, not a token. Go ADK creates an ordinary +ADK MCP toolset for each listing and tool call, closing every transport connection +at the operation boundary, including initialization failures and reconnects. +Only ADK-generated declarations survive discovery; the native toolset and its +caller-bound sessions are never shared between operations. Execution adds a +fresh discovery to recheck selected tools and model visibility, including after +a call reconnects. ADK owns successful result conversion; the local wrapper +sanitizes upstream failures. Calls inherit ordinary ADK's single retry on +connection loss, so they do not provide at-most-once execution if a server +completes a tool call before the connection fails. +Python creates agents per request and pools MCP sessions +by effective headers within that lifetime. Request headers use ADK's +`temp:headers` state, so credentials are not written to its native SQLite +conversation or event history. Go ADK and Python passthrough HTTP clients reject +redirects and foreign SSE endpoint destinations. + +This setting is supported only by Go and Python ADK. Claude and Codex +compilers reject it; native runtime support is not implemented. + +This per-server setting does not enable the older global ADK token-propagation +switch. That independently configured behavior remains unchanged for servers +without passthrough; it cannot override a passthrough server's credential. + +MCP Apps `CallTool` and `ReadResource` control-plane calls do not carry the +restricted A2A invocation context. They are unsupported for passthrough +servers and fail before resolving discovery credentials or opening a +connection. The controller must never use discovery Authorization to perform +an app's tool call. + ## Deferred API fields The source retains commented declarations for Harness and SandboxTemplate diff --git a/go/adk/pkg/mcp/baggage_test.go b/go/adk/pkg/mcp/baggage_test.go index 7904e748f7..2ccf803905 100644 --- a/go/adk/pkg/mcp/baggage_test.go +++ b/go/adk/pkg/mcp/baggage_test.go @@ -196,8 +196,11 @@ type toolContext struct { callID string } -func (c toolContext) Value(key any) any { return c.ctx.Value(key) } -func (c toolContext) FunctionCallID() string { return c.callID } +func (c toolContext) Value(key any) any { return c.ctx.Value(key) } +func (c toolContext) FunctionCallID() string { return c.callID } +func (c toolContext) Deadline() (time.Time, bool) { return c.ctx.Deadline() } +func (c toolContext) Done() <-chan struct{} { return c.ctx.Done() } +func (c toolContext) Err() error { return c.ctx.Err() } type readonlyContext struct { adkagent.ReadonlyContext diff --git a/go/adk/pkg/mcp/passthrough.go b/go/adk/pkg/mcp/passthrough.go new file mode 100644 index 0000000000..84f36369ed --- /dev/null +++ b/go/adk/pkg/mcp/passthrough.go @@ -0,0 +1,175 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "slices" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + adkagent "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/mcptoolset" + "google.golang.org/adk/v2/tool/toolutils" + "google.golang.org/genai" + + "github.com/kagent-dev/kagent/go/adk/pkg/models" + "github.com/kagent-dev/kagent/go/pkg/logging" +) + +// passthroughToolset keeps only configuration, never a client session or caller +// credential. Each operation owns an ordinary ADK toolset and its connections. +type passthroughToolset struct { + params mcpServerParams + filter map[string]bool +} + +func (*passthroughToolset) Name() string { return "mcp_tool_set" } + +// passthroughTransport observes connections without wrapping them: the SDK uses +// private connection hooks to update session identity and protocol negotiation. +// ADK connects synchronously, including retries, within the owning operation. +type passthroughTransport struct { + params mcpServerParams + connections []mcpsdk.Connection +} + +func (p *passthroughTransport) Connect(ctx context.Context) (mcpsdk.Connection, error) { + // SDK transports are single-use; ADK may reconnect through this wrapper. + transport, err := createTransport(ctx, p.params) + if err != nil { + return nil, err + } + conn, err := transport.Connect(ctx) + if conn != nil { + p.connections = append(p.connections, conn) + } + return conn, err +} + +func (p *passthroughTransport) close(ctx context.Context) { + for _, conn := range p.connections { + // Cancellation alone does not stop the SDK's detached background work. + // Close is idempotent, including after failed initialization or retries. + if err := conn.Close(); err != nil { + logging.FromContext(ctx).WarnContext(ctx, "failed to close passthrough MCP connection") + } + } + p.connections = nil +} + +func (p *passthroughToolset) tools(ctx adkagent.ReadonlyContext, transport *passthroughTransport) ([]tool.Tool, error) { + if _, ok := models.PassthroughToken(ctx, true); !ok { + return nil, fmt.Errorf("MCP bearer token passthrough requires an authenticated invocation bearer token") + } + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "kagent", Version: "1"}, + &mcpsdk.ClientOptions{Capabilities: mcpUIClientCapabilities()}) + // ADK drops MCP metadata during conversion. Filter each discovery page at + // the SDK boundary, before delegating declarations and results to ADK. + var listedSession mcpsdk.Session + client.AddSendingMiddleware(func(next mcpsdk.MethodHandler) mcpsdk.MethodHandler { + return func(ctx context.Context, method string, req mcpsdk.Request) (mcpsdk.Result, error) { + if method == "tools/call" && req.GetSession() != listedSession { + // A native ADK call can reconnect after discovery. Recheck access + // on the replacement session before allowing the retried call. + allowed := false + for item, err := range req.GetSession().(*mcpsdk.ClientSession).Tools(ctx, nil) { + if err != nil { + return nil, err + } + if item.Name == req.GetParams().(*mcpsdk.CallToolParams).Name { + allowed = true + } + } + if !allowed { + return nil, fmt.Errorf("passthrough MCP tool is no longer available") + } + } + result, err := next(ctx, method, req) + if err == nil && method == "tools/list" { + list := result.(*mcpsdk.ListToolsResult) + list.Tools = slices.DeleteFunc(list.Tools, func(item *mcpsdk.Tool) bool { + return item == nil || len(p.filter) > 0 && !p.filter[item.Name] || + mcpToolKindOf(item.Meta) == mcpToolKindAppInternal + }) + listedSession = req.GetSession() + } + return result, err + } + }) + native, err := mcptoolset.New(mcptoolset.Config{Client: client, Transport: transport}) + if err != nil { + return nil, passthroughError(err) + } + tools, err := native.Tools(ctx) + if err != nil { + return nil, passthroughError(err) + } + return tools, nil +} + +func (p *passthroughToolset) Tools(ctx adkagent.ReadonlyContext) ([]tool.Tool, error) { + transport := &passthroughTransport{params: p.params} + defer transport.close(ctx) + tools, err := p.tools(ctx, transport) + if err != nil { + return nil, err + } + for i, t := range tools { + // Keep only ADK's declaration, not its tool/client and closed session + // (which can still retain the discovery caller's context). + tools[i] = &passthroughTool{set: p, declaration: t.(functionTool).Declaration()} + } + return tools, nil +} + +type passthroughTool struct { + set *passthroughToolset + declaration *genai.FunctionDeclaration +} + +func (p *passthroughTool) Name() string { return p.declaration.Name } +func (p *passthroughTool) Description() string { return p.declaration.Description } +func (*passthroughTool) IsLongRunning() bool { return false } +func (p *passthroughTool) Declaration() *genai.FunctionDeclaration { return p.declaration } +func (p *passthroughTool) ProcessRequest(_ adkagent.Context, req *model.LLMRequest) error { + return toolutils.PackTool(req, p) +} + +func (p *passthroughTool) Run(ctx adkagent.Context, args any) (map[string]any, error) { + transport := &passthroughTransport{params: p.set.params} + defer transport.close(ctx) + tools, err := p.set.tools(ctx, transport) + if err != nil { + return nil, err + } + for _, t := range tools { + if t.Name() == p.Name() { + result, err := t.(functionTool).Run(ctx, args) + if err != nil { + return nil, passthroughError(err) + } + return result, nil + } + } + return nil, fmt.Errorf("passthrough MCP tool is no longer available") +} + +// Upstream errors may contain server content or credentials. Preserve only +// framework classifications, never the original error or its wrapping text. +func passthroughError(err error) error { + for _, safe := range []error{ + context.Canceled, context.DeadlineExceeded, + tool.ErrConfirmationRequired, tool.ErrConfirmationRejected, + } { + if errors.Is(err, safe) { + return safe + } + } + return fmt.Errorf("passthrough MCP operation failed") +} + +var _ mcpsdk.Transport = (*passthroughTransport)(nil) +var _ tool.Toolset = (*passthroughToolset)(nil) +var _ functionTool = (*passthroughTool)(nil) diff --git a/go/adk/pkg/mcp/passthrough_test.go b/go/adk/pkg/mcp/passthrough_test.go new file mode 100644 index 0000000000..4057f23f3e --- /dev/null +++ b/go/adk/pkg/mcp/passthrough_test.go @@ -0,0 +1,548 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "slices" + "sync" + "sync/atomic" + "testing" + "time" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/mcptoolset" + "google.golang.org/adk/v2/tool/toolconfirmation" + "google.golang.org/genai" + + "github.com/kagent-dev/kagent/go/adk/pkg/models" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" +) + +type passthroughResultContext struct{ toolContext } + +func (passthroughResultContext) ToolConfirmation() *toolconfirmation.ToolConfirmation { return nil } + +func TestPassthroughRunSuccessParity(t *testing.T) { + type testCase struct { + name string + result *mcpsdk.CallToolResult + want any + } + tests := []testCase{ + {name: "empty success", result: &mcpsdk.CallToolResult{}, want: ""}, + {name: "empty text", result: &mcpsdk.CallToolResult{Content: []mcpsdk.Content{ + &mcpsdk.TextContent{}, + }}, want: ""}, + {name: "adjacent text", result: &mcpsdk.CallToolResult{Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: "one"}, &mcpsdk.TextContent{Text: "two"}, + }}, want: "onetwo"}, + {name: "embedded file text", result: &mcpsdk.CallToolResult{Content: []mcpsdk.Content{ + &mcpsdk.EmbeddedResource{Resource: &mcpsdk.ResourceContents{ + URI: "repo://test/file.go", MIMEType: "text/plain", Text: "package test\n", + }}, + }}, want: "[MCP embedded resource: uri=\"repo://test/file.go\", mimeType=\"text/plain\"]\npackage test\n"}, + {name: "ordered mixed blocks", result: &mcpsdk.CallToolResult{Content: []mcpsdk.Content{ + &mcpsdk.TextContent{Text: "before"}, &mcpsdk.TextContent{Text: " file\n"}, + &mcpsdk.EmbeddedResource{Resource: &mcpsdk.ResourceContents{ + URI: "file:///notes", Text: "line one\nline two", + }}, + &mcpsdk.TextContent{}, &mcpsdk.TextContent{Text: "after"}, + &mcpsdk.ImageContent{MIMEType: "image/png", Data: []byte{1, 2}}, + &mcpsdk.TextContent{Text: "\nend"}, + }}, want: "before file\n[MCP embedded resource: uri=\"file:///notes\"]\nline one\nline two\nafter\n" + + "[MCP image: mimeType=\"image/png\", size=2 bytes]\nend"}, + {name: "resource link and media metadata", result: &mcpsdk.CallToolResult{Content: []mcpsdk.Content{ + &mcpsdk.ResourceLink{URI: "file:///report", MIMEType: "application/pdf", + Name: "report", Title: "Report", Description: "A report", Size: new(int64(42))}, + &mcpsdk.ImageContent{MIMEType: "image/png", Data: []byte{1, 2, 3}}, + &mcpsdk.AudioContent{MIMEType: "audio/wav", Data: []byte{4, 5}}, + }}, want: "[MCP resource link: uri=\"file:///report\", mimeType=\"application/pdf\", " + + "name=\"report\", title=\"Report\", description=\"A report\", size=42 bytes]\n" + + "[MCP image: mimeType=\"image/png\", size=3 bytes]\n[MCP audio: mimeType=\"audio/wav\", size=2 bytes]"}, + {name: "text takes precedence over blob", result: &mcpsdk.CallToolResult{Content: []mcpsdk.Content{ + &mcpsdk.EmbeddedResource{Resource: &mcpsdk.ResourceContents{ + URI: "file:///both", Text: "caption", Blob: []byte("blob"), + }}, + }}, want: "[MCP embedded resource: uri=\"file:///both\", size=4 bytes]\ncaption"}, + {name: "structured content takes precedence", result: &mcpsdk.CallToolResult{ + StructuredContent: map[string]any{"done": true}, + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "ignored"}, + &mcpsdk.ImageContent{MIMEType: "image/png", Data: []byte{1}}}, + }, want: map[string]any{"done": true}}, + {name: "empty structured content takes precedence", result: &mcpsdk.CallToolResult{ + StructuredContent: map[string]any{}, + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "ignored"}}, + }, want: map[string]any{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "1"}, nil) + server.AddTool(&mcpsdk.Tool{Name: "result", InputSchema: map[string]any{"type": "object"}}, + func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + return tt.result, nil + }) + srv := httptest.NewServer(mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { + return server + }, nil)) + t.Cleanup(srv.Close) + ctx := context.WithValue(t.Context(), models.BearerTokenKey, "test-caller") + ts := &passthroughToolset{params: mcpServerParams{ + URL: srv.URL, ServerType: "http", BearerTokenPassthrough: true, + }} + tools, err := ts.Tools(testReadonlyContext{Context: ctx}) + require.NoError(t, err) + require.Len(t, tools, 1) + callCtx := passthroughResultContext{toolContext{ctx: ctx}} + got, err := tools[0].(functionTool).Run(callCtx, map[string]any{}) + require.NoError(t, err) + require.Equal(t, map[string]any{"output": tt.want}, got) + + // Use the actual pinned ADK converter as an independent oracle, + // without using its shared session on the passthrough path. + clientTransport, serverTransport := mcpsdk.NewInMemoryTransports() + session, err := server.Connect(t.Context(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, session.Close()) }) + ordinary, err := mcptoolset.New(mcptoolset.Config{Transport: clientTransport}) + require.NoError(t, err) + ordinaryTools, err := ordinary.Tools(testReadonlyContext{Context: ctx}) + require.NoError(t, err) + require.Len(t, ordinaryTools, 1) + require.Equal(t, ordinaryTools[0].(functionTool).Declaration(), tools[0].(functionTool).Declaration()) + want, err := ordinaryTools[0].(functionTool).Run(callCtx, map[string]any{}) + require.NoError(t, err) + require.Equal(t, want, got) + }) + } +} + +func TestPassthroughRunCredentialSafeErrors(t *testing.T) { + const token = "test-sensitive-caller-token" + for _, failure := range []string{"connect", "call", "result", "cancel"} { + t.Run(failure, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.WithValue(t.Context(), models.BearerTokenKey, token)) + defer cancel() + server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "1"}, nil) + server.AddTool(&mcpsdk.Tool{Name: "fail", InputSchema: map[string]any{"type": "object"}}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if failure == "call" { + return nil, fmt.Errorf("upstream echoed %s", token) + } + if failure == "cancel" { + cancel() + <-ctx.Done() + return nil, ctx.Err() + } + return &mcpsdk.CallToolResult{ + IsError: true, StructuredContent: map[string]any{"token": token}, + Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: token}}, + }, nil + }) + handler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return server }, nil) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if failure == "connect" { + http.Error(w, token, http.StatusUnauthorized) + return + } + handler.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + p := &passthroughTool{ + set: &passthroughToolset{params: mcpServerParams{ + URL: srv.URL, ServerType: "http", BearerTokenPassthrough: true, + }}, + declaration: &genai.FunctionDeclaration{Name: "fail"}, + } + got, err := p.Run(passthroughResultContext{toolContext{ctx: ctx}}, map[string]any{}) + require.Nil(t, got) + if failure == "cancel" { + require.ErrorIs(t, err, context.Canceled) + } else { + require.EqualError(t, err, "passthrough MCP operation failed") + } + require.NotContains(t, err.Error(), token) + require.Eventually(t, func() bool { + return len(slices.Collect(server.Sessions())) == 0 + }, time.Second, time.Millisecond) + }) + } +} + +func TestPassthroughDiscoveryFailureCleanup(t *testing.T) { + for _, protocol := range []string{"http", "sse"} { + for _, failure := range []string{"initialize", "list"} { + t.Run(protocol+"/"+failure, func(t *testing.T) { + server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "1"}, nil) + server.AddReceivingMiddleware(func(next mcpsdk.MethodHandler) mcpsdk.MethodHandler { + return func(ctx context.Context, method string, req mcpsdk.Request) (mcpsdk.Result, error) { + if method == "tools/list" && failure == "list" { + return nil, fmt.Errorf("echoed test-sensitive-token") + } + result, err := next(ctx, method, req) + if method == "initialize" && err == nil && failure == "initialize" { + // SDK v1.5.0 returns without closing on unsupported + // protocol versions; the transport owner must close it. + result.(*mcpsdk.InitializeResult).ProtocolVersion = "unsupported" + } + return result, err + } + }) + getServer := func(*http.Request) *mcpsdk.Server { return server } + var handler http.Handler = mcpsdk.NewStreamableHTTPHandler(getServer, nil) + if protocol == "sse" { + handler = mcpsdk.NewSSEHandler(getServer, nil) + } + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + ts := &passthroughToolset{params: mcpServerParams{ + URL: srv.URL, ServerType: protocol, BearerTokenPassthrough: true, + }} + ctx := context.WithValue(t.Context(), models.BearerTokenKey, "test-sensitive-token") + _, err := ts.Tools(testReadonlyContext{Context: ctx}) + require.EqualError(t, err, "passthrough MCP operation failed") + require.Eventually(t, func() bool { + return len(slices.Collect(server.Sessions())) == 0 + }, time.Second, time.Millisecond) + }) + } + } +} + +func TestPassthroughRetryCleanupAndVisibility(t *testing.T) { + for _, tc := range []struct { + name string + method string + revoke bool + failAgain bool + }{ + {name: "list", method: "tools/list"}, + {name: "call", method: "tools/call"}, + {name: "revoked on call retry", method: "tools/call", revoke: true}, + {name: "list retry fails", method: "tools/list", failAgain: true}, + {name: "call retry fails", method: "tools/call", failAgain: true}, + } { + t.Run(tc.name, func(t *testing.T) { + server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "1"}, nil) + var calls, initialized atomic.Int32 + server.AddTool(&mcpsdk.Tool{Name: "echo", InputSchema: map[string]any{"type": "object"}}, + func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + calls.Add(1) + return &mcpsdk.CallToolResult{}, nil + }) + server.AddReceivingMiddleware(func(next mcpsdk.MethodHandler) mcpsdk.MethodHandler { + return func(ctx context.Context, method string, req mcpsdk.Request) (mcpsdk.Result, error) { + if method == "initialize" { + initialized.Add(1) + } + result, err := next(ctx, method, req) + if method == "tools/list" && err == nil && tc.revoke && initialized.Load() > 1 { + // The same name is no longer model-visible after reconnect. + result.(*mcpsdk.ListToolsResult).Tools = []*mcpsdk.Tool{{ + Name: "echo", InputSchema: map[string]any{"type": "object"}, + Meta: mcpsdk.Meta{"ui": map[string]any{"visibility": []any{"app"}}}, + }} + } + return result, err + } + }) + handler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return server }, nil) + var failures atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + r.Body = io.NopCloser(bytes.NewReader(body)) + var request struct{ Method string } + if err := json.Unmarshal(body, &request); err != nil { + t.Error(err) + return + } + if request.Method == tc.method && (failures.Add(1) == 1 || tc.failAgain) { + // Expire the session at the actual HTTP boundary so ADK + // must reconnect, not reuse a single-use SDK transport. + for ss := range server.Sessions() { + if ss.ID() == r.Header.Get("Mcp-Session-Id") { + if err := ss.Close(); err != nil { + t.Error(err) + } + } + } + http.Error(w, "expired", http.StatusNotFound) + return + } + } + handler.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + ts := &passthroughToolset{params: mcpServerParams{ + URL: srv.URL, ServerType: "http", BearerTokenPassthrough: true, + }} + ctx := context.WithValue(t.Context(), models.BearerTokenKey, "caller") + var err error + if tc.method == "tools/list" { + _, err = ts.Tools(testReadonlyContext{Context: ctx}) + } else { + p := &passthroughTool{set: ts, declaration: &genai.FunctionDeclaration{Name: "echo"}} + _, err = p.Run(passthroughResultContext{toolContext{ctx: ctx}}, map[string]any{}) + } + if tc.revoke || tc.failAgain { + require.EqualError(t, err, "passthrough MCP operation failed") + } else { + require.NoError(t, err) + } + require.EqualValues(t, 2, initialized.Load()) + if tc.method == "tools/call" && !tc.revoke && !tc.failAgain { + require.EqualValues(t, 1, calls.Load()) + } else { + require.Zero(t, calls.Load()) + } + require.Eventually(t, func() bool { + return len(slices.Collect(server.Sessions())) == 0 + }, time.Second, time.Millisecond) + }) + } +} + +type passthroughApprovalContext struct { + passthroughResultContext + confirmation *toolconfirmation.ToolConfirmation + actions session.EventActions +} + +func (p *passthroughApprovalContext) ToolConfirmation() *toolconfirmation.ToolConfirmation { + return p.confirmation +} +func (*passthroughApprovalContext) RequestConfirmation(string, any) error { return nil } +func (p *passthroughApprovalContext) Actions() *session.EventActions { return &p.actions } + +func TestPassthroughApprovalAndVisibility(t *testing.T) { + var requests, calls atomic.Int32 + server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "1"}, nil) + definition := &mcpsdk.Tool{Name: "echo", InputSchema: map[string]any{"type": "object"}} + run := func(ctx context.Context, _ *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + calls.Add(1) + return &mcpsdk.CallToolResult{}, nil + } + server.AddTool(definition, run) + handler := mcpsdk.NewStreamableHTTPHandler(func(*http.Request) *mcpsdk.Server { return server }, nil) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + handler.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + sets := CreateToolsets(t.Context(), []*configv1.ADKHTTPMCPServerConfig{{ + Params: &configv1.ADKHTTPConnectionParams{Url: srv.URL}, BearerTokenPassthrough: true, + RequireApproval: true, Tools: []string{"echo"}, + }}, nil, nil, false, nil) + require.Len(t, sets, 1) + ctx := context.WithValue(t.Context(), models.BearerTokenKey, "alice") + tools, err := sets[0].Tools(testReadonlyContext{Context: ctx}) + require.NoError(t, err) + require.Len(t, tools, 1) + fn := tools[0].(functionTool) + callCtx := &passthroughApprovalContext{passthroughResultContext: passthroughResultContext{toolContext{ctx: ctx}}} + req := &model.LLMRequest{} + require.NoError(t, fn.ProcessRequest(callCtx, req)) + require.Same(t, fn, req.Tools["echo"], "ADK must execute the approval/lifecycle wrapper") + before := requests.Load() + _, err = fn.Run(callCtx, nil) + require.ErrorIs(t, err, tool.ErrConfirmationRequired) + require.True(t, callCtx.actions.SkipSummarization) + callCtx.confirmation = &toolconfirmation.ToolConfirmation{Confirmed: false} + _, err = fn.Run(callCtx, nil) + require.ErrorIs(t, err, tool.ErrConfirmationRejected) + require.Equal(t, before, requests.Load(), "pending/rejected approval must not connect") + callCtx.confirmation.Confirmed = true + callCtx.ctx = context.WithValue(t.Context(), models.BearerTokenKey, "bob") + _, err = fn.Run(callCtx, map[string]any{}) + require.NoError(t, err) + require.EqualValues(t, 1, calls.Load()) + + hidden := *definition + hidden.Meta = mcpsdk.Meta{"ui": map[string]any{"visibility": []any{"app"}}} + server.AddTool(&hidden, run) + _, err = fn.Run(callCtx, nil) + require.ErrorContains(t, err, "no longer available") + require.EqualValues(t, 1, calls.Load(), "approval cannot bypass changed visibility") +} + +func TestPassthroughErrorClassification(t *testing.T) { + for _, safe := range []error{ + context.Canceled, context.DeadlineExceeded, tool.ErrConfirmationRequired, tool.ErrConfirmationRejected, + } { + err := passthroughError(fmt.Errorf("echoed test-sensitive-token: %w", safe)) + require.ErrorIs(t, err, safe) + require.NotContains(t, err.Error(), "test-sensitive-token") + } + require.EqualError(t, passthroughError(errors.New("test-sensitive-token")), "passthrough MCP operation failed") +} + +func TestPassthroughMCPInvocationIsolation(t *testing.T) { + for _, protocol := range []string{"http", "sse"} { + t.Run(protocol, func(t *testing.T) { + server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "1"}, nil) + server.AddTool(&mcpsdk.Tool{Name: "echo", InputSchema: map[string]any{"type": "object"}}, + func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + return &mcpsdk.CallToolResult{Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "ok"}}}, nil + }) + server.AddTool(&mcpsdk.Tool{Name: "hidden", InputSchema: map[string]any{"type": "object"}, + Meta: mcpsdk.Meta{"ui": map[string]any{"visibility": []any{"app"}}}}, + func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + t.Error("app-only tool must not be exposed") + return nil, nil + }) + getServer := func(*http.Request) *mcpsdk.Server { return server } + var handler http.Handler = mcpsdk.NewStreamableHTTPHandler(getServer, nil) + if protocol == "sse" { + handler = mcpsdk.NewSSEHandler(getServer, nil) + } + var mu sync.Mutex + var received []string + owners := make(map[string]string) + var mixedIdentity bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + received = append(received, r.Header.Get("Authorization")) + mu.Unlock() + handler.ServeHTTP(w, r) + id := r.Header.Get("Mcp-Session-Id") + if id == "" { + id = r.URL.Query().Get("sessionid") + } + if id == "" { + id = w.Header().Get("Mcp-Session-Id") + } + if id != "" { + mu.Lock() + auth := r.Header.Get("Authorization") + if owner, ok := owners[id]; ok && owner != auth { + mixedIdentity = true + } + owners[id] = auth + mu.Unlock() + } + })) + defer srv.Close() + ts := &passthroughToolset{params: mcpServerParams{URL: srv.URL, ServerType: protocol, + BearerTokenPassthrough: true, PropagateToken: true, + Headers: map[string]string{"authorization": "discovery-only"}, + HeaderProvider: func(context.Context) map[string]string { panic("must not consult generic token provider") }, + }} + missing := a2aCtx(map[string][]string{"Authorization": {"Bearer runtime-service-token"}}) + _, err := ts.Tools(testReadonlyContext{Context: missing}) + require.ErrorContains(t, err, "requires an authenticated invocation") + require.Empty(t, received, "no initialize or tools/list before token validation") + + alice := context.WithValue(t.Context(), models.BearerTokenKey, "alice") + tools, err := ts.Tools(testReadonlyContext{Context: alice}) + require.NoError(t, err) + require.Len(t, tools, 1) + require.Equal(t, "echo", tools[0].Name()) + mu.Lock() + for _, auth := range received { + require.Equal(t, "Bearer alice", auth) + } + received = nil + mu.Unlock() + ts.filter = map[string]bool{"not-exposed": true} + filtered, err := ts.Tools(testReadonlyContext{Context: alice}) + require.NoError(t, err) + require.Empty(t, filtered) + _, err = tools[0].(functionTool).Run(passthroughResultContext{toolContext{ctx: alice}}, nil) + require.ErrorContains(t, err, "no longer available", "execution must reapply the selected-tool filter") + ts.filter = nil + mu.Lock() + received = nil + mu.Unlock() + + // A tool obtained by Alice must use Bob's current invocation when a + // deferred/resumed call executes, not Alice's old session context. + bob := context.WithValue(t.Context(), models.BearerTokenKey, "bob") + output, err := tools[0].(functionTool).Run(passthroughResultContext{toolContext{ctx: bob}}, map[string]any{}) + require.NoError(t, err) + require.Equal(t, map[string]any{"output": "ok"}, output) + mu.Lock() + require.NotEmpty(t, received) + for _, auth := range received { + require.Equal(t, "Bearer bob", auth) + } + before := len(received) + mu.Unlock() + _, err = tools[0].(functionTool).Run(toolContext{ctx: missing}, nil) + require.Error(t, err) + mu.Lock() + require.Len(t, received, before) + mu.Unlock() + + // The same discovered descriptor is safe for simultaneous callers. + results := make(chan error, 2) + for _, ctx := range []context.Context{alice, bob} { + go func() { + output, err := tools[0].(functionTool).Run(passthroughResultContext{toolContext{ctx: ctx}}, map[string]any{}) + if err == nil && output["output"] != "ok" { + err = fmt.Errorf("unexpected tool output") + } + results <- err + }() + } + for range 2 { + require.NoError(t, <-results) + } + require.Eventually(t, func() bool { + return len(slices.Collect(server.Sessions())) == 0 + }, time.Second, time.Millisecond, "all HTTP/SSE sessions must close at operation boundaries") + mu.Lock() + require.False(t, mixedIdentity, "initialize/list/call/cleanup must keep one identity per session") + require.Len(t, owners, 6, "each listing and call must create a separate session") + counts := make(map[string]int) + for _, owner := range owners { + counts[owner]++ + } + require.Equal(t, map[string]int{"Bearer alice": 4, "Bearer bob": 2}, counts) + mu.Unlock() + }) + } +} + +func TestPassthroughToolsetsDoNotDiscoverAtStartup(t *testing.T) { + toolsets := CreateToolsets(t.Context(), + []*configv1.ADKHTTPMCPServerConfig{{Params: &configv1.ADKHTTPConnectionParams{Url: "http://invalid.invalid"}, + BearerTokenPassthrough: true}}, nil, nil, false, nil) + require.Len(t, toolsets, 1) + _, err := toolsets[0].Tools(testReadonlyContext{Context: t.Context()}) + require.ErrorContains(t, err, "requires an authenticated invocation") +} + +func TestPassthroughRejectsRedirectAndForeignSSEEndpoint(t *testing.T) { + var foreignRequests int + foreign := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { foreignRequests++ })) + defer foreign.Close() + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, foreign.URL, http.StatusTemporaryRedirect) + })) + defer origin.Close() + ctx := context.WithValue(t.Context(), models.BearerTokenKey, "caller") + ts := &passthroughToolset{params: mcpServerParams{URL: origin.URL, ServerType: "http", BearerTokenPassthrough: true}} + _, err := ts.Tools(testReadonlyContext{Context: ctx}) + require.Error(t, err) + require.Zero(t, foreignRequests) + rt := &headerRoundTripper{base: http.DefaultTransport, bearerTokenPassthrough: true, endpoint: origin.URL} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, foreign.URL, nil) + require.NoError(t, err) + _, err = rt.RoundTrip(req) + require.ErrorContains(t, err, "cannot change destination") + require.Zero(t, foreignRequests) +} diff --git a/go/adk/pkg/mcp/registry.go b/go/adk/pkg/mcp/registry.go index 4c0c9ab522..b8c58b843a 100644 --- a/go/adk/pkg/mcp/registry.go +++ b/go/adk/pkg/mcp/registry.go @@ -7,8 +7,10 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "os" "os/exec" + "strings" "time" "github.com/a2aproject/a2a-go/v2/a2asrv" @@ -20,6 +22,7 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/auth" "github.com/kagent-dev/kagent/go/adk/pkg/constants" + "github.com/kagent-dev/kagent/go/adk/pkg/models" configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/pkg/logging" ) @@ -71,21 +74,22 @@ func allowedRequestHeaders(ctx context.Context, allowed []string) map[string]str // mcpServerParams groups connection parameters for an MCP server, // reducing parameter sprawl across createTransport / initializeToolSet. type mcpServerParams struct { - URL string - Headers map[string]string - AllowedHeaders []string // header names to forward from incoming request - PropagateToken bool // when true, Authorization is forwarded independently of AllowedHeaders - HeaderProvider DynamicHeaderProvider // optional per-request headers derived from invocation context (e.g., STS exchanged access tokens) - ServerType string // "http" or "sse" - Command string - Args []string - Env map[string]string - Dir string - Timeout *durationpb.Duration - SseReadTimeout *durationpb.Duration - TLSInsecureSkipVerify *bool - TLSCACertPath *string - TLSDisableSystemCAs *bool + URL string + Headers map[string]string + AllowedHeaders []string // header names to forward from incoming request + PropagateToken bool // when true, Authorization is forwarded independently of AllowedHeaders + BearerTokenPassthrough bool + HeaderProvider DynamicHeaderProvider // optional per-request headers derived from invocation context (e.g., STS exchanged access tokens) + ServerType string // "http" or "sse" + Command string + Args []string + Env map[string]string + Dir string + Timeout *durationpb.Duration + SseReadTimeout *durationpb.Duration + TLSInsecureSkipVerify *bool + TLSCACertPath *string + TLSDisableSystemCAs *bool } // CreateToolsets creates toolsets from all configured HTTP and SSE MCP servers. @@ -125,17 +129,18 @@ func CreateToolsets( log.InfoContext(ctx, "processing HTTP MCP tools", "http_tools_count", len(httpTools)) for i, httpTool := range httpTools { params := mcpServerParams{ - URL: httpTool.Params.Url, - Headers: httpTool.Params.Headers, - AllowedHeaders: httpTool.AllowedHeaders, - PropagateToken: propagateToken, - HeaderProvider: headerProvider, - ServerType: "http", - Timeout: httpTool.Params.Timeout, - SseReadTimeout: httpTool.Params.SseReadTimeout, - TLSInsecureSkipVerify: httpTool.Params.TlsInsecureSkipVerify, - TLSCACertPath: httpTool.Params.TlsCaCertPath, - TLSDisableSystemCAs: httpTool.Params.TlsDisableSystemCas, + URL: httpTool.Params.Url, + Headers: httpTool.Params.Headers, + AllowedHeaders: httpTool.AllowedHeaders, + PropagateToken: propagateToken, + BearerTokenPassthrough: httpTool.BearerTokenPassthrough, + HeaderProvider: headerProvider, + ServerType: "http", + Timeout: httpTool.Params.Timeout, + SseReadTimeout: httpTool.Params.SseReadTimeout, + TLSInsecureSkipVerify: httpTool.Params.TlsInsecureSkipVerify, + TLSCACertPath: httpTool.Params.TlsCaCertPath, + TLSDisableSystemCAs: httpTool.Params.TlsDisableSystemCas, } ts, err := addToolset(ctx, log, params, httpTool.Tools, httpTool.RequireApproval, "HTTP", i+1) if err != nil { @@ -147,17 +152,18 @@ func CreateToolsets( log.InfoContext(ctx, "processing SSE MCP tools", "sse_tools_count", len(sseTools)) for i, sseTool := range sseTools { params := mcpServerParams{ - URL: sseTool.Params.Url, - Headers: sseTool.Params.Headers, - AllowedHeaders: sseTool.AllowedHeaders, - PropagateToken: propagateToken, - HeaderProvider: headerProvider, - ServerType: "sse", - Timeout: sseTool.Params.Timeout, - SseReadTimeout: sseTool.Params.SseReadTimeout, - TLSInsecureSkipVerify: sseTool.Params.TlsInsecureSkipVerify, - TLSCACertPath: sseTool.Params.TlsCaCertPath, - TLSDisableSystemCAs: sseTool.Params.TlsDisableSystemCas, + URL: sseTool.Params.Url, + Headers: sseTool.Params.Headers, + AllowedHeaders: sseTool.AllowedHeaders, + PropagateToken: propagateToken, + BearerTokenPassthrough: sseTool.BearerTokenPassthrough, + HeaderProvider: headerProvider, + ServerType: "sse", + Timeout: sseTool.Params.Timeout, + SseReadTimeout: sseTool.Params.SseReadTimeout, + TLSInsecureSkipVerify: sseTool.Params.TlsInsecureSkipVerify, + TLSCACertPath: sseTool.Params.TlsCaCertPath, + TLSDisableSystemCAs: sseTool.Params.TlsDisableSystemCas, } ts, err := addToolset(ctx, log, params, sseTool.Tools, sseTool.RequireApproval, "SSE", i+1) if err != nil { @@ -186,7 +192,15 @@ func addToolset(ctx context.Context, log *slog.Logger, params mcpServerParams, t log.InfoContext(ctx, "adding MCP tool", "transport", label, "index", index, "url", params.URL, "tool_filter_count", "all") } - ts, err := initializeToolSet(ctx, params, toolFilter) + var ts *mcpAppToolset + var err error + if params.BearerTokenPassthrough { + // No startup discovery: there is no caller identity yet. Each listing + // and call owns a short-lived connection instead of the shared ADK one. + ts = &mcpAppToolset{inner: &passthroughToolset{params: params, filter: toolFilter}} + } else { + ts, err = initializeToolSet(ctx, params, toolFilter) + } if err != nil { log.ErrorContext(ctx, "failed to fetch MCP tools", "transport", label, "error", err, "url", params.URL) return nil, err @@ -205,6 +219,11 @@ func addToolset(ctx context.Context, log *slog.Logger, params mcpServerParams, t // Uses the official MCP SDK (github.com/modelcontextprotocol/go-sdk/mcp). func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transport, error) { log := logging.FromContext(ctx) + if params.BearerTokenPassthrough { + if _, ok := models.PassthroughToken(ctx, true); !ok { + return nil, fmt.Errorf("MCP bearer token passthrough requires an authenticated invocation bearer token") + } + } if params.ServerType == "stdio" { command := exec.CommandContext(ctx, params.Command, params.Args...) command.Dir = params.Dir @@ -228,7 +247,7 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp } } - baseTransport := &http.Transport{} + baseTransport := &http.Transport{DisableKeepAlives: params.BearerTokenPassthrough} if params.TLSInsecureSkipVerify != nil && *params.TLSInsecureSkipVerify { log.WarnContext(ctx, "TLS certificate verification disabled for MCP server", "url", params.URL) @@ -261,13 +280,15 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp } var httpTransport http.RoundTripper = baseTransport - if len(params.Headers) > 0 || len(params.AllowedHeaders) > 0 || params.PropagateToken || params.HeaderProvider != nil { + if len(params.Headers) > 0 || len(params.AllowedHeaders) > 0 || params.PropagateToken || params.HeaderProvider != nil || params.BearerTokenPassthrough { httpTransport = &headerRoundTripper{ - base: baseTransport, - headers: params.Headers, - allowedHeaders: params.AllowedHeaders, - propagateToken: params.PropagateToken, - headerProvider: params.HeaderProvider, + base: baseTransport, + headers: params.Headers, + allowedHeaders: params.AllowedHeaders, + propagateToken: params.PropagateToken, + headerProvider: params.HeaderProvider, + bearerTokenPassthrough: params.BearerTokenPassthrough, + endpoint: params.URL, } } @@ -280,6 +301,9 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp Timeout: httpTimeout, Transport: httpTransport, } + if params.BearerTokenPassthrough { + httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + } var mcpTransport mcpsdk.Transport if params.ServerType == "sse" { @@ -307,15 +331,36 @@ func createTransport(ctx context.Context, params mcpServerParams) (mcpsdk.Transp // 4. headers: static key/value pairs configured on the MCP server spec (highest // priority — always wins). type headerRoundTripper struct { - base http.RoundTripper - headers map[string]string - allowedHeaders []string // header names (case-insensitive) to forward from A2A context - propagateToken bool // when true, Authorization is forwarded independently - headerProvider DynamicHeaderProvider + base http.RoundTripper + headers map[string]string + allowedHeaders []string // header names (case-insensitive) to forward from A2A context + propagateToken bool // when true, Authorization is forwarded independently + headerProvider DynamicHeaderProvider + bearerTokenPassthrough bool + endpoint string } func (rt *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) + if rt.bearerTokenPassthrough { + target, err := url.Parse(rt.endpoint) + if err != nil || req.URL.Scheme != target.Scheme || req.URL.Host != target.Host { + return nil, fmt.Errorf("MCP bearer token passthrough cannot change destination") + } + token, ok := models.PassthroughToken(req.Context(), true) + if !ok { + return nil, fmt.Errorf("MCP bearer token passthrough requires an authenticated invocation bearer token") + } + // Dedicated passthrough never consults the general A2A metadata or STS + // provider. Compiler filtering is repeated defensively for direct ADK use. + for key, value := range rt.headers { + if !strings.EqualFold(key, constants.AuthorizationHeader) { + req.Header.Set(key, value) + } + } + req.Header.Set(constants.AuthorizationHeader, "Bearer "+token) + return rt.base.RoundTrip(req) + } // When propagateToken is enabled, forward Authorization from the incoming // A2A request independently of allowedHeaders. Carry the authenticated user diff --git a/go/api/gen/kagent/config/v1/adk.pb.go b/go/api/gen/kagent/config/v1/adk.pb.go index 08378f073e..6e60659abb 100644 --- a/go/api/gen/kagent/config/v1/adk.pb.go +++ b/go/api/gen/kagent/config/v1/adk.pb.go @@ -1709,8 +1709,10 @@ type ADKHTTPMCPServerConfig struct { Tools []string `protobuf:"bytes,2,rep,name=tools,proto3" json:"tools,omitempty"` AllowedHeaders []string `protobuf:"bytes,3,rep,name=allowed_headers,json=allowedHeaders,proto3" json:"allowed_headers,omitempty"` RequireApproval bool `protobuf:"varint,4,opt,name=require_approval,json=requireApproval,proto3" json:"require_approval,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Require the current invocation's bearer token, never a runtime credential. + BearerTokenPassthrough bool `protobuf:"varint,5,opt,name=bearer_token_passthrough,json=bearerTokenPassthrough,proto3" json:"bearer_token_passthrough,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ADKHTTPMCPServerConfig) Reset() { @@ -1771,14 +1773,23 @@ func (x *ADKHTTPMCPServerConfig) GetRequireApproval() bool { return false } +func (x *ADKHTTPMCPServerConfig) GetBearerTokenPassthrough() bool { + if x != nil { + return x.BearerTokenPassthrough + } + return false +} + type ADKSSEMCPServerConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Params *ADKSSEConnectionParams `protobuf:"bytes,1,opt,name=params,proto3" json:"params,omitempty"` Tools []string `protobuf:"bytes,2,rep,name=tools,proto3" json:"tools,omitempty"` AllowedHeaders []string `protobuf:"bytes,3,rep,name=allowed_headers,json=allowedHeaders,proto3" json:"allowed_headers,omitempty"` RequireApproval bool `protobuf:"varint,4,opt,name=require_approval,json=requireApproval,proto3" json:"require_approval,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Require the current invocation's bearer token, never a runtime credential. + BearerTokenPassthrough bool `protobuf:"varint,5,opt,name=bearer_token_passthrough,json=bearerTokenPassthrough,proto3" json:"bearer_token_passthrough,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ADKSSEMCPServerConfig) Reset() { @@ -1839,6 +1850,13 @@ func (x *ADKSSEMCPServerConfig) GetRequireApproval() bool { return false } +func (x *ADKSSEMCPServerConfig) GetBearerTokenPassthrough() bool { + if x != nil { + return x.BearerTokenPassthrough + } + return false +} + type ADKStdioMCPServerConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Command string `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"` @@ -3120,17 +3138,19 @@ const file_kagent_config_v1_adk_proto_rawDesc = "" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x1b\n" + "\x19_tls_insecure_skip_verifyB\x13\n" + "\x11_tls_ca_cert_pathB\x19\n" + - "\x17_tls_disable_system_cas\"\xcd\x01\n" + + "\x17_tls_disable_system_cas\"\x87\x02\n" + "\x16ADKHTTPMCPServerConfig\x12I\n" + "\x06params\x18\x01 \x01(\v2).kagent.config.v1.ADKHTTPConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n" + "\x05tools\x18\x02 \x03(\tR\x05tools\x12'\n" + "\x0fallowed_headers\x18\x03 \x03(\tR\x0eallowedHeaders\x12)\n" + - "\x10require_approval\x18\x04 \x01(\bR\x0frequireApproval\"\xcb\x01\n" + + "\x10require_approval\x18\x04 \x01(\bR\x0frequireApproval\x128\n" + + "\x18bearer_token_passthrough\x18\x05 \x01(\bR\x16bearerTokenPassthrough\"\x85\x02\n" + "\x15ADKSSEMCPServerConfig\x12H\n" + "\x06params\x18\x01 \x01(\v2(.kagent.config.v1.ADKSSEConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n" + "\x05tools\x18\x02 \x03(\tR\x05tools\x12'\n" + "\x0fallowed_headers\x18\x03 \x03(\tR\x0eallowedHeaders\x12)\n" + - "\x10require_approval\x18\x04 \x01(\bR\x0frequireApproval\"\xd7\x01\n" + + "\x10require_approval\x18\x04 \x01(\bR\x0frequireApproval\x128\n" + + "\x18bearer_token_passthrough\x18\x05 \x01(\bR\x16bearerTokenPassthrough\"\xd7\x01\n" + "\x17ADKStdioMCPServerConfig\x12\x18\n" + "\acommand\x18\x01 \x01(\tR\acommand\x12\x12\n" + "\x04args\x18\x02 \x03(\tR\x04args\x12D\n" + diff --git a/go/api/v1alpha3/configuration_crd_cel_test.go b/go/api/v1alpha3/configuration_crd_cel_test.go index ab236cd4b7..9861c13110 100644 --- a/go/api/v1alpha3/configuration_crd_cel_test.go +++ b/go/api/v1alpha3/configuration_crd_cel_test.go @@ -26,6 +26,7 @@ import ( "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -64,6 +65,7 @@ func TestConfigurationCRDValidation(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) require.NoError(t, AddToScheme(scheme)) + require.NoError(t, apiextensionsv1.AddToScheme(scheme)) cl, err := ctrlclient.New(cfg, ctrlclient.Options{Scheme: scheme}) require.NoError(t, err) @@ -71,6 +73,33 @@ func TestConfigurationCRDValidation(t *testing.T) { const namespace = "configuration-crd-cel" require.NoError(t, cl.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}})) + t.Run("MCP token passthrough defaults and round trip", func(t *testing.T) { + crd := &apiextensionsv1.CustomResourceDefinition{} + require.NoError(t, cl.Get(ctx, ctrlclient.ObjectKey{Name: "remotemcpservers.api.kagent.dev"}, crd)) + properties := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties + require.Contains(t, properties, "tokenPassthrough") + require.NotContains(t, properties, "bearerTokenPassthrough") + for _, value := range []*bool{nil, new(false), new(true)} { + server := &RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{GenerateName: "mcp-passthrough-", Namespace: namespace}, + Spec: RemoteMCPServerSpec{URL: "https://mcp.example.com", TokenPassthrough: value}} + require.NoError(t, cl.Create(ctx, server)) + stored := &RemoteMCPServer{} + require.NoError(t, cl.Get(ctx, ctrlclient.ObjectKeyFromObject(server), stored)) + require.NotNil(t, stored.Spec.TokenPassthrough) + require.Equal(t, value != nil && *value, *stored.Spec.TokenPassthrough) + data, err := json.Marshal(stored) + require.NoError(t, err) + require.Contains(t, string(data), `"tokenPassthrough":`) + require.NotContains(t, string(data), "bearerTokenPassthrough") + } + invalid := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": GroupVersion.String(), "kind": "RemoteMCPServer", + "metadata": map[string]any{"name": "invalid-passthrough", "namespace": namespace}, + "spec": map[string]any{"description": "", "url": "https://mcp.example.com", "tokenPassthrough": "true"}, + }} + require.ErrorContains(t, cl.Create(ctx, invalid), "tokenPassthrough") + }) + t.Run("MCP timeout bounds", func(t *testing.T) { for _, field := range []string{"timeout", "sseReadTimeout"} { for _, value := range []time.Duration{-time.Second, 0, 250 * time.Millisecond, 9223372036 * time.Second, 9223372036*time.Second + 1} { @@ -92,6 +121,48 @@ func TestConfigurationCRDValidation(t *testing.T) { } }) + t.Run("model token passthrough", func(t *testing.T) { + crd := &apiextensionsv1.CustomResourceDefinition{} + require.NoError(t, cl.Get(ctx, ctrlclient.ObjectKey{Name: "modelconfigs.api.kagent.dev"}, crd)) + properties := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties + require.Contains(t, properties, "tokenPassthrough") + require.NotContains(t, properties, "apiKeyPassthrough") + for _, tc := range []struct { + name string + provider ModelProvider + enabled bool + secret string + wantErr string + }{ + {name: "enabled", provider: ModelProviderOpenAI, enabled: true}, + {name: "disabled with secret", provider: ModelProviderOpenAI, secret: "model-key"}, + {name: "mutually exclusive", provider: ModelProviderOpenAI, enabled: true, secret: "model-key", wantErr: "mutually exclusive"}, + {name: "Gemini", provider: ModelProviderGemini, enabled: true, wantErr: "tokenPassthrough must be false"}, + {name: "Gemini Vertex", provider: ModelProviderGeminiVertexAI, enabled: true, wantErr: "tokenPassthrough must be false"}, + {name: "Anthropic Vertex", provider: ModelProviderAnthropicVertexAI, enabled: true, wantErr: "tokenPassthrough must be false"}, + } { + t.Run(tc.name, func(t *testing.T) { + model := &ModelConfig{ObjectMeta: metav1.ObjectMeta{GenerateName: "model-passthrough-", Namespace: namespace}, + Spec: ModelConfigSpec{Provider: tc.provider, Model: "model", TokenPassthrough: tc.enabled, APIKeySecret: tc.secret}} + if tc.secret != "" { + model.Spec.APIKeySecretKey = "key" + } + err := cl.Create(ctx, model) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + return + } + require.NoError(t, err) + stored := &ModelConfig{} + require.NoError(t, cl.Get(ctx, ctrlclient.ObjectKeyFromObject(model), stored)) + require.Equal(t, tc.enabled, stored.Spec.TokenPassthrough) + data, err := json.Marshal(stored) + require.NoError(t, err) + require.NotContains(t, string(data), "apiKeyPassthrough") + }) + } + }) + t.Run("optional Harness workload", func(t *testing.T) { for _, variant := range []struct { name string diff --git a/go/api/v1alpha3/modelconfig_types.go b/go/api/v1alpha3/modelconfig_types.go index 040b8badd1..37f0e4ea57 100644 --- a/go/api/v1alpha3/modelconfig_types.go +++ b/go/api/v1alpha3/modelconfig_types.go @@ -571,8 +571,8 @@ func (t *TLSConfig) IsEmpty() bool { // +kubebuilder:validation:XValidation:message="provider.mistral must be nil if the provider is not Mistral",rule="!(has(self.mistral) && self.provider != 'Mistral')" // +kubebuilder:validation:XValidation:message="apiKeySecret must be set if apiKeySecretKey is set",rule="!(has(self.apiKeySecretKey) && !has(self.apiKeySecret))" // +kubebuilder:validation:XValidation:message="apiKeySecretKey must be set if apiKeySecret is set (except for Bedrock and SAPAICore providers)",rule="!(has(self.apiKeySecret) && !has(self.apiKeySecretKey) && self.provider != 'Bedrock' && self.provider != 'SAPAICore')" -// +kubebuilder:validation:XValidation:message="apiKeyPassthrough and apiKeySecret are mutually exclusive",rule="!(has(self.apiKeyPassthrough) && self.apiKeyPassthrough && has(self.apiKeySecret) && size(self.apiKeySecret) > 0)" -// +kubebuilder:validation:XValidation:message="apiKeyPassthrough must be false if provider is Gemini;GeminiVertexAI;AnthropicVertexAI",rule="!(has(self.apiKeyPassthrough) && self.apiKeyPassthrough && (self.provider == 'Gemini' || self.provider == 'GeminiVertexAI' || self.provider == 'AnthropicVertexAI'))" +// +kubebuilder:validation:XValidation:message="tokenPassthrough and apiKeySecret are mutually exclusive",rule="!(has(self.tokenPassthrough) && self.tokenPassthrough && has(self.apiKeySecret) && size(self.apiKeySecret) > 0)" +// +kubebuilder:validation:XValidation:message="tokenPassthrough must be false if provider is Gemini;GeminiVertexAI;AnthropicVertexAI",rule="!(has(self.tokenPassthrough) && self.tokenPassthrough && (self.provider == 'Gemini' || self.provider == 'GeminiVertexAI' || self.provider == 'AnthropicVertexAI'))" type ModelConfigSpec struct { // +required Model string `json:"model"` @@ -594,12 +594,12 @@ type ModelConfigSpec struct { // +optional APIKeySecretKey string `json:"apiKeySecretKey,omitempty"` - // APIKeyPassthrough enables forwarding the Bearer token from incoming A2A requests + // TokenPassthrough enables forwarding the bearer token from incoming A2A requests // directly to the LLM provider as the API key. This is useful for organizations // with federated identity that want to avoid separate secret management. // Mutually exclusive with apiKeySecret. // +optional - APIKeyPassthrough bool `json:"apiKeyPassthrough,omitempty"` + TokenPassthrough bool `json:"tokenPassthrough,omitempty"` // +optional DefaultHeaders map[string]string `json:"defaultHeaders,omitempty"` diff --git a/go/api/v1alpha3/remotemcpserver_types.go b/go/api/v1alpha3/remotemcpserver_types.go index 4fb2b9ce06..7ad71f52a6 100644 --- a/go/api/v1alpha3/remotemcpserver_types.go +++ b/go/api/v1alpha3/remotemcpserver_types.go @@ -56,6 +56,12 @@ type RemoteMCPServerSpec struct { // +kubebuilder:validation:items:XValidation:rule="!has(self.valueFrom) || self.valueFrom.type != 'Secret' || self.name.matches('^[!#$%&\\\\x27*+.^_`|~0-9A-Za-z-]+$')",message="Secret-backed headers require a valid HTTP header name",fieldPath=".name" // +kubebuilder:validation:items:XValidation:rule="!has(self.valueFrom) || self.valueFrom.type != 'Secret' || !(self.name.lowerAscii() in ['host', 'connection', 'content-length', 'transfer-encoding', 'te', 'trailer', 'upgrade', 'keep-alive', 'proxy-authorization', 'proxy-authenticate', 'cookie'])",message="this header cannot carry an egress credential",fieldPath=".name" HeadersFrom []ValueRef `json:"headersFrom,omitempty"` + // TokenPassthrough forwards the authenticated invocation's bearer token + // to this server. Authorization entries in HeadersFrom are discovery-only. + // Invocations without a bearer token fail before connecting. + // +optional + // +kubebuilder:default=false + TokenPassthrough *bool `json:"tokenPassthrough,omitempty"` // +optional // +kubebuilder:default="30s" // +kubebuilder:validation:XValidation:rule="duration(self) > duration('0s') && duration(self) <= duration('9223372036s')",message="timeout must be positive and at most 9223372036s" diff --git a/go/api/v1alpha3/zz_generated.deepcopy.go b/go/api/v1alpha3/zz_generated.deepcopy.go index 0179022d34..3a70a3e4bf 100644 --- a/go/api/v1alpha3/zz_generated.deepcopy.go +++ b/go/api/v1alpha3/zz_generated.deepcopy.go @@ -1559,6 +1559,11 @@ func (in *RemoteMCPServerSpec) DeepCopyInto(out *RemoteMCPServerSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.TokenPassthrough != nil { + in, out := &in.TokenPassthrough, &out.TokenPassthrough + *out = new(bool) + **out = **in + } if in.Timeout != nil { in, out := &in.Timeout, &out.Timeout *out = new(metav1.Duration) diff --git a/go/core/internal/controller/remotemcpserver/passthrough_test.go b/go/core/internal/controller/remotemcpserver/passthrough_test.go new file mode 100644 index 0000000000..787802e6f7 --- /dev/null +++ b/go/core/internal/controller/remotemcpserver/passthrough_test.go @@ -0,0 +1,71 @@ +package remotemcpserver + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + apiMeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/pkg/consts" +) + +func TestPassthroughDiscovery(t *testing.T) { + for _, tc := range []struct { + name string + headers []v1alpha3.ValueRef + disabled bool + reason string + calls int + failure bool + }{ + {name: "no credential", reason: discoveryCredentialRequired}, + {name: "empty credential", headers: []v1alpha3.ValueRef{{Name: "Authorization"}}, reason: discoveryCredentialRequired}, + {name: "disabled takes precedence", disabled: true, reason: "DiscoveryDisabled"}, + {name: "literal discovery auth", headers: []v1alpha3.ValueRef{{Name: "aUtHoRiZaTiOn", Value: "discovery-token"}}, reason: "DiscoverySucceeded", calls: 1}, + {name: "secret discovery auth", headers: []v1alpha3.ValueRef{{Name: "Authorization", ValueFrom: &v1alpha3.ValueSource{ + Type: v1alpha3.SecretValueSource, Name: "discovery", Key: "token"}}}, reason: "DiscoverySucceeded", calls: 1}, + {name: "failure redacts upstream", headers: []v1alpha3.ValueRef{{Name: "Authorization", Value: "discovery-token"}}, reason: "DiscoveryFailed", calls: 1, failure: true}, + } { + t.Run(tc.name, func(t *testing.T) { + server := testServer() + server.Spec.TokenPassthrough = new(true) + server.Spec.HeadersFrom = tc.headers + server.Status.DiscoveredTools = []*v1alpha3.MCPTool{{Name: "stale"}} + if tc.disabled { + server.Labels = map[string]string{consts.DiscoveryLabel: consts.DiscoveryDisabled} + } + kube := testClient(t, server) + discoverer, catalog := &fakeDiscoverer{}, &fakeCatalog{} + if tc.failure { + discoverer.err = errors.New("upstream echoed discovery-token") + } + result, err := New(kube, discoverer, catalog).Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(server)}) + if tc.failure { + require.Error(t, err) + require.NotContains(t, err.Error(), "discovery-token") + } else { + require.NoError(t, err) + require.Equal(t, refreshInterval, result.RequeueAfter) + } + require.Equal(t, tc.calls, discoverer.calls) + status := getServer(t, kube, server).Status + require.Empty(t, status.DiscoveredTools) + condition := apiMeta.FindStatusCondition(status.Conditions, conditionAccepted) + require.NotNil(t, condition) + require.Equal(t, tc.reason, condition.Reason) + require.NotContains(t, condition.Message, "discovery-token") + if tc.reason == discoveryCredentialRequired || tc.failure { + require.Equal(t, metav1.ConditionFalse, condition.Status) + } + require.Empty(t, catalog.tools) + if tc.calls == 0 || tc.failure { + require.Nil(t, catalog.server.LastConnected) + } + }) + } +} diff --git a/go/core/internal/controller/remotemcpserver/reconciler.go b/go/core/internal/controller/remotemcpserver/reconciler.go index 034c13e2b5..816020b440 100644 --- a/go/core/internal/controller/remotemcpserver/reconciler.go +++ b/go/core/internal/controller/remotemcpserver/reconciler.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "reflect" + "strings" "time" corev1 "k8s.io/api/core/v1" @@ -45,8 +46,9 @@ import ( ) const ( - conditionAccepted = "Accepted" - refreshInterval = 5 * time.Minute + conditionAccepted = "Accepted" + refreshInterval = 5 * time.Minute + discoveryCredentialRequired = "DiscoveryCredentialRequired" // discoveryDisabledMessage explains an Accepted RemoteMCPServer that publishes // no discovered tools because its operator opted out of discovery. @@ -108,11 +110,27 @@ func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) ( return reconcile.Result{RequeueAfter: refreshInterval}, nil } + if server.Spec.TokenPassthrough != nil && *server.Spec.TokenPassthrough && !hasDiscoveryAuthorization(server) { + statusErr := r.updateStatus(ctx, server, nil, metav1.ConditionFalse, discoveryCredentialRequired, + "Bearer token passthrough requires an Authorization entry in headersFrom for discovery; alternatively disable discovery") + catalogErr := r.updateCatalog(ctx, server, nil, false) + return reconcile.Result{RequeueAfter: refreshInterval}, errors.Join( + wrapError("update RemoteMCPServer discovery status", statusErr), + wrapError("clear RemoteMCPServer tool catalog", catalogErr), + ) + } + tools, err := r.discoverer.ListTools(ctx, toolservice.MCPServerRef{ Ref: request.NamespacedName, GroupKind: remoteGroupKind, }) if err != nil { - statusErr := r.updateStatus(ctx, server, nil, metav1.ConditionFalse, "DiscoveryFailed", err.Error()) + message := err.Error() + if server.Spec.TokenPassthrough != nil && *server.Spec.TokenPassthrough { + // Upstream errors can echo the discovery Authorization value. + message = "MCP discovery failed" + err = errors.New(message) + } + statusErr := r.updateStatus(ctx, server, nil, metav1.ConditionFalse, "DiscoveryFailed", message) catalogErr := r.updateCatalog(ctx, server, nil, false) return reconcile.Result{}, errors.Join( fmt.Errorf("discover RemoteMCPServer tools: %w", err), @@ -141,6 +159,15 @@ func (r *Reconciler) Reconcile(ctx context.Context, request reconcile.Request) ( return reconcile.Result{RequeueAfter: refreshInterval}, nil } +func hasDiscoveryAuthorization(server *v1alpha3.RemoteMCPServer) bool { + for _, header := range server.Spec.HeadersFrom { + if strings.EqualFold(header.Name, "Authorization") && (header.ValueFrom != nil || strings.TrimSpace(header.Value) != "") { + return true + } + } + return false +} + // discoveryDisabled reports whether the operator opted the server out of tool // discovery with the kagent.dev/discovery=disabled label. func discoveryDisabled(server *v1alpha3.RemoteMCPServer) bool { diff --git a/go/core/internal/service/tool/mcp.go b/go/core/internal/service/tool/mcp.go index 20490de206..63ac5bad4c 100644 --- a/go/core/internal/service/tool/mcp.go +++ b/go/core/internal/service/tool/mcp.go @@ -34,7 +34,7 @@ func NewRuntimeMCPClient(kubeClient client.Client) *RuntimeMCPClient { } func (c *RuntimeMCPClient) ListTools(ctx context.Context, ref MCPServerRef) ([]MCPAppTool, error) { - session, cleanup, err := c.connect(ctx, ref) + session, cleanup, err := c.connect(ctx, ref, true) if err != nil { return nil, serviceerrors.NewInternal("Failed to connect to MCP server", err) } @@ -62,7 +62,7 @@ func (c *RuntimeMCPClient) ListTools(ctx context.Context, ref MCPServerRef) ([]M } func (c *RuntimeMCPClient) CallTool(ctx context.Context, ref MCPServerRef, toolName string, arguments any) (*mcp.CallToolResult, error) { - session, cleanup, err := c.connect(ctx, ref) + session, cleanup, err := c.connect(ctx, ref, false) if err != nil { return nil, serviceerrors.NewInternal("Failed to connect to MCP server", err) } @@ -90,7 +90,7 @@ func (c *RuntimeMCPClient) CallTool(ctx context.Context, ref MCPServerRef, toolN } func (c *RuntimeMCPClient) ReadResource(ctx context.Context, ref MCPServerRef, uri string) (*mcp.ReadResourceResult, error) { - session, cleanup, err := c.connect(ctx, ref) + session, cleanup, err := c.connect(ctx, ref, false) if err != nil { return nil, serviceerrors.NewInternal("Failed to connect to MCP server", err) } @@ -121,11 +121,16 @@ func (c *RuntimeMCPClient) ResolveServer(ctx context.Context, ref MCPServerRef) return server, nil } -func (c *RuntimeMCPClient) connect(ctx context.Context, ref MCPServerRef) (*mcp.ClientSession, func(), error) { +func (c *RuntimeMCPClient) connect(ctx context.Context, ref MCPServerRef, discovery bool) (*mcp.ClientSession, func(), error) { server, err := c.ResolveServer(ctx, ref) if err != nil { return nil, nil, err } + if !discovery && server.Spec.TokenPassthrough != nil && *server.Spec.TokenPassthrough { + // MCP Apps use the control-plane API, not an admitted A2A invocation. + // Never let those calls reuse discovery-only Authorization. + return nil, nil, fmt.Errorf("MCP bearer token passthrough requires an A2A invocation; MCP Apps calls are unsupported") + } timeout := 30 * time.Second if server.Spec.Timeout != nil && server.Spec.Timeout.Duration > 0 { timeout = server.Spec.Timeout.Duration @@ -137,6 +142,16 @@ func (c *RuntimeMCPClient) connect(ctx context.Context, ref MCPServerRef) (*mcp. cancel() return nil, nil, fmt.Errorf("failed to resolve RemoteMCPServer headers: %w", err) } + if discovery && server.Spec.TokenPassthrough != nil && *server.Spec.TokenPassthrough { + hasAuthorization := false + for name, value := range headers { + hasAuthorization = hasAuthorization || strings.EqualFold(name, "Authorization") && strings.TrimSpace(value) != "" + } + if !hasAuthorization { + cancel() + return nil, nil, fmt.Errorf("MCP discovery requires a discovery Authorization header") + } + } httpClient := newMCPAppsHTTPClient(headers) var transport mcp.Transport diff --git a/go/core/internal/service/tool/mcp_test.go b/go/core/internal/service/tool/mcp_test.go index 9f5b347849..680ca0f753 100644 --- a/go/core/internal/service/tool/mcp_test.go +++ b/go/core/internal/service/tool/mcp_test.go @@ -77,6 +77,20 @@ func TestValidateMCPAppResource(t *testing.T) { } } +func TestMCPAppsNeverResolvePassthroughDiscoveryCredential(t *testing.T) { + server := &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "tools", Namespace: "default"}, + Spec: v1alpha3.RemoteMCPServerSpec{URL: "https://must-not-dial.invalid", TokenPassthrough: new(true), + HeadersFrom: []v1alpha3.ValueRef{{Name: "Authorization", ValueFrom: &v1alpha3.ValueSource{ + Type: v1alpha3.SecretValueSource, Name: "must-not-resolve", Key: "token", + }}}}} + client := NewRuntimeMCPClient(toolTestKube(t, server)) + ref := MCPServerRef{Ref: types.NamespacedName{Name: server.Name, Namespace: server.Namespace}} + _, err := client.CallTool(t.Context(), ref, "write", nil) + require.ErrorContains(t, err, "MCP Apps calls are unsupported") + _, err = client.ReadResource(t.Context(), ref, "ui://widget") + require.ErrorContains(t, err, "MCP Apps calls are unsupported") +} + func TestRuntimeMCPClientResolveServerMatrix(t *testing.T) { remote := &v1alpha3.RemoteMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "remote", Namespace: "default"}, @@ -85,6 +99,7 @@ func TestRuntimeMCPClientResolveServerMatrix(t *testing.T) { Protocol: v1alpha3.RemoteMCPServerProtocolStreamableHttp, }, } + collideRemote := &v1alpha3.RemoteMCPServer{ ObjectMeta: metav1.ObjectMeta{Name: "shared", Namespace: "clash"}, Spec: v1alpha3.RemoteMCPServerSpec{URL: "https://remote.example.com/mcp"}, diff --git a/go/core/internal/translator/adkconfig/builder.go b/go/core/internal/translator/adkconfig/builder.go index a7e5e54ed0..dbcf4828c3 100644 --- a/go/core/internal/translator/adkconfig/builder.go +++ b/go/core/internal/translator/adkconfig/builder.go @@ -119,7 +119,7 @@ func (c *Builder) compileAgent(ctx context.Context, input *translator.AgentInput cfg.AgentPlugins = runtimeconfig.PluginConfig(plugins.Resources) } for _, tool := range input.MCPTools { - headers, credentialEnv, err := c.resolveAgentTemplateHeaders(ctx, input.Template.Namespace, tool.Server.Spec.HeadersFrom) + headers, credentialEnv, err := c.resolveAgentTemplateHeaders(ctx, input.Template.Namespace, translator.RuntimeMCPHeaders(tool.Server)) if err != nil { return nil, fmt.Errorf("resolve %s %q: %w", tool.Binding.Server.Kind, tool.Binding.Server.Name, err) } @@ -353,7 +353,7 @@ func agentConfigDestinations(cfg *configv1.ADKAgentConfig, modelConfig *v1alpha3 // ignoring the operator's host. That over-allowed egress: with a // host set the request goes to that host, so api.ollama.com never // needs to be reachable. - hasCredential := modelConfig.Spec.APIKeySecret != "" || modelConfig.Spec.APIKeyPassthrough + hasCredential := modelConfig.Spec.APIKeySecret != "" || modelConfig.Spec.TokenPassthrough if models.OllamaReachesCloud(modelConfig.Spec.Model, ollama.Host, hasCredential) { destinations = append(destinations, "https://api.ollama.com:443") } diff --git a/go/core/internal/translator/adkconfig/mcp.go b/go/core/internal/translator/adkconfig/mcp.go index 04c354bbcc..3f39921500 100644 --- a/go/core/internal/translator/adkconfig/mcp.go +++ b/go/core/internal/translator/adkconfig/mcp.go @@ -24,6 +24,7 @@ func (c *Builder) addRemoteMCPServer(config *configv1.ADKAgentConfig, server *v1 params.TlsInsecureSkipVerify = tlsInsecureSkipVerify(server.Spec.TLS) config.SseTools = append(config.SseTools, &configv1.ADKSSEMCPServerConfig{ Params: params, Tools: tools, RequireApproval: requireApproval, + BearerTokenPassthrough: server.Spec.TokenPassthrough != nil && *server.Spec.TokenPassthrough, }) default: params := &configv1.ADKHTTPConnectionParams{Url: targetURL, Headers: headers} @@ -39,6 +40,7 @@ func (c *Builder) addRemoteMCPServer(config *configv1.ADKAgentConfig, server *v1 params.TlsInsecureSkipVerify = tlsInsecureSkipVerify(server.Spec.TLS) config.HttpTools = append(config.HttpTools, &configv1.ADKHTTPMCPServerConfig{ Params: params, Tools: tools, RequireApproval: requireApproval, + BearerTokenPassthrough: server.Spec.TokenPassthrough != nil && *server.Spec.TokenPassthrough, }) } diff --git a/go/core/internal/translator/adkconfig/mcp_test.go b/go/core/internal/translator/adkconfig/mcp_test.go index b7839cce56..5f5b5d1e98 100644 --- a/go/core/internal/translator/adkconfig/mcp_test.go +++ b/go/core/internal/translator/adkconfig/mcp_test.go @@ -4,11 +4,45 @@ import ( "testing" "github.com/stretchr/testify/require" + "istio.io/istio/pkg/kube/krt" configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/translator" ) +func TestCompileMCPFiltersDiscoveryAuthorizationBeforeLookup(t *testing.T) { + for _, enabled := range []bool{false, true} { + t.Run(map[bool]string{false: "disabled resolves headers", true: "enabled skips discovery credentials"}[enabled], func(t *testing.T) { + server := &v1alpha3.RemoteMCPServer{Spec: v1alpha3.RemoteMCPServerSpec{ + URL: "https://mcp.example.test", TokenPassthrough: new(enabled), + HeadersFrom: []v1alpha3.ValueRef{ + {Name: "aUtHoRiZaTiOn", ValueFrom: &v1alpha3.ValueSource{ + Type: v1alpha3.ConfigMapValueSource, Name: "absent-discovery-config", Key: "token", + }}, + {Name: "Authorization", ValueFrom: &v1alpha3.ValueSource{ + Type: v1alpha3.SecretValueSource, Name: "discovery-secret", Key: "token", + }}, + {Name: "X-Tenant", Value: "team"}, + }, + }} + builder := NewBuilder(krt.TestingDummyContext{}, contextTestCollections(t)) + result, err := builder.compileAgent(t.Context(), &translator.AgentInput{ + Template: &translator.TemplateConfiguration{}, + MCPTools: []translator.ResolvedMCPTool{{Server: server}}, + }) + if !enabled { + require.ErrorContains(t, err, "absent-discovery-config") + return + } + require.NoError(t, err) + require.Len(t, result.Config.HttpTools, 1) + require.Equal(t, map[string]string{"X-Tenant": "team"}, result.Config.HttpTools[0].Params.Headers) + require.Empty(t, result.Environment) + }) + } +} + func TestAddRemoteMCPServerPreservesBindingApproval(t *testing.T) { tests := []struct { name string @@ -21,6 +55,7 @@ func TestAddRemoteMCPServerPreservesBindingApproval(t *testing.T) { assert: func(t *testing.T, config *configv1.ADKAgentConfig) { require.Len(t, config.HttpTools, 1) require.True(t, config.HttpTools[0].RequireApproval) + require.True(t, config.HttpTools[0].BearerTokenPassthrough) require.Nil(t, config.HttpTools[0].Tools) }, }, @@ -30,6 +65,7 @@ func TestAddRemoteMCPServerPreservesBindingApproval(t *testing.T) { assert: func(t *testing.T, config *configv1.ADKAgentConfig) { require.Len(t, config.SseTools, 1) require.True(t, config.SseTools[0].RequireApproval) + require.True(t, config.SseTools[0].BearerTokenPassthrough) require.Equal(t, []string{"write"}, config.SseTools[0].Tools) }, }, @@ -40,6 +76,7 @@ func TestAddRemoteMCPServerPreservesBindingApproval(t *testing.T) { config := &configv1.ADKAgentConfig{} server := &v1alpha3.RemoteMCPServer{Spec: v1alpha3.RemoteMCPServerSpec{ URL: "https://mcp.example.test", Protocol: test.protocol, + TokenPassthrough: new(true), }} var tools []string if test.protocol == v1alpha3.RemoteMCPServerProtocolSse { diff --git a/go/core/internal/translator/adkconfig/model.go b/go/core/internal/translator/adkconfig/model.go index 1ede7b2b68..0118cbef6f 100644 --- a/go/core/internal/translator/adkconfig/model.go +++ b/go/core/internal/translator/adkconfig/model.go @@ -74,7 +74,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode switch model.Spec.Provider { case v1alpha3.ModelProviderOpenAI: - if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { + if !model.Spec.TokenPassthrough && model.Spec.APIKeySecret != "" { modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.OpenAIAPIKey.Name(), ValueFrom: &corev1.EnvVarSource{ @@ -94,7 +94,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode } // Populate shared transport settings. populateTLSFields(openai, model.Spec.TLS) - openai.ApiKeyPassthrough = model.Spec.APIKeyPassthrough + openai.ApiKeyPassthrough = model.Spec.TokenPassthrough if model.Spec.OpenAI != nil { openai.GetOpenai().BaseUrl = model.Spec.OpenAI.BaseURL @@ -135,7 +135,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode } return openai, modelDeploymentData, nil case v1alpha3.ModelProviderAnthropic: - if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { + if !model.Spec.TokenPassthrough && model.Spec.APIKeySecret != "" { modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.AnthropicAPIKey.Name(), ValueFrom: &corev1.EnvVarSource{ @@ -155,7 +155,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode } // Populate shared transport settings. populateTLSFields(anthropic, model.Spec.TLS) - anthropic.ApiKeyPassthrough = model.Spec.APIKeyPassthrough + anthropic.ApiKeyPassthrough = model.Spec.TokenPassthrough if model.Spec.Anthropic != nil { spec := model.Spec.Anthropic @@ -176,7 +176,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode if model.Spec.AzureOpenAI == nil { return nil, nil, fmt.Errorf("AzureOpenAI model config is required") } - if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { + if !model.Spec.TokenPassthrough && model.Spec.APIKeySecret != "" { modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.AzureOpenAIAPIKey.Name(), ValueFrom: &corev1.EnvVarSource{ @@ -221,7 +221,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode } // Populate shared transport settings. populateTLSFields(azureOpenAI, model.Spec.TLS) - azureOpenAI.ApiKeyPassthrough = model.Spec.APIKeyPassthrough + azureOpenAI.ApiKeyPassthrough = model.Spec.TokenPassthrough if model.Spec.AzureOpenAI.APIFormat != nil && *model.Spec.AzureOpenAI.APIFormat != "" { azureOpenAI.GetAzureOpenai().ApiFormat = string(*model.Spec.AzureOpenAI.APIFormat) } @@ -268,7 +268,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode } // Populate shared transport settings. populateTLSFields(gemini, model.Spec.TLS) - gemini.ApiKeyPassthrough = model.Spec.APIKeyPassthrough + gemini.ApiKeyPassthrough = model.Spec.TokenPassthrough if model.Spec.GeminiVertexAI.MaxOutputTokens > 0 { gemini.GetGeminiVertexAi().MaxOutputTokens = runtimeInt(&model.Spec.GeminiVertexAI.MaxOutputTokens) @@ -312,7 +312,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode } // Populate shared transport settings. populateTLSFields(anthropic, model.Spec.TLS) - anthropic.ApiKeyPassthrough = model.Spec.APIKeyPassthrough + anthropic.ApiKeyPassthrough = model.Spec.TokenPassthrough return anthropic, modelDeploymentData, nil case v1alpha3.ModelProviderOllama: @@ -334,7 +334,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode // placeholder. The same predicate decides the credential binding. // Local models and operator-supplied daemon hosts have no cloud // binding, so they must not receive a Secret-backed environment ref. - if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" && + if !model.Spec.TokenPassthrough && model.Spec.APIKeySecret != "" && models.OllamaReachesCloud(model.Spec.Model, model.Spec.Ollama.Host, true) { modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.OllamaAPIKey.Name(), @@ -357,7 +357,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode } // Populate shared transport settings. populateTLSFields(ollama, model.Spec.TLS) - ollama.ApiKeyPassthrough = model.Spec.APIKeyPassthrough + ollama.ApiKeyPassthrough = model.Spec.TokenPassthrough return ollama, modelDeploymentData, nil case v1alpha3.ModelProviderGemini: @@ -396,7 +396,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode // If AWS_BEARER_TOKEN_BEDROCK key exists: use bearer token auth // Otherwise, use IAM credentials - if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { + if !model.Spec.TokenPassthrough && model.Spec.APIKeySecret != "" { secret := types.NamespacedName{Namespace: model.Namespace, Name: model.Spec.APIKeySecret} hasSecretKey := func(key string) bool { for _, ref := range resolved.References { @@ -480,7 +480,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode // Populate shared transport settings. populateTLSFields(bedrock, model.Spec.TLS) - bedrock.ApiKeyPassthrough = model.Spec.APIKeyPassthrough + bedrock.ApiKeyPassthrough = model.Spec.TokenPassthrough return bedrock, modelDeploymentData, nil case v1alpha3.ModelProviderSAPAICore: @@ -488,7 +488,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode return nil, nil, fmt.Errorf("sapAICore model config is required") } - if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { + if !model.Spec.TokenPassthrough && model.Spec.APIKeySecret != "" { modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.SAPAICoreClientID.Name(), ValueFrom: &corev1.EnvVarSource{ @@ -524,7 +524,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode } populateTLSFields(sapAICore, model.Spec.TLS) - sapAICore.ApiKeyPassthrough = model.Spec.APIKeyPassthrough + sapAICore.ApiKeyPassthrough = model.Spec.TokenPassthrough return sapAICore, modelDeploymentData, nil case v1alpha3.ModelProviderFoundry: @@ -538,7 +538,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode // Implicit auth: mount the API key only when a secret is provided and // passthrough is off; otherwise the runtime uses DefaultAzureCredential // (Workload Identity) or the passed-through caller token. - if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { + if !model.Spec.TokenPassthrough && model.Spec.APIKeySecret != "" { modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.FoundryAPIKey.Name(), ValueFrom: &corev1.EnvVarSource{ @@ -588,11 +588,11 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode }}, } populateTLSFields(foundry, model.Spec.TLS) - foundry.ApiKeyPassthrough = model.Spec.APIKeyPassthrough + foundry.ApiKeyPassthrough = model.Spec.TokenPassthrough return foundry, modelDeploymentData, nil case v1alpha3.ModelProviderMistral: - if !model.Spec.APIKeyPassthrough && model.Spec.APIKeySecret != "" { + if !model.Spec.TokenPassthrough && model.Spec.APIKeySecret != "" { modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.MistralAPIKey.Name(), ValueFrom: &corev1.EnvVarSource{ @@ -611,7 +611,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode Provider: &configv1.ADKModelConfig_Mistral{Mistral: &configv1.ADKMistral{}}, } populateTLSFields(mistral, model.Spec.TLS) - mistral.ApiKeyPassthrough = model.Spec.APIKeyPassthrough + mistral.ApiKeyPassthrough = model.Spec.TokenPassthrough if model.Spec.Mistral != nil { spec := model.Spec.Mistral diff --git a/go/core/internal/translator/adkconfig/model_test.go b/go/core/internal/translator/adkconfig/model_test.go index 41049692c7..74552e80b5 100644 --- a/go/core/internal/translator/adkconfig/model_test.go +++ b/go/core/internal/translator/adkconfig/model_test.go @@ -1,6 +1,7 @@ package adkconfig import ( + "encoding/json" "testing" "github.com/stretchr/testify/require" @@ -15,6 +16,44 @@ import ( "github.com/kagent-dev/kagent/go/core/pkg/env" ) +func TestTranslateModelTokenPassthrough(t *testing.T) { + specs := []v1alpha3.ModelConfigSpec{ + {Provider: v1alpha3.ModelProviderOpenAI}, + {Provider: v1alpha3.ModelProviderAnthropic}, + {Provider: v1alpha3.ModelProviderAzureOpenAI, AzureOpenAI: &v1alpha3.AzureOpenAIConfig{DeploymentName: "model"}}, + {Provider: v1alpha3.ModelProviderOllama, Ollama: &v1alpha3.OllamaConfig{}}, + {Provider: v1alpha3.ModelProviderBedrock, Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1"}}, + {Provider: v1alpha3.ModelProviderSAPAICore, SAPAICore: &v1alpha3.SAPAICoreConfig{}}, + {Provider: v1alpha3.ModelProviderFoundry, Foundry: &v1alpha3.FoundryConfig{Deployment: "model"}}, + {Provider: v1alpha3.ModelProviderMistral}, + } + for _, spec := range specs { + t.Run(string(spec.Provider), func(t *testing.T) { + for _, tt := range []struct { + name string + raw string + want bool + }{ + {name: "omitted", raw: `{}`}, + {name: "disabled", raw: `{"tokenPassthrough":false}`}, + {name: "enabled", raw: `{"tokenPassthrough":true}`, want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + input := spec + input.Model = "model" + require.NoError(t, json.Unmarshal([]byte(tt.raw), &input)) + got, _, err := translateModel(&translator.ResolvedModelConfig{ + Config: &v1alpha3.ModelConfig{Spec: input}, + }) + require.NoError(t, err) + // The public CRD name changes; the ADK wire contract does not. + require.Equal(t, tt.want, got.GetApiKeyPassthrough()) + }) + } + }) + } +} + func TestBedrockProviderJSONRoundTrip(t *testing.T) { for _, raw := range []string{"", "{}", `{"thinking":{"budget_tokens":1024},"large":9007199254740993,"values":[0,false,null,0.25]}`} { t.Run(raw, func(t *testing.T) { @@ -129,8 +168,8 @@ func TestTranslateOllamaEnvironment(t *testing.T) { spec: v1alpha3.ModelConfigSpec{ Provider: v1alpha3.ModelProviderOllama, Model: "deepseek-v4-flash:0731-cloud", APIKeySecret: "ollama-cloud", APIKeySecretKey: "OLLAMA_API_KEY", - APIKeyPassthrough: true, - Ollama: &v1alpha3.OllamaConfig{}, + TokenPassthrough: true, + Ollama: &v1alpha3.OllamaConfig{}, }, wantNames: []string{}, noEnv: env.OllamaAPIKey.Name(), diff --git a/go/core/internal/translator/claude/compiler.go b/go/core/internal/translator/claude/compiler.go index d5bb297486..0ae31bb582 100644 --- a/go/core/internal/translator/claude/compiler.go +++ b/go/core/internal/translator/claude/compiler.go @@ -43,8 +43,8 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if strings.TrimSpace(model.Spec.Model) == "" { return nil, translator.NewValidationError("Claude ModelConfig model is required") } - if len(model.Spec.DefaultHeaders) != 0 || !model.Spec.TLS.IsEmpty() || model.Spec.APIKeyPassthrough { - return nil, translator.NewValidationError("Claude does not support ModelConfig defaultHeaders, TLS, or apiKeyPassthrough yet") + if len(model.Spec.DefaultHeaders) != 0 || !model.Spec.TLS.IsEmpty() || model.Spec.TokenPassthrough { + return nil, translator.NewValidationError("Claude does not support ModelConfig defaultHeaders, TLS, or tokenPassthrough yet") } telemetryConfig, err := translator.TelemetryConfigFromSettings(c.settings.GetTelemetry()) if err != nil { diff --git a/go/core/internal/translator/claude/compiler_test.go b/go/core/internal/translator/claude/compiler_test.go index d67f2604e0..e07cc75790 100644 --- a/go/core/internal/translator/claude/compiler_test.go +++ b/go/core/internal/translator/claude/compiler_test.go @@ -138,7 +138,7 @@ func TestCompileRejectsUnsupportedConfiguration(t *testing.T) { model v1alpha3.ModelConfigSpec }{ {name: "provider", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt"}}, - {name: "passthrough", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", APIKeyPassthrough: true}}, + {name: "passthrough", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", TokenPassthrough: true}}, {name: "headers", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", DefaultHeaders: map[string]string{"x": "y"}}}, {name: "Anthropic options", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", Anthropic: &v1alpha3.AnthropicConfig{Temperature: "0.5"}}}, {name: "Anthropic prompt caching", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropic, Model: "claude", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", Anthropic: &v1alpha3.AnthropicConfig{PromptCaching: true, CacheTTL: "5m"}}}, diff --git a/go/core/internal/translator/claude/mcp.go b/go/core/internal/translator/claude/mcp.go index 15d2f2ad16..8d845b6a81 100644 --- a/go/core/internal/translator/claude/mcp.go +++ b/go/core/internal/translator/claude/mcp.go @@ -41,6 +41,9 @@ func (c *Compiler) compileMCP( if server == nil { return mcpCompilation{}, fmt.Errorf("resolved Claude MCP binding has no server") } + if server.Spec.TokenPassthrough != nil && *server.Spec.TokenPassthrough { + return mcpCompilation{}, translator.NewValidationError("Claude does not support RemoteMCPServer %q tokenPassthrough", server.Name) + } name := strings.ReplaceAll(server.Name, ".", "_") if previous, exists := identities[name]; exists { return mcpCompilation{}, translator.NewValidationError("Claude MCP servers %q and %q map to the same native name %q", previous, server.Name, name) diff --git a/go/core/internal/translator/claude/mcp_passthrough_test.go b/go/core/internal/translator/claude/mcp_passthrough_test.go new file mode 100644 index 0000000000..01893e8b07 --- /dev/null +++ b/go/core/internal/translator/claude/mcp_passthrough_test.go @@ -0,0 +1,56 @@ +package claude + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/translator" +) + +func TestCompileMCPBearerPassthrough(t *testing.T) { + for _, tt := range []struct { + name string + passthrough *bool + wantErr bool + }{ + {name: "enabled", passthrough: new(true), wantErr: true}, + {name: "disabled", passthrough: new(false)}, + {name: "absent"}, + } { + t.Run(tt.name, func(t *testing.T) { + server := &v1alpha3.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "tools"}, + Spec: v1alpha3.RemoteMCPServerSpec{ + URL: "https://mcp.example.com", TokenPassthrough: tt.passthrough, + HeadersFrom: []v1alpha3.ValueRef{ + {Name: "aUtHoRiZaTiOn", Value: "static-auth"}, + {Name: "X-Tenant", Value: "tenant"}, + }, + }, + } + if tt.wantErr { + // Reject before looking up discovery credentials. + server.Spec.HeadersFrom = append(server.Spec.HeadersFrom, v1alpha3.ValueRef{ + Name: "Authorization", ValueFrom: &v1alpha3.ValueSource{ + Type: v1alpha3.ConfigMapValueSource, Name: "missing", Key: "token", + }, + }) + } + result, err := (&Compiler{}).compileMCP(t.Context(), "test", []translator.ResolvedMCPTool{{Server: server}}) + if tt.wantErr { + var validationErr *translator.ValidationError + require.ErrorAs(t, err, &validationErr) + require.EqualError(t, err, `Claude does not support RemoteMCPServer "tools" tokenPassthrough`) + require.Equal(t, mcpCompilation{}, result) + return + } + require.NoError(t, err) + require.Empty(t, result.environment) + require.Equal(t, map[string]string{"aUtHoRiZaTiOn": "static-auth", "X-Tenant": "tenant"}, result.servers["tools"].Headers) + require.Equal(t, server.Spec.URL, result.servers["tools"].URL) + }) + } +} diff --git a/go/core/internal/translator/codex/compiler.go b/go/core/internal/translator/codex/compiler.go index 4300916d8f..c8370aefc6 100644 --- a/go/core/internal/translator/codex/compiler.go +++ b/go/core/internal/translator/codex/compiler.go @@ -58,8 +58,8 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if strings.TrimSpace(model.Spec.Model) == "" { return nil, translator.NewValidationError("Codex ModelConfig model is required") } - if len(model.Spec.DefaultHeaders) != 0 || !model.Spec.TLS.IsEmpty() || model.Spec.APIKeyPassthrough { - return nil, translator.NewValidationError("Codex does not support ModelConfig defaultHeaders, TLS, or apiKeyPassthrough") + if len(model.Spec.DefaultHeaders) != 0 || !model.Spec.TLS.IsEmpty() || model.Spec.TokenPassthrough { + return nil, translator.NewValidationError("Codex does not support ModelConfig defaultHeaders, TLS, or tokenPassthrough") } telemetryConfig, err := translator.TelemetryConfigFromSettings(c.settings.GetTelemetry()) if err != nil { diff --git a/go/core/internal/translator/codex/compiler_test.go b/go/core/internal/translator/codex/compiler_test.go index 2cc9eab92a..8fb9643660 100644 --- a/go/core/internal/translator/codex/compiler_test.go +++ b/go/core/internal/translator/codex/compiler_test.go @@ -144,6 +144,7 @@ func TestCompileRejectsUnsupportedProviderConfiguration(t *testing.T) { responses, chat := v1alpha3.OpenAIAPIFormatResponses, v1alpha3.OpenAIAPIFormatChatCompletions tests := []v1alpha3.ModelConfigSpec{ {Provider: v1alpha3.ModelProviderAnthropic, Model: "claude"}, + {Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt", TokenPassthrough: true, OpenAI: &v1alpha3.OpenAIConfig{APIFormat: &responses}}, {Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", OpenAI: &v1alpha3.OpenAIConfig{APIFormat: &chat}}, {Provider: v1alpha3.ModelProviderOpenAI, Model: "gpt", APIKeySecret: "model-auth", APIKeySecretKey: "api-key", OpenAI: &v1alpha3.OpenAIConfig{APIFormat: &responses, Temperature: "1"}}, {Provider: v1alpha3.ModelProviderBedrock, Model: "gpt", APIKeySecret: "model-auth", Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1", PromptCaching: true}}, diff --git a/go/core/internal/translator/codex/mcp.go b/go/core/internal/translator/codex/mcp.go index dc4726f781..09f20d89f1 100644 --- a/go/core/internal/translator/codex/mcp.go +++ b/go/core/internal/translator/codex/mcp.go @@ -35,6 +35,9 @@ func (c *Compiler) compileMCP(ctx context.Context, namespace string, tools []tra if server == nil { return mcpCompilation{}, fmt.Errorf("resolved Codex MCP binding has no server") } + if server.Spec.TokenPassthrough != nil && *server.Spec.TokenPassthrough { + return mcpCompilation{}, translator.NewValidationError("Codex does not support RemoteMCPServer %q tokenPassthrough", server.Name) + } name := strings.ReplaceAll(server.Name, ".", "_") if previous, ok := identities[name]; ok { return mcpCompilation{}, translator.NewValidationError("Codex MCP servers %q and %q map to the same native name %q", previous, server.Name, name) diff --git a/go/core/internal/translator/codex/mcp_passthrough_test.go b/go/core/internal/translator/codex/mcp_passthrough_test.go new file mode 100644 index 0000000000..7989db5170 --- /dev/null +++ b/go/core/internal/translator/codex/mcp_passthrough_test.go @@ -0,0 +1,56 @@ +package codex + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/translator" +) + +func TestCompileMCPBearerPassthrough(t *testing.T) { + for _, tt := range []struct { + name string + passthrough *bool + wantErr bool + }{ + {name: "enabled", passthrough: new(true), wantErr: true}, + {name: "disabled", passthrough: new(false)}, + {name: "absent"}, + } { + t.Run(tt.name, func(t *testing.T) { + server := &v1alpha3.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{Name: "tools"}, + Spec: v1alpha3.RemoteMCPServerSpec{ + URL: "https://mcp.example.com", TokenPassthrough: tt.passthrough, + HeadersFrom: []v1alpha3.ValueRef{ + {Name: "aUtHoRiZaTiOn", Value: "static-auth"}, + {Name: "X-Tenant", Value: "tenant"}, + }, + }, + } + if tt.wantErr { + // Reject before looking up discovery credentials. + server.Spec.HeadersFrom = append(server.Spec.HeadersFrom, v1alpha3.ValueRef{ + Name: "Authorization", ValueFrom: &v1alpha3.ValueSource{ + Type: v1alpha3.ConfigMapValueSource, Name: "missing", Key: "token", + }, + }) + } + result, err := (&Compiler{}).compileMCP(t.Context(), "test", []translator.ResolvedMCPTool{{Server: server}}) + if tt.wantErr { + var validationErr *translator.ValidationError + require.ErrorAs(t, err, &validationErr) + require.EqualError(t, err, `Codex does not support RemoteMCPServer "tools" tokenPassthrough`) + require.Equal(t, mcpCompilation{}, result) + return + } + require.NoError(t, err) + require.Empty(t, result.environment) + require.Equal(t, map[string]string{"aUtHoRiZaTiOn": "static-auth", "X-Tenant": "tenant"}, result.servers["tools"].Headers) + require.Equal(t, server.Spec.URL, result.servers["tools"].URL) + }) + } +} diff --git a/go/core/internal/translator/credentials.go b/go/core/internal/translator/credentials.go index 99fce4bbfe..6cf0e763d6 100644 --- a/go/core/internal/translator/credentials.go +++ b/go/core/internal/translator/credentials.go @@ -25,6 +25,7 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, var tokenPassthroughOrigins []string boundModels := map[string]bool{} boundMCP := map[string]bool{} + var passthroughDestinations []string bind := func(rawURL, header, prefix, namespace, name, key string) error { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.Fragment != "" { @@ -41,7 +42,10 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, models = append(models, agent.ResolvedModelConfig) } for _, tool := range agent.MCPTools { - for _, ref := range tool.Server.Spec.HeadersFrom { + if tool.Server.Spec.TokenPassthrough != nil && *tool.Server.Spec.TokenPassthrough { + passthroughDestinations = append(passthroughDestinations, tool.Server.Spec.URL) + } + for _, ref := range RuntimeMCPHeaders(tool.Server) { if ref.ValueFrom != nil && ref.ValueFrom.Type == v1alpha3.SecretValueSource { boundMCP[ref.ValueFrom.Name+"\x00"+ref.ValueFrom.Key] = true if err := bind(tool.Server.Spec.URL, ref.Name, "", tool.Server.Namespace, ref.ValueFrom.Name, ref.ValueFrom.Key); err != nil { @@ -62,7 +66,7 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, } for _, resolved := range models { model := resolved.Config - if model.Spec.APIKeyPassthrough || model.Spec.APIKeySecret == "" { + if model.Spec.TokenPassthrough || model.Spec.APIKeySecret == "" { continue } name, endpoint, header, prefix := modelCredentialTarget(resolved) @@ -88,11 +92,14 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, boundModels[name+"\x00"+model.Spec.APIKeySecret+"\x00"+key] = true } for _, resolved := range models { - if !resolved.Config.Spec.APIKeyPassthrough { + if !resolved.Config.Spec.TokenPassthrough { continue } _, endpoint, _, _ := modelCredentialTarget(resolved) - u, err := url.Parse(endpoint) + passthroughDestinations = append(passthroughDestinations, endpoint) + } + for _, endpoint := range passthroughDestinations { + u, err := url.Parse(strings.TrimSpace(endpoint)) if err != nil { return nil, nil, nil, NewValidationError("invalid passthrough credential destination") } @@ -172,7 +179,7 @@ func modelCredentialTarget(resolved *ResolvedModelConfig) (name, endpoint, heade if spec.Ollama == nil { break } - hasCredential := spec.APIKeySecret != "" || spec.APIKeyPassthrough + hasCredential := spec.APIKeySecret != "" || spec.TokenPassthrough if !models.OllamaReachesCloud(spec.Model, spec.Ollama.Host, hasCredential) { break } diff --git a/go/core/internal/translator/credentials_test.go b/go/core/internal/translator/credentials_test.go index 752362029d..e5d6e9a6b2 100644 --- a/go/core/internal/translator/credentials_test.go +++ b/go/core/internal/translator/credentials_test.go @@ -71,7 +71,7 @@ func TestCompileCredentialsRejectsLocalSecrets(t *testing.T) { } func TestCompileCredentialsPreservesPassthrough(t *testing.T) { - input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, APIKeyPassthrough: true}) + input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, TokenPassthrough: true}) _, bindings, _, err := CompileCredentials(input, nil, nil) require.NoError(t, err) require.Empty(t, bindings) @@ -126,7 +126,7 @@ func TestModelCredentialTargetOllamaCloud(t *testing.T) { name: "cloud model with passthrough binds the cloud", spec: v1alpha3.ModelConfigSpec{ Provider: v1alpha3.ModelProviderOllama, Model: "minimax-m3:cloud", - APIKeyPassthrough: true, Ollama: &v1alpha3.OllamaConfig{}, + TokenPassthrough: true, Ollama: &v1alpha3.OllamaConfig{}, }, wantName: "OLLAMA_API_KEY", wantEndpoint: "https://api.ollama.com", @@ -175,7 +175,7 @@ func TestModelCredentialTargetOllamaCloud(t *testing.T) { } func TestPassthroughCredentialUsesCanonicalOrigin(t *testing.T) { - input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, APIKeyPassthrough: true, OpenAI: &v1alpha3.OpenAIConfig{BaseURL: "https://API.OPENAI.COM.:0443/v1"}}) + input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, TokenPassthrough: true, OpenAI: &v1alpha3.OpenAIConfig{BaseURL: "https://API.OPENAI.COM.:0443/v1"}}) environment, credentials, origins, err := CompileCredentials(input, nil, nil) require.NoError(t, err) require.Empty(t, environment) diff --git a/go/core/internal/translator/egress_test.go b/go/core/internal/translator/egress_test.go index ca53c00058..7dddeacd4e 100644 --- a/go/core/internal/translator/egress_test.go +++ b/go/core/internal/translator/egress_test.go @@ -148,7 +148,7 @@ func TestAgentEgressPoliciesDoNotChangeRuntimeAcrossHarnesses(t *testing.T) { return } model.Spec.APIKeySecret, model.Spec.APIKeySecretKey = "", "" - model.Spec.APIKeyPassthrough = true + model.Spec.TokenPassthrough = true _, err = compiler(t, model, harness, policy, secret).CompileAgent(t.Context(), agent) require.NoError(t, err) }) diff --git a/go/core/internal/translator/mcp_headers.go b/go/core/internal/translator/mcp_headers.go new file mode 100644 index 0000000000..196ce43077 --- /dev/null +++ b/go/core/internal/translator/mcp_headers.go @@ -0,0 +1,22 @@ +package translator + +import ( + "strings" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +// RuntimeMCPHeaders excludes discovery credentials before any value resolution, +// including Secret environment placeholders and gateway credential bindings. +func RuntimeMCPHeaders(server *v1alpha3.RemoteMCPServer) []v1alpha3.ValueRef { + if server.Spec.TokenPassthrough == nil || !*server.Spec.TokenPassthrough { + return server.Spec.HeadersFrom + } + refs := make([]v1alpha3.ValueRef, 0, len(server.Spec.HeadersFrom)) + for _, ref := range server.Spec.HeadersFrom { + if !strings.EqualFold(ref.Name, "Authorization") { + refs = append(refs, ref) + } + } + return refs +} diff --git a/go/core/internal/translator/mcp_headers_test.go b/go/core/internal/translator/mcp_headers_test.go new file mode 100644 index 0000000000..b25b374bf7 --- /dev/null +++ b/go/core/internal/translator/mcp_headers_test.go @@ -0,0 +1,97 @@ +package translator + +import ( + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +func TestMCPPassthroughCredentialCompilation(t *testing.T) { + secret := func(name string) v1alpha3.ValueRef { + return v1alpha3.ValueRef{Name: name, ValueFrom: &v1alpha3.ValueSource{ + Type: v1alpha3.SecretValueSource, Name: "auth", Key: "token", + }} + } + for _, tc := range []struct { + name string + enabled bool + headers []v1alpha3.ValueRef + model v1alpha3.ModelConfigSpec + wantError bool + wantBindings int + }{ + {name: "discovery secret omitted", enabled: true, headers: []v1alpha3.ValueRef{secret("aUtHoRiZaTiOn")}}, + {name: "disabled keeps secret", headers: []v1alpha3.ValueRef{secret("Authorization")}, wantBindings: 1}, + {name: "own other secret conflicts", enabled: true, headers: []v1alpha3.ValueRef{secret("X-Key")}, wantError: true}, + {name: "static model conflicts on normalized origin", enabled: true, + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, APIKeySecret: "model", APIKeySecretKey: "key", + OpenAI: &v1alpha3.OpenAIConfig{BaseURL: "https://TOOLS.EXAMPLE.COM.:443/v1"}}, wantError: true}, + {name: "static model on different origin allowed", enabled: true, + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, APIKeySecret: "model", APIKeySecretKey: "key", + OpenAI: &v1alpha3.OpenAIConfig{BaseURL: "http://TOOLS.EXAMPLE.COM.:8080/v1"}}, wantBindings: 1}, + {name: "model and MCP passthrough can share origin", enabled: true, + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, TokenPassthrough: true, + OpenAI: &v1alpha3.OpenAIConfig{BaseURL: "https://TOOLS.EXAMPLE.COM.:443/v1"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + server := &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Namespace: "team"}, + Spec: v1alpha3.RemoteMCPServerSpec{URL: "https://tools.example.com/mcp", + TokenPassthrough: new(tc.enabled), HeadersFrom: tc.headers}} + input := credentialInput(tc.model) + input.Root.MCPTools = []ResolvedMCPTool{{Server: server}} + _, bindings, origins, err := CompileCredentials(input, nil, nil) + if tc.wantError { + require.ErrorContains(t, err, "cannot combine caller-token passthrough") + } else { + require.NoError(t, err) + require.Len(t, bindings, tc.wantBindings) + if tc.enabled { + require.Contains(t, origins, "https://tools.example.com:443") + } else { + require.Empty(t, origins) + } + } + require.Equal(t, tc.headers, server.Spec.HeadersFrom) + }) + } +} + +func TestRuntimeMCPHeadersFiltersBeforeResolution(t *testing.T) { + server := &v1alpha3.RemoteMCPServer{Spec: v1alpha3.RemoteMCPServerSpec{ + TokenPassthrough: new(true), + HeadersFrom: []v1alpha3.ValueRef{ + {Name: "Authorization", Value: "discovery"}, + {Name: "authorization", ValueFrom: &v1alpha3.ValueSource{Type: v1alpha3.ConfigMapValueSource, Name: "absent", Key: "key"}}, + {Name: "X-Tenant", Value: "tenant"}, + }, + }} + require.Equal(t, []v1alpha3.ValueRef{{Name: "X-Tenant", Value: "tenant"}}, RuntimeMCPHeaders(server)) + server.Spec.TokenPassthrough = nil + require.Equal(t, server.Spec.HeadersFrom, RuntimeMCPHeaders(server)) +} + +func TestMCPPassthroughConflictsWithOtherServer(t *testing.T) { + input := credentialInput(v1alpha3.ModelConfigSpec{}) + passthrough := &v1alpha3.RemoteMCPServer{Spec: v1alpha3.RemoteMCPServerSpec{ + URL: "https://TOOLS.EXAMPLE.COM.:8443/mcp", TokenPassthrough: new(true), + }} + static := &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Namespace: "team"}, Spec: v1alpha3.RemoteMCPServerSpec{ + URL: "https://tools.example.com:8443/other", HeadersFrom: []v1alpha3.ValueRef{{ + Name: "Authorization", ValueFrom: &v1alpha3.ValueSource{Type: v1alpha3.SecretValueSource, Name: "auth", Key: "token"}, + }}, + }} + input.Root.MCPTools = []ResolvedMCPTool{{Server: passthrough}, {Server: static}} + environment, bindings, origins, err := CompileCredentials(input, nil, nil) + require.ErrorContains(t, err, "cannot combine caller-token passthrough") + require.Nil(t, environment) + require.Nil(t, bindings) + require.Nil(t, origins) + static.Spec.TokenPassthrough = new(true) + _, bindings, origins, err = CompileCredentials(input, nil, nil) + require.NoError(t, err) + require.Empty(t, bindings, "multiple passthrough servers may share an origin") + require.Contains(t, origins, "https://tools.example.com:8443") +} diff --git a/go/core/internal/translator/modelconfig.go b/go/core/internal/translator/modelconfig.go index 20dbe50c2f..586ea1f217 100644 --- a/go/core/internal/translator/modelconfig.go +++ b/go/core/internal/translator/modelconfig.go @@ -113,11 +113,11 @@ func ResolveModelConfig(ctx krt.HandlerContext, collections Collections, config switch config.Spec.Provider { case v1alpha3.ModelProviderOpenAI: - if !config.Spec.APIKeyPassthrough && config.Spec.APIKeySecret != "" { + if !config.Spec.TokenPassthrough && config.Spec.APIKeySecret != "" { requireAPIKey() } case v1alpha3.ModelProviderAnthropic, v1alpha3.ModelProviderAzureOpenAI, v1alpha3.ModelProviderFoundry, v1alpha3.ModelProviderMistral: - if !config.Spec.APIKeyPassthrough && config.Spec.APIKeySecret != "" { + if !config.Spec.TokenPassthrough && config.Spec.APIKeySecret != "" { requireAPIKey() } case v1alpha3.ModelProviderGemini: @@ -150,7 +150,7 @@ func ResolveModelConfig(ctx krt.HandlerContext, collections Collections, config addSemanticFailure("InvalidProviderConfig", "bedrock model config is required") break } - if !config.Spec.APIKeyPassthrough && config.Spec.APIKeySecret != "" { + if !config.Spec.TokenPassthrough && config.Spec.APIKeySecret != "" { key := types.NamespacedName{Namespace: config.Namespace, Name: config.Spec.APIKeySecret} fetched := krt.FetchOne(ctx, collections.Secrets, krt.FilterObjectName(key)) if fetched == nil { @@ -171,7 +171,7 @@ func ResolveModelConfig(ctx krt.HandlerContext, collections Collections, config if config.Spec.SAPAICore == nil { addSemanticFailure("InvalidProviderConfig", "sapAICore model config is required") } - if !config.Spec.APIKeyPassthrough && config.Spec.APIKeySecret != "" { + if !config.Spec.TokenPassthrough && config.Spec.APIKeySecret != "" { requireSecret(config.Spec.APIKeySecret, "APIKeySecretNotFound", "SAPAICoreCredentialKeyNotFound", "client_id", "client_secret") } case v1alpha3.ModelProviderFoundry: diff --git a/go/harness/codex/README.md b/go/harness/codex/README.md index b4d9da5cbe..35b97cbb7a 100644 --- a/go/harness/codex/README.md +++ b/go/harness/codex/README.md @@ -42,7 +42,7 @@ Substrate Actor remains the security boundary. Its granular native approval policy enables MCP elicitation and rejects command, sandbox, rule, skill, and `request_permissions` prompts. MCP servers marked for approval prompt through App Server elicitation, while other MCP servers remain automatically approved. -Account login, API-key passthrough, custom TLS, legacy SSE MCP, Dedicated agents, +Account login, ModelConfig `tokenPassthrough`, custom TLS, legacy SSE MCP, Dedicated agents, checkpoint/fork guarantees, and configurable native policy are not advertised. Runtime configuration is supplied through `KAGENT_CONFIG_JSON` and diff --git a/helm/kagent-crds/templates/api.kagent.dev_modelconfigs.yaml b/helm/kagent-crds/templates/api.kagent.dev_modelconfigs.yaml index b235da711f..d875c4d372 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_modelconfigs.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_modelconfigs.yaml @@ -145,13 +145,6 @@ spec: - location - projectID type: object - apiKeyPassthrough: - description: |- - APIKeyPassthrough enables forwarding the Bearer token from incoming A2A requests - directly to the LLM provider as the API key. This is useful for organizations - with federated identity that want to avoid separate secret management. - Mutually exclusive with apiKeySecret. - type: boolean apiKeySecret: description: |- The name of the secret that contains the API key. Must be a reference to the name of a secret in the same namespace as the referencing ModelConfig. @@ -593,6 +586,13 @@ spec: Production deployments MUST use proper certificates. type: boolean type: object + tokenPassthrough: + description: |- + TokenPassthrough enables forwarding the bearer token from incoming A2A requests + directly to the LLM provider as the API key. This is useful for organizations + with federated identity that want to avoid separate secret management. + Mutually exclusive with apiKeySecret. + type: boolean required: - model type: object @@ -627,11 +627,11 @@ spec: for Bedrock and SAPAICore providers) rule: '!(has(self.apiKeySecret) && !has(self.apiKeySecretKey) && self.provider != ''Bedrock'' && self.provider != ''SAPAICore'')' - - message: apiKeyPassthrough and apiKeySecret are mutually exclusive - rule: '!(has(self.apiKeyPassthrough) && self.apiKeyPassthrough && has(self.apiKeySecret) + - message: tokenPassthrough and apiKeySecret are mutually exclusive + rule: '!(has(self.tokenPassthrough) && self.tokenPassthrough && has(self.apiKeySecret) && size(self.apiKeySecret) > 0)' - - message: apiKeyPassthrough must be false if provider is Gemini;GeminiVertexAI;AnthropicVertexAI - rule: '!(has(self.apiKeyPassthrough) && self.apiKeyPassthrough && (self.provider + - message: tokenPassthrough must be false if provider is Gemini;GeminiVertexAI;AnthropicVertexAI + rule: '!(has(self.tokenPassthrough) && self.tokenPassthrough && (self.provider == ''Gemini'' || self.provider == ''GeminiVertexAI'' || self.provider == ''AnthropicVertexAI''))' status: diff --git a/helm/kagent-crds/templates/api.kagent.dev_remotemcpservers.yaml b/helm/kagent-crds/templates/api.kagent.dev_remotemcpservers.yaml index 5f78ca86b9..00af345363 100644 --- a/helm/kagent-crds/templates/api.kagent.dev_remotemcpservers.yaml +++ b/helm/kagent-crds/templates/api.kagent.dev_remotemcpservers.yaml @@ -217,6 +217,13 @@ spec: Production deployments MUST use proper certificates. type: boolean type: object + tokenPassthrough: + default: false + description: |- + TokenPassthrough forwards the authenticated invocation's bearer token + to this server. Authorization entries in HeadersFrom are discovery-only. + Invocations without a bearer token fail before connecting. + type: boolean url: minLength: 1 type: string diff --git a/proto/kagent/config/v1/adk.proto b/proto/kagent/config/v1/adk.proto index 667fc7a811..e6bbb20f8a 100644 --- a/proto/kagent/config/v1/adk.proto +++ b/proto/kagent/config/v1/adk.proto @@ -272,12 +272,16 @@ message ADKHTTPMCPServerConfig { repeated string tools = 2; repeated string allowed_headers = 3; bool require_approval = 4; + // Require the current invocation's bearer token, never a runtime credential. + bool bearer_token_passthrough = 5; } message ADKSSEMCPServerConfig { ADKSSEConnectionParams params = 1 [(buf.validate.field).required = true]; repeated string tools = 2; repeated string allowed_headers = 3; bool require_approval = 4; + // Require the current invocation's bearer token, never a runtime credential. + bool bearer_token_passthrough = 5; } message ADKStdioMCPServerConfig { string command = 1; diff --git a/python/packages/agentsts-adk/src/agentsts/adk/_base.py b/python/packages/agentsts-adk/src/agentsts/adk/_base.py index 63df594b81..88f43cffeb 100644 --- a/python/packages/agentsts-adk/src/agentsts/adk/_base.py +++ b/python/packages/agentsts-adk/src/agentsts/adk/_base.py @@ -68,7 +68,7 @@ def _acting_credential(state: dict) -> Optional[str]: implementation reading a session-scoped field returns the same value for every caller. Only the inbound Authorization header is caller-scoped. """ - headers = state.get(HEADERS_KEY, None) + headers = state.get("temp:headers", state.get(HEADERS_KEY, None)) if not isinstance(headers, dict): headers = {} return _extract_jwt_from_headers(headers) @@ -106,8 +106,10 @@ class ADKSTSIntegration(STSIntegrationBase): """Google ADK-specific STS integration. By default, the subject token is read from the ``Authorization`` header - stored in the session state under the ``headers`` key. To retrieve the - subject token from a custom source, pass a ``get_subject_token`` callback:: + in invocation-temporary state under ``temp:headers``, with the legacy + ``headers`` state key as a fallback when ``temp:headers`` is absent. + To retrieve the subject token from a custom source, pass a + ``get_subject_token`` callback:: integration = ADKSTSIntegration( well_known_uri="https://example.com/.well-known/sts", @@ -139,7 +141,8 @@ def __init__( use_issuer_host: Replace the host:port in token_endpoint with the host:port from well_known_uri get_subject_token: Optional callback that takes session.state (dict) and returns the subject token string or None. If not set, defaults to extracting the - JWT from the Authorization header in session.state["headers"]. When + JWT from Authorization in invocation-temporary state["temp:headers"], + falling back to legacy state["headers"] when absent. When the caller sends no Authorization header, the callback also identifies the caller. """ diff --git a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py index d14de2c027..4f05d804f2 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py +++ b/python/packages/kagent-adk/src/kagent/adk/_agent_executor.py @@ -249,6 +249,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non if task_id: self._running_usage[task_id] = execution_state.usage identity_token = public_context_id.set(context.context_id) + bearer_marker = bearer_token.set(None) user_token = None try: context = self._translate_hitl_response(context) @@ -309,6 +310,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non if runner is not None: await self._safe_close_runner(runner) finally: + bearer_token.reset(bearer_marker) # The request handler holds the final event until cleanup ends, # so a cancel arriving meanwhile still reports this execution. if task_id and self._running_usage.get(task_id) is execution_state.usage: @@ -359,7 +361,9 @@ def _convert_request( ) headers = _call_state(context).get("headers", {}) headers = headers if isinstance(headers, dict) else {} - run_request.state_delta = {"headers": headers} + # ADK excludes temp: state from its native session database and events. + # Request credentials must not become durable conversation state. + run_request.state_delta = {"temp:headers": headers} # Also stash the token in a ContextVar for consumers with no # callback_context of their own - see _bearer_token.py. bearer_token.set(extract_bearer_token(headers)) diff --git a/python/packages/kagent-adk/src/kagent/adk/_llm_passthrough_plugin.py b/python/packages/kagent-adk/src/kagent/adk/_llm_passthrough_plugin.py index d60c7ae434..b454e47838 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_llm_passthrough_plugin.py +++ b/python/packages/kagent-adk/src/kagent/adk/_llm_passthrough_plugin.py @@ -37,7 +37,9 @@ def __init__(self): async def before_model_callback( self, *, callback_context: CallbackContext, llm_request: LlmRequest ) -> Optional[LlmResponse]: - token = extract_bearer_token(callback_context.state.get("headers", {})) + token = extract_bearer_token( + callback_context.state.get("temp:headers", callback_context.state.get("headers", {})) + ) if not token: return None diff --git a/python/packages/kagent-adk/src/kagent/adk/_mcp_toolset.py b/python/packages/kagent-adk/src/kagent/adk/_mcp_toolset.py index 0ed20b5147..9814347345 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_mcp_toolset.py +++ b/python/packages/kagent-adk/src/kagent/adk/_mcp_toolset.py @@ -6,6 +6,7 @@ import httpx2 from google.adk.tools import BaseTool +from google.adk.tools.mcp_tool.mcp_session_manager import MCPSessionManager from google.adk.tools.mcp_tool.mcp_tool import McpTool from google.adk.tools.mcp_tool.mcp_toolset import McpToolset, ReadonlyContext from google.adk.tools.tool_context import ToolContext @@ -127,6 +128,14 @@ async def run_async( return self._connection_error_response(error) +class _PassthroughSessionManager(MCPSessionManager): + async def _get_mtls_transport(self): + # ADK 2.11 replaces the configured client factory when automatic mTLS + # succeeds. That discards our TLS/destination guards and may use ADC. + # Caller-authenticated connections must use only the configured factory. + return None + + class KAgentMcpToolset(McpToolset): """McpToolset variant that catches and enriches errors during MCP session setup and handles cancel scope issues during cleanup. @@ -144,19 +153,43 @@ class KAgentMcpToolset(McpToolset): # bypass __init__) still resolve the attribute. _app_tool_names: Optional[MCPAppToolNames] = None _require_approval: bool = False + _bearer_token_passthrough: bool = False def __init__( self, *args: Any, app_tool_names: Optional[MCPAppToolNames] = None, require_approval: bool = False, + bearer_token_passthrough: bool = False, **kwargs: Any, ) -> None: super().__init__(*args, **kwargs) self._app_tool_names = app_tool_names self._require_approval = require_approval + self._bearer_token_passthrough = bearer_token_passthrough + self._passthrough_header_provider = self._header_provider if bearer_token_passthrough else None + if bearer_token_passthrough: + # The upstream manager has not opened any sessions at construction. + self._mcp_session_manager = _PassthroughSessionManager( + connection_params=self._connection_params, + errlog=self._errlog, + sampling_callback=self._sampling_callback, + sampling_capabilities=self._sampling_capabilities, + elicitation_callback=self._elicitation_callback, + ) + + async def _build_headers(self, readonly_context=None): + if self._bearer_token_passthrough: + # Upstream skips header_provider when no ReadonlyContext exists. + # Initialization must fail closed too, never try an anonymous dial. + return self._passthrough_header_provider(readonly_context) + return await super()._build_headers(readonly_context) async def get_tools(self, readonly_context: Optional[ReadonlyContext] = None) -> list[BaseTool]: + if self._bearer_token_passthrough: + # The global STS plugin can overwrite _header_provider. Dedicated + # per-server passthrough remains independent of that legacy switch. + self._header_provider = self._passthrough_header_provider try: tools = await super().get_tools(readonly_context) except asyncio.CancelledError as error: diff --git a/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py b/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py index f6e74e5ec8..3264d910b3 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py +++ b/python/packages/kagent-adk/src/kagent/adk/_remote_a2a_tool.py @@ -235,7 +235,7 @@ def _build_lineage_headers(self, tool_context: ToolContext) -> dict[str, str]: parent_context_id = public_context_id.get() or getattr(session, "id", None) state = getattr(session, "state", None) if isinstance(state, dict): - hdrs = state.get(_HEADERS_STATE_KEY) + hdrs = state.get("temp:headers", state.get(_HEADERS_STATE_KEY)) if isinstance(hdrs, dict): inbound_headers = hdrs diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 8bdc184838..147c40efae 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -89,6 +89,7 @@ from protovalidate import validate from kagent.adk._approval import make_approval_callback +from kagent.adk._bearer_token import bearer_token from kagent.adk._mcp_apps import MCPAppToolNames, make_mcp_app_model_result_callback from kagent.adk._mcp_toolset import KAgentMcpToolset from kagent.adk._remote_a2a_tool import KAgentRemoteA2AToolset @@ -117,6 +118,7 @@ def create_header_provider( allowed_headers: list[str] | None = None, sts_header_provider: Callable[[Optional[ReadonlyContext]], dict[str, str]] | None = None, + bearer_token_passthrough: bool = False, ) -> Callable[[Optional[ReadonlyContext]], dict[str, str]] | None: """Create a header provider that combines STS tokens and allowed headers. @@ -133,6 +135,15 @@ def create_header_provider( Returns: A header provider function, or None if no headers need to be propagated. """ + if bearer_token_passthrough: + + def caller_headers(_: Optional[ReadonlyContext]) -> dict[str, str]: + token = bearer_token.get() + if not token: + raise ValueError("MCP bearer token passthrough requires an authenticated invocation bearer token") + return {"Authorization": f"Bearer {token}"} + + return caller_headers if not allowed_headers and not sts_header_provider: return None @@ -144,7 +155,9 @@ def header_provider(readonly_context: Optional[ReadonlyContext]) -> dict[str, st # Add allowed headers from session state first if normalized_allowed and readonly_context: - request_headers = readonly_context.state.get(HEADERS_STATE_KEY, {}) + request_headers = readonly_context.state.get( + "temp:headers", readonly_context.state.get(HEADERS_STATE_KEY, {}) + ) for header_name, header_value in request_headers.items(): if header_name.lower() in normalized_allowed: headers[header_name] = header_value @@ -266,9 +279,16 @@ def _timeout_seconds(config, name): return duration.seconds + duration.nanos / 1_000_000_000 if duration is not None else None -def _mcp_params(config, params_type): +def _mcp_params(config, params_type, bearer_token_passthrough=False): # Only pass present fields, so the ADK retains its own timeout defaults. - values = {"url": config.url, "headers": dict(config.headers)} + values = { + "url": config.url, + "headers": { + name: value + for name, value in config.headers.items() + if not bearer_token_passthrough or name.lower() != "authorization" + }, + } for name in ("timeout", "sse_read_timeout"): if config.HasField(name): values[name] = _timeout_seconds(config, name) @@ -283,6 +303,33 @@ def _mcp_params(config, params_type): ca_cert_path=optional_field(config, "tls_ca_cert_path"), disable_system_cas=config.tls_disable_system_cas, ) + if bearer_token_passthrough: + base_factory = params.httpx_client_factory or _build_tls_httpx_client_factory( + disable_verify=False, + ca_cert_path=None, + disable_system_cas=False, + ) + target = httpx2.URL(config.url) + + async def restrict_destination(request): + if (request.url.scheme, request.url.host, request.url.port) != (target.scheme, target.host, target.port): + raise ValueError("MCP bearer token passthrough cannot change destination") + + async def reject_redirect(response): + # MCP 2.x manually follows same-origin redirects even when the + # client's follow_redirects is false. Stop before that SDK loop, + # without including the URL, Location, or response body in errors. + if response.is_redirect: + raise ValueError("MCP bearer token passthrough does not allow redirects") + + def caller_client_factory(**kwargs): + client = base_factory(**kwargs) + client.follow_redirects = False + client.event_hooks["request"].append(restrict_destination) + client.event_hooks["response"].append(reject_redirect) + return client + + params.httpx_client_factory = caller_client_factory return params @@ -310,11 +357,12 @@ def create_agent( # Install a TLS-aware httpx_client_factory on the params # before constructing the toolset, so every MCP session # the session manager opens trusts the configured CA. - params = _mcp_params(http_tool.params, StreamableHTTPConnectionParams) + params = _mcp_params(http_tool.params, StreamableHTTPConnectionParams, http_tool.bearer_token_passthrough) # Create header provider combining STS and allowed headers for this tool tool_header_provider = create_header_provider( allowed_headers=http_tool.allowed_headers, sts_header_provider=sts_header_provider, + bearer_token_passthrough=http_tool.bearer_token_passthrough, ) tools.append( KAgentMcpToolset( @@ -323,17 +371,19 @@ def create_agent( header_provider=tool_header_provider, app_tool_names=mcp_app_tool_names, require_approval=http_tool.require_approval, + bearer_token_passthrough=http_tool.bearer_token_passthrough, ) ) if http_tool.require_approval: has_tools_requiring_approval = True if config.sse_tools: for sse_tool in config.sse_tools: # add sse tools - params = _mcp_params(sse_tool.params, SseConnectionParams) + params = _mcp_params(sse_tool.params, SseConnectionParams, sse_tool.bearer_token_passthrough) # Create header provider combining STS and allowed headers for this tool tool_header_provider = create_header_provider( allowed_headers=sse_tool.allowed_headers, sts_header_provider=sts_header_provider, + bearer_token_passthrough=sse_tool.bearer_token_passthrough, ) tools.append( KAgentMcpToolset( @@ -342,6 +392,7 @@ def create_agent( header_provider=tool_header_provider, app_tool_names=mcp_app_tool_names, require_approval=sse_tool.require_approval, + bearer_token_passthrough=sse_tool.bearer_token_passthrough, ) ) if sse_tool.require_approval: diff --git a/python/packages/kagent-adk/tests/unittests/test_agent_executor.py b/python/packages/kagent-adk/tests/unittests/test_agent_executor.py index 3afb7c2ae9..49bc58dda2 100644 --- a/python/packages/kagent-adk/tests/unittests/test_agent_executor.py +++ b/python/packages/kagent-adk/tests/unittests/test_agent_executor.py @@ -57,7 +57,7 @@ def test_request_converter_adds_headers_without_mutating_message_metadata(stream run_request = executor._convert_request(context, None) assert run_request.run_config.streaming_mode == expected_mode - assert run_request.state_delta == {"headers": {"authorization": "Bearer token"}} + assert run_request.state_delta == {"temp:headers": {"authorization": "Bearer token"}} assert not context.message.metadata @@ -163,3 +163,38 @@ async def execute(self, request_context, queue): assert calls["config"].a2a_part_converter == executor_module._convert_public_a2a_part_to_genai_part executor._prepare_session.assert_awaited_once_with(context, run_request, runner) executor._safe_close_runner.assert_awaited_once_with(runner) + + +@pytest.mark.parametrize("cleanup_fails", [False, True]) +async def test_execute_restores_token_and_usage_after_cleanup(monkeypatch, cleanup_fails): + runner = InMemoryRunner(agent=BaseAgent(name="agent"), app_name="app") + context = _request_context(state={"headers": {"authorization": "Bearer caller"}}) + executor = A2aAgentExecutor(runner=lambda: runner) + executor._prepare_session = AsyncMock() + + async def close(_): + assert bearer_token.get() == "caller" + if cleanup_fails: + raise RuntimeError("cleanup failed") + + executor._safe_close_runner = close + + class FakeUpstreamExecutor: + def __init__(self, **kwargs): + pass + + async def execute(self, request_context, queue): + assert bearer_token.get() == "caller" + + monkeypatch.setattr(executor_module, "UpstreamA2aAgentExecutor", FakeUpstreamExecutor) + marker = bearer_token.set("outer") + try: + if cleanup_fails: + with pytest.raises(RuntimeError, match="cleanup failed"): + await executor.execute(context, object()) + else: + await executor.execute(context, object()) + assert bearer_token.get() == "outer" + assert not executor._running_usage + finally: + bearer_token.reset(marker) diff --git a/python/packages/kagent-adk/tests/unittests/test_mcp_passthrough.py b/python/packages/kagent-adk/tests/unittests/test_mcp_passthrough.py new file mode 100644 index 0000000000..92750f5959 --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_mcp_passthrough.py @@ -0,0 +1,379 @@ +import asyncio +import json +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock + +import httpx2 +import pytest +from google.adk.tools.mcp_tool import SseConnectionParams, StreamableHTTPConnectionParams +from kagent.config.v1.adk_pb2 import ADKHTTPConnectionParams, ADKSSEConnectionParams + +from kagent.adk._bearer_token import bearer_token +from kagent.adk._mcp_toolset import KAgentMcpToolset +from kagent.adk.types import _mcp_params, create_header_provider + + +@asynccontextmanager +async def _guard_test_server(protocol, redirect_status=None): + from aiohttp import web + + received = [] + finished = asyncio.Event() + stream = None + + async def handle(request): + nonlocal stream + received.append((request.path, request.headers.get("Authorization"))) + if request.path == "/redirect-target": + return web.Response(text="must not be reached", status=500) + if redirect_status and request.path == "/entry": + return web.Response( + status=redirect_status, + headers={"Location": "/redirect-target?private-location"}, + text="private-response-body", + ) + if protocol == "sse" and request.method == "GET": + stream = web.StreamResponse(headers={"Content-Type": "text/event-stream"}) + await stream.prepare(request) + # A valid SSE endpoint announcement changes path, not origin. + await stream.write(b"event: endpoint\ndata: /messages\n\n") + await finished.wait() + return stream + message = await request.json() + if "id" not in message: + return web.Response(status=202) + if message["method"] == "initialize": + result = { + "protocolVersion": message["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "guards", "version": "1"}, + } + else: + result = {"tools": []} + response = {"jsonrpc": "2.0", "id": message["id"], "result": result} + if protocol == "sse": + await stream.write(f"event: message\ndata: {json.dumps(response)}\n\n".encode()) + return web.Response(status=202) + return web.json_response(response) + + app = web.Application() + app.router.add_route("*", "/{path:.*}", handle) + runner = web.AppRunner(app) + await runner.setup() + await web.TCPSite(runner, "127.0.0.1", 0).start() + try: + yield f"http://127.0.0.1:{runner.addresses[0][1]}/entry", received + finally: + finished.set() + await runner.cleanup() + + +@pytest.fixture +def automatic_mtls(monkeypatch): + from google.adk.tools.mcp_tool import mcp_session_manager as sdk + + monkeypatch.setenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true") + monkeypatch.setattr(sdk, "_AIO_SUPPORTED", True) + adc = MagicMock(return_value=(MagicMock(), "project")) + auth_session = MagicMock(is_mtls=True) + auth_session.configure_mtls_channel = AsyncMock() + auth_session.close = AsyncMock() + constructor = MagicMock(return_value=auth_session) + monkeypatch.setattr(sdk.google.auth, "default", adc) + monkeypatch.setattr(sdk, "AsyncAuthorizedSession", constructor) + return adc, constructor + + +@pytest.mark.parametrize("protocol", ["http", "sse"]) +async def test_session_manager_keeps_configured_factory_when_automatic_mtls_available(protocol, automatic_mtls): + from google.adk.tools.mcp_tool.mcp_session_manager import MCPSessionManager + + config_type, params_type = ( + (ADKHTTPConnectionParams, StreamableHTTPConnectionParams) + if protocol == "http" + else (ADKSSEConnectionParams, SseConnectionParams) + ) + adc, auth_session = automatic_mtls + async with _guard_test_server(protocol) as (url, received): + params = _mcp_params(config_type(url=url, tls_insecure_skip_verify=True), params_type, True) + # Prove the environment would select automatic mTLS for ordinary + # toolsets; the passthrough-only override must not alter their behavior. + ordinary = KAgentMcpToolset(connection_params=params) + assert type(ordinary._mcp_session_manager) is MCPSessionManager + assert await ordinary._mcp_session_manager._get_mtls_transport() is not None + adc.assert_called_once() + auth_session.return_value.configure_mtls_channel.assert_awaited_once() + await ordinary.close() + adc.reset_mock() + auth_session.reset_mock() + + configured_factory = params.httpx_client_factory + clients = [] + + def track_factory(**kwargs): + client = configured_factory(**kwargs) + clients.append(client) + return client + + params.httpx_client_factory = track_factory + toolset = KAgentMcpToolset( + connection_params=params, + header_provider=create_header_provider(bearer_token_passthrough=True), + bearer_token_passthrough=True, + ) + marker = bearer_token.set("caller") + try: + assert await asyncio.wait_for(toolset.get_tools(), 5) == [] + adc.assert_not_called() + auth_session.assert_not_called() + assert len(clients) == 1 + client = clients[0] + assert not client.follow_redirects + # The explicitly configured TLS context, not ADC's transport. + import ssl + + assert client._transport._pool._ssl_context.verify_mode == ssl.CERT_NONE + with pytest.raises(ValueError, match="cannot change destination"): + await client.event_hooks["request"][0](httpx2.Request("POST", "https://foreign.example")) + assert received + assert all(auth == "Bearer caller" for _, auth in received) + if protocol == "sse": + assert any(path == "/messages" for path, _ in received) + finally: + await toolset.close() + bearer_token.reset(marker) + + +@pytest.mark.parametrize("protocol", ["http", "sse"]) +@pytest.mark.parametrize("status", [301, 302, 303, 307, 308]) +async def test_session_manager_rejects_redirect_before_sdk_follows(protocol, status, automatic_mtls, caplog): + config_type, params_type = ( + (ADKHTTPConnectionParams, StreamableHTTPConnectionParams) + if protocol == "http" + else (ADKSSEConnectionParams, SseConnectionParams) + ) + async with _guard_test_server(protocol, status) as (url, received): + toolset = KAgentMcpToolset( + connection_params=_mcp_params(config_type(url=url), params_type, True), + header_provider=create_header_provider(bearer_token_passthrough=True), + bearer_token_passthrough=True, + ) + marker = bearer_token.set("caller") + try: + with pytest.raises(Exception) as failure: + await asyncio.wait_for(toolset.get_tools(), 5) + assert not isinstance(failure.value, TimeoutError) + assert received + assert all(path == "/entry" for path, _ in received) + assert all(auth == "Bearer caller" for _, auth in received) + + def error_messages(error): + yield str(error) + if isinstance(error, BaseExceptionGroup): + for nested in error.exceptions: + yield from error_messages(nested) + if error.__cause__ is not None: + yield from error_messages(error.__cause__) + + messages = "\n".join(error_messages(failure.value)) + assert "does not allow redirects" in messages + assert url not in messages + for private in ("private-location", "private-response-body", "/redirect-target"): + assert private not in messages + assert private not in caplog.text + automatic_mtls[0].assert_not_called() + finally: + await toolset.close() + bearer_token.reset(marker) + + +@pytest.mark.parametrize("enabled", [False, True]) +async def test_http_requests_use_current_caller_and_isolate_sessions(enabled): + from aiohttp import web + + received = [] + + async def handle(request): + if request.method != "POST": + return web.Response(status=405) + message = await request.json() + received.append((message["method"], request.headers.get("Authorization"))) + if "id" not in message: + return web.Response(status=202) + if message["method"] == "initialize": + result = { + "protocolVersion": message["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "test", "version": "1"}, + } + elif message["method"] == "tools/list": + result = {"tools": [{"name": "echo", "inputSchema": {"type": "object"}}]} + elif message["method"] == "tools/call": + result = {"content": [{"type": "text", "text": "ok"}]} + else: + result = {} + return web.json_response({"jsonrpc": "2.0", "id": message["id"], "result": result}) + + app = web.Application() + app.router.add_route("*", "/mcp", handle) + server = web.AppRunner(app) + await server.setup() + site = web.TCPSite(server, "127.0.0.1", 0) + await site.start() + params = _mcp_params( + ADKHTTPConnectionParams( + url=f"http://127.0.0.1:{server.addresses[0][1]}/mcp", + headers={"authorization": "static-discovery"}, + ), + StreamableHTTPConnectionParams, + enabled, + ) + toolset = KAgentMcpToolset( + connection_params=params, + header_provider=create_header_provider(bearer_token_passthrough=enabled), + bearer_token_passthrough=enabled, + ) + marker = bearer_token.set(None) + try: + if enabled: + with pytest.raises(ValueError, match="requires an authenticated invocation"): + await toolset.get_tools() + assert not received + bearer_token.set("alice") + tools = await toolset.get_tools() + assert len(tools) == 1 + assert {auth for _, auth in received} == ({"Bearer alice"} if enabled else {"static-discovery"}) + received.clear() + bearer_token.set("bob") + # Even a tool discovered by Alice resolves the current token for calls. + context = MagicMock() + context.state = {} + await tools[0].run_async(args={}, tool_context=context) + assert any(method == "tools/call" for method, _ in received) + assert {auth for _, auth in received} == ({"Bearer bob"} if enabled else {"static-discovery"}) + assert len(toolset._mcp_session_manager._session_contexts) == (2 if enabled else 1) + received.clear() + bearer_token.set(None) + if enabled: + result = await tools[0].run_async(args={}, tool_context=context) + assert "requires an authenticated invocation" in result["error"] + assert not received + finally: + await toolset.close() + bearer_token.reset(marker) + await server.cleanup() + + +async def test_provider_isolated_and_never_falls_back(): + provider = create_header_provider( + allowed_headers=["Authorization"], + sts_header_provider=lambda _: {"Authorization": "service-token"}, + bearer_token_passthrough=True, + ) + marker = bearer_token.set(None) + try: + with pytest.raises(ValueError, match="requires an authenticated invocation"): + provider(None) + + async def invoke(token): + marker = bearer_token.set(token) + try: + await asyncio.sleep(0) + return provider(None) + finally: + bearer_token.reset(marker) + + assert await asyncio.gather(invoke("alice"), invoke("bob")) == [ + {"Authorization": "Bearer alice"}, + {"Authorization": "Bearer bob"}, + ] + assert bearer_token.get() is None + finally: + bearer_token.reset(marker) + + +@pytest.mark.parametrize( + "config_type,params_type", + [(ADKHTTPConnectionParams, StreamableHTTPConnectionParams), (ADKSSEConnectionParams, SseConnectionParams)], +) +async def test_params_filter_static_authorization_and_restrict_destination(config_type, params_type): + params = _mcp_params( + config_type(url="https://mcp.example.com/mcp", headers={"aUtHoRiZaTiOn": "discovery", "X-Tenant": "tenant"}), + params_type, + True, + ) + assert params.headers == {"X-Tenant": "tenant"} + async with params.httpx_client_factory(headers={"Authorization": "Bearer caller"}) as client: + assert not client.follow_redirects + hook = client.event_hooks["request"][-1] + await hook(httpx2.Request("POST", "https://mcp.example.com/alternate")) + with pytest.raises(ValueError, match="cannot change destination"): + await hook(httpx2.Request("POST", "https://elsewhere.example.com/mcp")) + + +async def test_toolset_initialization_missing_token_does_not_dial(): + provider = create_header_provider(bearer_token_passthrough=True) + toolset = KAgentMcpToolset( + connection_params=StreamableHTTPConnectionParams(url="https://mcp.example.com"), + header_provider=provider, + bearer_token_passthrough=True, + ) + toolset._mcp_session_manager.create_session = AsyncMock(side_effect=AssertionError("must not dial")) + # The global propagation plugin must not replace the restricted provider. + toolset._header_provider = lambda _: {"Authorization": "service-token"} + marker = bearer_token.set(None) + try: + with pytest.raises(ValueError, match="requires an authenticated invocation"): + await toolset.get_tools() + toolset._mcp_session_manager.create_session.assert_not_called() + bearer_token.set("caller") + assert await toolset._build_headers(None) == {"Authorization": "Bearer caller"} + finally: + bearer_token.reset(marker) + await toolset.close() + + +def test_disabled_passthrough_does_not_read_context_token(): + marker = bearer_token.set("caller") + try: + assert create_header_provider() is None + finally: + bearer_token.reset(marker) + + +async def test_request_headers_never_enter_native_session_database(tmp_path): + from google.adk.agents import BaseAgent + from google.adk.events import Event + from google.adk.runners import Runner + from google.genai import types + + from kagent.adk._local_session_service import LocalSessionService + + token = "passthrough-test-credential-not-for-persistence" + seen = [] + + class InspectHeaders(BaseAgent): + async def _run_async_impl(self, ctx): + seen.append(ctx.session.state.get("temp:headers")) + yield Event(author=self.name, content=types.Content(role="model", parts=[types.Part(text="ok")])) + + db = tmp_path / "session.db" + service = LocalSessionService(db_url=f"sqlite+aiosqlite:///{db}") + try: + await service.create_session(app_name="test", user_id="user", session_id="session") + runner = Runner(app_name="test", agent=InspectHeaders(name="inspect"), session_service=service) + async for _ in runner.run_async( + user_id="user", + session_id="session", + new_message=types.Content(role="user", parts=[types.Part(text="hello")]), + state_delta={"temp:headers": {"Authorization": f"Bearer {token}"}}, + ): + pass + stored = await service.get_session(app_name="test", user_id="user", session_id="session") + assert "temp:headers" not in stored.state + assert "headers" not in stored.state + assert token not in str(stored.events) + finally: + await service.close() + assert seen == [{"Authorization": f"Bearer {token}"}] + assert token.encode() not in db.read_bytes() diff --git a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py index 8e77e62283..05dfa4b364 100644 --- a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py +++ b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py @@ -27,7 +27,7 @@ from kagent.config.v1 import config_pb2 as kagent_dot_config_dot_v1_dot_config__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1akagent/config/v1/adk.proto\x12\x10kagent.config.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1dkagent/config/v1/config.proto\"\x81\x01\n\tADKConfig\x12<\n\x07runtime\x18\x01 \x01(\x0b\x32\".kagent.config.v1.ADKRuntimeConfigR\x07runtime\x12\x36\n\x05\x61gent\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\x05\x61gent\"\xff\x07\n\x0e\x41\x44KAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x36\n\x05model\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x05model\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12 \n\x0binstruction\x18\x04 \x01(\tR\x0binstruction\x12G\n\nhttp_tools\x18\x05 \x03(\x0b\x32(.kagent.config.v1.ADKHTTPMCPServerConfigR\thttpTools\x12\x44\n\tsse_tools\x18\x06 \x03(\x0b\x32\'.kagent.config.v1.ADKSSEMCPServerConfigR\x08sseTools\x12J\n\x0bstdio_tools\x18\x07 \x03(\x0b\x32).kagent.config.v1.ADKStdioMCPServerConfigR\nstdioTools\x12K\n\rremote_agents\x18\x08 \x03(\x0b\x32&.kagent.config.v1.ADKRemoteAgentConfigR\x0cremoteAgents\x12\x1b\n\x06stream\x18\t \x01(\x08H\x00R\x06stream\x88\x01\x01\x12\x39\n\x06memory\x18\n \x01(\x0b\x32!.kagent.config.v1.ADKMemoryConfigR\x06memory\x12<\n\x07network\x18\x0b \x01(\x0b\x32\".kagent.config.v1.ADKNetworkConfigR\x07network\x12\x46\n\ragent_plugins\x18\x0c \x01(\x0b\x32!.kagent.config.v1.ADKAgentPluginsR\x0c\x61gentPlugins\x12I\n\x0e\x63ontext_config\x18\r \x01(\x0b\x32\".kagent.config.v1.ADKContextConfigR\rcontextConfig\x12$\n\x0bshare_tools\x18\x0e \x01(\x08H\x01R\nshareTools\x88\x01\x01\x12$\n\x0esession_db_url\x18\x0f \x01(\tR\x0csessionDbUrl\x12)\n\x10skills_directory\x18\x10 \x01(\tR\x0fskillsDirectory\x12?\n\nsub_agents\x18\x11 \x03(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\tsubAgents\x12\x39\n\x06output\x18\x12 \x01(\x0b\x32!.kagent.config.v1.ADKOutputConfigR\x06outputB\t\n\x07_streamB\x0e\n\x0c_share_tools\"\xa1\t\n\x0e\x41\x44KModelConfig\x12\x14\n\x05model\x18\x01 \x01(\tR\x05model\x12G\n\x07headers\x18\x02 \x03(\x0b\x32-.kagent.config.v1.ADKModelConfig.HeadersEntryR\x07headers\x12<\n\x18tls_insecure_skip_verify\x18\x03 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x04 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x05 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x12.\n\x13\x61pi_key_passthrough\x18\x06 \x01(\x08R\x11\x61piKeyPassthrough\x12\x35\n\x06openai\x18\n \x01(\x0b\x32\x1b.kagent.config.v1.ADKOpenAIH\x00R\x06openai\x12\x45\n\x0c\x61zure_openai\x18\x0b \x01(\x0b\x32 .kagent.config.v1.ADKAzureOpenAIH\x00R\x0b\x61zureOpenai\x12>\n\tanthropic\x18\x0c \x01(\x0b\x32\x1e.kagent.config.v1.ADKAnthropicH\x00R\tanthropic\x12O\n\x10gemini_vertex_ai\x18\r \x01(\x0b\x32#.kagent.config.v1.ADKGeminiVertexAIH\x00R\x0egeminiVertexAi\x12Q\n\x10gemini_anthropic\x18\x0e \x01(\x0b\x32$.kagent.config.v1.ADKGeminiAnthropicH\x00R\x0fgeminiAnthropic\x12\x35\n\x06ollama\x18\x0f \x01(\x0b\x32\x1b.kagent.config.v1.ADKOllamaH\x00R\x06ollama\x12\x35\n\x06gemini\x18\x10 \x01(\x0b\x32\x1b.kagent.config.v1.ADKGeminiH\x00R\x06gemini\x12\x38\n\x07\x62\x65\x64rock\x18\x11 \x01(\x0b\x32\x1c.kagent.config.v1.ADKBedrockH\x00R\x07\x62\x65\x64rock\x12@\n\x0bsap_ai_core\x18\x12 \x01(\x0b\x32\x1e.kagent.config.v1.ADKSAPAICoreH\x00R\tsapAiCore\x12\x38\n\x07\x66oundry\x18\x13 \x01(\x0b\x32\x1c.kagent.config.v1.ADKFoundryH\x00R\x07\x66oundry\x12\x38\n\x07mistral\x18\x14 \x01(\x0b\x32\x1c.kagent.config.v1.ADKMistralH\x00R\x07mistral\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x11\n\x08provider\x12\x05\xbaH\x02\x08\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\x86\x08\n\tADKOpenAI\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12I\n\x11\x66requency_penalty\x18\x02 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x00R\x10\x66requencyPenalty\x88\x01\x01\x12+\n\nmax_tokens\x18\x03 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x01R\tmaxTokens\x88\x01\x01\x12@\n\x15max_completion_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x02R\x13maxCompletionTokens\x88\x01\x01\x12\x1a\n\x01n\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x03R\x01n\x88\x01\x01\x12G\n\x10presence_penalty\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x04R\x0fpresencePenalty\x88\x01\x01\x12.\n\x10reasoning_effort\x18\x07 \x01(\tH\x05R\x0freasoningEffort\x88\x01\x01\x12\x17\n\x04seed\x18\x08 \x01(\x03H\x06R\x04seed\x88\x01\x01\x12>\n\x0btemperature\x18\t \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x07R\x0btemperature\x88\x01\x01\x12\x45\n\x07timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12\x31\n\x05top_p\x18\x0b \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x08R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x0c \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormat\x12O\n\x0etoken_exchange\x18\r \x01(\x0b\x32(.kagent.config.v1.ADKTokenExchangeConfigR\rtokenExchange:\x94\x01\xbaH\x90\x01\x1a\x8d\x01\n\x13openai.token_limits\x12;max_tokens and max_completion_tokens are mutually exclusive\x1a\x39!has(this.max_tokens) || !has(this.max_completion_tokens)B\x14\n\x12_frequency_penaltyB\r\n\x0b_max_tokensB\x18\n\x16_max_completion_tokensB\x04\n\x02_nB\x13\n\x11_presence_penaltyB\x13\n\x11_reasoning_effortB\x07\n\x05_seedB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\x8b\x01\n\x16\x41\x44KTokenExchangeConfig\x12`\n\x14gdch_service_account\x18\x01 \x01(\x0b\x32,.kagent.config.v1.ADKGDCHTokenExchangeConfigH\x00R\x12gdchServiceAccountB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\"|\n\x1a\x41\x44KGDCHTokenExchangeConfig\x12\x39\n\x14service_account_path\x18\x01 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x12serviceAccountPath\x12#\n\x08\x61udience\x18\x02 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x08\x61udience\"\xfa\x02\n\x0e\x41\x44KAzureOpenAI\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12+\n\nmax_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x05 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x07 \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormatB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\xbb\x03\n\x0c\x41\x44KAnthropic\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12!\n\x05top_k\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x03R\x04topK\x88\x01\x01\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12%\n\x0eprompt_caching\x18\x07 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x08 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtlB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_pB\x08\n\x06_top_k\"\xb7\x02\n\nADKMistral\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x45\n\x07timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeoutB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"i\n\x11\x41\x44KGeminiVertexAI\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x42\x14\n\x12_max_output_tokens\"\x14\n\x12\x41\x44KGeminiAnthropic\"\x8b\x01\n\tADKOllama\x12\x42\n\x07options\x18\x01 \x03(\x0b\x32(.kagent.config.v1.ADKOllama.OptionsEntryR\x07options\x1a:\n\x0cOptionsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"a\n\tADKGemini\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x42\x14\n\x12_max_output_tokens\"\xab\x03\n\nADKBedrock\x12\x16\n\x06region\x18\x01 \x01(\tR\x06region\x12\x45\n\x1f\x61\x64\x64itional_model_request_fields\x18\x02 \x01(\x0cR\x1c\x61\x64\x64itionalModelRequestFields\x12%\n\x0eprompt_caching\x18\x03 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x04 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtl\x12\x43\n\tguardrail\x18\x05 \x01(\x0b\x32%.kagent.config.v1.ADKBedrockGuardrailR\tguardrail\x12N\n\x0cread_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0breadTimeout\x12T\n\x0f\x63onnect_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0e\x63onnectTimeout\"e\n\x13\x41\x44KBedrockGuardrail\x12\x1e\n\nidentifier\x18\x01 \x01(\tR\nidentifier\x12\x18\n\x07version\x18\x02 \x01(\tR\x07version\x12\x14\n\x05trace\x18\x03 \x01(\tR\x05trace\"k\n\x0c\x41\x44KSAPAICore\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12%\n\x0eresource_group\x18\x02 \x01(\tR\rresourceGroup\x12\x19\n\x08\x61uth_url\x18\x03 \x01(\tR\x07\x61uthUrl\"\xa4\x01\n\nADKFoundry\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12\x39\n\napi_format\x18\x04 \x01(\tB\x1a\xbaH\x17r\x15R\x00R\x06openaiR\tanthropicR\tapiFormat\"\x94\x05\n\x17\x41\x44KHTTPConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12P\n\x07headers\x18\x02 \x03(\x0b\x32\x36.kagent.config.v1.ADKHTTPConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12\x31\n\x12terminate_on_close\x18\x05 \x01(\x08H\x00R\x10terminateOnClose\x88\x01\x01\x12<\n\x18tls_insecure_skip_verify\x18\x06 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x07 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x08 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x15\n\x13_terminate_on_closeB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xc8\x04\n\x16\x41\x44KSSEConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12O\n\x07headers\x18\x02 \x03(\x0b\x32\x35.kagent.config.v1.ADKSSEConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xcd\x01\n\x16\x41\x44KHTTPMCPServerConfig\x12I\n\x06params\x18\x01 \x01(\x0b\x32).kagent.config.v1.ADKHTTPConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\"\xcb\x01\n\x15\x41\x44KSSEMCPServerConfig\x12H\n\x06params\x18\x01 \x01(\x0b\x32(.kagent.config.v1.ADKSSEConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\"\xd7\x01\n\x17\x41\x44KStdioMCPServerConfig\x12\x18\n\x07\x63ommand\x18\x01 \x01(\tR\x07\x63ommand\x12\x12\n\x04\x61rgs\x18\x02 \x03(\tR\x04\x61rgs\x12\x44\n\x03\x65nv\x18\x03 \x03(\x0b\x32\x32.kagent.config.v1.ADKStdioMCPServerConfig.EnvEntryR\x03\x65nv\x12\x10\n\x03\x64ir\x18\x04 \x01(\tR\x03\x64ir\x1a\x36\n\x08\x45nvEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdb\x02\n\x14\x41\x44KRemoteAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n\x03url\x18\x02 \x01(\tR\x03url\x12M\n\x07headers\x18\x03 \x03(\x0b\x32\x33.kagent.config.v1.ADKRemoteAgentConfig.HeadersEntryR\x07headers\x12 \n\x0b\x64\x65scription\x18\x04 \x01(\tR\x0b\x64\x65scription\x12)\n\x10isolate_sessions\x18\x05 \x01(\x08R\x0fisolateSessions\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xe1\x03\n\x12\x41\x44KEmbeddingConfig\x12\x1a\n\x08provider\x18\x01 \x01(\tR\x08provider\x12\x14\n\x05model\x18\x02 \x01(\tR\x05model\x12\x19\n\x08\x62\x61se_url\x18\x03 \x01(\tR\x07\x62\x61seUrl\x12.\n\x13\x61pi_key_passthrough\x18\x04 \x01(\x08R\x11\x61piKeyPassthrough\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x12\x1a\n\x08\x65ndpoint\x18\x08 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\t \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\n \x01(\tR\napiVersionB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"}\n\x0f\x41\x44KMemoryConfig\x12&\n\x08ttl_days\x18\x01 \x01(\x03\x42\x0b\xbaH\x08\"\x06\x18\xff\xc1\x06(\x00R\x07ttlDays\x12\x42\n\tembedding\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKEmbeddingConfigR\tembedding\";\n\x10\x41\x44KNetworkConfig\x12\'\n\x0f\x61llowed_domains\x18\x01 \x03(\tR\x0e\x61llowedDomains\"Z\n\x10\x41\x44KContextConfig\x12\x46\n\ncompaction\x18\x01 \x01(\x0b\x32&.kagent.config.v1.ADKCompressionConfigR\ncompaction\"\xe1\x03\n\x14\x41\x44KCompressionConfig\x12\x43\n\x13\x63ompaction_interval\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x12\x63ompactionInterval\x88\x01\x01\x12\x35\n\x0coverlap_size\x18\x02 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x01R\x0boverlapSize\x88\x01\x01\x12K\n\x10summarizer_model\x18\x03 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x0fsummarizerModel\x12\'\n\x0fprompt_template\x18\x04 \x01(\tR\x0epromptTemplate\x12;\n\x0ftoken_threshold\x18\x05 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x02R\x0etokenThreshold\x88\x01\x01\x12\x44\n\x14\x65vent_retention_size\x18\x06 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x03R\x12\x65ventRetentionSize\x88\x01\x01\x42\x16\n\x14_compaction_intervalB\x0f\n\r_overlap_sizeB\x12\n\x10_token_thresholdB\x17\n\x15_event_retention_size\"J\n\x0f\x41\x44KOutputConfig\x12\x1f\n\x0bjson_schema\x18\x01 \x01(\x0cR\njsonSchema\x12\x16\n\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x82\x01\n\x0f\x41\x44KAgentPlugins\x12\x32\n\x06skills\x18\x01 \x03(\x0b\x32\x1a.kagent.config.v1.ADKSkillR\x06skills\x12;\n\x07plugins\x18\x02 \x03(\x0b\x32!.kagent.config.v1.ADKPluginBundleR\x07plugins\"Y\n\x08\x41\x44KSkill\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x39\n\x06source\x18\x02 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\"d\n\x0f\x41\x44KPluginBundle\x12\x39\n\x06source\x18\x01 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\x12\x16\n\x06skills\x18\x02 \x03(\tR\x06skills\"\xc7\x01\n\x0f\x41\x44KPluginSource\x12\x38\n\x03git\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKPluginGitSourceH\x00R\x03git\x12\x35\n\x02s3\x18\x03 \x01(\x0b\x32#.kagent.config.v1.ADKPluginS3SourceH\x00R\x02s3\x12\x1e\n\tdirectory\x18\x05 \x01(\tH\x00R\tdirectory\x12\x12\n\x04path\x18\x04 \x01(\tR\x04pathB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\">\n\x12\x41\x44KPluginGitSource\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n\x06\x63ommit\x18\x02 \x01(\tR\x06\x63ommit\"\x90\x01\n\x11\x41\x44KPluginS3Source\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x16\n\x06\x62ucket\x18\x02 \x01(\tR\x06\x62ucket\x12\x10\n\x03key\x18\x03 \x01(\tR\x03key\x12\x1d\n\nversion_id\x18\x04 \x01(\tR\tversionId\x12\x16\n\x06region\x18\x05 \x01(\tR\x06region\"\xfd\x06\n\x10\x41\x44KRuntimeConfig\x12J\n\x11\x63ontrol_plane_url\x18\x01 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\x0f\x63ontrolPlaneUrl\x12?\n\x0bgateway_url\x18\x02 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\ngatewayUrl\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\x12\x1c\n\tnamespace\x18\x04 \x01(\tR\tnamespace\x12>\n\tlog_level\x18\x05 \x01(\tB!\xbaH\x1er\x1cR\x00R\x05\x64\x65\x62ugR\x04infoR\x04warnR\x05\x65rrorR\x08logLevel\x12\x12\n\x04host\x18\x06 \x01(\tR\x04host\x12$\n\x04port\x18\x07 \x01(\rB\x0b\xbaH\x08*\x06\x18\xff\xff\x03 \x00H\x00R\x04port\x88\x01\x01\x12:\n\x12max_content_length\x18\x08 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x01R\x10maxContentLength\x88\x01\x01\x12U\n\x11token_propagation\x18\t \x01(\x0b\x32(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12\xb7\x02\n\ttelemetry\x18\n \x01(\x0b\x32!.kagent.config.v1.TelemetryConfigB\xf5\x01\xbaH\xf1\x01\xba\x01\xed\x01\n\"runtime.telemetry.service_identity\x12\x62runtime telemetry resource_attributes must not override compiled service.name or service.namespace\x1a\x63!(\'service.name\' in this.resource_attributes) && !(\'service.namespace\' in this.resource_attributes)R\ttelemetry\x12\x43\n\x0bskill_tools\x18\x0b \x01(\x0b\x32\".kagent.config.v1.SkillToolsConfigR\nskillToolsB\x07\n\x05_portB\x15\n\x13_max_content_lengthBCZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1akagent/config/v1/adk.proto\x12\x10kagent.config.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1dkagent/config/v1/config.proto\"\x81\x01\n\tADKConfig\x12<\n\x07runtime\x18\x01 \x01(\x0b\x32\".kagent.config.v1.ADKRuntimeConfigR\x07runtime\x12\x36\n\x05\x61gent\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\x05\x61gent\"\xff\x07\n\x0e\x41\x44KAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x36\n\x05model\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x05model\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12 \n\x0binstruction\x18\x04 \x01(\tR\x0binstruction\x12G\n\nhttp_tools\x18\x05 \x03(\x0b\x32(.kagent.config.v1.ADKHTTPMCPServerConfigR\thttpTools\x12\x44\n\tsse_tools\x18\x06 \x03(\x0b\x32\'.kagent.config.v1.ADKSSEMCPServerConfigR\x08sseTools\x12J\n\x0bstdio_tools\x18\x07 \x03(\x0b\x32).kagent.config.v1.ADKStdioMCPServerConfigR\nstdioTools\x12K\n\rremote_agents\x18\x08 \x03(\x0b\x32&.kagent.config.v1.ADKRemoteAgentConfigR\x0cremoteAgents\x12\x1b\n\x06stream\x18\t \x01(\x08H\x00R\x06stream\x88\x01\x01\x12\x39\n\x06memory\x18\n \x01(\x0b\x32!.kagent.config.v1.ADKMemoryConfigR\x06memory\x12<\n\x07network\x18\x0b \x01(\x0b\x32\".kagent.config.v1.ADKNetworkConfigR\x07network\x12\x46\n\ragent_plugins\x18\x0c \x01(\x0b\x32!.kagent.config.v1.ADKAgentPluginsR\x0c\x61gentPlugins\x12I\n\x0e\x63ontext_config\x18\r \x01(\x0b\x32\".kagent.config.v1.ADKContextConfigR\rcontextConfig\x12$\n\x0bshare_tools\x18\x0e \x01(\x08H\x01R\nshareTools\x88\x01\x01\x12$\n\x0esession_db_url\x18\x0f \x01(\tR\x0csessionDbUrl\x12)\n\x10skills_directory\x18\x10 \x01(\tR\x0fskillsDirectory\x12?\n\nsub_agents\x18\x11 \x03(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\tsubAgents\x12\x39\n\x06output\x18\x12 \x01(\x0b\x32!.kagent.config.v1.ADKOutputConfigR\x06outputB\t\n\x07_streamB\x0e\n\x0c_share_tools\"\xa1\t\n\x0e\x41\x44KModelConfig\x12\x14\n\x05model\x18\x01 \x01(\tR\x05model\x12G\n\x07headers\x18\x02 \x03(\x0b\x32-.kagent.config.v1.ADKModelConfig.HeadersEntryR\x07headers\x12<\n\x18tls_insecure_skip_verify\x18\x03 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x04 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x05 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x12.\n\x13\x61pi_key_passthrough\x18\x06 \x01(\x08R\x11\x61piKeyPassthrough\x12\x35\n\x06openai\x18\n \x01(\x0b\x32\x1b.kagent.config.v1.ADKOpenAIH\x00R\x06openai\x12\x45\n\x0c\x61zure_openai\x18\x0b \x01(\x0b\x32 .kagent.config.v1.ADKAzureOpenAIH\x00R\x0b\x61zureOpenai\x12>\n\tanthropic\x18\x0c \x01(\x0b\x32\x1e.kagent.config.v1.ADKAnthropicH\x00R\tanthropic\x12O\n\x10gemini_vertex_ai\x18\r \x01(\x0b\x32#.kagent.config.v1.ADKGeminiVertexAIH\x00R\x0egeminiVertexAi\x12Q\n\x10gemini_anthropic\x18\x0e \x01(\x0b\x32$.kagent.config.v1.ADKGeminiAnthropicH\x00R\x0fgeminiAnthropic\x12\x35\n\x06ollama\x18\x0f \x01(\x0b\x32\x1b.kagent.config.v1.ADKOllamaH\x00R\x06ollama\x12\x35\n\x06gemini\x18\x10 \x01(\x0b\x32\x1b.kagent.config.v1.ADKGeminiH\x00R\x06gemini\x12\x38\n\x07\x62\x65\x64rock\x18\x11 \x01(\x0b\x32\x1c.kagent.config.v1.ADKBedrockH\x00R\x07\x62\x65\x64rock\x12@\n\x0bsap_ai_core\x18\x12 \x01(\x0b\x32\x1e.kagent.config.v1.ADKSAPAICoreH\x00R\tsapAiCore\x12\x38\n\x07\x66oundry\x18\x13 \x01(\x0b\x32\x1c.kagent.config.v1.ADKFoundryH\x00R\x07\x66oundry\x12\x38\n\x07mistral\x18\x14 \x01(\x0b\x32\x1c.kagent.config.v1.ADKMistralH\x00R\x07mistral\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x11\n\x08provider\x12\x05\xbaH\x02\x08\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\x86\x08\n\tADKOpenAI\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12I\n\x11\x66requency_penalty\x18\x02 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x00R\x10\x66requencyPenalty\x88\x01\x01\x12+\n\nmax_tokens\x18\x03 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x01R\tmaxTokens\x88\x01\x01\x12@\n\x15max_completion_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x02R\x13maxCompletionTokens\x88\x01\x01\x12\x1a\n\x01n\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x03R\x01n\x88\x01\x01\x12G\n\x10presence_penalty\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x04R\x0fpresencePenalty\x88\x01\x01\x12.\n\x10reasoning_effort\x18\x07 \x01(\tH\x05R\x0freasoningEffort\x88\x01\x01\x12\x17\n\x04seed\x18\x08 \x01(\x03H\x06R\x04seed\x88\x01\x01\x12>\n\x0btemperature\x18\t \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x07R\x0btemperature\x88\x01\x01\x12\x45\n\x07timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12\x31\n\x05top_p\x18\x0b \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x08R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x0c \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormat\x12O\n\x0etoken_exchange\x18\r \x01(\x0b\x32(.kagent.config.v1.ADKTokenExchangeConfigR\rtokenExchange:\x94\x01\xbaH\x90\x01\x1a\x8d\x01\n\x13openai.token_limits\x12;max_tokens and max_completion_tokens are mutually exclusive\x1a\x39!has(this.max_tokens) || !has(this.max_completion_tokens)B\x14\n\x12_frequency_penaltyB\r\n\x0b_max_tokensB\x18\n\x16_max_completion_tokensB\x04\n\x02_nB\x13\n\x11_presence_penaltyB\x13\n\x11_reasoning_effortB\x07\n\x05_seedB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\x8b\x01\n\x16\x41\x44KTokenExchangeConfig\x12`\n\x14gdch_service_account\x18\x01 \x01(\x0b\x32,.kagent.config.v1.ADKGDCHTokenExchangeConfigH\x00R\x12gdchServiceAccountB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\"|\n\x1a\x41\x44KGDCHTokenExchangeConfig\x12\x39\n\x14service_account_path\x18\x01 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x12serviceAccountPath\x12#\n\x08\x61udience\x18\x02 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x08\x61udience\"\xfa\x02\n\x0e\x41\x44KAzureOpenAI\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12+\n\nmax_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x05 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x07 \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormatB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\xbb\x03\n\x0c\x41\x44KAnthropic\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12!\n\x05top_k\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x03R\x04topK\x88\x01\x01\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12%\n\x0eprompt_caching\x18\x07 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x08 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtlB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_pB\x08\n\x06_top_k\"\xb7\x02\n\nADKMistral\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x45\n\x07timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeoutB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"i\n\x11\x41\x44KGeminiVertexAI\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x42\x14\n\x12_max_output_tokens\"\x14\n\x12\x41\x44KGeminiAnthropic\"\x8b\x01\n\tADKOllama\x12\x42\n\x07options\x18\x01 \x03(\x0b\x32(.kagent.config.v1.ADKOllama.OptionsEntryR\x07options\x1a:\n\x0cOptionsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"a\n\tADKGemini\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x42\x14\n\x12_max_output_tokens\"\xab\x03\n\nADKBedrock\x12\x16\n\x06region\x18\x01 \x01(\tR\x06region\x12\x45\n\x1f\x61\x64\x64itional_model_request_fields\x18\x02 \x01(\x0cR\x1c\x61\x64\x64itionalModelRequestFields\x12%\n\x0eprompt_caching\x18\x03 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x04 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtl\x12\x43\n\tguardrail\x18\x05 \x01(\x0b\x32%.kagent.config.v1.ADKBedrockGuardrailR\tguardrail\x12N\n\x0cread_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0breadTimeout\x12T\n\x0f\x63onnect_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0e\x63onnectTimeout\"e\n\x13\x41\x44KBedrockGuardrail\x12\x1e\n\nidentifier\x18\x01 \x01(\tR\nidentifier\x12\x18\n\x07version\x18\x02 \x01(\tR\x07version\x12\x14\n\x05trace\x18\x03 \x01(\tR\x05trace\"k\n\x0c\x41\x44KSAPAICore\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12%\n\x0eresource_group\x18\x02 \x01(\tR\rresourceGroup\x12\x19\n\x08\x61uth_url\x18\x03 \x01(\tR\x07\x61uthUrl\"\xa4\x01\n\nADKFoundry\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12\x39\n\napi_format\x18\x04 \x01(\tB\x1a\xbaH\x17r\x15R\x00R\x06openaiR\tanthropicR\tapiFormat\"\x94\x05\n\x17\x41\x44KHTTPConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12P\n\x07headers\x18\x02 \x03(\x0b\x32\x36.kagent.config.v1.ADKHTTPConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12\x31\n\x12terminate_on_close\x18\x05 \x01(\x08H\x00R\x10terminateOnClose\x88\x01\x01\x12<\n\x18tls_insecure_skip_verify\x18\x06 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x07 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x08 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x15\n\x13_terminate_on_closeB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xc8\x04\n\x16\x41\x44KSSEConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12O\n\x07headers\x18\x02 \x03(\x0b\x32\x35.kagent.config.v1.ADKSSEConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\x87\x02\n\x16\x41\x44KHTTPMCPServerConfig\x12I\n\x06params\x18\x01 \x01(\x0b\x32).kagent.config.v1.ADKHTTPConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\x12\x38\n\x18\x62\x65\x61rer_token_passthrough\x18\x05 \x01(\x08R\x16\x62\x65\x61rerTokenPassthrough\"\x85\x02\n\x15\x41\x44KSSEMCPServerConfig\x12H\n\x06params\x18\x01 \x01(\x0b\x32(.kagent.config.v1.ADKSSEConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\x12\x38\n\x18\x62\x65\x61rer_token_passthrough\x18\x05 \x01(\x08R\x16\x62\x65\x61rerTokenPassthrough\"\xd7\x01\n\x17\x41\x44KStdioMCPServerConfig\x12\x18\n\x07\x63ommand\x18\x01 \x01(\tR\x07\x63ommand\x12\x12\n\x04\x61rgs\x18\x02 \x03(\tR\x04\x61rgs\x12\x44\n\x03\x65nv\x18\x03 \x03(\x0b\x32\x32.kagent.config.v1.ADKStdioMCPServerConfig.EnvEntryR\x03\x65nv\x12\x10\n\x03\x64ir\x18\x04 \x01(\tR\x03\x64ir\x1a\x36\n\x08\x45nvEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdb\x02\n\x14\x41\x44KRemoteAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n\x03url\x18\x02 \x01(\tR\x03url\x12M\n\x07headers\x18\x03 \x03(\x0b\x32\x33.kagent.config.v1.ADKRemoteAgentConfig.HeadersEntryR\x07headers\x12 \n\x0b\x64\x65scription\x18\x04 \x01(\tR\x0b\x64\x65scription\x12)\n\x10isolate_sessions\x18\x05 \x01(\x08R\x0fisolateSessions\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xe1\x03\n\x12\x41\x44KEmbeddingConfig\x12\x1a\n\x08provider\x18\x01 \x01(\tR\x08provider\x12\x14\n\x05model\x18\x02 \x01(\tR\x05model\x12\x19\n\x08\x62\x61se_url\x18\x03 \x01(\tR\x07\x62\x61seUrl\x12.\n\x13\x61pi_key_passthrough\x18\x04 \x01(\x08R\x11\x61piKeyPassthrough\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x12\x1a\n\x08\x65ndpoint\x18\x08 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\t \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\n \x01(\tR\napiVersionB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"}\n\x0f\x41\x44KMemoryConfig\x12&\n\x08ttl_days\x18\x01 \x01(\x03\x42\x0b\xbaH\x08\"\x06\x18\xff\xc1\x06(\x00R\x07ttlDays\x12\x42\n\tembedding\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKEmbeddingConfigR\tembedding\";\n\x10\x41\x44KNetworkConfig\x12\'\n\x0f\x61llowed_domains\x18\x01 \x03(\tR\x0e\x61llowedDomains\"Z\n\x10\x41\x44KContextConfig\x12\x46\n\ncompaction\x18\x01 \x01(\x0b\x32&.kagent.config.v1.ADKCompressionConfigR\ncompaction\"\xe1\x03\n\x14\x41\x44KCompressionConfig\x12\x43\n\x13\x63ompaction_interval\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x12\x63ompactionInterval\x88\x01\x01\x12\x35\n\x0coverlap_size\x18\x02 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x01R\x0boverlapSize\x88\x01\x01\x12K\n\x10summarizer_model\x18\x03 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x0fsummarizerModel\x12\'\n\x0fprompt_template\x18\x04 \x01(\tR\x0epromptTemplate\x12;\n\x0ftoken_threshold\x18\x05 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x02R\x0etokenThreshold\x88\x01\x01\x12\x44\n\x14\x65vent_retention_size\x18\x06 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x03R\x12\x65ventRetentionSize\x88\x01\x01\x42\x16\n\x14_compaction_intervalB\x0f\n\r_overlap_sizeB\x12\n\x10_token_thresholdB\x17\n\x15_event_retention_size\"J\n\x0f\x41\x44KOutputConfig\x12\x1f\n\x0bjson_schema\x18\x01 \x01(\x0cR\njsonSchema\x12\x16\n\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x82\x01\n\x0f\x41\x44KAgentPlugins\x12\x32\n\x06skills\x18\x01 \x03(\x0b\x32\x1a.kagent.config.v1.ADKSkillR\x06skills\x12;\n\x07plugins\x18\x02 \x03(\x0b\x32!.kagent.config.v1.ADKPluginBundleR\x07plugins\"Y\n\x08\x41\x44KSkill\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x39\n\x06source\x18\x02 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\"d\n\x0f\x41\x44KPluginBundle\x12\x39\n\x06source\x18\x01 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\x12\x16\n\x06skills\x18\x02 \x03(\tR\x06skills\"\xc7\x01\n\x0f\x41\x44KPluginSource\x12\x38\n\x03git\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKPluginGitSourceH\x00R\x03git\x12\x35\n\x02s3\x18\x03 \x01(\x0b\x32#.kagent.config.v1.ADKPluginS3SourceH\x00R\x02s3\x12\x1e\n\tdirectory\x18\x05 \x01(\tH\x00R\tdirectory\x12\x12\n\x04path\x18\x04 \x01(\tR\x04pathB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\">\n\x12\x41\x44KPluginGitSource\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n\x06\x63ommit\x18\x02 \x01(\tR\x06\x63ommit\"\x90\x01\n\x11\x41\x44KPluginS3Source\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x16\n\x06\x62ucket\x18\x02 \x01(\tR\x06\x62ucket\x12\x10\n\x03key\x18\x03 \x01(\tR\x03key\x12\x1d\n\nversion_id\x18\x04 \x01(\tR\tversionId\x12\x16\n\x06region\x18\x05 \x01(\tR\x06region\"\xfd\x06\n\x10\x41\x44KRuntimeConfig\x12J\n\x11\x63ontrol_plane_url\x18\x01 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\x0f\x63ontrolPlaneUrl\x12?\n\x0bgateway_url\x18\x02 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\ngatewayUrl\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\x12\x1c\n\tnamespace\x18\x04 \x01(\tR\tnamespace\x12>\n\tlog_level\x18\x05 \x01(\tB!\xbaH\x1er\x1cR\x00R\x05\x64\x65\x62ugR\x04infoR\x04warnR\x05\x65rrorR\x08logLevel\x12\x12\n\x04host\x18\x06 \x01(\tR\x04host\x12$\n\x04port\x18\x07 \x01(\rB\x0b\xbaH\x08*\x06\x18\xff\xff\x03 \x00H\x00R\x04port\x88\x01\x01\x12:\n\x12max_content_length\x18\x08 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x01R\x10maxContentLength\x88\x01\x01\x12U\n\x11token_propagation\x18\t \x01(\x0b\x32(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12\xb7\x02\n\ttelemetry\x18\n \x01(\x0b\x32!.kagent.config.v1.TelemetryConfigB\xf5\x01\xbaH\xf1\x01\xba\x01\xed\x01\n\"runtime.telemetry.service_identity\x12\x62runtime telemetry resource_attributes must not override compiled service.name or service.namespace\x1a\x63!(\'service.name\' in this.resource_attributes) && !(\'service.namespace\' in this.resource_attributes)R\ttelemetry\x12\x43\n\x0bskill_tools\x18\x0b \x01(\x0b\x32\".kagent.config.v1.SkillToolsConfigR\nskillToolsB\x07\n\x05_portB\x15\n\x13_max_content_lengthBCZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -200,41 +200,41 @@ _globals['_ADKSSECONNECTIONPARAMS_HEADERSENTRY']._serialized_start=2330 _globals['_ADKSSECONNECTIONPARAMS_HEADERSENTRY']._serialized_end=2388 _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_start=7358 - _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_end=7563 - _globals['_ADKSSEMCPSERVERCONFIG']._serialized_start=7566 - _globals['_ADKSSEMCPSERVERCONFIG']._serialized_end=7769 - _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_start=7772 - _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_end=7987 - _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_start=7933 - _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_end=7987 - _globals['_ADKREMOTEAGENTCONFIG']._serialized_start=7990 - _globals['_ADKREMOTEAGENTCONFIG']._serialized_end=8337 + _globals['_ADKHTTPMCPSERVERCONFIG']._serialized_end=7621 + _globals['_ADKSSEMCPSERVERCONFIG']._serialized_start=7624 + _globals['_ADKSSEMCPSERVERCONFIG']._serialized_end=7885 + _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_start=7888 + _globals['_ADKSTDIOMCPSERVERCONFIG']._serialized_end=8103 + _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_start=8049 + _globals['_ADKSTDIOMCPSERVERCONFIG_ENVENTRY']._serialized_end=8103 + _globals['_ADKREMOTEAGENTCONFIG']._serialized_start=8106 + _globals['_ADKREMOTEAGENTCONFIG']._serialized_end=8453 _globals['_ADKREMOTEAGENTCONFIG_HEADERSENTRY']._serialized_start=2330 _globals['_ADKREMOTEAGENTCONFIG_HEADERSENTRY']._serialized_end=2388 - _globals['_ADKEMBEDDINGCONFIG']._serialized_start=8340 - _globals['_ADKEMBEDDINGCONFIG']._serialized_end=8821 - _globals['_ADKMEMORYCONFIG']._serialized_start=8823 - _globals['_ADKMEMORYCONFIG']._serialized_end=8948 - _globals['_ADKNETWORKCONFIG']._serialized_start=8950 - _globals['_ADKNETWORKCONFIG']._serialized_end=9009 - _globals['_ADKCONTEXTCONFIG']._serialized_start=9011 - _globals['_ADKCONTEXTCONFIG']._serialized_end=9101 - _globals['_ADKCOMPRESSIONCONFIG']._serialized_start=9104 - _globals['_ADKCOMPRESSIONCONFIG']._serialized_end=9585 - _globals['_ADKOUTPUTCONFIG']._serialized_start=9587 - _globals['_ADKOUTPUTCONFIG']._serialized_end=9661 - _globals['_ADKAGENTPLUGINS']._serialized_start=9664 - _globals['_ADKAGENTPLUGINS']._serialized_end=9794 - _globals['_ADKSKILL']._serialized_start=9796 - _globals['_ADKSKILL']._serialized_end=9885 - _globals['_ADKPLUGINBUNDLE']._serialized_start=9887 - _globals['_ADKPLUGINBUNDLE']._serialized_end=9987 - _globals['_ADKPLUGINSOURCE']._serialized_start=9990 - _globals['_ADKPLUGINSOURCE']._serialized_end=10189 - _globals['_ADKPLUGINGITSOURCE']._serialized_start=10191 - _globals['_ADKPLUGINGITSOURCE']._serialized_end=10253 - _globals['_ADKPLUGINS3SOURCE']._serialized_start=10256 - _globals['_ADKPLUGINS3SOURCE']._serialized_end=10400 - _globals['_ADKRUNTIMECONFIG']._serialized_start=10403 - _globals['_ADKRUNTIMECONFIG']._serialized_end=11296 + _globals['_ADKEMBEDDINGCONFIG']._serialized_start=8456 + _globals['_ADKEMBEDDINGCONFIG']._serialized_end=8937 + _globals['_ADKMEMORYCONFIG']._serialized_start=8939 + _globals['_ADKMEMORYCONFIG']._serialized_end=9064 + _globals['_ADKNETWORKCONFIG']._serialized_start=9066 + _globals['_ADKNETWORKCONFIG']._serialized_end=9125 + _globals['_ADKCONTEXTCONFIG']._serialized_start=9127 + _globals['_ADKCONTEXTCONFIG']._serialized_end=9217 + _globals['_ADKCOMPRESSIONCONFIG']._serialized_start=9220 + _globals['_ADKCOMPRESSIONCONFIG']._serialized_end=9701 + _globals['_ADKOUTPUTCONFIG']._serialized_start=9703 + _globals['_ADKOUTPUTCONFIG']._serialized_end=9777 + _globals['_ADKAGENTPLUGINS']._serialized_start=9780 + _globals['_ADKAGENTPLUGINS']._serialized_end=9910 + _globals['_ADKSKILL']._serialized_start=9912 + _globals['_ADKSKILL']._serialized_end=10001 + _globals['_ADKPLUGINBUNDLE']._serialized_start=10003 + _globals['_ADKPLUGINBUNDLE']._serialized_end=10103 + _globals['_ADKPLUGINSOURCE']._serialized_start=10106 + _globals['_ADKPLUGINSOURCE']._serialized_end=10305 + _globals['_ADKPLUGINGITSOURCE']._serialized_start=10307 + _globals['_ADKPLUGINGITSOURCE']._serialized_end=10369 + _globals['_ADKPLUGINS3SOURCE']._serialized_start=10372 + _globals['_ADKPLUGINS3SOURCE']._serialized_end=10516 + _globals['_ADKRUNTIMECONFIG']._serialized_start=10519 + _globals['_ADKRUNTIMECONFIG']._serialized_end=11412 # @@protoc_insertion_point(module_scope) diff --git a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi index 4303583a42..588e9a990c 100644 --- a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi +++ b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi @@ -332,28 +332,32 @@ class ADKSSEConnectionParams(_message.Message): def __init__(self, url: _Optional[str] = ..., headers: _Optional[_Mapping[str, str]] = ..., timeout: _Optional[_Union[datetime.timedelta, _duration_pb2.Duration, _Mapping]] = ..., sse_read_timeout: _Optional[_Union[datetime.timedelta, _duration_pb2.Duration, _Mapping]] = ..., tls_insecure_skip_verify: _Optional[bool] = ..., tls_ca_cert_path: _Optional[str] = ..., tls_disable_system_cas: _Optional[bool] = ...) -> None: ... class ADKHTTPMCPServerConfig(_message.Message): - __slots__ = ("params", "tools", "allowed_headers", "require_approval") + __slots__ = ("params", "tools", "allowed_headers", "require_approval", "bearer_token_passthrough") PARAMS_FIELD_NUMBER: _ClassVar[int] TOOLS_FIELD_NUMBER: _ClassVar[int] ALLOWED_HEADERS_FIELD_NUMBER: _ClassVar[int] REQUIRE_APPROVAL_FIELD_NUMBER: _ClassVar[int] + BEARER_TOKEN_PASSTHROUGH_FIELD_NUMBER: _ClassVar[int] params: ADKHTTPConnectionParams tools: _containers.RepeatedScalarFieldContainer[str] allowed_headers: _containers.RepeatedScalarFieldContainer[str] require_approval: bool - def __init__(self, params: _Optional[_Union[ADKHTTPConnectionParams, _Mapping]] = ..., tools: _Optional[_Iterable[str]] = ..., allowed_headers: _Optional[_Iterable[str]] = ..., require_approval: _Optional[bool] = ...) -> None: ... + bearer_token_passthrough: bool + def __init__(self, params: _Optional[_Union[ADKHTTPConnectionParams, _Mapping]] = ..., tools: _Optional[_Iterable[str]] = ..., allowed_headers: _Optional[_Iterable[str]] = ..., require_approval: _Optional[bool] = ..., bearer_token_passthrough: _Optional[bool] = ...) -> None: ... class ADKSSEMCPServerConfig(_message.Message): - __slots__ = ("params", "tools", "allowed_headers", "require_approval") + __slots__ = ("params", "tools", "allowed_headers", "require_approval", "bearer_token_passthrough") PARAMS_FIELD_NUMBER: _ClassVar[int] TOOLS_FIELD_NUMBER: _ClassVar[int] ALLOWED_HEADERS_FIELD_NUMBER: _ClassVar[int] REQUIRE_APPROVAL_FIELD_NUMBER: _ClassVar[int] + BEARER_TOKEN_PASSTHROUGH_FIELD_NUMBER: _ClassVar[int] params: ADKSSEConnectionParams tools: _containers.RepeatedScalarFieldContainer[str] allowed_headers: _containers.RepeatedScalarFieldContainer[str] require_approval: bool - def __init__(self, params: _Optional[_Union[ADKSSEConnectionParams, _Mapping]] = ..., tools: _Optional[_Iterable[str]] = ..., allowed_headers: _Optional[_Iterable[str]] = ..., require_approval: _Optional[bool] = ...) -> None: ... + bearer_token_passthrough: bool + def __init__(self, params: _Optional[_Union[ADKSSEConnectionParams, _Mapping]] = ..., tools: _Optional[_Iterable[str]] = ..., allowed_headers: _Optional[_Iterable[str]] = ..., require_approval: _Optional[bool] = ..., bearer_token_passthrough: _Optional[bool] = ...) -> None: ... class ADKStdioMCPServerConfig(_message.Message): __slots__ = ("command", "args", "env", "dir") diff --git a/ui/src/api/domain/models.ts b/ui/src/api/domain/models.ts index 3e4d945efc..6abee4d127 100644 --- a/ui/src/api/domain/models.ts +++ b/ui/src/api/domain/models.ts @@ -93,7 +93,7 @@ export interface ModelConfigSpec { provider: string; apiKeySecret?: string; apiKeySecretKey?: string; - apiKeyPassthrough?: boolean; + tokenPassthrough?: boolean; defaultHeaders?: Record; tls?: TLSConfig; openAI?: OpenAIConfig; diff --git a/ui/src/components/model-form/modelDraft.test.ts b/ui/src/components/model-form/modelDraft.test.ts index 95f046eec4..b390326563 100644 --- a/ui/src/components/model-form/modelDraft.test.ts +++ b/ui/src/components/model-form/modelDraft.test.ts @@ -67,7 +67,7 @@ describe("buildModelPayload — authentication modes", () => { const req = buildModelPayload(draft({ authType: "apiKey", apiKey: "sk-123" })); expect(req.apiKey).toBe("sk-123"); expect(req.spec.apiKeySecret).toBeUndefined(); - expect(req.spec.apiKeyPassthrough).toBeUndefined(); + expect(req.spec.tokenPassthrough).toBeUndefined(); }); it("secret: references an existing Secret and sends no inline key", () => { @@ -83,9 +83,13 @@ describe("buildModelPayload — authentication modes", () => { expect(req.spec.apiKeySecretKey).toBe("OPENAI_API_KEY"); }); - it("passthrough: sets apiKeyPassthrough and stores nothing", () => { + it("passthrough: sets tokenPassthrough and stores nothing", () => { const req = buildModelPayload(draft({ authType: "passthrough" })); - expect(req.spec.apiKeyPassthrough).toBe(true); + expect(req.spec).toEqual({ + provider: "OpenAI", + model: "gpt-4.1", + tokenPassthrough: true, + }); expect(req.apiKey).toBeUndefined(); expect(req.spec.apiKeySecret).toBeUndefined(); }); @@ -94,7 +98,7 @@ describe("buildModelPayload — authentication modes", () => { const req = buildModelPayload(draft({ authType: "none", apiKey: "ignored" })); expect(req.apiKey).toBeUndefined(); expect(req.spec.apiKeySecret).toBeUndefined(); - expect(req.spec.apiKeyPassthrough).toBeUndefined(); + expect(req.spec.tokenPassthrough).toBeUndefined(); }); it("Ollama sends no inline key even when the mode says apiKey", () => { @@ -105,7 +109,7 @@ describe("buildModelPayload — authentication modes", () => { ); expect(req.apiKey).toBeUndefined(); expect(req.spec.apiKeySecret).toBeUndefined(); - expect(req.spec.apiKeyPassthrough).toBeUndefined(); + expect(req.spec.tokenPassthrough).toBeUndefined(); }); it("Ollama names a Secret, which is what authenticates an Ollama Cloud model", () => { @@ -122,7 +126,7 @@ describe("buildModelPayload — authentication modes", () => { expect(req.spec.apiKeySecretKey).toBe("OLLAMA_API_KEY"); // The key is mounted for the runtime, never serialized into the request. expect(req.apiKey).toBeUndefined(); - expect(req.spec.apiKeyPassthrough).toBeUndefined(); + expect(req.spec.tokenPassthrough).toBeUndefined(); }); it("Ollama defaults the Secret key when only the Secret is named", () => { @@ -157,7 +161,7 @@ describe("buildModelPayload — authentication modes", () => { ); expect(req.apiKey).toBeUndefined(); expect(req.spec.apiKeySecret).toBeUndefined(); - expect(req.spec.apiKeyPassthrough).toBeUndefined(); + expect(req.spec.tokenPassthrough).toBeUndefined(); }); }); @@ -225,7 +229,7 @@ describe("modelDraftFrom — round trip", () => { expect( modelDraftFrom({ ref: "kagent/x", - spec: { provider: "OpenAI", model: "gpt-4.1", apiKeyPassthrough: true }, + spec: { provider: "OpenAI", model: "gpt-4.1", tokenPassthrough: true }, }).authType, ).toBe("passthrough"); diff --git a/ui/src/components/model-form/modelDraft.ts b/ui/src/components/model-form/modelDraft.ts index 979c98d454..6ae776a49f 100644 --- a/ui/src/components/model-form/modelDraft.ts +++ b/ui/src/components/model-form/modelDraft.ts @@ -202,7 +202,7 @@ function authTypeFrom(spec: ModelConfigSpec): ModelAuthType { if (spec.provider === OLLAMA_PROVIDER) { return spec.apiKeySecret ? "secret" : "none"; } - if (spec.apiKeyPassthrough) return "passthrough"; + if (spec.tokenPassthrough) return "passthrough"; if (spec.apiKeySecret) return "secret"; return "none"; } @@ -288,7 +288,7 @@ export function buildModelPayload(draft: ModelDraft): CreateModelConfigRequest { } } } else if (draft.authType === "passthrough") { - spec.apiKeyPassthrough = true; + spec.tokenPassthrough = true; } } else if (draft.authType === "secret" && draft.apiKeySecret.trim()) { spec.apiKeySecret = draft.apiKeySecret.trim(); diff --git a/ui/src/components/model-form/providerInfo.ts b/ui/src/components/model-form/providerInfo.ts index 8fb90b9594..274ec08ab2 100644 --- a/ui/src/components/model-form/providerInfo.ts +++ b/ui/src/components/model-form/providerInfo.ts @@ -87,7 +87,7 @@ export function providerDisplayName(type: string): string { export const OLLAMA_PROVIDER = "Ollama"; /** - * Providers the CRD permits `apiKeyPassthrough` on — where the caller's own + * Providers the CRD permits `tokenPassthrough` on — where the caller's own * credential is forwarded rather than one stored in the cluster. The passthrough * auth option is offered only for these. */