From 2ac5007a7e5b49a12540e2b42c9f108910b4b29f Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sun, 18 Oct 2026 02:28:49 +0000 Subject: [PATCH 1/2] feat: add token passthrough for remote MCP servers Add invocation-scoped caller-token authentication for Go and Python MCP clients and integrate origin-based egress policy. Rename the ModelConfig field to tokenPassthrough for a consistent public API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Eitan Yarmush --- docs/architecture/credential-injection.md | 82 +++- go/adk/pkg/mcp/baggage_test.go | 7 +- go/adk/pkg/mcp/passthrough.go | 311 ++++++++++++++ go/adk/pkg/mcp/passthrough_test.go | 322 +++++++++++++++ go/adk/pkg/mcp/registry.go | 145 ++++--- go/api/gen/kagent/config/v1/adk.pb.go | 34 +- go/api/v1alpha3/configuration_crd_cel_test.go | 71 ++++ go/api/v1alpha3/modelconfig_types.go | 8 +- go/api/v1alpha3/remotemcpserver_types.go | 6 + go/api/v1alpha3/zz_generated.deepcopy.go | 5 + .../remotemcpserver/passthrough_test.go | 71 ++++ .../controller/remotemcpserver/reconciler.go | 33 +- go/core/internal/service/tool/mcp.go | 23 +- go/core/internal/service/tool/mcp_test.go | 15 + .../internal/translator/adkconfig/builder.go | 4 +- go/core/internal/translator/adkconfig/mcp.go | 2 + .../internal/translator/adkconfig/mcp_test.go | 37 ++ .../internal/translator/adkconfig/model.go | 36 +- .../translator/adkconfig/model_test.go | 43 +- .../internal/translator/claude/compiler.go | 4 +- .../translator/claude/compiler_test.go | 2 +- go/core/internal/translator/claude/mcp.go | 3 + .../translator/claude/mcp_passthrough_test.go | 56 +++ go/core/internal/translator/codex/compiler.go | 4 +- .../translator/codex/compiler_test.go | 1 + go/core/internal/translator/codex/mcp.go | 3 + .../translator/codex/mcp_passthrough_test.go | 56 +++ go/core/internal/translator/credentials.go | 17 +- .../internal/translator/credentials_test.go | 6 +- go/core/internal/translator/egress_test.go | 2 +- go/core/internal/translator/mcp_headers.go | 22 + .../internal/translator/mcp_headers_test.go | 97 +++++ go/core/internal/translator/modelconfig.go | 8 +- go/harness/codex/README.md | 2 +- .../api.kagent.dev_modelconfigs.yaml | 22 +- .../api.kagent.dev_remotemcpservers.yaml | 7 + proto/kagent/config/v1/adk.proto | 4 + .../agentsts-adk/src/agentsts/adk/_base.py | 11 +- .../src/kagent/adk/_agent_executor.py | 6 +- .../src/kagent/adk/_llm_passthrough_plugin.py | 4 +- .../kagent-adk/src/kagent/adk/_mcp_toolset.py | 33 ++ .../src/kagent/adk/_remote_a2a_tool.py | 2 +- .../kagent-adk/src/kagent/adk/types.py | 61 ++- .../tests/unittests/test_agent_executor.py | 37 +- .../tests/unittests/test_mcp_passthrough.py | 379 ++++++++++++++++++ .../src/kagent/config/v1/adk_pb2.py | 72 ++-- .../src/kagent/config/v1/adk_pb2.pyi | 12 +- ui/src/api/domain/models.ts | 2 +- .../components/model-form/modelDraft.test.ts | 20 +- ui/src/components/model-form/modelDraft.ts | 4 +- ui/src/components/model-form/providerInfo.ts | 2 +- 51 files changed, 2028 insertions(+), 188 deletions(-) create mode 100644 go/adk/pkg/mcp/passthrough.go create mode 100644 go/adk/pkg/mcp/passthrough_test.go create mode 100644 go/core/internal/controller/remotemcpserver/passthrough_test.go create mode 100644 go/core/internal/translator/claude/mcp_passthrough_test.go create mode 100644 go/core/internal/translator/codex/mcp_passthrough_test.go create mode 100644 go/core/internal/translator/mcp_headers.go create mode 100644 go/core/internal/translator/mcp_headers_test.go create mode 100644 python/packages/kagent-adk/tests/unittests/test_mcp_passthrough.py diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index 78def5dc58..b48677ffc5 100644 --- a/docs/architecture/credential-injection.md +++ b/docs/architecture/credential-injection.md @@ -80,10 +80,90 @@ 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 uses a fresh MCP +session for each listing and tool call, closing it at the operation boundary: +the ordinary ADK toolset retains its first caller's session context and is not +safe for this mode. 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..e4de8bd7f5 --- /dev/null +++ b/go/adk/pkg/mcp/passthrough.go @@ -0,0 +1,311 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "mime" + "strings" + "unicode/utf8" + + 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/toolutils" + "google.golang.org/genai" + + "github.com/kagent-dev/kagent/go/pkg/logging" +) + +// passthroughToolset keeps only configuration, never a client session or caller +// credential. ADK's ordinary toolset shares one MCP session for its lifetime; +// that session can retain the first caller's identity for background requests. +type passthroughToolset struct { + params mcpServerParams + filter map[string]bool +} + +func (*passthroughToolset) Name() string { return "mcp_tool_set" } + +func (p *passthroughToolset) connect(ctx context.Context) (*mcpsdk.ClientSession, error) { + transport, err := createTransport(ctx, p.params) + if err != nil { + return nil, err + } + client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "kagent", Version: "1"}, + &mcpsdk.ClientOptions{Capabilities: mcpUIClientCapabilities()}) + session, err := client.Connect(ctx, transport, nil) + if err != nil { + // Remote errors can echo request credentials. + return nil, fmt.Errorf("failed to connect passthrough MCP server") + } + return session, nil +} + +func closePassthroughSession(ctx context.Context, session *mcpsdk.ClientSession) { + if err := session.Close(); err != nil { + logging.FromContext(ctx).WarnContext(ctx, "failed to close passthrough MCP session") + } +} + +func (p *passthroughToolset) Tools(ctx adkagent.ReadonlyContext) ([]tool.Tool, error) { + session, err := p.connect(ctx) + if err != nil { + return nil, err + } + defer closePassthroughSession(ctx, session) + var tools []tool.Tool + for item, err := range session.Tools(ctx, nil) { + if err != nil { + return nil, fmt.Errorf("failed to list passthrough MCP tools") + } + if len(p.filter) > 0 && !p.filter[item.Name] || mcpToolKindOf(item.Meta) == mcpToolKindAppInternal { + continue + } + declaration := &genai.FunctionDeclaration{Name: item.Name, Description: item.Description} + if item.InputSchema != nil { + declaration.ParametersJsonSchema = item.InputSchema + } + if item.OutputSchema != nil { + declaration.ResponseJsonSchema = item.OutputSchema + } + tools = append(tools, &passthroughTool{set: p, declaration: 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) { + session, err := p.set.connect(ctx) + if err != nil { + return nil, err + } + defer closePassthroughSession(ctx, session) + result, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: p.Name(), Arguments: args}) + if err != nil { + return nil, fmt.Errorf("failed to call passthrough MCP tool") + } + if result.IsError { + return nil, fmt.Errorf("passthrough MCP tool execution failed") + } + if result.StructuredContent != nil { + return map[string]any{"output": result.StructuredContent}, nil + } + return map[string]any{"output": formatPassthroughContent(result.Content)}, nil +} + +// The success formatter below follows google.golang.org/adk/v2 v2.5.0 +// tool/mcptoolset/tool.go. Its converter is private, and using the ordinary +// toolset would retain the first caller's session. Keep error handling above +// separate: upstream error formatting can expose caller credentials. +// +// Adapted from Google ADK, Copyright 2025 Google LLC, under the Apache License, +// Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0). +func formatPassthroughContent(contents []mcpsdk.Content) string { + var result strings.Builder + var previous string + previousPlain := true + for _, content := range contents { + text, plain := formatPassthroughBlock(content) + if text == "" { + continue + } + if previous != "" && (!previousPlain || !plain) && + !strings.HasSuffix(previous, "\n") && !strings.HasPrefix(text, "\n") { + result.WriteByte('\n') + } + result.WriteString(text) + previous, previousPlain = text, plain + } + return result.String() +} + +func formatPassthroughBlock(content mcpsdk.Content) (string, bool) { + switch content := content.(type) { + case nil: + return "[MCP content: unavailable]", false + case *mcpsdk.TextContent: + if content == nil { + return "[MCP text content: unavailable]", false + } + return content.Text, true + case *mcpsdk.EmbeddedResource: + if content == nil || content.Resource == nil { + return "[MCP embedded resource: unavailable]", false + } + resource := content.Resource + attributes := passthroughResourceAttributes(resource.URI, resource.MIMEType) + body := resource.Text + if body == "" { + body, _ = decodePassthroughTextBlob(resource.Blob, resource.MIMEType) + } + if len(resource.Blob) > 0 && (resource.Text != "" || body == "") { + attributes = append(attributes, fmt.Sprintf("size=%d bytes", len(resource.Blob))) + } + text := passthroughContentLabel("embedded resource", attributes) + if body != "" { + text += "\n" + body + } + return text, false + case *mcpsdk.ResourceLink: + if content == nil { + return "[MCP resource link: unavailable]", false + } + attributes := passthroughResourceAttributes(content.URI, content.MIMEType) + if content.Name != "" { + attributes = append(attributes, fmt.Sprintf("name=%q", content.Name)) + } + if content.Title != "" { + attributes = append(attributes, fmt.Sprintf("title=%q", content.Title)) + } + if content.Description != "" { + attributes = append(attributes, fmt.Sprintf("description=%q", content.Description)) + } + if content.Size != nil { + attributes = append(attributes, fmt.Sprintf("size=%d bytes", *content.Size)) + } + return passthroughContentLabel("resource link", attributes), false + case *mcpsdk.ImageContent: + if content == nil { + return "[MCP image: unavailable]", false + } + return passthroughMediaLabel("image", content.MIMEType, len(content.Data)), false + case *mcpsdk.AudioContent: + if content == nil { + return "[MCP audio: unavailable]", false + } + return passthroughMediaLabel("audio", content.MIMEType, len(content.Data)), false + default: + return "[MCP content: unsupported]", false + } +} + +func passthroughResourceAttributes(uri, mimeType string) []string { + attributes := make([]string, 0, 2) + if uri != "" { + attributes = append(attributes, fmt.Sprintf("uri=%q", uri)) + } + if mimeType != "" { + attributes = append(attributes, fmt.Sprintf("mimeType=%q", mimeType)) + } + return attributes +} + +func passthroughMediaLabel(kind, mimeType string, size int) string { + attributes := passthroughResourceAttributes("", mimeType) + attributes = append(attributes, fmt.Sprintf("size=%d bytes", size)) + return passthroughContentLabel(kind, attributes) +} + +func passthroughContentLabel(kind string, attributes []string) string { + if len(attributes) == 0 { + return "[MCP " + kind + "]" + } + return "[MCP " + kind + ": " + strings.Join(attributes, ", ") + "]" +} + +func decodePassthroughTextBlob(blob []byte, mimeType string) (string, bool) { + if len(blob) == 0 { + return "", false + } + declaresCharset := passthroughHasMIMECharset(mimeType) + mediaType, params, err := mime.ParseMediaType(mimeType) + if err != nil && (!errors.Is(err, mime.ErrInvalidMediaParameter) || declaresCharset) { + return "", false + } + if !passthroughTextMediaType(mediaType) { + return "", false + } + charset, parsedCharset := params["charset"] + if declaresCharset && !parsedCharset { + return "", false + } + switch strings.ToLower(charset) { + case "", "utf-8", "utf8": + if !utf8.Valid(blob) { + return "", false + } + case "us-ascii", "ascii": + for _, b := range blob { + if b >= utf8.RuneSelf { + return "", false + } + } + default: + return "", false + } + return string(blob), true +} + +func passthroughHasMIMECharset(value string) bool { + _, params, ok := strings.Cut(value, ";") + if !ok { + return false + } + start := 0 + inQuote := false + for i := 0; i <= len(params); i++ { + if i != len(params) { + switch params[i] { + case '\\': + if inQuote && i+1 < len(params) { + i++ + } + continue + case '"': + if inQuote { + // Validate the quoted parameter before trusting its boundaries. + if _, _, err := mime.ParseMediaType("text/plain;" + params[start:i+1]); err != nil { + return true + } + } else { + _, prefix, ok := strings.Cut(params[start:i], "=") + if !ok || strings.TrimSpace(prefix) != "" { + return true + } + } + inQuote = !inQuote + continue + } + } + if i != len(params) && (params[i] != ';' || inQuote) { + continue + } + name, _, _ := strings.Cut(params[start:i], "=") + name = strings.ToLower(strings.TrimSpace(name)) + if name == "charset" || strings.HasPrefix(name, "charset*") { + return true + } + start = i + 1 + } + return inQuote +} + +func passthroughTextMediaType(mediaType string) bool { + if strings.HasPrefix(mediaType, "text/") || strings.HasSuffix(mediaType, "+json") || strings.HasSuffix(mediaType, "+xml") { + return true + } + switch mediaType { + case "application/json", "application/javascript", "application/toml", "application/x-www-form-urlencoded", "application/xml", + "application/x-yaml", "application/yaml": + return true + default: + return false + } +} + +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..4800e04084 --- /dev/null +++ b/go/adk/pkg/mcp/passthrough_test.go @@ -0,0 +1,322 @@ +package mcp + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + "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{}}, + } + // Exercise upstream's MIME/charset rules over the wire as well as text + // resources: unsupported or ambiguous encodings must stay metadata-only. + for _, blob := range []struct { + name, mimeType string + data []byte + decoded bool + }{ + {"UTF-8 JSON", "application/problem+json; charset=utf-8", []byte(`{"ok":true}`), true}, + {"ASCII", "text/plain; charset=us-ascii", []byte("hello"), true}, + {"binary", "application/pdf", []byte{1, 2, 3}, false}, + {"empty blob", "text/plain", nil, false}, + {"invalid UTF-8", "text/plain", []byte{0xff}, false}, + {"non-ASCII", "text/plain; charset=ascii", []byte("é"), false}, + {"unsupported charset", "text/plain; charset=utf-16", []byte("hello"), false}, + {"invalid charset", "text/plain; charset", []byte("hello"), false}, + {"extended charset", "text/plain; charset*=utf-16", []byte("hello"), false}, + {"unclosed quote", `text/plain; name="a; charset=utf-16`, []byte("hello"), false}, + {"misplaced quote", `text/plain; name=a"; charset=utf-16; x="b`, []byte("hello"), false}, + {"invalid quoted name", `text/plain; "charset"=utf-16`, []byte("hello"), false}, + {"quoted charset text", `text/plain; name="a; charset=utf-16"`, []byte("hello"), true}, + {"escaped quote", `text/plain; name="a\"; charset=utf-16"`, []byte("hello"), true}, + {"unrelated invalid parameter", "text/plain; broken", []byte("hello"), true}, + } { + want := fmt.Sprintf("[MCP embedded resource: uri=\"file:///blob\", mimeType=%q", blob.mimeType) + if blob.decoded { + want += "]\n" + string(blob.data) + } else if len(blob.data) > 0 { + want += fmt.Sprintf(", size=%d bytes]", len(blob.data)) + } else { + want += "]" + } + tests = append(tests, testCase{name: blob.name, result: &mcpsdk.CallToolResult{Content: []mcpsdk.Content{ + &mcpsdk.EmbeddedResource{Resource: &mcpsdk.ResourceContents{ + URI: "file:///blob", MIMEType: blob.mimeType, Blob: blob.data, + }}, + }}, want: want}) + } + 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) + want, err := ordinaryTools[0].(functionTool).Run(callCtx, map[string]any{}) + require.NoError(t, err) + require.Equal(t, want, got) + }) + } +} + +type unsupportedPassthroughContent struct{ mcpsdk.Content } + +func TestFormatPassthroughUnavailableContent(t *testing.T) { + got := formatPassthroughContent([]mcpsdk.Content{ + nil, (*mcpsdk.TextContent)(nil), (*mcpsdk.EmbeddedResource)(nil), + &mcpsdk.EmbeddedResource{}, (*mcpsdk.ResourceLink)(nil), + (*mcpsdk.ImageContent)(nil), (*mcpsdk.AudioContent)(nil), &unsupportedPassthroughContent{}, + }) + require.Equal(t, "[MCP content: unavailable]\n[MCP text content: unavailable]\n"+ + "[MCP embedded resource: unavailable]\n[MCP embedded resource: unavailable]\n"+ + "[MCP resource link: unavailable]\n[MCP image: unavailable]\n"+ + "[MCP audio: unavailable]\n[MCP content: unsupported]", got) +} + +func TestPassthroughRunCredentialSafeErrors(t *testing.T) { + const token = "test-sensitive-caller-token" + for _, failure := range []string{"connect", "call", "result"} { + t.Run(failure, func(t *testing.T) { + server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "1"}, nil) + server.AddTool(&mcpsdk.Tool{Name: "fail", InputSchema: map[string]any{"type": "object"}}, + func(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + if failure == "call" { + return nil, fmt.Errorf("upstream echoed %s", token) + } + 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"}, + } + ctx := context.WithValue(t.Context(), models.BearerTokenKey, token) + got, err := p.Run(toolContext{ctx: ctx}, map[string]any{}) + require.Nil(t, got) + switch failure { + case "connect": + require.EqualError(t, err, "failed to connect passthrough MCP server") + case "call": + require.EqualError(t, err, "failed to call passthrough MCP tool") + case "result": + require.EqualError(t, err, "passthrough MCP tool execution failed") + } + require.NotContains(t, err.Error(), token) + }) + } +} + +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 + 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) + })) + 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) + 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(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() + }) + } +} + +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. */ From eb6c16c0bfe945ffe93d35d534be2201fabc3d7b Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Sun, 18 Oct 2026 02:50:38 +0000 Subject: [PATCH 2/2] refactor: reuse ADK toolsets for MCP token passthrough Scope ordinary ADK toolsets and tracked transports to each operation instead of duplicating tool result parsing. Preserve credential isolation, visibility checks, and cleanup across reconnects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Eitan Yarmush --- docs/architecture/credential-injection.md | 15 +- go/adk/pkg/mcp/passthrough.go | 342 +++++++-------------- go/adk/pkg/mcp/passthrough_test.go | 352 ++++++++++++++++++---- 3 files changed, 403 insertions(+), 306 deletions(-) diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index b48677ffc5..6f647c3c01 100644 --- a/docs/architecture/credential-injection.md +++ b/docs/architecture/credential-injection.md @@ -142,10 +142,17 @@ 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 uses a fresh MCP -session for each listing and tool call, closing it at the operation boundary: -the ordinary ADK toolset retains its first caller's session context and is not -safe for this mode. Python creates agents per request and pools MCP sessions +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 diff --git a/go/adk/pkg/mcp/passthrough.go b/go/adk/pkg/mcp/passthrough.go index e4de8bd7f5..84f36369ed 100644 --- a/go/adk/pkg/mcp/passthrough.go +++ b/go/adk/pkg/mcp/passthrough.go @@ -4,23 +4,22 @@ import ( "context" "errors" "fmt" - "mime" - "strings" - "unicode/utf8" + "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. ADK's ordinary toolset shares one MCP session for its lifetime; -// that session can retain the first caller's identity for background requests. +// credential. Each operation owns an ordinary ADK toolset and its connections. type passthroughToolset struct { params mcpServerParams filter map[string]bool @@ -28,49 +27,99 @@ type passthroughToolset struct { func (*passthroughToolset) Name() string { return "mcp_tool_set" } -func (p *passthroughToolset) connect(ctx context.Context) (*mcpsdk.ClientSession, error) { +// 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()}) - session, err := client.Connect(ctx, transport, nil) + // 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 { - // Remote errors can echo request credentials. - return nil, fmt.Errorf("failed to connect passthrough MCP server") + return nil, passthroughError(err) } - return session, nil -} - -func closePassthroughSession(ctx context.Context, session *mcpsdk.ClientSession) { - if err := session.Close(); err != nil { - logging.FromContext(ctx).WarnContext(ctx, "failed to close passthrough MCP session") + 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) { - session, err := p.connect(ctx) + transport := &passthroughTransport{params: p.params} + defer transport.close(ctx) + tools, err := p.tools(ctx, transport) if err != nil { return nil, err } - defer closePassthroughSession(ctx, session) - var tools []tool.Tool - for item, err := range session.Tools(ctx, nil) { - if err != nil { - return nil, fmt.Errorf("failed to list passthrough MCP tools") - } - if len(p.filter) > 0 && !p.filter[item.Name] || mcpToolKindOf(item.Meta) == mcpToolKindAppInternal { - continue - } - declaration := &genai.FunctionDeclaration{Name: item.Name, Description: item.Description} - if item.InputSchema != nil { - declaration.ParametersJsonSchema = item.InputSchema - } - if item.OutputSchema != nil { - declaration.ResponseJsonSchema = item.OutputSchema - } - tools = append(tools, &passthroughTool{set: p, declaration: declaration}) + 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 } @@ -89,223 +138,38 @@ func (p *passthroughTool) ProcessRequest(_ adkagent.Context, req *model.LLMReque } func (p *passthroughTool) Run(ctx adkagent.Context, args any) (map[string]any, error) { - session, err := p.set.connect(ctx) + transport := &passthroughTransport{params: p.set.params} + defer transport.close(ctx) + tools, err := p.set.tools(ctx, transport) if err != nil { return nil, err } - defer closePassthroughSession(ctx, session) - result, err := session.CallTool(ctx, &mcpsdk.CallToolParams{Name: p.Name(), Arguments: args}) - if err != nil { - return nil, fmt.Errorf("failed to call passthrough MCP tool") - } - if result.IsError { - return nil, fmt.Errorf("passthrough MCP tool execution failed") - } - if result.StructuredContent != nil { - return map[string]any{"output": result.StructuredContent}, nil - } - return map[string]any{"output": formatPassthroughContent(result.Content)}, nil -} - -// The success formatter below follows google.golang.org/adk/v2 v2.5.0 -// tool/mcptoolset/tool.go. Its converter is private, and using the ordinary -// toolset would retain the first caller's session. Keep error handling above -// separate: upstream error formatting can expose caller credentials. -// -// Adapted from Google ADK, Copyright 2025 Google LLC, under the Apache License, -// Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0). -func formatPassthroughContent(contents []mcpsdk.Content) string { - var result strings.Builder - var previous string - previousPlain := true - for _, content := range contents { - text, plain := formatPassthroughBlock(content) - if text == "" { - continue - } - if previous != "" && (!previousPlain || !plain) && - !strings.HasSuffix(previous, "\n") && !strings.HasPrefix(text, "\n") { - result.WriteByte('\n') - } - result.WriteString(text) - previous, previousPlain = text, plain - } - return result.String() -} - -func formatPassthroughBlock(content mcpsdk.Content) (string, bool) { - switch content := content.(type) { - case nil: - return "[MCP content: unavailable]", false - case *mcpsdk.TextContent: - if content == nil { - return "[MCP text content: unavailable]", false - } - return content.Text, true - case *mcpsdk.EmbeddedResource: - if content == nil || content.Resource == nil { - return "[MCP embedded resource: unavailable]", false - } - resource := content.Resource - attributes := passthroughResourceAttributes(resource.URI, resource.MIMEType) - body := resource.Text - if body == "" { - body, _ = decodePassthroughTextBlob(resource.Blob, resource.MIMEType) - } - if len(resource.Blob) > 0 && (resource.Text != "" || body == "") { - attributes = append(attributes, fmt.Sprintf("size=%d bytes", len(resource.Blob))) - } - text := passthroughContentLabel("embedded resource", attributes) - if body != "" { - text += "\n" + body - } - return text, false - case *mcpsdk.ResourceLink: - if content == nil { - return "[MCP resource link: unavailable]", false - } - attributes := passthroughResourceAttributes(content.URI, content.MIMEType) - if content.Name != "" { - attributes = append(attributes, fmt.Sprintf("name=%q", content.Name)) - } - if content.Title != "" { - attributes = append(attributes, fmt.Sprintf("title=%q", content.Title)) - } - if content.Description != "" { - attributes = append(attributes, fmt.Sprintf("description=%q", content.Description)) - } - if content.Size != nil { - attributes = append(attributes, fmt.Sprintf("size=%d bytes", *content.Size)) - } - return passthroughContentLabel("resource link", attributes), false - case *mcpsdk.ImageContent: - if content == nil { - return "[MCP image: unavailable]", false - } - return passthroughMediaLabel("image", content.MIMEType, len(content.Data)), false - case *mcpsdk.AudioContent: - if content == nil { - return "[MCP audio: unavailable]", false - } - return passthroughMediaLabel("audio", content.MIMEType, len(content.Data)), false - default: - return "[MCP content: unsupported]", false - } -} - -func passthroughResourceAttributes(uri, mimeType string) []string { - attributes := make([]string, 0, 2) - if uri != "" { - attributes = append(attributes, fmt.Sprintf("uri=%q", uri)) - } - if mimeType != "" { - attributes = append(attributes, fmt.Sprintf("mimeType=%q", mimeType)) - } - return attributes -} - -func passthroughMediaLabel(kind, mimeType string, size int) string { - attributes := passthroughResourceAttributes("", mimeType) - attributes = append(attributes, fmt.Sprintf("size=%d bytes", size)) - return passthroughContentLabel(kind, attributes) -} - -func passthroughContentLabel(kind string, attributes []string) string { - if len(attributes) == 0 { - return "[MCP " + kind + "]" - } - return "[MCP " + kind + ": " + strings.Join(attributes, ", ") + "]" -} - -func decodePassthroughTextBlob(blob []byte, mimeType string) (string, bool) { - if len(blob) == 0 { - return "", false - } - declaresCharset := passthroughHasMIMECharset(mimeType) - mediaType, params, err := mime.ParseMediaType(mimeType) - if err != nil && (!errors.Is(err, mime.ErrInvalidMediaParameter) || declaresCharset) { - return "", false - } - if !passthroughTextMediaType(mediaType) { - return "", false - } - charset, parsedCharset := params["charset"] - if declaresCharset && !parsedCharset { - return "", false - } - switch strings.ToLower(charset) { - case "", "utf-8", "utf8": - if !utf8.Valid(blob) { - return "", false - } - case "us-ascii", "ascii": - for _, b := range blob { - if b >= utf8.RuneSelf { - return "", false + 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 } - default: - return "", false } - return string(blob), true + return nil, fmt.Errorf("passthrough MCP tool is no longer available") } -func passthroughHasMIMECharset(value string) bool { - _, params, ok := strings.Cut(value, ";") - if !ok { - return false - } - start := 0 - inQuote := false - for i := 0; i <= len(params); i++ { - if i != len(params) { - switch params[i] { - case '\\': - if inQuote && i+1 < len(params) { - i++ - } - continue - case '"': - if inQuote { - // Validate the quoted parameter before trusting its boundaries. - if _, _, err := mime.ParseMediaType("text/plain;" + params[start:i+1]); err != nil { - return true - } - } else { - _, prefix, ok := strings.Cut(params[start:i], "=") - if !ok || strings.TrimSpace(prefix) != "" { - return true - } - } - inQuote = !inQuote - continue - } - } - if i != len(params) && (params[i] != ';' || inQuote) { - continue - } - name, _, _ := strings.Cut(params[start:i], "=") - name = strings.ToLower(strings.TrimSpace(name)) - if name == "charset" || strings.HasPrefix(name, "charset*") { - return true +// 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 } - start = i + 1 - } - return inQuote -} - -func passthroughTextMediaType(mediaType string) bool { - if strings.HasPrefix(mediaType, "text/") || strings.HasSuffix(mediaType, "+json") || strings.HasSuffix(mediaType, "+xml") { - return true - } - switch mediaType { - case "application/json", "application/javascript", "application/toml", "application/x-www-form-urlencoded", "application/xml", - "application/x-yaml", "application/yaml": - return true - default: - return false } + 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 index 4800e04084..4057f23f3e 100644 --- a/go/adk/pkg/mcp/passthrough_test.go +++ b/go/adk/pkg/mcp/passthrough_test.go @@ -1,15 +1,25 @@ 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" @@ -74,43 +84,6 @@ func TestPassthroughRunSuccessParity(t *testing.T) { Content: []mcpsdk.Content{&mcpsdk.TextContent{Text: "ignored"}}, }, want: map[string]any{}}, } - // Exercise upstream's MIME/charset rules over the wire as well as text - // resources: unsupported or ambiguous encodings must stay metadata-only. - for _, blob := range []struct { - name, mimeType string - data []byte - decoded bool - }{ - {"UTF-8 JSON", "application/problem+json; charset=utf-8", []byte(`{"ok":true}`), true}, - {"ASCII", "text/plain; charset=us-ascii", []byte("hello"), true}, - {"binary", "application/pdf", []byte{1, 2, 3}, false}, - {"empty blob", "text/plain", nil, false}, - {"invalid UTF-8", "text/plain", []byte{0xff}, false}, - {"non-ASCII", "text/plain; charset=ascii", []byte("é"), false}, - {"unsupported charset", "text/plain; charset=utf-16", []byte("hello"), false}, - {"invalid charset", "text/plain; charset", []byte("hello"), false}, - {"extended charset", "text/plain; charset*=utf-16", []byte("hello"), false}, - {"unclosed quote", `text/plain; name="a; charset=utf-16`, []byte("hello"), false}, - {"misplaced quote", `text/plain; name=a"; charset=utf-16; x="b`, []byte("hello"), false}, - {"invalid quoted name", `text/plain; "charset"=utf-16`, []byte("hello"), false}, - {"quoted charset text", `text/plain; name="a; charset=utf-16"`, []byte("hello"), true}, - {"escaped quote", `text/plain; name="a\"; charset=utf-16"`, []byte("hello"), true}, - {"unrelated invalid parameter", "text/plain; broken", []byte("hello"), true}, - } { - want := fmt.Sprintf("[MCP embedded resource: uri=\"file:///blob\", mimeType=%q", blob.mimeType) - if blob.decoded { - want += "]\n" + string(blob.data) - } else if len(blob.data) > 0 { - want += fmt.Sprintf(", size=%d bytes]", len(blob.data)) - } else { - want += "]" - } - tests = append(tests, testCase{name: blob.name, result: &mcpsdk.CallToolResult{Content: []mcpsdk.Content{ - &mcpsdk.EmbeddedResource{Resource: &mcpsdk.ResourceContents{ - URI: "file:///blob", MIMEType: blob.mimeType, Blob: blob.data, - }}, - }}, want: want}) - } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { server := mcpsdk.NewServer(&mcpsdk.Implementation{Name: "test", Version: "1"}, nil) @@ -145,6 +118,7 @@ func TestPassthroughRunSuccessParity(t *testing.T) { 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) @@ -152,30 +126,23 @@ func TestPassthroughRunSuccessParity(t *testing.T) { } } -type unsupportedPassthroughContent struct{ mcpsdk.Content } - -func TestFormatPassthroughUnavailableContent(t *testing.T) { - got := formatPassthroughContent([]mcpsdk.Content{ - nil, (*mcpsdk.TextContent)(nil), (*mcpsdk.EmbeddedResource)(nil), - &mcpsdk.EmbeddedResource{}, (*mcpsdk.ResourceLink)(nil), - (*mcpsdk.ImageContent)(nil), (*mcpsdk.AudioContent)(nil), &unsupportedPassthroughContent{}, - }) - require.Equal(t, "[MCP content: unavailable]\n[MCP text content: unavailable]\n"+ - "[MCP embedded resource: unavailable]\n[MCP embedded resource: unavailable]\n"+ - "[MCP resource link: unavailable]\n[MCP image: unavailable]\n"+ - "[MCP audio: unavailable]\n[MCP content: unsupported]", got) -} - func TestPassthroughRunCredentialSafeErrors(t *testing.T) { const token = "test-sensitive-caller-token" - for _, failure := range []string{"connect", "call", "result"} { + 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(context.Context, *mcpsdk.CallToolRequest) (*mcpsdk.CallToolResult, error) { + 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}}, @@ -196,22 +163,233 @@ func TestPassthroughRunCredentialSafeErrors(t *testing.T) { }}, declaration: &genai.FunctionDeclaration{Name: "fail"}, } - ctx := context.WithValue(t.Context(), models.BearerTokenKey, token) - got, err := p.Run(toolContext{ctx: ctx}, map[string]any{}) + got, err := p.Run(passthroughResultContext{toolContext{ctx: ctx}}, map[string]any{}) require.Nil(t, got) - switch failure { - case "connect": - require.EqualError(t, err, "failed to connect passthrough MCP server") - case "call": - require.EqualError(t, err, "failed to call passthrough MCP tool") - case "result": - require.EqualError(t, err, "passthrough MCP tool execution failed") + 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) { @@ -233,11 +411,29 @@ func TestPassthroughMCPInvocationIsolation(t *testing.T) { } 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, @@ -265,6 +461,9 @@ func TestPassthroughMCPInvocationIsolation(t *testing.T) { 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() @@ -272,7 +471,7 @@ func TestPassthroughMCPInvocationIsolation(t *testing.T) { // 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(toolContext{ctx: bob}, map[string]any{}) + 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() @@ -287,6 +486,33 @@ func TestPassthroughMCPInvocationIsolation(t *testing.T) { 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() }) } }