From 72a4239cb67933c44501e4c98bc298bf74b97cd9 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Tue, 30 Dec 2025 08:14:24 -0700 Subject: [PATCH 01/11] Add proxy support for kagent-adk Signed-off-by: Jeremy Alvis --- .../translator/agent/adk_api_translator.go | 113 +++- .../agent/adk_translator_golden_test.go | 15 +- .../controller/translator/agent/proxy_test.go | 162 ++++++ .../translator/agent/security_context_test.go | 8 +- .../testdata/inputs/agent_with_proxy.yaml | 64 +++ .../testdata/outputs/agent_with_proxy.json | 311 +++++++++++ go/internal/httpserver/handlers/agents.go | 2 + .../httpserver/handlers/agents_test.go | 2 + go/internal/httpserver/handlers/handlers.go | 6 +- go/internal/httpserver/server.go | 4 +- go/pkg/app/app.go | 11 + go/pkg/app/app_test.go | 8 + .../templates/controller-configmap.yaml | 6 + .../tests/controller-deployment_test.yaml | 33 ++ helm/kagent/values.yaml | 13 + python/packages/kagent-adk/pyproject.toml | 2 +- .../kagent-adk/src/kagent/adk/types.py | 39 +- .../tests/unittests/test_proxy_integration.py | 498 ++++++++++++++++++ python/uv.lock | 8 +- .../app/a2a/[namespace]/[agentName]/route.ts | 2 +- 20 files changed, 1264 insertions(+), 43 deletions(-) create mode 100644 go/internal/controller/translator/agent/proxy_test.go create mode 100644 go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy.yaml create mode 100644 go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json create mode 100644 python/packages/kagent-adk/tests/unittests/test_proxy_integration.py diff --git a/go/internal/controller/translator/agent/adk_api_translator.go b/go/internal/controller/translator/agent/adk_api_translator.go index 0eeafb507..14f8c9d35 100644 --- a/go/internal/controller/translator/agent/adk_api_translator.go +++ b/go/internal/controller/translator/agent/adk_api_translator.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "maps" + "net/url" "os" "slices" "strconv" @@ -73,18 +74,22 @@ type AdkApiTranslator interface { type TranslatorPlugin = translator.TranslatorPlugin -func NewAdkApiTranslator(kube client.Client, defaultModelConfig types.NamespacedName, plugins []TranslatorPlugin) AdkApiTranslator { +func NewAdkApiTranslator(kube client.Client, defaultModelConfig types.NamespacedName, plugins []TranslatorPlugin, globalAgentProxyURL, globalEgressProxyURL string) AdkApiTranslator { return &adkApiTranslator{ - kube: kube, - defaultModelConfig: defaultModelConfig, - plugins: plugins, + kube: kube, + defaultModelConfig: defaultModelConfig, + plugins: plugins, + globalAgentProxyURL: globalAgentProxyURL, + globalEgressProxyURL: globalEgressProxyURL, } } type adkApiTranslator struct { - kube client.Client - defaultModelConfig types.NamespacedName - plugins []TranslatorPlugin + kube client.Client + defaultModelConfig types.NamespacedName + plugins []TranslatorPlugin + globalAgentProxyURL string // Global agent proxy URL for agent -> agent traffic + globalEgressProxyURL string // Global egress proxy URL for agent -> MCP server traffic } const MAX_DEPTH = 10 @@ -532,7 +537,8 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al // Skip tools that are not applicable to the model provider switch { case tool.McpServer != nil: - err := a.translateMCPServerTarget(ctx, cfg, agent.Namespace, tool.McpServer, tool.HeadersFrom) + // Use egress proxy for MCP server/tool communication + err := a.translateMCPServerTarget(ctx, cfg, agent.Namespace, tool.McpServer, tool.HeadersFrom, a.globalEgressProxyURL) if err != nil { return nil, nil, nil, err } @@ -555,15 +561,36 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al switch toolAgent.Spec.Type { case v1alpha2.AgentType_BYO, v1alpha2.AgentType_Declarative: - url := fmt.Sprintf("http://%s.%s:8080", toolAgent.Name, toolAgent.Namespace) + originalURL := fmt.Sprintf("http://%s.%s:8080", toolAgent.Name, toolAgent.Namespace) headers, err := tool.ResolveHeaders(ctx, a.kube, agent.Namespace) if err != nil { return nil, nil, nil, err } + // If proxy is configured, use proxy URL and set Host header for Gateway API routing + targetURL := originalURL + if a.globalAgentProxyURL != "" { + // Parse original URL to extract path and hostname + originalURLParsed, err := url.Parse(originalURL) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to parse agent URL %q: %w", originalURL, err) + } + proxyURLParsed, err := url.Parse(a.globalAgentProxyURL) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to parse proxy URL %q: %w", a.globalAgentProxyURL, err) + } + // Use proxy URL with original path + targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURLParsed.Path) + // Set Host header to original hostname (without port) for Gateway API routing + if headers == nil { + headers = make(map[string]string) + } + headers["Host"] = originalURLParsed.Hostname() + } + cfg.RemoteAgents = append(cfg.RemoteAgents, adk.RemoteAgentConfig{ Name: utils.ConvertToPythonIdentifier(utils.GetObjectRef(toolAgent)), - Url: url, + Url: targetURL, Headers: headers, Description: toolAgent.Spec.Description, }) @@ -921,14 +948,36 @@ func (a *adkApiTranslator) translateModel(ctx context.Context, namespace, modelC return nil, nil, nil, fmt.Errorf("unknown model provider: %s", model.Spec.Provider) } -func (a *adkApiTranslator) translateStreamableHttpTool(ctx context.Context, tool *v1alpha2.RemoteMCPServerSpec, namespace string) (*adk.StreamableHTTPConnectionParams, error) { +func (a *adkApiTranslator) translateStreamableHttpTool(ctx context.Context, tool *v1alpha2.RemoteMCPServerSpec, namespace string, proxyURL string) (*adk.StreamableHTTPConnectionParams, error) { headers, err := tool.ResolveHeaders(ctx, a.kube, namespace) if err != nil { return nil, err } + // If proxy is configured, use proxy URL and set Host header for Gateway API routing + targetURL := tool.URL + if proxyURL != "" { + // Parse original URL to extract path and hostname + originalURL, err := url.Parse(tool.URL) + if err != nil { + return nil, fmt.Errorf("failed to parse tool URL %q: %w", tool.URL, err) + } + proxyURLParsed, err := url.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("failed to parse proxy URL %q: %w", proxyURL, err) + } + // Use proxy URL with original path + targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURL.Path) + // Set Host header to original hostname (without port) for Gateway API routing + // Gateway API HTTPRoute hostname matching ignores ports, but we strip it for clarity + if headers == nil { + headers = make(map[string]string) + } + headers["Host"] = originalURL.Hostname() + } + params := &adk.StreamableHTTPConnectionParams{ - Url: tool.URL, + Url: targetURL, Headers: headers, } if tool.Timeout != nil { @@ -943,14 +992,36 @@ func (a *adkApiTranslator) translateStreamableHttpTool(ctx context.Context, tool return params, nil } -func (a *adkApiTranslator) translateSseHttpTool(ctx context.Context, tool *v1alpha2.RemoteMCPServerSpec, namespace string) (*adk.SseConnectionParams, error) { +func (a *adkApiTranslator) translateSseHttpTool(ctx context.Context, tool *v1alpha2.RemoteMCPServerSpec, namespace string, proxyURL string) (*adk.SseConnectionParams, error) { headers, err := tool.ResolveHeaders(ctx, a.kube, namespace) if err != nil { return nil, err } + // If proxy is configured, use proxy URL and set Host header for Gateway API routing + targetURL := tool.URL + if proxyURL != "" { + // Parse original URL to extract path and hostname + originalURL, err := url.Parse(tool.URL) + if err != nil { + return nil, fmt.Errorf("failed to parse tool URL %q: %w", tool.URL, err) + } + proxyURLParsed, err := url.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("failed to parse proxy URL %q: %w", proxyURL, err) + } + // Use proxy URL with original path + targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURL.Path) + // Set Host header to original hostname (without port) for Gateway API routing + // Gateway API HTTPRoute hostname matching ignores ports, but we strip it for clarity + if headers == nil { + headers = make(map[string]string) + } + headers["Host"] = originalURL.Hostname() + } + params := &adk.SseConnectionParams{ - Url: tool.URL, + Url: targetURL, Headers: headers, } if tool.Timeout != nil { @@ -962,7 +1033,7 @@ func (a *adkApiTranslator) translateSseHttpTool(ctx context.Context, tool *v1alp return params, nil } -func (a *adkApiTranslator) translateMCPServerTarget(ctx context.Context, agent *adk.AgentConfig, agentNamespace string, toolServer *v1alpha2.McpServerTool, toolHeaders []v1alpha2.ValueRef) error { +func (a *adkApiTranslator) translateMCPServerTarget(ctx context.Context, agent *adk.AgentConfig, agentNamespace string, toolServer *v1alpha2.McpServerTool, toolHeaders []v1alpha2.ValueRef, proxyURL string) error { gvk := toolServer.GroupKind() switch gvk { @@ -993,7 +1064,7 @@ func (a *adkApiTranslator) translateMCPServerTarget(ctx context.Context, agent * spec.HeadersFrom = append(spec.HeadersFrom, toolHeaders...) - return a.translateRemoteMCPServerTarget(ctx, agent, agentNamespace, spec, toolServer.ToolNames) + return a.translateRemoteMCPServerTarget(ctx, agent, agentNamespace, spec, toolServer.ToolNames, proxyURL) case schema.GroupKind{ Group: "", Kind: "RemoteMCPServer", @@ -1011,7 +1082,7 @@ func (a *adkApiTranslator) translateMCPServerTarget(ctx context.Context, agent * remoteMcpServer.Spec.HeadersFrom = append(remoteMcpServer.Spec.HeadersFrom, toolHeaders...) - return a.translateRemoteMCPServerTarget(ctx, agent, agentNamespace, &remoteMcpServer.Spec, toolServer.ToolNames) + return a.translateRemoteMCPServerTarget(ctx, agent, agentNamespace, &remoteMcpServer.Spec, toolServer.ToolNames, proxyURL) case schema.GroupKind{ Group: "", Kind: "Service", @@ -1034,7 +1105,7 @@ func (a *adkApiTranslator) translateMCPServerTarget(ctx context.Context, agent * spec.HeadersFrom = append(spec.HeadersFrom, toolHeaders...) - return a.translateRemoteMCPServerTarget(ctx, agent, agentNamespace, spec, toolServer.ToolNames) + return a.translateRemoteMCPServerTarget(ctx, agent, agentNamespace, spec, toolServer.ToolNames, proxyURL) default: return fmt.Errorf("unknown tool server type: %s", gvk) @@ -1099,10 +1170,10 @@ func ConvertMCPServerToRemoteMCPServer(mcpServer *v1alpha1.MCPServer) (*v1alpha2 }, nil } -func (a *adkApiTranslator) translateRemoteMCPServerTarget(ctx context.Context, agent *adk.AgentConfig, agentNamespace string, remoteMcpServer *v1alpha2.RemoteMCPServerSpec, toolNames []string) error { +func (a *adkApiTranslator) translateRemoteMCPServerTarget(ctx context.Context, agent *adk.AgentConfig, agentNamespace string, remoteMcpServer *v1alpha2.RemoteMCPServerSpec, toolNames []string, proxyURL string) error { switch remoteMcpServer.Protocol { case v1alpha2.RemoteMCPServerProtocolSse: - tool, err := a.translateSseHttpTool(ctx, remoteMcpServer, agentNamespace) + tool, err := a.translateSseHttpTool(ctx, remoteMcpServer, agentNamespace, proxyURL) if err != nil { return err } @@ -1111,7 +1182,7 @@ func (a *adkApiTranslator) translateRemoteMCPServerTarget(ctx context.Context, a Tools: toolNames, }) default: - tool, err := a.translateStreamableHttpTool(ctx, remoteMcpServer, agentNamespace) + tool, err := a.translateStreamableHttpTool(ctx, remoteMcpServer, agentNamespace, proxyURL) if err != nil { return err } diff --git a/go/internal/controller/translator/agent/adk_translator_golden_test.go b/go/internal/controller/translator/agent/adk_translator_golden_test.go index 7ba17cde4..a2c4c520b 100644 --- a/go/internal/controller/translator/agent/adk_translator_golden_test.go +++ b/go/internal/controller/translator/agent/adk_translator_golden_test.go @@ -24,10 +24,12 @@ import ( // TestInput represents the structure of input test files type TestInput struct { - Objects []map[string]any `yaml:"objects"` - Operation string `yaml:"operation"` // "translateAgent", "translateTeam", "translateToolServer" - TargetObject string `yaml:"targetObject"` // name of the object to translate - Namespace string `yaml:"namespace"` + Objects []map[string]any `yaml:"objects"` + Operation string `yaml:"operation"` // "translateAgent", "translateTeam", "translateToolServer" + TargetObject string `yaml:"targetObject"` // name of the object to translate + Namespace string `yaml:"namespace"` + ProxyAgentURL string `yaml:"proxyAgentURL,omitempty"` // Optional proxy URL for A2A + ProxyEgressURL string `yaml:"proxyEgressURL,omitempty"` // Optional proxy URL for egress } // TestGoldenAdkTranslator runs golden tests for the ADK API translator @@ -119,7 +121,10 @@ func runGoldenTest(t *testing.T, inputFile, outputsDir, testName string, updateG }, agent) require.NoError(t, err) - result, err = translator.NewAdkApiTranslator(kubeClient, defaultModel, nil).TranslateAgent(ctx, agent) + // Use proxy URLs from test input if provided + proxyAgentURL := testInput.ProxyAgentURL + proxyEgressURL := testInput.ProxyEgressURL + result, err = translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, proxyAgentURL, proxyEgressURL).TranslateAgent(ctx, agent) require.NoError(t, err) default: diff --git a/go/internal/controller/translator/agent/proxy_test.go b/go/internal/controller/translator/agent/proxy_test.go new file mode 100644 index 000000000..b9621a01d --- /dev/null +++ b/go/internal/controller/translator/agent/proxy_test.go @@ -0,0 +1,162 @@ +package agent_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + schemev1 "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/kagent-dev/kagent/go/api/v1alpha2" + translator "github.com/kagent-dev/kagent/go/internal/controller/translator/agent" +) + +// TestProxyConfiguration_ThroughTranslateAgent tests proxy URL rewriting through the public API +func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { + ctx := context.Background() + scheme := schemev1.Scheme + err := v1alpha2.AddToScheme(scheme) + require.NoError(t, err) + + // Create test objects + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-model", + Namespace: "test", + }, + Spec: v1alpha2.ModelConfigSpec{ + Provider: "OpenAI", + Model: "gpt-4o", + }, + } + + remoteMcpServer := &v1alpha2.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcp", + Namespace: "test", + }, + Spec: v1alpha2.RemoteMCPServerSpec{ + URL: "http://test-mcp-server.kagent:8084/mcp", + Protocol: v1alpha2.RemoteMCPServerProtocolStreamableHttp, + }, + } + + nestedAgent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "nested-agent", + Namespace: "test", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "Test", + ModelConfig: "default-model", + }, + }, + } + + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-agent", + Namespace: "test", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "Test", + ModelConfig: "default-model", + Tools: []*v1alpha2.Tool{ + { + Type: v1alpha2.ToolProviderType_Agent, + Agent: &v1alpha2.TypedLocalReference{ + Name: "nested-agent", + }, + }, + { + Type: v1alpha2.ToolProviderType_McpServer, + McpServer: &v1alpha2.McpServerTool{ + TypedLocalReference: v1alpha2.TypedLocalReference{ + Name: "test-mcp", + Kind: "RemoteMCPServer", + }, + ToolNames: []string{"test-tool"}, + }, + }, + }, + }, + }, + } + + kubeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(agent, nestedAgent, remoteMcpServer, modelConfig). + Build() + + t.Run("with proxy URLs", func(t *testing.T) { + translator := translator.NewAdkApiTranslator( + kubeClient, + types.NamespacedName{Name: "default-model", Namespace: "test"}, + nil, + "http://agent-a2a-proxy:8081", + "http://agent-egress-proxy:8082", + ) + + result, err := translator.TranslateAgent(ctx, agent) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Config) + + // Verify A2A proxy configuration + require.Len(t, result.Config.RemoteAgents, 1) + remoteAgent := result.Config.RemoteAgents[0] + assert.Equal(t, "http://agent-a2a-proxy:8081", remoteAgent.Url) + assert.NotNil(t, remoteAgent.Headers) + assert.Equal(t, "nested-agent.test", remoteAgent.Headers["Host"]) + + // Verify egress proxy configuration + require.Len(t, result.Config.HttpTools, 1) + httpTool := result.Config.HttpTools[0] + assert.Equal(t, "http://agent-egress-proxy:8082/mcp", httpTool.Params.Url) + assert.NotNil(t, httpTool.Params.Headers) + assert.Equal(t, "test-mcp-server.kagent", httpTool.Params.Headers["Host"]) + }) + + t.Run("without proxy URLs", func(t *testing.T) { + translator := translator.NewAdkApiTranslator( + kubeClient, + types.NamespacedName{Name: "default-model", Namespace: "test"}, + nil, + "", // No A2A proxy + "", // No egress proxy + ) + + result, err := translator.TranslateAgent(ctx, agent) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Config) + + // Verify A2A direct URL (no proxy) + require.Len(t, result.Config.RemoteAgents, 1) + remoteAgent := result.Config.RemoteAgents[0] + assert.Equal(t, "http://nested-agent.test:8080", remoteAgent.Url) + // Host header should not be set when no proxy + if remoteAgent.Headers != nil { + _, hasHost := remoteAgent.Headers["Host"] + assert.False(t, hasHost, "Host header should not be set when no proxy") + } + + // Verify egress direct URL (no proxy) + require.Len(t, result.Config.HttpTools, 1) + httpTool := result.Config.HttpTools[0] + assert.Equal(t, "http://test-mcp-server.kagent:8084/mcp", httpTool.Params.Url) + // Host header should not be set when no proxy + if httpTool.Params.Headers != nil { + _, hasHost := httpTool.Params.Headers["Host"] + assert.False(t, hasHost, "Host header should not be set when no proxy") + } + }) +} diff --git a/go/internal/controller/translator/agent/security_context_test.go b/go/internal/controller/translator/agent/security_context_test.go index 71f1d5296..1728474e7 100644 --- a/go/internal/controller/translator/agent/security_context_test.go +++ b/go/internal/controller/translator/agent/security_context_test.go @@ -84,7 +84,7 @@ func TestSecurityContext_AppliedToPodSpec(t *testing.T) { Namespace: "test", Name: "test-model", } - translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil) + translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "", "") // Translate agent result, err := translatorInstance.TranslateAgent(ctx, agent) @@ -175,7 +175,7 @@ func TestSecurityContext_OnlyPodSecurityContext(t *testing.T) { Namespace: "test", Name: "test-model", } - translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil) + translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "", "") result, err := translatorInstance.TranslateAgent(ctx, agent) require.NoError(t, err) @@ -250,7 +250,7 @@ func TestSecurityContext_OnlyContainerSecurityContext(t *testing.T) { Namespace: "test", Name: "test-model", } - translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil) + translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "", "") result, err := translatorInstance.TranslateAgent(ctx, agent) require.NoError(t, err) @@ -328,7 +328,7 @@ func TestSecurityContext_WithSandbox(t *testing.T) { Namespace: "test", Name: "test-model", } - translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil) + translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "", "") result, err := translatorInstance.TranslateAgent(ctx, agent) require.NoError(t, err) diff --git a/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy.yaml b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy.yaml new file mode 100644 index 000000000..e035ee6ef --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy.yaml @@ -0,0 +1,64 @@ +operation: translateAgent +targetObject: agent-with-proxy +namespace: test +proxyAgentURL: http://agent-a2a-proxy.kagent.svc.cluster.local:8081 +proxyEgressURL: http://agent-egress-proxy.kagent.svc.cluster.local:8082 +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: openai-secret + namespace: test + data: + api-key: c2stdGVzdC1hcGkta2V5 # base64 encoded "sk-test-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: default-model + namespace: test + spec: + provider: OpenAI + model: gpt-4o + apiKeySecret: openai-secret + apiKeySecretKey: api-key + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: nested-agent + namespace: test + spec: + type: Declarative + declarative: + description: A nested agent for testing proxy + systemMessage: You are a nested agent. + modelConfig: default-model + tools: [] + - apiVersion: kagent.dev/v1alpha2 + kind: RemoteMCPServer + metadata: + name: test-mcp-server + namespace: test + spec: + url: http://test-mcp-server.kagent:8084/mcp + description: "Test MCP Server" + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: agent-with-proxy + namespace: test + spec: + type: Declarative + declarative: + description: An agent with proxy configuration + systemMessage: You are an agent that uses proxies. + modelConfig: default-model + tools: + - agent: + name: nested-agent + - type: MCPServer + mcpServer: + name: test-mcp-server + kind: RemoteMCPServer + toolNames: + - test-tool + diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json new file mode 100644 index 000000000..4a28523a9 --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json @@ -0,0 +1,311 @@ +{ + "agentCard": { + "capabilities": { + "pushNotifications": false, + "stateTransitionHistory": true, + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "agent_with_proxy", + "skills": null, + "url": "http://agent-with-proxy.test:8080", + "version": "" + }, + "config": { + "description": "", + "http_tools": [ + { + "params": { + "headers": { + "Host": "test-mcp-server.kagent" + }, + "url": "http://agent-egress-proxy.kagent.svc.cluster.local:8082/mcp" + }, + "tools": [ + "test-tool" + ] + } + ], + "instruction": "You are an agent that uses proxies.", + "model": { + "base_url": "", + "model": "gpt-4o", + "type": "openai" + }, + "remote_agents": [ + { + "headers": { + "Host": "nested-agent.test" + }, + "name": "test__NS__nested_agent", + "url": "http://agent-a2a-proxy.kagent.svc.cluster.local:8081" + } + ], + "sse_tools": null + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy" + }, + "name": "agent-with-proxy", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\"name\":\"agent_with_proxy\",\"description\":\"\",\"url\":\"http://agent-with-proxy.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://agent-egress-proxy.kagent.svc.cluster.local:8082/mcp\",\"headers\":{\"Host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://agent-a2a-proxy.kagent.svc.cluster.local:8081\",\"headers\":{\"Host\":\"nested-agent.test\"}}]}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy" + }, + "name": "agent-with-proxy", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy" + }, + "name": "agent-with-proxy", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy", + "uid": "" + } + ] + }, + "spec": { + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "agent-with-proxy" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "9348903286599888130" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "openai-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "valueFrom": { + "fieldRef": { + "fieldPath": "spec.serviceAccountName" + } + } + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + } + ], + "image": "cr.kagent.dev/kagent-dev/kagent/app:dev", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/health", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "2", + "memory": "1Gi" + }, + "requests": { + "cpu": "100m", + "memory": "384Mi" + } + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "serviceAccountName": "agent-with-proxy", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "agent-with-proxy" + } + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy" + }, + "name": "agent-with-proxy", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "agent-with-proxy" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + } + ] +} \ No newline at end of file diff --git a/go/internal/httpserver/handlers/agents.go b/go/internal/httpserver/handlers/agents.go index 5127244b3..806d777a4 100644 --- a/go/internal/httpserver/handlers/agents.go +++ b/go/internal/httpserver/handlers/agents.go @@ -200,6 +200,8 @@ func (h *AgentsHandler) HandleCreateAgent(w ErrorResponseWriter, r *http.Request kubeClientWrapper, h.DefaultModelConfig, nil, + h.AgentProxyURL, + h.EgressProxyURL, ) log.V(1).Info("Translating Agent to ADK format") diff --git a/go/internal/httpserver/handlers/agents_test.go b/go/internal/httpserver/handlers/agents_test.go index b258f87a5..6926f7cb0 100644 --- a/go/internal/httpserver/handlers/agents_test.go +++ b/go/internal/httpserver/handlers/agents_test.go @@ -78,6 +78,8 @@ func setupTestHandler(objects ...client.Object) (*handlers.AgentsHandler, string }, DatabaseService: dbClient, Authorizer: &auth.NoopAuthorizer{}, + AgentProxyURL: "", + EgressProxyURL: "", } return handlers.NewAgentsHandler(base), userID diff --git a/go/internal/httpserver/handlers/handlers.go b/go/internal/httpserver/handlers/handlers.go index 99bda31a1..8c2773391 100644 --- a/go/internal/httpserver/handlers/handlers.go +++ b/go/internal/httpserver/handlers/handlers.go @@ -33,15 +33,19 @@ type Base struct { DefaultModelConfig types.NamespacedName DatabaseService database.Client Authorizer auth.Authorizer // Interface for authorization checks + AgentProxyURL string // Agent proxy URL for agent-to-agent traffic + EgressProxyURL string // Egress proxy URL for agent-to-MCP/tool traffic } // NewHandlers creates a new Handlers instance with all handler components -func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedName, dbService database.Client, watchedNamespaces []string, authorizer auth.Authorizer) *Handlers { +func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedName, dbService database.Client, watchedNamespaces []string, authorizer auth.Authorizer, agentProxyURL, egressProxyURL string) *Handlers { base := &Base{ KubeClient: kubeClient, DefaultModelConfig: defaultModelConfig, DatabaseService: dbService, Authorizer: authorizer, + AgentProxyURL: agentProxyURL, + EgressProxyURL: egressProxyURL, } return &Handlers{ diff --git a/go/internal/httpserver/server.go b/go/internal/httpserver/server.go index 0875cf025..230af08e3 100644 --- a/go/internal/httpserver/server.go +++ b/go/internal/httpserver/server.go @@ -55,6 +55,8 @@ type ServerConfig struct { DbClient database.Client Authenticator auth.AuthProvider Authorizer auth.Authorizer + AgentProxyURL string // Agent proxy URL for agent-to-agent traffic + EgressProxyURL string // Egress proxy URL for agent-to-MCP/tool traffic } // HTTPServer is the structure that manages the HTTP server @@ -74,7 +76,7 @@ func NewHTTPServer(config ServerConfig) (*HTTPServer, error) { return &HTTPServer{ config: config, router: config.Router, - handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer), + handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer, config.AgentProxyURL, config.EgressProxyURL), authenticator: config.Authenticator, }, nil } diff --git a/go/pkg/app/app.go b/go/pkg/app/app.go index 8ee3b9589..8b20ba911 100644 --- a/go/pkg/app/app.go +++ b/go/pkg/app/app.go @@ -109,6 +109,10 @@ type Config struct { InitialBufSize resource.QuantityValue `default:"4Ki"` Timeout time.Duration `default:"60s"` } + Proxy struct { + AgentURL string // Agent proxy URL for agent -> agent traffic + EgressURL string // Egress proxy URL for agent -> MCP server traffic + } LeaderElection bool ProbeAddr string SecureMetrics bool @@ -158,6 +162,9 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.Var(&cfg.Streaming.InitialBufSize, "streaming-initial-buf-size", "The initial size of the streaming buffer.") commandLine.DurationVar(&cfg.Streaming.Timeout, "streaming-timeout", 60*time.Second, "The timeout for the streaming connection.") + commandLine.StringVar(&cfg.Proxy.AgentURL, "proxy-agent-url", "", "Agent proxy URL for agent -> agent traffic (e.g., http://agent-a2a-proxy.kagent.svc.cluster.local:8081)") + commandLine.StringVar(&cfg.Proxy.EgressURL, "proxy-egress-url", "", "Egress proxy URL for agent -> MCP server traffic (e.g., http://agent-egress-proxy.kagent.svc.cluster.local:8082)") + commandLine.StringVar(&agent_translator.DefaultImageConfig.Registry, "image-registry", agent_translator.DefaultImageConfig.Registry, "The registry to use for the image.") commandLine.StringVar(&agent_translator.DefaultImageConfig.Tag, "image-tag", agent_translator.DefaultImageConfig.Tag, "The tag to use for the image.") commandLine.StringVar(&agent_translator.DefaultImageConfig.PullPolicy, "image-pull-policy", agent_translator.DefaultImageConfig.PullPolicy, "The pull policy to use for the image.") @@ -372,6 +379,8 @@ func Start(getExtensionConfig GetExtensionConfig) { mgr.GetClient(), cfg.DefaultModelConfig, extensionCfg.AgentPlugins, + cfg.Proxy.AgentURL, + cfg.Proxy.EgressURL, ) rcnclr := reconciler.NewKagentReconciler( @@ -484,6 +493,8 @@ func Start(getExtensionConfig GetExtensionConfig) { DbClient: dbClient, Authorizer: extensionCfg.Authorizer, Authenticator: extensionCfg.Authenticator, + AgentProxyURL: cfg.Proxy.AgentURL, + EgressProxyURL: cfg.Proxy.EgressURL, }) if err != nil { setupLog.Error(err, "unable to create HTTP server") diff --git a/go/pkg/app/app_test.go b/go/pkg/app/app_test.go index a75b06a58..172547d93 100644 --- a/go/pkg/app/app_test.go +++ b/go/pkg/app/app_test.go @@ -259,6 +259,8 @@ func TestLoadFromEnvIntegration(t *testing.T) { "DEFAULT_MODEL_CONFIG_NAMESPACE": "custom-ns", "HTTP_SERVER_ADDRESS": ":9000", "A2A_BASE_URL": "http://example.com:9000", + "PROXY_AGENT_URL": "http://agent-a2a-proxy:8081", + "PROXY_EGRESS_URL": "http://agent-egress-proxy:8082", "DATABASE_TYPE": "postgres", "POSTGRES_DATABASE_URL": "postgres://localhost:5432/testdb", "WATCH_NAMESPACES": "ns1,ns2,ns3", @@ -304,6 +306,12 @@ func TestLoadFromEnvIntegration(t *testing.T) { if cfg.HttpServerAddr != ":9000" { t.Errorf("HttpServerAddr = %v, want :9000", cfg.HttpServerAddr) } + if cfg.Proxy.AgentURL != "http://agent-a2a-proxy:8081" { + t.Errorf("Proxy.AgentURL = %v, want http://agent-a2a-proxy:8081", cfg.Proxy.AgentURL) + } + if cfg.Proxy.EgressURL != "http://agent-egress-proxy:8082" { + t.Errorf("Proxy.EgressURL = %v, want http://agent-egress-proxy:8082", cfg.Proxy.EgressURL) + } if cfg.A2ABaseUrl != "http://example.com:9000" { t.Errorf("A2ABaseUrl = %v, want http://example.com:9000", cfg.A2ABaseUrl) } diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index 66095fda7..f8c551799 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -26,6 +26,12 @@ data: OTEL_LOGGING_ENABLED: {{ .Values.otel.logging.enabled | quote }} OTEL_TRACING_ENABLED: {{ .Values.otel.tracing.enabled | quote }} OTEL_TRACING_EXPORTER_OTLP_ENDPOINT: {{ .Values.otel.tracing.exporter.otlp.endpoint | quote }} + {{- if .Values.proxy.agentUrl }} + PROXY_AGENT_URL: {{ .Values.proxy.agentUrl | quote }} + {{- end }} + {{- if .Values.proxy.egressUrl }} + PROXY_EGRESS_URL: {{ .Values.proxy.egressUrl | quote }} + {{- end }} {{- if eq .Values.database.type "sqlite" }} SQLITE_DATABASE_PATH: /sqlite-volume/{{ .Values.database.sqlite.databaseName }} {{- else if and (eq .Values.database.type "postgres") (not (eq .Values.database.postgres.url "")) }} diff --git a/helm/kagent/tests/controller-deployment_test.yaml b/helm/kagent/tests/controller-deployment_test.yaml index c07c3c2c3..71a2fddb4 100644 --- a/helm/kagent/tests/controller-deployment_test.yaml +++ b/helm/kagent/tests/controller-deployment_test.yaml @@ -106,6 +106,39 @@ tests: path: data.A2A_BASE_URL value: "https://kagent.example.com" + - it: should set PROXY_AGENT_URL when set + template: controller-configmap.yaml + set: + proxy: + agentUrl: "http://agent-a2a-proxy:8081" + asserts: + - equal: + path: data.PROXY_AGENT_URL + value: "http://agent-a2a-proxy:8081" + + - it: should set PROXY_EGRESS_URL when set + + template: controller-configmap.yaml + set: + proxy: + egressUrl: "http://agent-egress-proxy:8082" + asserts: + - equal: + path: data.PROXY_EGRESS_URL + value: "http://agent-egress-proxy:8082" + + - it: should not set PROXY_AGENT_URL when not set + template: controller-configmap.yaml + asserts: + - notExists: + path: data.PROXY_AGENT_URL + + - it: should not set PROXY_EGRESS_URL when not set + template: controller-configmap.yaml + asserts: + - notExists: + path: data.PROXY_EGRESS_URL + - it: should use custom loglevel when set template: controller-configmap.yaml set: diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 1bb8168e6..e599b3bfc 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -230,6 +230,19 @@ kagent-tools: # AGENTS # ============================================================================== +# Global proxy configuration for the controller +# These proxies handle the following traffic paths: +# - agent: Agent -> Agent traffic (applied to all agent pods) +# - egress: Agent -> MCP Server/tool traffic (applied to all agent pods) +# Set these once and the controller will apply them to all agents automatically +proxy: + # Agent proxy URL for agent -> agent traffic + # Example: "http://agent-a2a-proxy:8081" + agentUrl: "" + # Egress proxy URL for agent to MCP server/tool traffic + # Example: "http://agent-egress-proxy:8082" + egressUrl: "" + agents: k8s-agent: enabled: true diff --git a/python/packages/kagent-adk/pyproject.toml b/python/packages/kagent-adk/pyproject.toml index 7daa18f49..8dee04183 100644 --- a/python/packages/kagent-adk/pyproject.toml +++ b/python/packages/kagent-adk/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "pydantic>=2.5.0", "typing-extensions>=4.8.0", "jsonref>=1.1.0", - "a2a-sdk>=0.3.1", + "a2a-sdk>=0.3.22", ] [tool.uv.sources] diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 90350e95d..6c95c648b 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -112,6 +112,7 @@ def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugi header_provider = sts_integration.header_provider if self.http_tools: for http_tool in self.http_tools: # add http tools + # If the proxy is configured, the url and headers are set in the json configuration tools.append( McpToolset( connection_params=http_tool.params, tool_filter=http_tool.tools, header_provider=header_provider @@ -119,6 +120,7 @@ def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugi ) if self.sse_tools: for sse_tool in self.sse_tools: # add sse tools + # If the proxy is configured, the url and headers are set in the json configuration tools.append( McpToolset( connection_params=sse_tool.params, tool_filter=sse_tool.tools, header_provider=header_provider @@ -126,16 +128,43 @@ def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugi ) if self.remote_agents: for remote_agent in self.remote_agents: # Add remote agents as tools - client = None + # Always create httpx client + client_kwargs: dict[str, Any] = { + "timeout": httpx.Timeout(timeout=remote_agent.timeout), + "trust_env": False, + } if remote_agent.headers: - client = httpx.AsyncClient( - headers=remote_agent.headers, timeout=httpx.Timeout(timeout=remote_agent.timeout) - ) + client_kwargs["headers"] = remote_agent.headers + + # If headers include Host header, it means we're using a proxy + # RemoteA2aAgent may use URLs from agent card response, so we need to + # rewrite all request URLs to use the proxy URL while preserving Host header + if remote_agent.headers and "Host" in remote_agent.headers: + # Parse the proxy URL to extract base URL + from urllib.parse import urlparse as parse_url + parsed_proxy = parse_url(remote_agent.url) + proxy_base = f"{parsed_proxy.scheme}://{parsed_proxy.netloc}" + target_host = remote_agent.headers["Host"] + + # Event hook to rewrite request URLs to use proxy while preserving Host header + async def rewrite_url_to_proxy(request: httpx.Request) -> None: + parsed = parse_url(str(request.url)) + new_url = f"{proxy_base}{parsed.path}" + + if parsed.query: + new_url += f"?{parsed.query}" + + request.url = httpx.URL(new_url) + request.headers["Host"] = target_host + + client_kwargs["event_hooks"] = {"request": [rewrite_url_to_proxy]} + + client = httpx.AsyncClient(**client_kwargs) remote_a2a_agent = RemoteA2aAgent( name=remote_agent.name, - agent_card=f"{remote_agent.url}/{AGENT_CARD_WELL_KNOWN_PATH}", + agent_card=f"{remote_agent.url}{AGENT_CARD_WELL_KNOWN_PATH}", description=remote_agent.description, httpx_client=client, ) diff --git a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py new file mode 100644 index 000000000..62b5b4f63 --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py @@ -0,0 +1,498 @@ +import json +import socket +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +import httpx +import pytest +from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH + +from kagent.adk.types import AgentConfig, OpenAI, RemoteAgentConfig + + +class RequestRecordingHandler(BaseHTTPRequestHandler): + """HTTP handler that records all incoming requests.""" + + requests_received = [] + + def do_GET(self): + """Handle GET requests.""" + self.requests_received.append( + { + "method": self.command, + "path": self.path, + "headers": dict(self.headers), + } + ) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + # Return a mock agent card response + response = { + "name": "remote_agent", + "description": "Remote agent", + "url": "http://remote-agent.kagent:8080", + "capabilities": {"streaming": True}, + "skills": [], + } + self.wfile.write(json.dumps(response).encode()) + + def do_POST(self): + """Handle POST requests.""" + self.do_GET() # Same handling for now + + def log_message(self, format, *args): + """Suppress log messages.""" + pass + + +class TestHTTPServer: + """Context manager for running a test HTTP server that records requests.""" + + def __init__(self, port: int = 0): + self.port = port + self.server: HTTPServer | None = None + self.thread: threading.Thread | None = None + # Clear requests before starting + RequestRecordingHandler.requests_received = [] + + def __enter__(self) -> "TestHTTPServer": + """Start the HTTP server in a background thread.""" + # Find an available port if port is 0 + if self.port == 0: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + self.port = s.getsockname()[1] + + self.server = HTTPServer(("localhost", self.port), RequestRecordingHandler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + # Wait for server to be ready + time.sleep(0.1) + + return self + + def __exit__(self, *args: Any) -> None: + """Shutdown the HTTP server.""" + if self.server: + self.server.shutdown() + self.server.server_close() + if self.thread: + self.thread.join(timeout=1.0) + + @property + def url(self) -> str: + """Get the base URL of the test server.""" + return f"http://localhost:{self.port}" + + @property + def requests(self) -> list[dict]: + """Get all requests received by the server.""" + return RequestRecordingHandler.requests_received + + +@pytest.mark.asyncio +async def test_remote_agent_with_proxy_url(): + """Test that RemoteA2aAgent requests go through the proxy URL with correct Host header. + + When proxy is configured, requests should be made to the proxy URL (our test server) + with the Host header set for proxy routing. This test uses a real HTTP server + to verify actual request behavior. + """ + with TestHTTPServer() as test_server: + config = AgentConfig( + model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), + description="Test agent", + instruction="You are a test agent", + remote_agents=[ + RemoteAgentConfig( + name="remote_agent", + url=test_server.url, # Use test server as proxy URL + description="Remote agent", + headers={"Host": "remote-agent.kagent"}, # Host header for proxy routing + ) + ], + ) + + agent = config.to_agent("test_agent") + + # Find the RemoteA2aAgent tool + from google.adk.tools.agent_tool import AgentTool + + remote_agent_tool = None + for tool in agent.tools: + if isinstance(tool, AgentTool): + remote_agent_tool = tool.agent + break + + assert remote_agent_tool is not None + + # Make a request - this should go through the proxy (test server) + async with remote_agent_tool._httpx_client as client: + await client.get(f"{AGENT_CARD_WELL_KNOWN_PATH}") + + # Verify that requests were made to the proxy URL (test server) + assert len(test_server.requests) > 0, "No requests were received by test server" + request = test_server.requests[0] + assert request["path"] == AGENT_CARD_WELL_KNOWN_PATH + # Verify Host header is set for proxy routing + assert request["headers"].get("Host") == "remote-agent.kagent" or request["headers"].get("host") == "remote-agent.kagent" + + +def test_remote_agent_no_proxy_when_not_configured(): + """Test that RemoteA2aAgent HTTP client works without proxy.""" + config = AgentConfig( + model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), + description="Test agent", + instruction="You are a test agent", + remote_agents=[ + RemoteAgentConfig( + name="remote_agent", + url="http://remote-agent:8080", + description="Remote agent", + ) + ], + ) + + agent = config.to_agent("test_agent") + + # Find the RemoteA2aAgent tool + # AgentTool wraps the RemoteA2aAgent + remote_agent_tool = None + for tool in agent.tools: + if hasattr(tool, "agent"): + remote_agent_tool = tool.agent + break + + assert remote_agent_tool is not None, ( + f"No RemoteA2aAgent tool found. Tools: {[type(t).__name__ for t in agent.tools]}" + ) + + # Verify agent was created successfully (no proxy configuration means no special setup needed) + assert remote_agent_tool.name == "remote_agent" + + +@pytest.mark.asyncio +async def test_remote_agent_direct_url_no_proxy(): + """Test that RemoteA2aAgent makes requests to direct URL when no proxy is configured.""" + with TestHTTPServer() as test_server: + config = AgentConfig( + model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), + description="Test agent", + instruction="You are a test agent", + remote_agents=[ + RemoteAgentConfig( + name="remote_agent", + url=test_server.url, # Direct URL (no proxy) + description="Remote agent", + ) + ], + ) + + agent = config.to_agent("test_agent") + + # Find the RemoteA2aAgent tool + from google.adk.tools.agent_tool import AgentTool + + remote_agent_tool = None + for tool in agent.tools: + if isinstance(tool, AgentTool): + remote_agent_tool = tool.agent + break + + assert remote_agent_tool is not None + + # Make a request - should go directly to the configured URL + # When no proxy is configured, we need to use the full URL + async with remote_agent_tool._httpx_client as client: + await client.get(f"{test_server.url}{AGENT_CARD_WELL_KNOWN_PATH}") + + # Verify request went to direct URL (no proxy) + assert len(test_server.requests) > 0 + assert test_server.requests[0]["path"] == AGENT_CARD_WELL_KNOWN_PATH + # Verify Host header is set automatically by httpx based on URL + headers = test_server.requests[0]["headers"] + assert headers.get("Host") == f"localhost:{test_server.port}" or headers.get("host") == f"localhost:{test_server.port}" + + +@pytest.mark.asyncio +async def test_remote_agent_with_headers(): + """Test that RemoteA2aAgent preserves headers including Host header for proxy routing.""" + with TestHTTPServer() as test_server: + config = AgentConfig( + model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), + description="Test agent", + instruction="You are a test agent", + remote_agents=[ + RemoteAgentConfig( + name="remote_agent", + url=test_server.url, # Use test server as proxy URL + description="Remote agent", + headers={ + "Authorization": "Bearer token123", + "Host": "remote-agent.kagent", # Host header for proxy routing + }, + ) + ], + ) + + agent = config.to_agent("test_agent") + + # Find the RemoteA2aAgent tool + from google.adk.tools.agent_tool import AgentTool + + remote_agent_tool = None + for tool in agent.tools: + if isinstance(tool, AgentTool): + remote_agent_tool = tool.agent + break + + assert remote_agent_tool is not None + + # Make a request using the client + async with remote_agent_tool._httpx_client as client: + await client.get("/test") + + # Verify headers are preserved in actual requests + assert len(test_server.requests) > 0 + headers = test_server.requests[0]["headers"] + assert headers.get("Authorization") == "Bearer token123" or headers.get("authorization") == "Bearer token123" + assert headers.get("Host") == "remote-agent.kagent" or headers.get("host") == "remote-agent.kagent" + + +@pytest.mark.asyncio +async def test_remote_agent_url_rewrite_event_hook(): + """Test that URL rewrite event hook rewrites URLs to proxy when Host header is present. + + When a Host header is present, the event hook rewrites all request URLs to use the proxy + base URL while preserving the Host header. This ensures that even if RemoteA2aAgent + uses URLs from the agent card response, they still go through the proxy. + """ + with TestHTTPServer() as test_server: + config = AgentConfig( + model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), + description="Test agent", + instruction="You are a test agent", + remote_agents=[ + RemoteAgentConfig( + name="remote_agent", + url=test_server.url, # Use test server as proxy URL + description="Remote agent", + headers={"Host": "remote-agent.kagent"}, # Host header indicates proxy usage + ) + ], + ) + + agent = config.to_agent("test_agent") + + # Find the RemoteA2aAgent tool + from google.adk.tools.agent_tool import AgentTool + + remote_agent_tool = None + for tool in agent.tools: + if isinstance(tool, AgentTool): + remote_agent_tool = tool.agent + break + + assert remote_agent_tool is not None + + # Make a request that would normally use a direct URL + # The event hook should rewrite it to use the proxy (test server) + async with remote_agent_tool._httpx_client as client: + # Simulate what happens when RemoteA2aAgent makes a request using + # a URL that would normally bypass the proxy (e.g., from agent card response) + await client.get("http://remote-agent.kagent:8080/some/path") + + # Verify the request was rewritten to use the proxy (test server) + assert len(test_server.requests) > 0 + # The path should be rewritten to /some/path (proxy base URL + path) + assert test_server.requests[0]["path"] == "/some/path" + headers = test_server.requests[0]["headers"] + assert headers.get("Host") == "remote-agent.kagent" or headers.get("host") == "remote-agent.kagent" + + +def test_mcp_tool_with_proxy_url(): + """Test that MCP tools are configured with proxy URL and Host header. + + When proxy is configured, the URL is set to the proxy URL and the Host header + is included for proxy routing. These are passed through directly to McpToolset. + + Note: We verify connection_params configuration because McpToolset doesn't expose + a public API to verify proxy setup. The connection_params are what McpToolset uses + internally to create its HTTP client, so verifying them ensures our configuration + is correctly applied. + """ + from google.adk.tools.mcp_tool import StreamableHTTPConnectionParams + from kagent.adk.types import HttpMcpServerConfig + + # Configuration with proxy URL and Host header + config = AgentConfig( + model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), + description="Test agent", + instruction="You are a test agent", + http_tools=[ + HttpMcpServerConfig( + params=StreamableHTTPConnectionParams( + url="http://agent-egress-proxy:8082/mcp", # Proxy URL + headers={"Host": "test-mcp-server.kagent"}, # Host header for proxy routing + ), + tools=["test-tool"], + ) + ], + ) + + agent = config.to_agent("test_agent") + + # Find the McpToolset + mcp_tool = None + for tool in agent.tools: + if type(tool).__name__ == "McpToolset": + mcp_tool = tool + break + + assert mcp_tool is not None, f"No McpToolset found. Tools: {[type(t).__name__ for t in agent.tools]}" + + # Verify connection params are configured correctly + # Note: We access connection_params (which may be private) because McpToolset doesn't expose + # a public API to verify connection configuration. We're testing our code's configuration logic. + connection_params = getattr(mcp_tool, "_connection_params", None) or getattr(mcp_tool, "connection_params", None) + assert connection_params is not None + assert connection_params.url == "http://agent-egress-proxy:8082/mcp" + assert connection_params.headers is not None + assert connection_params.headers["Host"] == "test-mcp-server.kagent" + + +def test_mcp_tool_without_proxy(): + """Test that MCP tools are configured with direct URL when proxy is not configured. + + Note: We verify connection_params configuration because McpToolset doesn't expose + a public API to verify connection setup. The connection_params are what McpToolset uses + internally to create its HTTP client. + """ + from google.adk.tools.mcp_tool import StreamableHTTPConnectionParams + from kagent.adk.types import HttpMcpServerConfig + + config = AgentConfig( + model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), + description="Test agent", + instruction="You are a test agent", + http_tools=[ + HttpMcpServerConfig( + params=StreamableHTTPConnectionParams( + url="http://test-mcp-server.kagent:8084/mcp", # Direct URL + headers=None, # No headers + ), + tools=["test-tool"], + ) + ], + ) + + agent = config.to_agent("test_agent") + + # Find the McpToolset + mcp_tool = None + for tool in agent.tools: + if type(tool).__name__ == "McpToolset": + mcp_tool = tool + break + + assert mcp_tool is not None, f"No McpToolset found. Tools: {[type(t).__name__ for t in agent.tools]}" + + # Verify connection params use the direct URL + connection_params = getattr(mcp_tool, "_connection_params", None) or getattr(mcp_tool, "connection_params", None) + assert connection_params is not None + assert connection_params.url == "http://test-mcp-server.kagent:8084/mcp" + + +def test_sse_mcp_tool_with_proxy_url(): + """Test that SSE MCP tools are configured with proxy URL and Host header. + + When proxy is configured, the URL is set to the proxy URL and the Host header + is included for proxy routing. These are passed through directly to McpToolset. + + Note: We verify connection_params configuration because McpToolset doesn't expose + a public API to verify proxy setup. The connection_params are what McpToolset uses + internally to create its HTTP client, so verifying them ensures our configuration + is correctly applied. + """ + from google.adk.tools.mcp_tool import SseConnectionParams + from kagent.adk.types import SseMcpServerConfig + + # Configuration with proxy URL and Host header + config = AgentConfig( + model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), + description="Test agent", + instruction="You are a test agent", + sse_tools=[ + SseMcpServerConfig( + params=SseConnectionParams( + url="http://agent-egress-proxy:8082/mcp", # Proxy URL + headers={"Host": "test-sse-mcp-server.kagent"}, # Host header for proxy routing + ), + tools=["test-sse-tool"], + ) + ], + ) + + agent = config.to_agent("test_agent") + + # Find the McpToolset + mcp_tool = None + for tool in agent.tools: + if type(tool).__name__ == "McpToolset": + mcp_tool = tool + break + + assert mcp_tool is not None, f"No McpToolset found. Tools: {[type(t).__name__ for t in agent.tools]}" + + # Verify connection params are configured correctly + connection_params = getattr(mcp_tool, "_connection_params", None) or getattr(mcp_tool, "connection_params", None) + assert connection_params is not None + assert connection_params.url == "http://agent-egress-proxy:8082/mcp" + assert connection_params.headers is not None + assert connection_params.headers["Host"] == "test-sse-mcp-server.kagent" + + +def test_sse_mcp_tool_without_proxy(): + """Test that SSE MCP tools are configured with direct URL when proxy is not configured. + + Note: We verify connection_params configuration because McpToolset doesn't expose + a public API to verify connection setup. The connection_params are what McpToolset uses + internally to create its HTTP client. + """ + from google.adk.tools.mcp_tool import SseConnectionParams + from kagent.adk.types import SseMcpServerConfig + + config = AgentConfig( + model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), + description="Test agent", + instruction="You are a test agent", + sse_tools=[ + SseMcpServerConfig( + params=SseConnectionParams( + url="http://test-sse-mcp-server.kagent:8084/mcp", # Direct URL + headers=None, # No headers + ), + tools=["test-sse-tool"], + ) + ], + ) + + agent = config.to_agent("test_agent") + + # Find the McpToolset + mcp_tool = None + for tool in agent.tools: + if type(tool).__name__ == "McpToolset": + mcp_tool = tool + break + + assert mcp_tool is not None, f"No McpToolset found. Tools: {[type(t).__name__ for t in agent.tools]}" + + # Verify connection params use the direct URL + connection_params = getattr(mcp_tool, "_connection_params", None) or getattr(mcp_tool, "connection_params", None) + assert connection_params is not None + assert connection_params.url == "http://test-sse-mcp-server.kagent:8084/mcp" diff --git a/python/uv.lock b/python/uv.lock index 943e35e96..d278a913a 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -28,7 +28,7 @@ dev = [ [[package]] name = "a2a-sdk" -version = "0.3.9" +version = "0.3.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "google-api-core" }, @@ -37,9 +37,9 @@ dependencies = [ { name = "protobuf" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/0b/80671e784f61b55ac4c340d125d121ba91eba58ad7ba0f03b53b3831cd32/a2a_sdk-0.3.9.tar.gz", hash = "sha256:1dff7b5b1cab0b221519d0faed50176e200a1a87a8de8b64308d876505cc7c77", size = 224528, upload-time = "2025-10-15T17:35:28.299Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/a3/76f2d94a32a1b0dc760432d893a09ec5ed31de5ad51b1ef0f9d199ceb260/a2a_sdk-0.3.22.tar.gz", hash = "sha256:77a5694bfc4f26679c11b70c7f1062522206d430b34bc1215cfbb1eba67b7e7d", size = 231535, upload-time = "2025-12-16T18:39:21.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/ee/53b2da6d2768b136f996b8c6ab00ebcc44852f9a33816a64deaca6b279fe/a2a_sdk-0.3.9-py3-none-any.whl", hash = "sha256:7ed03a915bae98def46ea0313786da0a7a488346c3dc8af88407bb0b2a763926", size = 139027, upload-time = "2025-10-15T17:35:26.628Z" }, + { url = "https://files.pythonhosted.org/packages/64/e8/f4e39fd1cf0b3c4537b974637143f3ebfe1158dad7232d9eef15666a81ba/a2a_sdk-0.3.22-py3-none-any.whl", hash = "sha256:b98701135bb90b0ff85d35f31533b6b7a299bf810658c1c65f3814a6c15ea385", size = 144347, upload-time = "2025-12-16T18:39:19.218Z" }, ] [package.optional-dependencies] @@ -1930,7 +1930,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "a2a-sdk", specifier = ">=0.3.1" }, + { name = "a2a-sdk", specifier = ">=0.3.22" }, { name = "agentsts-adk", specifier = ">=0.0.8" }, { name = "agentsts-core", specifier = ">=0.0.8" }, { name = "aiofiles", specifier = ">=24.1.0" }, diff --git a/ui/src/app/a2a/[namespace]/[agentName]/route.ts b/ui/src/app/a2a/[namespace]/[agentName]/route.ts index 6bc0664e8..7d94cf880 100644 --- a/ui/src/app/a2a/[namespace]/[agentName]/route.ts +++ b/ui/src/app/a2a/[namespace]/[agentName]/route.ts @@ -141,4 +141,4 @@ export async function OPTIONS() { 'Access-Control-Max-Age': '86400', }, }); -} \ No newline at end of file +} From 10209b1f178261eaafc6c62d06d29a47ac6e7135 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Wed, 31 Dec 2025 07:20:34 -0700 Subject: [PATCH 02/11] Run Ruff check and fix issues Signed-off-by: Jeremy Alvis --- .../translator/agent/adk_api_translator.go | 17 +++++++++++++ .../kagent-adk/src/kagent/adk/types.py | 25 ++++++++++++------- .../tests/unittests/test_proxy_integration.py | 4 +++ .../kagent/core/tracing/_span_processor.py | 2 +- .../src/kagent/crewai/_executor.py | 1 - .../src/kagent/langgraph/_executor.py | 4 +-- 6 files changed, 40 insertions(+), 13 deletions(-) diff --git a/go/internal/controller/translator/agent/adk_api_translator.go b/go/internal/controller/translator/agent/adk_api_translator.go index 14f8c9d35..026f048a9 100644 --- a/go/internal/controller/translator/agent/adk_api_translator.go +++ b/go/internal/controller/translator/agent/adk_api_translator.go @@ -222,6 +222,23 @@ func (a *adkApiTranslator) validateAgent(ctx context.Context, agent *v1alpha2.Ag return nil } +kind delete cluster --name kagent \ +make create-kind-cluster \ +make use-kind-cluster \ +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml || true \ +kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/experimental-install.yaml || true \ +kubectl wait --for=condition=Established --timeout=90s crd/gateways.gateway.networking.k8s.io || true \ +kubectl wait --for=condition=Established --timeout=90s crd/httproutes.gateway.networking.k8s.io || true \ +helm upgrade -i --create-namespace --namespace agentgateway-system --version v2.2.0-main agentgateway-crds oci://ghcr.io/kgateway-dev/charts/agentgateway-crds \ +helm upgrade -i -n agentgateway-system agentgateway oci://ghcr.io/kgateway-dev/charts/agentgateway --version v2.2.0-main \ +kubectl apply -f examples/proxy-test-kgateway.yaml \ +make helm-install KAGENT_HELM_EXTRA_ARGS="-f examples/proxy-values.yaml" \ +kubectl port-forward svc/kagent-ui 8001:8080 + + + + + func (a *adkApiTranslator) buildManifest( ctx context.Context, agent *v1alpha2.Agent, diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 6c95c648b..22e494374 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Literal, Optional, Union +from typing import Any, Callable, Literal, Optional, Union import httpx from agentsts.adk import ADKTokenPropagationPlugin @@ -148,17 +148,24 @@ def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugi target_host = remote_agent.headers["Host"] # Event hook to rewrite request URLs to use proxy while preserving Host header - async def rewrite_url_to_proxy(request: httpx.Request) -> None: - parsed = parse_url(str(request.url)) - new_url = f"{proxy_base}{parsed.path}" + def make_rewrite_url_to_proxy( + proxy_base: str, target_host: str + ) -> Callable[[httpx.Request], None]: + async def rewrite_url_to_proxy(request: httpx.Request) -> None: + parsed = parse_url(str(request.url)) + new_url = f"{proxy_base}{parsed.path}" - if parsed.query: - new_url += f"?{parsed.query}" + if parsed.query: + new_url += f"?{parsed.query}" - request.url = httpx.URL(new_url) - request.headers["Host"] = target_host + request.url = httpx.URL(new_url) + request.headers["Host"] = target_host - client_kwargs["event_hooks"] = {"request": [rewrite_url_to_proxy]} + return rewrite_url_to_proxy + + client_kwargs["event_hooks"] = { + "request": [make_rewrite_url_to_proxy(proxy_base, target_host)] + } client = httpx.AsyncClient(**client_kwargs) diff --git a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py index 62b5b4f63..6a6759571 100644 --- a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py +++ b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py @@ -326,6 +326,7 @@ def test_mcp_tool_with_proxy_url(): is correctly applied. """ from google.adk.tools.mcp_tool import StreamableHTTPConnectionParams + from kagent.adk.types import HttpMcpServerConfig # Configuration with proxy URL and Host header @@ -373,6 +374,7 @@ def test_mcp_tool_without_proxy(): internally to create its HTTP client. """ from google.adk.tools.mcp_tool import StreamableHTTPConnectionParams + from kagent.adk.types import HttpMcpServerConfig config = AgentConfig( @@ -419,6 +421,7 @@ def test_sse_mcp_tool_with_proxy_url(): is correctly applied. """ from google.adk.tools.mcp_tool import SseConnectionParams + from kagent.adk.types import SseMcpServerConfig # Configuration with proxy URL and Host header @@ -464,6 +467,7 @@ def test_sse_mcp_tool_without_proxy(): internally to create its HTTP client. """ from google.adk.tools.mcp_tool import SseConnectionParams + from kagent.adk.types import SseMcpServerConfig config = AgentConfig( diff --git a/python/packages/kagent-core/src/kagent/core/tracing/_span_processor.py b/python/packages/kagent-core/src/kagent/core/tracing/_span_processor.py index 673d1949b..d7ab3c2eb 100644 --- a/python/packages/kagent-core/src/kagent/core/tracing/_span_processor.py +++ b/python/packages/kagent-core/src/kagent/core/tracing/_span_processor.py @@ -1,8 +1,8 @@ """Custom span processor to add kagent attributes to all spans in a request context.""" import logging -from typing import Optional from contextvars import Token +from typing import Optional from opentelemetry import context as otel_context from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor diff --git a/python/packages/kagent-crewai/src/kagent/crewai/_executor.py b/python/packages/kagent-crewai/src/kagent/crewai/_executor.py index f74244a60..03feb8ac2 100644 --- a/python/packages/kagent-crewai/src/kagent/crewai/_executor.py +++ b/python/packages/kagent-crewai/src/kagent/crewai/_executor.py @@ -23,7 +23,6 @@ from crewai import Crew, Flow from crewai.memory import LongTermMemory - from kagent.core.tracing._span_processor import ( clear_kagent_span_attributes, set_kagent_span_attributes, diff --git a/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py b/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py index bc82a5b1c..1684603c5 100644 --- a/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py +++ b/python/packages/kagent-langgraph/src/kagent/langgraph/_executor.py @@ -26,8 +26,6 @@ TextPart, ) from langchain_core.runnables import RunnableConfig -from langgraph.graph.state import CompiledStateGraph -from langgraph.types import Command from pydantic import BaseModel from kagent.core.a2a import ( @@ -43,6 +41,8 @@ clear_kagent_span_attributes, set_kagent_span_attributes, ) +from langgraph.graph.state import CompiledStateGraph +from langgraph.types import Command from ._converters import _convert_langgraph_event_to_a2a from ._error_mappings import get_error_metadata, get_user_friendly_error_message From 18840dafc5010e8867eda33f01da8a88a0a2cef5 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Wed, 31 Dec 2025 08:36:40 -0700 Subject: [PATCH 03/11] Remove accidental code inclusion Signed-off-by: Jeremy Alvis --- .../translator/agent/adk_api_translator.go | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/go/internal/controller/translator/agent/adk_api_translator.go b/go/internal/controller/translator/agent/adk_api_translator.go index 026f048a9..14f8c9d35 100644 --- a/go/internal/controller/translator/agent/adk_api_translator.go +++ b/go/internal/controller/translator/agent/adk_api_translator.go @@ -222,23 +222,6 @@ func (a *adkApiTranslator) validateAgent(ctx context.Context, agent *v1alpha2.Ag return nil } -kind delete cluster --name kagent \ -make create-kind-cluster \ -make use-kind-cluster \ -kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml || true \ -kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/experimental-install.yaml || true \ -kubectl wait --for=condition=Established --timeout=90s crd/gateways.gateway.networking.k8s.io || true \ -kubectl wait --for=condition=Established --timeout=90s crd/httproutes.gateway.networking.k8s.io || true \ -helm upgrade -i --create-namespace --namespace agentgateway-system --version v2.2.0-main agentgateway-crds oci://ghcr.io/kgateway-dev/charts/agentgateway-crds \ -helm upgrade -i -n agentgateway-system agentgateway oci://ghcr.io/kgateway-dev/charts/agentgateway --version v2.2.0-main \ -kubectl apply -f examples/proxy-test-kgateway.yaml \ -make helm-install KAGENT_HELM_EXTRA_ARGS="-f examples/proxy-values.yaml" \ -kubectl port-forward svc/kagent-ui 8001:8080 - - - - - func (a *adkApiTranslator) buildManifest( ctx context.Context, agent *v1alpha2.Agent, From 62b938842ff07bb4faaeef83f0f51a2f8dc49f23 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Wed, 31 Dec 2025 10:47:13 -0700 Subject: [PATCH 04/11] Apply proxy to internal k8s urls Signed-off-by: Jeremy Alvis --- Makefile | 1 + .../translator/agent/adk_api_translator.go | 75 ++++- .../agent/adk_translator_golden_test.go | 62 +++- .../controller/translator/agent/proxy_test.go | 318 +++++++++++++++++- .../translator/agent/security_context_test.go | 8 +- .../testdata/inputs/agent_with_proxy.yaml | 3 +- .../agent_with_proxy_external_remotemcp.yaml | 49 +++ .../inputs/agent_with_proxy_mcpserver.yaml | 48 +++ .../inputs/agent_with_proxy_service.yaml | 56 +++ .../testdata/outputs/agent_with_proxy.json | 8 +- .../agent_with_proxy_external_remotemcp.json | 301 +++++++++++++++++ .../outputs/agent_with_proxy_mcpserver.json | 303 +++++++++++++++++ .../outputs/agent_with_proxy_service.json | 303 +++++++++++++++++ go/internal/httpserver/handlers/agents.go | 3 +- .../httpserver/handlers/agents_test.go | 3 +- go/internal/httpserver/handlers/handlers.go | 8 +- go/internal/httpserver/server.go | 5 +- go/pkg/app/app.go | 12 +- go/pkg/app/app_test.go | 10 +- .../templates/controller-configmap.yaml | 7 +- .../tests/controller-deployment_test.yaml | 29 +- helm/kagent/values.yaml | 19 +- .../kagent-adk/src/kagent/adk/types.py | 9 +- .../tests/unittests/test_proxy_integration.py | 10 +- 24 files changed, 1527 insertions(+), 123 deletions(-) create mode 100644 go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_external_remotemcp.yaml create mode 100644 go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_mcpserver.yaml create mode 100644 go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_service.yaml create mode 100644 go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json create mode 100644 go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json create mode 100644 go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json diff --git a/Makefile b/Makefile index ab2ba5e84..84acca426 100644 --- a/Makefile +++ b/Makefile @@ -153,6 +153,7 @@ check-api-key: buildx-create: docker buildx inspect $(BUILDX_BUILDER_NAME) 2>&1 > /dev/null || \ docker buildx create --name $(BUILDX_BUILDER_NAME) --platform linux/amd64,linux/arm64 --driver docker-container --use --driver-opt network=host || true + docker buildx use $(BUILDX_BUILDER_NAME) || true .PHONY: build-all # for test purpose build all but output to /dev/null build-all: BUILD_ARGS ?= --progress=plain --builder $(BUILDX_BUILDER_NAME) --platform linux/amd64,linux/arm64 --output type=tar,dest=/dev/null diff --git a/go/internal/controller/translator/agent/adk_api_translator.go b/go/internal/controller/translator/agent/adk_api_translator.go index 14f8c9d35..01d7ce8a8 100644 --- a/go/internal/controller/translator/agent/adk_api_translator.go +++ b/go/internal/controller/translator/agent/adk_api_translator.go @@ -74,22 +74,20 @@ type AdkApiTranslator interface { type TranslatorPlugin = translator.TranslatorPlugin -func NewAdkApiTranslator(kube client.Client, defaultModelConfig types.NamespacedName, plugins []TranslatorPlugin, globalAgentProxyURL, globalEgressProxyURL string) AdkApiTranslator { +func NewAdkApiTranslator(kube client.Client, defaultModelConfig types.NamespacedName, plugins []TranslatorPlugin, globalProxyURL string) AdkApiTranslator { return &adkApiTranslator{ - kube: kube, - defaultModelConfig: defaultModelConfig, - plugins: plugins, - globalAgentProxyURL: globalAgentProxyURL, - globalEgressProxyURL: globalEgressProxyURL, + kube: kube, + defaultModelConfig: defaultModelConfig, + plugins: plugins, + globalProxyURL: globalProxyURL, } } type adkApiTranslator struct { - kube client.Client - defaultModelConfig types.NamespacedName - plugins []TranslatorPlugin - globalAgentProxyURL string // Global agent proxy URL for agent -> agent traffic - globalEgressProxyURL string // Global egress proxy URL for agent -> MCP server traffic + kube client.Client + defaultModelConfig types.NamespacedName + plugins []TranslatorPlugin + globalProxyURL string } const MAX_DEPTH = 10 @@ -537,8 +535,8 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al // Skip tools that are not applicable to the model provider switch { case tool.McpServer != nil: - // Use egress proxy for MCP server/tool communication - err := a.translateMCPServerTarget(ctx, cfg, agent.Namespace, tool.McpServer, tool.HeadersFrom, a.globalEgressProxyURL) + // Use proxy for MCP server/tool communication + err := a.translateMCPServerTarget(ctx, cfg, agent.Namespace, tool.McpServer, tool.HeadersFrom, a.globalProxyURL) if err != nil { return nil, nil, nil, err } @@ -569,15 +567,15 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al // If proxy is configured, use proxy URL and set Host header for Gateway API routing targetURL := originalURL - if a.globalAgentProxyURL != "" { + if a.globalProxyURL != "" { // Parse original URL to extract path and hostname originalURLParsed, err := url.Parse(originalURL) if err != nil { return nil, nil, nil, fmt.Errorf("failed to parse agent URL %q: %w", originalURL, err) } - proxyURLParsed, err := url.Parse(a.globalAgentProxyURL) + proxyURLParsed, err := url.Parse(a.globalProxyURL) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to parse proxy URL %q: %w", a.globalAgentProxyURL, err) + return nil, nil, nil, fmt.Errorf("failed to parse proxy URL %q: %w", a.globalProxyURL, err) } // Use proxy URL with original path targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURLParsed.Path) @@ -1082,6 +1080,12 @@ func (a *adkApiTranslator) translateMCPServerTarget(ctx context.Context, agent * remoteMcpServer.Spec.HeadersFrom = append(remoteMcpServer.Spec.HeadersFrom, toolHeaders...) + // RemoteMCPServer uses user-supplied URLs, but if the URL points to an internal k8s service, + // apply proxy to route through the gateway + proxyURL := "" + if a.globalProxyURL != "" && a.isInternalK8sURL(ctx, remoteMcpServer.Spec.URL, agentNamespace) { + proxyURL = a.globalProxyURL + } return a.translateRemoteMCPServerTarget(ctx, agent, agentNamespace, &remoteMcpServer.Spec, toolServer.ToolNames, proxyURL) case schema.GroupKind{ Group: "", @@ -1196,6 +1200,45 @@ func (a *adkApiTranslator) translateRemoteMCPServerTarget(ctx context.Context, a // Helper functions +// isInternalK8sURL checks if a URL points to an internal Kubernetes service. +// Internal k8s URLs follow the pattern: http://{name}.{namespace}:{port} or +// http://{name}.{namespace}.svc.cluster.local:{port} +// This method checks if the namespace exists in the cluster to determine if it's internal. +func (a *adkApiTranslator) isInternalK8sURL(ctx context.Context, urlStr, namespace string) bool { + parsedURL, err := url.Parse(urlStr) + if err != nil { + return false + } + + hostname := parsedURL.Hostname() + if hostname == "" { + return false + } + + // Check if it ends with .svc.cluster.local (definitely internal) + if strings.HasSuffix(hostname, ".svc.cluster.local") { + return true + } + + // Extract namespace from hostname pattern: {name}.{namespace} + // Examples: test-mcp-server.kagent -> namespace is "kagent" + parts := strings.Split(hostname, ".") + if len(parts) == 2 { + potentialNamespace := parts[1] + + // Check if this namespace exists in the cluster + ns := &corev1.Namespace{} + err := a.kube.Get(ctx, types.NamespacedName{Name: potentialNamespace}, ns) + if err == nil { + // Namespace exists, so this is an internal k8s URL + return true + } + // If namespace doesn't exist, it's likely a TLD or external domain + } + + return false +} + func computeConfigHash(agentCfg, agentCard, secretData []byte) uint64 { hasher := sha256.New() hasher.Write(agentCfg) diff --git a/go/internal/controller/translator/agent/adk_translator_golden_test.go b/go/internal/controller/translator/agent/adk_translator_golden_test.go index a2c4c520b..97326dca3 100644 --- a/go/internal/controller/translator/agent/adk_translator_golden_test.go +++ b/go/internal/controller/translator/agent/adk_translator_golden_test.go @@ -14,6 +14,9 @@ import ( "github.com/kagent-dev/kagent/go/api/v1alpha2" translator "github.com/kagent-dev/kagent/go/internal/controller/translator/agent" + "github.com/kagent-dev/kmcp/api/v1alpha1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -24,12 +27,11 @@ import ( // TestInput represents the structure of input test files type TestInput struct { - Objects []map[string]any `yaml:"objects"` - Operation string `yaml:"operation"` // "translateAgent", "translateTeam", "translateToolServer" - TargetObject string `yaml:"targetObject"` // name of the object to translate - Namespace string `yaml:"namespace"` - ProxyAgentURL string `yaml:"proxyAgentURL,omitempty"` // Optional proxy URL for A2A - ProxyEgressURL string `yaml:"proxyEgressURL,omitempty"` // Optional proxy URL for egress + Objects []map[string]any `yaml:"objects"` + Operation string `yaml:"operation"` // "translateAgent", "translateTeam", "translateToolServer" + TargetObject string `yaml:"targetObject"` // name of the object to translate + Namespace string `yaml:"namespace"` + ProxyURL string `yaml:"proxyURL,omitempty"` // Optional proxy URL for internally-built k8s URLs } // TestGoldenAdkTranslator runs golden tests for the ADK API translator @@ -76,20 +78,61 @@ func runGoldenTest(t *testing.T, inputFile, outputsDir, testName string, updateG scheme := schemev1.Scheme err = v1alpha2.AddToScheme(scheme) require.NoError(t, err) + err = v1alpha1.AddToScheme(scheme) + require.NoError(t, err) // Convert map objects to unstructured and then to typed objects clientBuilder := fake.NewClientBuilder().WithScheme(scheme) + // Track namespaces we've seen to add them to the fake client + namespacesSeen := make(map[string]bool) + for _, objMap := range testInput.Objects { // Convert map to unstructured unstrObj := &unstructured.Unstructured{Object: objMap} + // Track namespace if present + if metadata, ok := objMap["metadata"].(map[string]any); ok { + if ns, ok := metadata["namespace"].(string); ok && ns != "" { + namespacesSeen[ns] = true + } + } + + // Extract namespace from URLs in RemoteMCPServer specs + if kind, ok := objMap["kind"].(string); ok && kind == "RemoteMCPServer" { + if spec, ok := objMap["spec"].(map[string]any); ok { + if url, ok := spec["url"].(string); ok { + // Parse URL to extract namespace (e.g., http://service.namespace:port/path) + parts := strings.Split(url, "://") + if len(parts) == 2 { + hostPart := strings.Split(parts[1], "/")[0] + hostParts := strings.Split(hostPart, ":") + hostname := hostParts[0] + hostnameParts := strings.Split(hostname, ".") + if len(hostnameParts) == 2 { + namespacesSeen[hostnameParts[1]] = true + } + } + } + } + } + // Convert to typed object typedObj, err := convertUnstructuredToTyped(unstrObj, scheme) require.NoError(t, err) clientBuilder = clientBuilder.WithObjects(typedObj) } + // Add namespaces to fake client so namespace existence checks work + for nsName := range namespacesSeen { + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: nsName, + }, + } + clientBuilder = clientBuilder.WithObjects(ns) + } + kubeClient := clientBuilder.Build() // Create translator with a default model config that points to the first ModelConfig in the objects @@ -121,10 +164,9 @@ func runGoldenTest(t *testing.T, inputFile, outputsDir, testName string, updateG }, agent) require.NoError(t, err) - // Use proxy URLs from test input if provided - proxyAgentURL := testInput.ProxyAgentURL - proxyEgressURL := testInput.ProxyEgressURL - result, err = translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, proxyAgentURL, proxyEgressURL).TranslateAgent(ctx, agent) + // Use proxy URL from test input if provided + proxyURL := testInput.ProxyURL + result, err = translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, proxyURL).TranslateAgent(ctx, agent) require.NoError(t, err) default: diff --git a/go/internal/controller/translator/agent/proxy_test.go b/go/internal/controller/translator/agent/proxy_test.go index b9621a01d..e3a8fc38f 100644 --- a/go/internal/controller/translator/agent/proxy_test.go +++ b/go/internal/controller/translator/agent/proxy_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" schemev1 "k8s.io/client-go/kubernetes/scheme" @@ -13,6 +14,7 @@ import ( "github.com/kagent-dev/kagent/go/api/v1alpha2" translator "github.com/kagent-dev/kagent/go/internal/controller/translator/agent" + "github.com/kagent-dev/kmcp/api/v1alpha1" ) // TestProxyConfiguration_ThroughTranslateAgent tests proxy URL rewriting through the public API @@ -91,18 +93,29 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { }, } + // Add namespaces to fake client so namespace existence checks work + kagentNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kagent", + }, + } + testNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + } + kubeClient := fake.NewClientBuilder(). WithScheme(scheme). - WithObjects(agent, nestedAgent, remoteMcpServer, modelConfig). + WithObjects(agent, nestedAgent, remoteMcpServer, modelConfig, kagentNamespace, testNamespace). Build() - t.Run("with proxy URLs", func(t *testing.T) { + t.Run("with proxy URL - RemoteMCPServer with internal k8s URL uses proxy", func(t *testing.T) { translator := translator.NewAdkApiTranslator( kubeClient, types.NamespacedName{Name: "default-model", Namespace: "test"}, nil, - "http://agent-a2a-proxy:8081", - "http://agent-egress-proxy:8082", + "http://proxy.kagent.svc.cluster.local:8080", ) result, err := translator.TranslateAgent(ctx, agent) @@ -110,28 +123,28 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { require.NotNil(t, result) require.NotNil(t, result.Config) - // Verify A2A proxy configuration + // Verify agent tool proxy configuration require.Len(t, result.Config.RemoteAgents, 1) remoteAgent := result.Config.RemoteAgents[0] - assert.Equal(t, "http://agent-a2a-proxy:8081", remoteAgent.Url) + assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080", remoteAgent.Url) assert.NotNil(t, remoteAgent.Headers) assert.Equal(t, "nested-agent.test", remoteAgent.Headers["Host"]) - // Verify egress proxy configuration + // Verify RemoteMCPServer with internal k8s URL DOES use proxy require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] - assert.Equal(t, "http://agent-egress-proxy:8082/mcp", httpTool.Params.Url) - assert.NotNil(t, httpTool.Params.Headers) + assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) + // Host header should be set for RemoteMCPServer with internal k8s URL (uses proxy) + require.NotNil(t, httpTool.Params.Headers) assert.Equal(t, "test-mcp-server.kagent", httpTool.Params.Headers["Host"]) }) - t.Run("without proxy URLs", func(t *testing.T) { + t.Run("without proxy URL", func(t *testing.T) { translator := translator.NewAdkApiTranslator( kubeClient, types.NamespacedName{Name: "default-model", Namespace: "test"}, nil, - "", // No A2A proxy - "", // No egress proxy + "", // No proxy ) result, err := translator.TranslateAgent(ctx, agent) @@ -139,7 +152,7 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { require.NotNil(t, result) require.NotNil(t, result.Config) - // Verify A2A direct URL (no proxy) + // Verify agent tool direct URL (no proxy) require.Len(t, result.Config.RemoteAgents, 1) remoteAgent := result.Config.RemoteAgents[0] assert.Equal(t, "http://nested-agent.test:8080", remoteAgent.Url) @@ -149,7 +162,7 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { assert.False(t, hasHost, "Host header should not be set when no proxy") } - // Verify egress direct URL (no proxy) + // Verify RemoteMCPServer direct URL (no proxy) require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://test-mcp-server.kagent:8084/mcp", httpTool.Params.Url) @@ -160,3 +173,280 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { } }) } + +// TestProxyConfiguration_RemoteMCPServer_ExternalURL tests that RemoteMCPServer with external URLs does NOT use proxy +func TestProxyConfiguration_RemoteMCPServer_ExternalURL(t *testing.T) { + ctx := context.Background() + scheme := schemev1.Scheme + err := v1alpha2.AddToScheme(scheme) + require.NoError(t, err) + + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-model", + Namespace: "test", + }, + Spec: v1alpha2.ModelConfigSpec{ + Provider: "OpenAI", + Model: "gpt-4o", + }, + } + + // RemoteMCPServer with external URL (not internal k8s) + remoteMcpServer := &v1alpha2.RemoteMCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "external-mcp", + Namespace: "test", + }, + Spec: v1alpha2.RemoteMCPServerSpec{ + URL: "https://external-mcp.example.com/mcp", + Protocol: v1alpha2.RemoteMCPServerProtocolStreamableHttp, + }, + } + + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-agent", + Namespace: "test", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "Test", + ModelConfig: "default-model", + Tools: []*v1alpha2.Tool{ + { + Type: v1alpha2.ToolProviderType_McpServer, + McpServer: &v1alpha2.McpServerTool{ + TypedLocalReference: v1alpha2.TypedLocalReference{ + Name: "external-mcp", + Kind: "RemoteMCPServer", + }, + ToolNames: []string{"test-tool"}, + }, + }, + }, + }, + }, + } + + testNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + } + + kubeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(agent, remoteMcpServer, modelConfig, testNamespace). + Build() + + translator := translator.NewAdkApiTranslator( + kubeClient, + types.NamespacedName{Name: "default-model", Namespace: "test"}, + nil, + "http://proxy.kagent.svc.cluster.local:8080", + ) + + result, err := translator.TranslateAgent(ctx, agent) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Config) + + // Verify RemoteMCPServer with external URL does NOT use proxy + require.Len(t, result.Config.HttpTools, 1) + httpTool := result.Config.HttpTools[0] + assert.Equal(t, "https://external-mcp.example.com/mcp", httpTool.Params.Url) + // Host header should not be set for external URLs (no proxy) + if httpTool.Params.Headers != nil { + _, hasHost := httpTool.Params.Headers["Host"] + assert.False(t, hasHost, "Host header should not be set for RemoteMCPServer with external URL (no proxy)") + } +} + +// TestProxyConfiguration_MCPServer tests that MCPServer resources use proxy +func TestProxyConfiguration_MCPServer(t *testing.T) { + ctx := context.Background() + scheme := schemev1.Scheme + err := v1alpha2.AddToScheme(scheme) + require.NoError(t, err) + err = v1alpha1.AddToScheme(scheme) + require.NoError(t, err) + + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-model", + Namespace: "test", + }, + Spec: v1alpha2.ModelConfigSpec{ + Provider: "OpenAI", + Model: "gpt-4o", + }, + } + + mcpServer := &v1alpha1.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mcp-server", + Namespace: "test", + }, + Spec: v1alpha1.MCPServerSpec{ + Deployment: v1alpha1.MCPServerDeployment{ + Port: 8084, + }, + }, + } + + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-agent", + Namespace: "test", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "Test", + ModelConfig: "default-model", + Tools: []*v1alpha2.Tool{ + { + Type: v1alpha2.ToolProviderType_McpServer, + McpServer: &v1alpha2.McpServerTool{ + TypedLocalReference: v1alpha2.TypedLocalReference{ + Name: "test-mcp-server", + Kind: "MCPServer", + }, + ToolNames: []string{"test-tool"}, + }, + }, + }, + }, + }, + } + + testNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + } + + kubeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(agent, mcpServer, modelConfig, testNamespace). + Build() + + translator := translator.NewAdkApiTranslator( + kubeClient, + types.NamespacedName{Name: "default-model", Namespace: "test"}, + nil, + "http://proxy.kagent.svc.cluster.local:8080", + ) + + result, err := translator.TranslateAgent(ctx, agent) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Config) + + // Verify MCPServer uses proxy + require.Len(t, result.Config.HttpTools, 1) + httpTool := result.Config.HttpTools[0] + assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) + // Host header should be set for MCPServer (uses proxy) + require.NotNil(t, httpTool.Params.Headers) + assert.Equal(t, "test-mcp-server.test", httpTool.Params.Headers["Host"]) +} + +// TestProxyConfiguration_Service tests that Services as MCP Tools use proxy +func TestProxyConfiguration_Service(t *testing.T) { + ctx := context.Background() + scheme := schemev1.Scheme + err := v1alpha2.AddToScheme(scheme) + require.NoError(t, err) + + modelConfig := &v1alpha2.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default-model", + Namespace: "test", + }, + Spec: v1alpha2.ModelConfigSpec{ + Provider: "OpenAI", + Model: "gpt-4o", + }, + } + + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-service", + Namespace: "test", + Annotations: map[string]string{ + "kagent.dev/mcp-service-port": "8084", + "kagent.dev/mcp-service-path": "/mcp", + "kagent.dev/mcp-service-protocol": "streamable-http", + }, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "mcp", + Port: 8084, + Protocol: corev1.ProtocolTCP, + }, + }, + }, + } + + agent := &v1alpha2.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-agent", + Namespace: "test", + }, + Spec: v1alpha2.AgentSpec{ + Type: v1alpha2.AgentType_Declarative, + Declarative: &v1alpha2.DeclarativeAgentSpec{ + SystemMessage: "Test", + ModelConfig: "default-model", + Tools: []*v1alpha2.Tool{ + { + Type: v1alpha2.ToolProviderType_McpServer, + McpServer: &v1alpha2.McpServerTool{ + TypedLocalReference: v1alpha2.TypedLocalReference{ + Name: "test-service", + Kind: "Service", + }, + ToolNames: []string{"test-tool"}, + }, + }, + }, + }, + }, + } + + testNamespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + } + + kubeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(agent, service, modelConfig, testNamespace). + Build() + + translator := translator.NewAdkApiTranslator( + kubeClient, + types.NamespacedName{Name: "default-model", Namespace: "test"}, + nil, + "http://proxy.kagent.svc.cluster.local:8080", + ) + + result, err := translator.TranslateAgent(ctx, agent) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Config) + + // Verify Service uses proxy + require.Len(t, result.Config.HttpTools, 1) + httpTool := result.Config.HttpTools[0] + assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) + // Host header should be set for Service (uses proxy) + require.NotNil(t, httpTool.Params.Headers) + assert.Equal(t, "test-service.test", httpTool.Params.Headers["Host"]) +} diff --git a/go/internal/controller/translator/agent/security_context_test.go b/go/internal/controller/translator/agent/security_context_test.go index 1728474e7..9ba7a0007 100644 --- a/go/internal/controller/translator/agent/security_context_test.go +++ b/go/internal/controller/translator/agent/security_context_test.go @@ -84,7 +84,7 @@ func TestSecurityContext_AppliedToPodSpec(t *testing.T) { Namespace: "test", Name: "test-model", } - translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "", "") + translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "") // Translate agent result, err := translatorInstance.TranslateAgent(ctx, agent) @@ -175,7 +175,7 @@ func TestSecurityContext_OnlyPodSecurityContext(t *testing.T) { Namespace: "test", Name: "test-model", } - translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "", "") + translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "") result, err := translatorInstance.TranslateAgent(ctx, agent) require.NoError(t, err) @@ -250,7 +250,7 @@ func TestSecurityContext_OnlyContainerSecurityContext(t *testing.T) { Namespace: "test", Name: "test-model", } - translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "", "") + translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "") result, err := translatorInstance.TranslateAgent(ctx, agent) require.NoError(t, err) @@ -328,7 +328,7 @@ func TestSecurityContext_WithSandbox(t *testing.T) { Namespace: "test", Name: "test-model", } - translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "", "") + translatorInstance := translator.NewAdkApiTranslator(kubeClient, defaultModel, nil, "") result, err := translatorInstance.TranslateAgent(ctx, agent) require.NoError(t, err) diff --git a/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy.yaml b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy.yaml index e035ee6ef..6b8c84781 100644 --- a/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy.yaml +++ b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy.yaml @@ -1,8 +1,7 @@ operation: translateAgent targetObject: agent-with-proxy namespace: test -proxyAgentURL: http://agent-a2a-proxy.kagent.svc.cluster.local:8081 -proxyEgressURL: http://agent-egress-proxy.kagent.svc.cluster.local:8082 +proxyURL: http://proxy.kagent.svc.cluster.local:8080 objects: - apiVersion: v1 kind: Secret diff --git a/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_external_remotemcp.yaml b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_external_remotemcp.yaml new file mode 100644 index 000000000..3f83be46d --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_external_remotemcp.yaml @@ -0,0 +1,49 @@ +operation: translateAgent +targetObject: agent-with-proxy-external +namespace: test +proxyURL: http://proxy.kagent.svc.cluster.local:8080 +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: openai-secret + namespace: test + data: + api-key: c2stdGVzdC1hcGkta2V5 # base64 encoded "sk-test-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: default-model + namespace: test + spec: + provider: OpenAI + model: gpt-4o + apiKeySecret: openai-secret + apiKeySecretKey: api-key + - apiVersion: kagent.dev/v1alpha2 + kind: RemoteMCPServer + metadata: + name: external-mcp-server + namespace: test + spec: + url: https://external-mcp.example.com/mcp + description: "External MCP Server" + protocol: STREAMABLE_HTTP + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: agent-with-proxy-external + namespace: test + spec: + type: Declarative + declarative: + description: An agent with proxy configuration and external RemoteMCPServer + systemMessage: You are an agent that uses proxies. + modelConfig: default-model + tools: + - type: MCPServer + mcpServer: + name: external-mcp-server + kind: RemoteMCPServer + toolNames: + - test-tool diff --git a/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_mcpserver.yaml b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_mcpserver.yaml new file mode 100644 index 000000000..31f80b6be --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_mcpserver.yaml @@ -0,0 +1,48 @@ +operation: translateAgent +targetObject: agent-with-proxy-mcpserver +namespace: test +proxyURL: http://proxy.kagent.svc.cluster.local:8080 +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: openai-secret + namespace: test + data: + api-key: c2stdGVzdC1hcGkta2V5 # base64 encoded "sk-test-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: default-model + namespace: test + spec: + provider: OpenAI + model: gpt-4o + apiKeySecret: openai-secret + apiKeySecretKey: api-key + - apiVersion: kagent.dev/v1alpha1 + kind: MCPServer + metadata: + name: test-mcp-server + namespace: test + spec: + deployment: + port: 8084 + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: agent-with-proxy-mcpserver + namespace: test + spec: + type: Declarative + declarative: + description: An agent with proxy configuration and MCPServer resource + systemMessage: You are an agent that uses proxies. + modelConfig: default-model + tools: + - type: MCPServer + mcpServer: + name: test-mcp-server + kind: MCPServer + toolNames: + - test-tool diff --git a/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_service.yaml b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_service.yaml new file mode 100644 index 000000000..edf2e509d --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy_service.yaml @@ -0,0 +1,56 @@ +operation: translateAgent +targetObject: agent-with-proxy-service +namespace: test +proxyURL: http://proxy.kagent.svc.cluster.local:8080 +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: openai-secret + namespace: test + data: + api-key: c2stdGVzdC1hcGkta2V5 # base64 encoded "sk-test-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: default-model + namespace: test + spec: + provider: OpenAI + model: gpt-4o + apiKeySecret: openai-secret + apiKeySecretKey: api-key + - apiVersion: v1 + kind: Service + metadata: + name: toolserver + namespace: test + annotations: + kagent.dev/mcp-service-port: "8084" + kagent.dev/mcp-service-path: "/mcp" + kagent.dev/mcp-service-protocol: "streamable-http" + spec: + ports: + - name: mcp + port: 8084 + targetPort: 8084 + protocol: TCP + appProtocol: mcp + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: agent-with-proxy-service + namespace: test + spec: + type: Declarative + declarative: + description: An agent with proxy configuration and Service as MCP Tool + systemMessage: You are an agent that uses proxies. + modelConfig: default-model + tools: + - type: MCPServer + mcpServer: + name: toolserver + kind: Service + toolNames: + - k8s_get_resources diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json index 4a28523a9..91a987cfa 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json @@ -25,7 +25,7 @@ "headers": { "Host": "test-mcp-server.kagent" }, - "url": "http://agent-egress-proxy.kagent.svc.cluster.local:8082/mcp" + "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" }, "tools": [ "test-tool" @@ -44,7 +44,7 @@ "Host": "nested-agent.test" }, "name": "test__NS__nested_agent", - "url": "http://agent-a2a-proxy.kagent.svc.cluster.local:8081" + "url": "http://proxy.kagent.svc.cluster.local:8080" } ], "sse_tools": null @@ -76,7 +76,7 @@ }, "stringData": { "agent-card.json": "{\"name\":\"agent_with_proxy\",\"description\":\"\",\"url\":\"http://agent-with-proxy.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", - "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://agent-egress-proxy.kagent.svc.cluster.local:8082/mcp\",\"headers\":{\"Host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://agent-a2a-proxy.kagent.svc.cluster.local:8081\",\"headers\":{\"Host\":\"nested-agent.test\"}}]}" + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"Host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://proxy.kagent.svc.cluster.local:8080\",\"headers\":{\"Host\":\"nested-agent.test\"}}]}" } }, { @@ -145,7 +145,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "9348903286599888130" + "kagent.dev/config-hash": "7631898334138088108" }, "labels": { "app": "kagent", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json new file mode 100644 index 000000000..6269a4201 --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_external_remotemcp.json @@ -0,0 +1,301 @@ +{ + "agentCard": { + "capabilities": { + "pushNotifications": false, + "stateTransitionHistory": true, + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "agent_with_proxy_external", + "skills": null, + "url": "http://agent-with-proxy-external.test:8080", + "version": "" + }, + "config": { + "description": "", + "http_tools": [ + { + "params": { + "headers": {}, + "url": "https://external-mcp.example.com/mcp" + }, + "tools": [ + "test-tool" + ] + } + ], + "instruction": "You are an agent that uses proxies.", + "model": { + "base_url": "", + "model": "gpt-4o", + "type": "openai" + }, + "remote_agents": null, + "sse_tools": null + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-external", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-external" + }, + "name": "agent-with-proxy-external", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-external", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\"name\":\"agent_with_proxy_external\",\"description\":\"\",\"url\":\"http://agent-with-proxy-external.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"https://external-mcp.example.com/mcp\",\"headers\":{}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":null}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-external", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-external" + }, + "name": "agent-with-proxy-external", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-external", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-external", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-external" + }, + "name": "agent-with-proxy-external", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-external", + "uid": "" + } + ] + }, + "spec": { + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "agent-with-proxy-external" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "12862703226447143214" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-external", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-external" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "openai-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "valueFrom": { + "fieldRef": { + "fieldPath": "spec.serviceAccountName" + } + } + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + } + ], + "image": "cr.kagent.dev/kagent-dev/kagent/app:dev", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/health", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "2", + "memory": "1Gi" + }, + "requests": { + "cpu": "100m", + "memory": "384Mi" + } + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "serviceAccountName": "agent-with-proxy-external", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "agent-with-proxy-external" + } + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-external", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-external" + }, + "name": "agent-with-proxy-external", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-external", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "agent-with-proxy-external" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + } + ] +} \ No newline at end of file diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json new file mode 100644 index 000000000..af9292d14 --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json @@ -0,0 +1,303 @@ +{ + "agentCard": { + "capabilities": { + "pushNotifications": false, + "stateTransitionHistory": true, + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "agent_with_proxy_mcpserver", + "skills": null, + "url": "http://agent-with-proxy-mcpserver.test:8080", + "version": "" + }, + "config": { + "description": "", + "http_tools": [ + { + "params": { + "headers": { + "Host": "test-mcp-server.test" + }, + "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" + }, + "tools": [ + "test-tool" + ] + } + ], + "instruction": "You are an agent that uses proxies.", + "model": { + "base_url": "", + "model": "gpt-4o", + "type": "openai" + }, + "remote_agents": null, + "sse_tools": null + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-mcpserver", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-mcpserver" + }, + "name": "agent-with-proxy-mcpserver", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-mcpserver", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\"name\":\"agent_with_proxy_mcpserver\",\"description\":\"\",\"url\":\"http://agent-with-proxy-mcpserver.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"Host\":\"test-mcp-server.test\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":null}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-mcpserver", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-mcpserver" + }, + "name": "agent-with-proxy-mcpserver", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-mcpserver", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-mcpserver", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-mcpserver" + }, + "name": "agent-with-proxy-mcpserver", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-mcpserver", + "uid": "" + } + ] + }, + "spec": { + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "agent-with-proxy-mcpserver" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "4010839200108182816" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-mcpserver", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-mcpserver" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "openai-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "valueFrom": { + "fieldRef": { + "fieldPath": "spec.serviceAccountName" + } + } + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + } + ], + "image": "cr.kagent.dev/kagent-dev/kagent/app:dev", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/health", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "2", + "memory": "1Gi" + }, + "requests": { + "cpu": "100m", + "memory": "384Mi" + } + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "serviceAccountName": "agent-with-proxy-mcpserver", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "agent-with-proxy-mcpserver" + } + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-mcpserver", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-mcpserver" + }, + "name": "agent-with-proxy-mcpserver", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-mcpserver", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "agent-with-proxy-mcpserver" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + } + ] +} \ No newline at end of file diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json new file mode 100644 index 000000000..7a59fcd0f --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json @@ -0,0 +1,303 @@ +{ + "agentCard": { + "capabilities": { + "pushNotifications": false, + "stateTransitionHistory": true, + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "agent_with_proxy_service", + "skills": null, + "url": "http://agent-with-proxy-service.test:8080", + "version": "" + }, + "config": { + "description": "", + "http_tools": [ + { + "params": { + "headers": { + "Host": "toolserver.test" + }, + "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" + }, + "tools": [ + "k8s_get_resources" + ] + } + ], + "instruction": "You are an agent that uses proxies.", + "model": { + "base_url": "", + "model": "gpt-4o", + "type": "openai" + }, + "remote_agents": null, + "sse_tools": null + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-service", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-service" + }, + "name": "agent-with-proxy-service", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-service", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\"name\":\"agent_with_proxy_service\",\"description\":\"\",\"url\":\"http://agent-with-proxy-service.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"Host\":\"toolserver.test\"}},\"tools\":[\"k8s_get_resources\"]}],\"sse_tools\":null,\"remote_agents\":null}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-service", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-service" + }, + "name": "agent-with-proxy-service", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-service", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-service", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-service" + }, + "name": "agent-with-proxy-service", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-service", + "uid": "" + } + ] + }, + "spec": { + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "agent-with-proxy-service" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "4989088388029251717" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-service", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-service" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "openai-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "valueFrom": { + "fieldRef": { + "fieldPath": "spec.serviceAccountName" + } + } + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + } + ], + "image": "cr.kagent.dev/kagent-dev/kagent/app:dev", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/health", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "2", + "memory": "1Gi" + }, + "requests": { + "cpu": "100m", + "memory": "384Mi" + } + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "serviceAccountName": "agent-with-proxy-service", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "agent-with-proxy-service" + } + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "agent-with-proxy-service", + "app.kubernetes.io/part-of": "kagent", + "kagent": "agent-with-proxy-service" + }, + "name": "agent-with-proxy-service", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "agent-with-proxy-service", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "agent-with-proxy-service" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + } + ] +} \ No newline at end of file diff --git a/go/internal/httpserver/handlers/agents.go b/go/internal/httpserver/handlers/agents.go index 806d777a4..72794b4a8 100644 --- a/go/internal/httpserver/handlers/agents.go +++ b/go/internal/httpserver/handlers/agents.go @@ -200,8 +200,7 @@ func (h *AgentsHandler) HandleCreateAgent(w ErrorResponseWriter, r *http.Request kubeClientWrapper, h.DefaultModelConfig, nil, - h.AgentProxyURL, - h.EgressProxyURL, + h.ProxyURL, ) log.V(1).Info("Translating Agent to ADK format") diff --git a/go/internal/httpserver/handlers/agents_test.go b/go/internal/httpserver/handlers/agents_test.go index 6926f7cb0..fab9664f5 100644 --- a/go/internal/httpserver/handlers/agents_test.go +++ b/go/internal/httpserver/handlers/agents_test.go @@ -78,8 +78,7 @@ func setupTestHandler(objects ...client.Object) (*handlers.AgentsHandler, string }, DatabaseService: dbClient, Authorizer: &auth.NoopAuthorizer{}, - AgentProxyURL: "", - EgressProxyURL: "", + ProxyURL: "", } return handlers.NewAgentsHandler(base), userID diff --git a/go/internal/httpserver/handlers/handlers.go b/go/internal/httpserver/handlers/handlers.go index 8c2773391..c2c4aa785 100644 --- a/go/internal/httpserver/handlers/handlers.go +++ b/go/internal/httpserver/handlers/handlers.go @@ -33,19 +33,17 @@ type Base struct { DefaultModelConfig types.NamespacedName DatabaseService database.Client Authorizer auth.Authorizer // Interface for authorization checks - AgentProxyURL string // Agent proxy URL for agent-to-agent traffic - EgressProxyURL string // Egress proxy URL for agent-to-MCP/tool traffic + ProxyURL string } // NewHandlers creates a new Handlers instance with all handler components -func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedName, dbService database.Client, watchedNamespaces []string, authorizer auth.Authorizer, agentProxyURL, egressProxyURL string) *Handlers { +func NewHandlers(kubeClient client.Client, defaultModelConfig types.NamespacedName, dbService database.Client, watchedNamespaces []string, authorizer auth.Authorizer, proxyURL string) *Handlers { base := &Base{ KubeClient: kubeClient, DefaultModelConfig: defaultModelConfig, DatabaseService: dbService, Authorizer: authorizer, - AgentProxyURL: agentProxyURL, - EgressProxyURL: egressProxyURL, + ProxyURL: proxyURL, } return &Handlers{ diff --git a/go/internal/httpserver/server.go b/go/internal/httpserver/server.go index 230af08e3..1e7896d57 100644 --- a/go/internal/httpserver/server.go +++ b/go/internal/httpserver/server.go @@ -55,8 +55,7 @@ type ServerConfig struct { DbClient database.Client Authenticator auth.AuthProvider Authorizer auth.Authorizer - AgentProxyURL string // Agent proxy URL for agent-to-agent traffic - EgressProxyURL string // Egress proxy URL for agent-to-MCP/tool traffic + ProxyURL string } // HTTPServer is the structure that manages the HTTP server @@ -76,7 +75,7 @@ func NewHTTPServer(config ServerConfig) (*HTTPServer, error) { return &HTTPServer{ config: config, router: config.Router, - handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer, config.AgentProxyURL, config.EgressProxyURL), + handlers: handlers.NewHandlers(config.KubeClient, defaultModelConfig, config.DbClient, config.WatchedNamespaces, config.Authorizer, config.ProxyURL), authenticator: config.Authenticator, }, nil } diff --git a/go/pkg/app/app.go b/go/pkg/app/app.go index 8b20ba911..8fa6762ee 100644 --- a/go/pkg/app/app.go +++ b/go/pkg/app/app.go @@ -110,8 +110,7 @@ type Config struct { Timeout time.Duration `default:"60s"` } Proxy struct { - AgentURL string // Agent proxy URL for agent -> agent traffic - EgressURL string // Egress proxy URL for agent -> MCP server traffic + URL string } LeaderElection bool ProbeAddr string @@ -162,8 +161,7 @@ func (cfg *Config) SetFlags(commandLine *flag.FlagSet) { commandLine.Var(&cfg.Streaming.InitialBufSize, "streaming-initial-buf-size", "The initial size of the streaming buffer.") commandLine.DurationVar(&cfg.Streaming.Timeout, "streaming-timeout", 60*time.Second, "The timeout for the streaming connection.") - commandLine.StringVar(&cfg.Proxy.AgentURL, "proxy-agent-url", "", "Agent proxy URL for agent -> agent traffic (e.g., http://agent-a2a-proxy.kagent.svc.cluster.local:8081)") - commandLine.StringVar(&cfg.Proxy.EgressURL, "proxy-egress-url", "", "Egress proxy URL for agent -> MCP server traffic (e.g., http://agent-egress-proxy.kagent.svc.cluster.local:8082)") + commandLine.StringVar(&cfg.Proxy.URL, "proxy-url", "", "Proxy URL for internally-built k8s URLs (e.g., http://proxy.kagent.svc.cluster.local:8080)") commandLine.StringVar(&agent_translator.DefaultImageConfig.Registry, "image-registry", agent_translator.DefaultImageConfig.Registry, "The registry to use for the image.") commandLine.StringVar(&agent_translator.DefaultImageConfig.Tag, "image-tag", agent_translator.DefaultImageConfig.Tag, "The tag to use for the image.") @@ -379,8 +377,7 @@ func Start(getExtensionConfig GetExtensionConfig) { mgr.GetClient(), cfg.DefaultModelConfig, extensionCfg.AgentPlugins, - cfg.Proxy.AgentURL, - cfg.Proxy.EgressURL, + cfg.Proxy.URL, ) rcnclr := reconciler.NewKagentReconciler( @@ -493,8 +490,7 @@ func Start(getExtensionConfig GetExtensionConfig) { DbClient: dbClient, Authorizer: extensionCfg.Authorizer, Authenticator: extensionCfg.Authenticator, - AgentProxyURL: cfg.Proxy.AgentURL, - EgressProxyURL: cfg.Proxy.EgressURL, + ProxyURL: cfg.Proxy.URL, }) if err != nil { setupLog.Error(err, "unable to create HTTP server") diff --git a/go/pkg/app/app_test.go b/go/pkg/app/app_test.go index 172547d93..ffb6ea2d7 100644 --- a/go/pkg/app/app_test.go +++ b/go/pkg/app/app_test.go @@ -259,8 +259,7 @@ func TestLoadFromEnvIntegration(t *testing.T) { "DEFAULT_MODEL_CONFIG_NAMESPACE": "custom-ns", "HTTP_SERVER_ADDRESS": ":9000", "A2A_BASE_URL": "http://example.com:9000", - "PROXY_AGENT_URL": "http://agent-a2a-proxy:8081", - "PROXY_EGRESS_URL": "http://agent-egress-proxy:8082", + "PROXY_URL": "http://proxy.kagent.svc.cluster.local:8080", "DATABASE_TYPE": "postgres", "POSTGRES_DATABASE_URL": "postgres://localhost:5432/testdb", "WATCH_NAMESPACES": "ns1,ns2,ns3", @@ -306,11 +305,8 @@ func TestLoadFromEnvIntegration(t *testing.T) { if cfg.HttpServerAddr != ":9000" { t.Errorf("HttpServerAddr = %v, want :9000", cfg.HttpServerAddr) } - if cfg.Proxy.AgentURL != "http://agent-a2a-proxy:8081" { - t.Errorf("Proxy.AgentURL = %v, want http://agent-a2a-proxy:8081", cfg.Proxy.AgentURL) - } - if cfg.Proxy.EgressURL != "http://agent-egress-proxy:8082" { - t.Errorf("Proxy.EgressURL = %v, want http://agent-egress-proxy:8082", cfg.Proxy.EgressURL) + if cfg.Proxy.URL != "http://proxy.kagent.svc.cluster.local:8080" { + t.Errorf("Proxy.URL = %v, want http://proxy.kagent.svc.cluster.local:8080", cfg.Proxy.URL) } if cfg.A2ABaseUrl != "http://example.com:9000" { t.Errorf("A2ABaseUrl = %v, want http://example.com:9000", cfg.A2ABaseUrl) diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index f8c551799..fef9eb80a 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -26,11 +26,8 @@ data: OTEL_LOGGING_ENABLED: {{ .Values.otel.logging.enabled | quote }} OTEL_TRACING_ENABLED: {{ .Values.otel.tracing.enabled | quote }} OTEL_TRACING_EXPORTER_OTLP_ENDPOINT: {{ .Values.otel.tracing.exporter.otlp.endpoint | quote }} - {{- if .Values.proxy.agentUrl }} - PROXY_AGENT_URL: {{ .Values.proxy.agentUrl | quote }} - {{- end }} - {{- if .Values.proxy.egressUrl }} - PROXY_EGRESS_URL: {{ .Values.proxy.egressUrl | quote }} + {{- if .Values.proxy.url }} + PROXY_URL: {{ .Values.proxy.url | quote }} {{- end }} {{- if eq .Values.database.type "sqlite" }} SQLITE_DATABASE_PATH: /sqlite-volume/{{ .Values.database.sqlite.databaseName }} diff --git a/helm/kagent/tests/controller-deployment_test.yaml b/helm/kagent/tests/controller-deployment_test.yaml index 71a2fddb4..2b43c31be 100644 --- a/helm/kagent/tests/controller-deployment_test.yaml +++ b/helm/kagent/tests/controller-deployment_test.yaml @@ -106,38 +106,21 @@ tests: path: data.A2A_BASE_URL value: "https://kagent.example.com" - - it: should set PROXY_AGENT_URL when set + - it: should set PROXY_URL when set template: controller-configmap.yaml set: proxy: - agentUrl: "http://agent-a2a-proxy:8081" + url: "http://proxy.kagent.svc.cluster.local:8080" asserts: - equal: - path: data.PROXY_AGENT_URL - value: "http://agent-a2a-proxy:8081" + path: data.PROXY_URL + value: "http://proxy.kagent.svc.cluster.local:8080" - - it: should set PROXY_EGRESS_URL when set - - template: controller-configmap.yaml - set: - proxy: - egressUrl: "http://agent-egress-proxy:8082" - asserts: - - equal: - path: data.PROXY_EGRESS_URL - value: "http://agent-egress-proxy:8082" - - - it: should not set PROXY_AGENT_URL when not set - template: controller-configmap.yaml - asserts: - - notExists: - path: data.PROXY_AGENT_URL - - - it: should not set PROXY_EGRESS_URL when not set + - it: should not set PROXY_URL when not set template: controller-configmap.yaml asserts: - notExists: - path: data.PROXY_EGRESS_URL + path: data.PROXY_URL - it: should use custom loglevel when set template: controller-configmap.yaml diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index e599b3bfc..b0d4d90e0 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -231,17 +231,16 @@ kagent-tools: # ============================================================================== # Global proxy configuration for the controller -# These proxies handle the following traffic paths: -# - agent: Agent -> Agent traffic (applied to all agent pods) -# - egress: Agent -> MCP Server/tool traffic (applied to all agent pods) -# Set these once and the controller will apply them to all agents automatically +# This proxy applies to all internally-built k8s URLs: +# - Agents as tools (agent -> agent traffic) +# - Services as MCP Tools +# - MCPServer resources and RemoteMCPServer resources with internal k8s URLs +# Set this once and the controller will apply it to all agents automatically +# Note: RemoteMCPServer resources use user-supplied URLs and do not use this proxy unless the URL is an internal k8s URL. proxy: - # Agent proxy URL for agent -> agent traffic - # Example: "http://agent-a2a-proxy:8081" - agentUrl: "" - # Egress proxy URL for agent to MCP server/tool traffic - # Example: "http://agent-egress-proxy:8082" - egressUrl: "" + # Proxy URL for internally-built k8s URLs + # Example: "http://proxy.kagent.svc.cluster.local:8080" + url: "" agents: k8s-agent: diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 22e494374..ab6bb96f8 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -143,14 +143,13 @@ def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugi if remote_agent.headers and "Host" in remote_agent.headers: # Parse the proxy URL to extract base URL from urllib.parse import urlparse as parse_url + parsed_proxy = parse_url(remote_agent.url) proxy_base = f"{parsed_proxy.scheme}://{parsed_proxy.netloc}" target_host = remote_agent.headers["Host"] # Event hook to rewrite request URLs to use proxy while preserving Host header - def make_rewrite_url_to_proxy( - proxy_base: str, target_host: str - ) -> Callable[[httpx.Request], None]: + def make_rewrite_url_to_proxy(proxy_base: str, target_host: str) -> Callable[[httpx.Request], None]: async def rewrite_url_to_proxy(request: httpx.Request) -> None: parsed = parse_url(str(request.url)) new_url = f"{proxy_base}{parsed.path}" @@ -163,9 +162,7 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: return rewrite_url_to_proxy - client_kwargs["event_hooks"] = { - "request": [make_rewrite_url_to_proxy(proxy_base, target_host)] - } + client_kwargs["event_hooks"] = {"request": [make_rewrite_url_to_proxy(proxy_base, target_host)]} client = httpx.AsyncClient(**client_kwargs) diff --git a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py index 6a6759571..01ee72dd4 100644 --- a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py +++ b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py @@ -139,7 +139,10 @@ async def test_remote_agent_with_proxy_url(): request = test_server.requests[0] assert request["path"] == AGENT_CARD_WELL_KNOWN_PATH # Verify Host header is set for proxy routing - assert request["headers"].get("Host") == "remote-agent.kagent" or request["headers"].get("host") == "remote-agent.kagent" + assert ( + request["headers"].get("Host") == "remote-agent.kagent" + or request["headers"].get("host") == "remote-agent.kagent" + ) def test_remote_agent_no_proxy_when_not_configured(): @@ -215,7 +218,10 @@ async def test_remote_agent_direct_url_no_proxy(): assert test_server.requests[0]["path"] == AGENT_CARD_WELL_KNOWN_PATH # Verify Host header is set automatically by httpx based on URL headers = test_server.requests[0]["headers"] - assert headers.get("Host") == f"localhost:{test_server.port}" or headers.get("host") == f"localhost:{test_server.port}" + assert ( + headers.get("Host") == f"localhost:{test_server.port}" + or headers.get("host") == f"localhost:{test_server.port}" + ) @pytest.mark.asyncio From c1499effdfe9a2fe89d3baad1625db4433ffa6ff Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Wed, 31 Dec 2025 11:01:19 -0700 Subject: [PATCH 05/11] Update helm comments Signed-off-by: Jeremy Alvis --- helm/kagent/values.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index b0d4d90e0..5f5d18878 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -227,9 +227,8 @@ kagent-tools: loglevel: "debug" # ============================================================================== -# AGENTS +# PROXY CONFIGURATION # ============================================================================== - # Global proxy configuration for the controller # This proxy applies to all internally-built k8s URLs: # - Agents as tools (agent -> agent traffic) @@ -242,6 +241,9 @@ proxy: # Example: "http://proxy.kagent.svc.cluster.local:8080" url: "" +# ============================================================================== +# AGENTS +# ============================================================================== agents: k8s-agent: enabled: true From 643fbd701cb66dff6fe100edcdba06cc1e10e864 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Fri, 2 Jan 2026 07:38:35 -0700 Subject: [PATCH 06/11] Use X-Host header for proxying requets Signed-off-by: Jeremy Alvis --- .../translator/agent/adk_api_translator.go | 20 ++++--- .../controller/translator/agent/proxy_test.go | 32 ++++++------ .../testdata/outputs/agent_with_proxy.json | 8 +-- .../outputs/agent_with_proxy_mcpserver.json | 6 +-- .../outputs/agent_with_proxy_service.json | 6 +-- .../kagent-adk/src/kagent/adk/types.py | 13 ++--- .../tests/unittests/test_proxy_integration.py | 52 ++++++++++--------- 7 files changed, 69 insertions(+), 68 deletions(-) diff --git a/go/internal/controller/translator/agent/adk_api_translator.go b/go/internal/controller/translator/agent/adk_api_translator.go index 01d7ce8a8..57bd3c91f 100644 --- a/go/internal/controller/translator/agent/adk_api_translator.go +++ b/go/internal/controller/translator/agent/adk_api_translator.go @@ -565,7 +565,7 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al return nil, nil, nil, err } - // If proxy is configured, use proxy URL and set Host header for Gateway API routing + // If proxy is configured, use proxy URL and set X-Host header for Gateway API routing targetURL := originalURL if a.globalProxyURL != "" { // Parse original URL to extract path and hostname @@ -579,11 +579,11 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al } // Use proxy URL with original path targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURLParsed.Path) - // Set Host header to original hostname (without port) for Gateway API routing + // Set X-Host header to original hostname (without port) for Gateway API routing if headers == nil { headers = make(map[string]string) } - headers["Host"] = originalURLParsed.Hostname() + headers["X-Host"] = originalURLParsed.Hostname() } cfg.RemoteAgents = append(cfg.RemoteAgents, adk.RemoteAgentConfig{ @@ -952,7 +952,7 @@ func (a *adkApiTranslator) translateStreamableHttpTool(ctx context.Context, tool return nil, err } - // If proxy is configured, use proxy URL and set Host header for Gateway API routing + // If proxy is configured, use proxy URL and set X-Host header for Gateway API routing targetURL := tool.URL if proxyURL != "" { // Parse original URL to extract path and hostname @@ -966,12 +966,11 @@ func (a *adkApiTranslator) translateStreamableHttpTool(ctx context.Context, tool } // Use proxy URL with original path targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURL.Path) - // Set Host header to original hostname (without port) for Gateway API routing - // Gateway API HTTPRoute hostname matching ignores ports, but we strip it for clarity + // Set X-Host header to original hostname (without port) for Gateway API routing if headers == nil { headers = make(map[string]string) } - headers["Host"] = originalURL.Hostname() + headers["X-Host"] = originalURL.Hostname() } params := &adk.StreamableHTTPConnectionParams{ @@ -996,7 +995,7 @@ func (a *adkApiTranslator) translateSseHttpTool(ctx context.Context, tool *v1alp return nil, err } - // If proxy is configured, use proxy URL and set Host header for Gateway API routing + // If proxy is configured, use proxy URL and set X-Host header for Gateway API routing targetURL := tool.URL if proxyURL != "" { // Parse original URL to extract path and hostname @@ -1010,12 +1009,11 @@ func (a *adkApiTranslator) translateSseHttpTool(ctx context.Context, tool *v1alp } // Use proxy URL with original path targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURL.Path) - // Set Host header to original hostname (without port) for Gateway API routing - // Gateway API HTTPRoute hostname matching ignores ports, but we strip it for clarity + // Set X-Host header to original hostname (without port) for Gateway API routing if headers == nil { headers = make(map[string]string) } - headers["Host"] = originalURL.Hostname() + headers["X-Host"] = originalURL.Hostname() } params := &adk.SseConnectionParams{ diff --git a/go/internal/controller/translator/agent/proxy_test.go b/go/internal/controller/translator/agent/proxy_test.go index e3a8fc38f..c144caf5e 100644 --- a/go/internal/controller/translator/agent/proxy_test.go +++ b/go/internal/controller/translator/agent/proxy_test.go @@ -128,15 +128,15 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { remoteAgent := result.Config.RemoteAgents[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080", remoteAgent.Url) assert.NotNil(t, remoteAgent.Headers) - assert.Equal(t, "nested-agent.test", remoteAgent.Headers["Host"]) + assert.Equal(t, "nested-agent.test", remoteAgent.Headers["X-Host"]) // Verify RemoteMCPServer with internal k8s URL DOES use proxy require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) - // Host header should be set for RemoteMCPServer with internal k8s URL (uses proxy) + // X-Host header should be set for RemoteMCPServer with internal k8s URL (uses proxy) require.NotNil(t, httpTool.Params.Headers) - assert.Equal(t, "test-mcp-server.kagent", httpTool.Params.Headers["Host"]) + assert.Equal(t, "test-mcp-server.kagent", httpTool.Params.Headers["X-Host"]) }) t.Run("without proxy URL", func(t *testing.T) { @@ -156,20 +156,20 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { require.Len(t, result.Config.RemoteAgents, 1) remoteAgent := result.Config.RemoteAgents[0] assert.Equal(t, "http://nested-agent.test:8080", remoteAgent.Url) - // Host header should not be set when no proxy + // X-Host header should not be set when no proxy if remoteAgent.Headers != nil { - _, hasHost := remoteAgent.Headers["Host"] - assert.False(t, hasHost, "Host header should not be set when no proxy") + _, hasHost := remoteAgent.Headers["X-Host"] + assert.False(t, hasHost, "X-Host header should not be set when no proxy") } // Verify RemoteMCPServer direct URL (no proxy) require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://test-mcp-server.kagent:8084/mcp", httpTool.Params.Url) - // Host header should not be set when no proxy + // X-Host header should not be set when no proxy if httpTool.Params.Headers != nil { - _, hasHost := httpTool.Params.Headers["Host"] - assert.False(t, hasHost, "Host header should not be set when no proxy") + _, hasHost := httpTool.Params.Headers["X-Host"] + assert.False(t, hasHost, "X-Host header should not be set when no proxy") } }) } @@ -257,10 +257,10 @@ func TestProxyConfiguration_RemoteMCPServer_ExternalURL(t *testing.T) { require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "https://external-mcp.example.com/mcp", httpTool.Params.Url) - // Host header should not be set for external URLs (no proxy) + // X-Host header should not be set for external URLs (no proxy) if httpTool.Params.Headers != nil { - _, hasHost := httpTool.Params.Headers["Host"] - assert.False(t, hasHost, "Host header should not be set for RemoteMCPServer with external URL (no proxy)") + _, hasHost := httpTool.Params.Headers["X-Host"] + assert.False(t, hasHost, "X-Host header should not be set for RemoteMCPServer with external URL (no proxy)") } } @@ -349,9 +349,9 @@ func TestProxyConfiguration_MCPServer(t *testing.T) { require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) - // Host header should be set for MCPServer (uses proxy) + // X-Host header should be set for MCPServer (uses proxy) require.NotNil(t, httpTool.Params.Headers) - assert.Equal(t, "test-mcp-server.test", httpTool.Params.Headers["Host"]) + assert.Equal(t, "test-mcp-server.test", httpTool.Params.Headers["X-Host"]) } // TestProxyConfiguration_Service tests that Services as MCP Tools use proxy @@ -446,7 +446,7 @@ func TestProxyConfiguration_Service(t *testing.T) { require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) - // Host header should be set for Service (uses proxy) + // X-Host header should be set for Service (uses proxy) require.NotNil(t, httpTool.Params.Headers) - assert.Equal(t, "test-service.test", httpTool.Params.Headers["Host"]) + assert.Equal(t, "test-service.test", httpTool.Params.Headers["X-Host"]) } diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json index 91a987cfa..86800e417 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json @@ -23,7 +23,7 @@ { "params": { "headers": { - "Host": "test-mcp-server.kagent" + "X-Host": "test-mcp-server.kagent" }, "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" }, @@ -41,7 +41,7 @@ "remote_agents": [ { "headers": { - "Host": "nested-agent.test" + "X-Host": "nested-agent.test" }, "name": "test__NS__nested_agent", "url": "http://proxy.kagent.svc.cluster.local:8080" @@ -76,7 +76,7 @@ }, "stringData": { "agent-card.json": "{\"name\":\"agent_with_proxy\",\"description\":\"\",\"url\":\"http://agent-with-proxy.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", - "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"Host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://proxy.kagent.svc.cluster.local:8080\",\"headers\":{\"Host\":\"nested-agent.test\"}}]}" + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://proxy.kagent.svc.cluster.local:8080\",\"headers\":{\"X-Host\":\"nested-agent.test\"}}]}" } }, { @@ -145,7 +145,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "7631898334138088108" + "kagent.dev/config-hash": "8542820760737254710" }, "labels": { "app": "kagent", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json index af9292d14..e97498441 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json @@ -23,7 +23,7 @@ { "params": { "headers": { - "Host": "test-mcp-server.test" + "X-Host": "test-mcp-server.test" }, "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" }, @@ -68,7 +68,7 @@ }, "stringData": { "agent-card.json": "{\"name\":\"agent_with_proxy_mcpserver\",\"description\":\"\",\"url\":\"http://agent-with-proxy-mcpserver.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", - "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"Host\":\"test-mcp-server.test\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":null}" + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Host\":\"test-mcp-server.test\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":null}" } }, { @@ -137,7 +137,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "4010839200108182816" + "kagent.dev/config-hash": "8765961336067912007" }, "labels": { "app": "kagent", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json index 7a59fcd0f..36184ddd1 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json @@ -23,7 +23,7 @@ { "params": { "headers": { - "Host": "toolserver.test" + "X-Host": "toolserver.test" }, "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" }, @@ -68,7 +68,7 @@ }, "stringData": { "agent-card.json": "{\"name\":\"agent_with_proxy_service\",\"description\":\"\",\"url\":\"http://agent-with-proxy-service.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", - "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"Host\":\"toolserver.test\"}},\"tools\":[\"k8s_get_resources\"]}],\"sse_tools\":null,\"remote_agents\":null}" + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Host\":\"toolserver.test\"}},\"tools\":[\"k8s_get_resources\"]}],\"sse_tools\":null,\"remote_agents\":null}" } }, { @@ -137,7 +137,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "4989088388029251717" + "kagent.dev/config-hash": "1054793996523090805" }, "labels": { "app": "kagent", diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index ab6bb96f8..7694c79b4 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -137,18 +137,18 @@ def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugi if remote_agent.headers: client_kwargs["headers"] = remote_agent.headers - # If headers include Host header, it means we're using a proxy + # If headers include X-Host header, it means we're using a proxy # RemoteA2aAgent may use URLs from agent card response, so we need to - # rewrite all request URLs to use the proxy URL while preserving Host header - if remote_agent.headers and "Host" in remote_agent.headers: + # rewrite all request URLs to use the proxy URL while preserving X-Host header + if remote_agent.headers and "X-Host" in remote_agent.headers: # Parse the proxy URL to extract base URL from urllib.parse import urlparse as parse_url parsed_proxy = parse_url(remote_agent.url) proxy_base = f"{parsed_proxy.scheme}://{parsed_proxy.netloc}" - target_host = remote_agent.headers["Host"] + target_host = remote_agent.headers["X-Host"] - # Event hook to rewrite request URLs to use proxy while preserving Host header + # Event hook to rewrite request URLs to use proxy while preserving X-Host header def make_rewrite_url_to_proxy(proxy_base: str, target_host: str) -> Callable[[httpx.Request], None]: async def rewrite_url_to_proxy(request: httpx.Request) -> None: parsed = parse_url(str(request.url)) @@ -158,7 +158,8 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: new_url += f"?{parsed.query}" request.url = httpx.URL(new_url) - request.headers["Host"] = target_host + # Preserve X-Host header for Gateway API routing + request.headers["X-Host"] = target_host return rewrite_url_to_proxy diff --git a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py index 01ee72dd4..b337bfcbb 100644 --- a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py +++ b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py @@ -96,10 +96,10 @@ def requests(self) -> list[dict]: @pytest.mark.asyncio async def test_remote_agent_with_proxy_url(): - """Test that RemoteA2aAgent requests go through the proxy URL with correct Host header. + """Test that RemoteA2aAgent requests go through the proxy URL with correct X-Host header. When proxy is configured, requests should be made to the proxy URL (our test server) - with the Host header set for proxy routing. This test uses a real HTTP server + with the X-Host header set for proxy routing. This test uses a real HTTP server to verify actual request behavior. """ with TestHTTPServer() as test_server: @@ -112,7 +112,7 @@ async def test_remote_agent_with_proxy_url(): name="remote_agent", url=test_server.url, # Use test server as proxy URL description="Remote agent", - headers={"Host": "remote-agent.kagent"}, # Host header for proxy routing + headers={"X-Host": "remote-agent.kagent"}, # X-Host header for proxy routing ) ], ) @@ -138,10 +138,10 @@ async def test_remote_agent_with_proxy_url(): assert len(test_server.requests) > 0, "No requests were received by test server" request = test_server.requests[0] assert request["path"] == AGENT_CARD_WELL_KNOWN_PATH - # Verify Host header is set for proxy routing + # Verify X-Host header is set for proxy routing assert ( - request["headers"].get("Host") == "remote-agent.kagent" - or request["headers"].get("host") == "remote-agent.kagent" + request["headers"].get("X-Host") == "remote-agent.kagent" + or request["headers"].get("x-host") == "remote-agent.kagent" ) @@ -217,11 +217,13 @@ async def test_remote_agent_direct_url_no_proxy(): assert len(test_server.requests) > 0 assert test_server.requests[0]["path"] == AGENT_CARD_WELL_KNOWN_PATH # Verify Host header is set automatically by httpx based on URL + # (X-Host should not be present when no proxy is configured) headers = test_server.requests[0]["headers"] assert ( headers.get("Host") == f"localhost:{test_server.port}" or headers.get("host") == f"localhost:{test_server.port}" ) + assert "X-Host" not in headers and "x-host" not in headers @pytest.mark.asyncio @@ -271,10 +273,10 @@ async def test_remote_agent_with_headers(): @pytest.mark.asyncio async def test_remote_agent_url_rewrite_event_hook(): - """Test that URL rewrite event hook rewrites URLs to proxy when Host header is present. + """Test that URL rewrite event hook rewrites URLs to proxy when X-Host header is present. - When a Host header is present, the event hook rewrites all request URLs to use the proxy - base URL while preserving the Host header. This ensures that even if RemoteA2aAgent + When an X-Host header is present, the event hook rewrites all request URLs to use the proxy + base URL while preserving the X-Host header. This ensures that even if RemoteA2aAgent uses URLs from the agent card response, they still go through the proxy. """ with TestHTTPServer() as test_server: @@ -287,7 +289,7 @@ async def test_remote_agent_url_rewrite_event_hook(): name="remote_agent", url=test_server.url, # Use test server as proxy URL description="Remote agent", - headers={"Host": "remote-agent.kagent"}, # Host header indicates proxy usage + headers={"X-Host": "remote-agent.kagent"}, # X-Host header indicates proxy usage ) ], ) @@ -317,13 +319,13 @@ async def test_remote_agent_url_rewrite_event_hook(): # The path should be rewritten to /some/path (proxy base URL + path) assert test_server.requests[0]["path"] == "/some/path" headers = test_server.requests[0]["headers"] - assert headers.get("Host") == "remote-agent.kagent" or headers.get("host") == "remote-agent.kagent" + assert headers.get("X-Host") == "remote-agent.kagent" or headers.get("x-host") == "remote-agent.kagent" def test_mcp_tool_with_proxy_url(): - """Test that MCP tools are configured with proxy URL and Host header. + """Test that MCP tools are configured with proxy URL and X-Host header. - When proxy is configured, the URL is set to the proxy URL and the Host header + When proxy is configured, the URL is set to the proxy URL and the X-Host header is included for proxy routing. These are passed through directly to McpToolset. Note: We verify connection_params configuration because McpToolset doesn't expose @@ -335,7 +337,7 @@ def test_mcp_tool_with_proxy_url(): from kagent.adk.types import HttpMcpServerConfig - # Configuration with proxy URL and Host header + # Configuration with proxy URL and X-Host header config = AgentConfig( model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), description="Test agent", @@ -343,8 +345,8 @@ def test_mcp_tool_with_proxy_url(): http_tools=[ HttpMcpServerConfig( params=StreamableHTTPConnectionParams( - url="http://agent-egress-proxy:8082/mcp", # Proxy URL - headers={"Host": "test-mcp-server.kagent"}, # Host header for proxy routing + url="http://proxy.kagent.svc.cluster.local:8080/mcp", # Proxy URL + headers={"X-Host": "test-mcp-server.kagent"}, # X-Host header for proxy routing ), tools=["test-tool"], ) @@ -367,9 +369,9 @@ def test_mcp_tool_with_proxy_url(): # a public API to verify connection configuration. We're testing our code's configuration logic. connection_params = getattr(mcp_tool, "_connection_params", None) or getattr(mcp_tool, "connection_params", None) assert connection_params is not None - assert connection_params.url == "http://agent-egress-proxy:8082/mcp" + assert connection_params.url == "http://proxy.kagent.svc.cluster.local:8080/mcp" assert connection_params.headers is not None - assert connection_params.headers["Host"] == "test-mcp-server.kagent" + assert connection_params.headers["X-Host"] == "test-mcp-server.kagent" def test_mcp_tool_without_proxy(): @@ -416,9 +418,9 @@ def test_mcp_tool_without_proxy(): def test_sse_mcp_tool_with_proxy_url(): - """Test that SSE MCP tools are configured with proxy URL and Host header. + """Test that SSE MCP tools are configured with proxy URL and X-Host header. - When proxy is configured, the URL is set to the proxy URL and the Host header + When proxy is configured, the URL is set to the proxy URL and the X-Host header is included for proxy routing. These are passed through directly to McpToolset. Note: We verify connection_params configuration because McpToolset doesn't expose @@ -430,7 +432,7 @@ def test_sse_mcp_tool_with_proxy_url(): from kagent.adk.types import SseMcpServerConfig - # Configuration with proxy URL and Host header + # Configuration with proxy URL and X-Host header config = AgentConfig( model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), description="Test agent", @@ -438,8 +440,8 @@ def test_sse_mcp_tool_with_proxy_url(): sse_tools=[ SseMcpServerConfig( params=SseConnectionParams( - url="http://agent-egress-proxy:8082/mcp", # Proxy URL - headers={"Host": "test-sse-mcp-server.kagent"}, # Host header for proxy routing + url="http://proxy.kagent.svc.cluster.local:8080/mcp", # Proxy URL + headers={"X-Host": "test-sse-mcp-server.kagent"}, # X-Host header for proxy routing ), tools=["test-sse-tool"], ) @@ -460,9 +462,9 @@ def test_sse_mcp_tool_with_proxy_url(): # Verify connection params are configured correctly connection_params = getattr(mcp_tool, "_connection_params", None) or getattr(mcp_tool, "connection_params", None) assert connection_params is not None - assert connection_params.url == "http://agent-egress-proxy:8082/mcp" + assert connection_params.url == "http://proxy.kagent.svc.cluster.local:8080/mcp" assert connection_params.headers is not None - assert connection_params.headers["Host"] == "test-sse-mcp-server.kagent" + assert connection_params.headers["X-Host"] == "test-sse-mcp-server.kagent" def test_sse_mcp_tool_without_proxy(): From 0265248b10464b9c96efab59ed8b9a54ca25e23b Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Mon, 5 Jan 2026 11:04:26 -0700 Subject: [PATCH 07/11] Use 'X-Kagent-Host' instead of 'X-Host' header Signed-off-by: Jeremy Alvis --- .../translator/agent/adk_api_translator.go | 18 ++++---- .../controller/translator/agent/proxy_test.go | 32 ++++++------- .../testdata/outputs/agent_with_proxy.json | 6 +-- .../outputs/agent_with_proxy_mcpserver.json | 4 +- .../outputs/agent_with_proxy_service.json | 4 +- .../kagent-adk/src/kagent/adk/types.py | 14 +++--- .../tests/unittests/test_proxy_integration.py | 46 +++++++++---------- 7 files changed, 62 insertions(+), 62 deletions(-) diff --git a/go/internal/controller/translator/agent/adk_api_translator.go b/go/internal/controller/translator/agent/adk_api_translator.go index 57bd3c91f..f170fa5ea 100644 --- a/go/internal/controller/translator/agent/adk_api_translator.go +++ b/go/internal/controller/translator/agent/adk_api_translator.go @@ -565,7 +565,7 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al return nil, nil, nil, err } - // If proxy is configured, use proxy URL and set X-Host header for Gateway API routing + // If proxy is configured, use proxy URL and set X-Kagent-Host header for Gateway API routing targetURL := originalURL if a.globalProxyURL != "" { // Parse original URL to extract path and hostname @@ -579,11 +579,11 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al } // Use proxy URL with original path targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURLParsed.Path) - // Set X-Host header to original hostname (without port) for Gateway API routing + // Set X-Kagent-Host header to original hostname (without port) for Gateway API routing if headers == nil { headers = make(map[string]string) } - headers["X-Host"] = originalURLParsed.Hostname() + headers["X-Kagent-Host"] = originalURLParsed.Hostname() } cfg.RemoteAgents = append(cfg.RemoteAgents, adk.RemoteAgentConfig{ @@ -952,7 +952,7 @@ func (a *adkApiTranslator) translateStreamableHttpTool(ctx context.Context, tool return nil, err } - // If proxy is configured, use proxy URL and set X-Host header for Gateway API routing + // If proxy is configured, use proxy URL and set X-Kagent-Host header for Gateway API routing targetURL := tool.URL if proxyURL != "" { // Parse original URL to extract path and hostname @@ -966,11 +966,11 @@ func (a *adkApiTranslator) translateStreamableHttpTool(ctx context.Context, tool } // Use proxy URL with original path targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURL.Path) - // Set X-Host header to original hostname (without port) for Gateway API routing + // Set X-Kagent-Host header to original hostname (without port) for Gateway API routing if headers == nil { headers = make(map[string]string) } - headers["X-Host"] = originalURL.Hostname() + headers["X-Kagent-Host"] = originalURL.Hostname() } params := &adk.StreamableHTTPConnectionParams{ @@ -995,7 +995,7 @@ func (a *adkApiTranslator) translateSseHttpTool(ctx context.Context, tool *v1alp return nil, err } - // If proxy is configured, use proxy URL and set X-Host header for Gateway API routing + // If proxy is configured, use proxy URL and set X-Kagent-Host header for Gateway API routing targetURL := tool.URL if proxyURL != "" { // Parse original URL to extract path and hostname @@ -1009,11 +1009,11 @@ func (a *adkApiTranslator) translateSseHttpTool(ctx context.Context, tool *v1alp } // Use proxy URL with original path targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURL.Path) - // Set X-Host header to original hostname (without port) for Gateway API routing + // Set X-Kagent-Host header to original hostname (without port) for Gateway API routing if headers == nil { headers = make(map[string]string) } - headers["X-Host"] = originalURL.Hostname() + headers["X-Kagent-Host"] = originalURL.Hostname() } params := &adk.SseConnectionParams{ diff --git a/go/internal/controller/translator/agent/proxy_test.go b/go/internal/controller/translator/agent/proxy_test.go index c144caf5e..f3774b1ff 100644 --- a/go/internal/controller/translator/agent/proxy_test.go +++ b/go/internal/controller/translator/agent/proxy_test.go @@ -128,15 +128,15 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { remoteAgent := result.Config.RemoteAgents[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080", remoteAgent.Url) assert.NotNil(t, remoteAgent.Headers) - assert.Equal(t, "nested-agent.test", remoteAgent.Headers["X-Host"]) + assert.Equal(t, "nested-agent.test", remoteAgent.Headers["X-Kagent-Host"]) // Verify RemoteMCPServer with internal k8s URL DOES use proxy require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) - // X-Host header should be set for RemoteMCPServer with internal k8s URL (uses proxy) + // X-Kagent-Host header should be set for RemoteMCPServer with internal k8s URL (uses proxy) require.NotNil(t, httpTool.Params.Headers) - assert.Equal(t, "test-mcp-server.kagent", httpTool.Params.Headers["X-Host"]) + assert.Equal(t, "test-mcp-server.kagent", httpTool.Params.Headers["X-Kagent-Host"]) }) t.Run("without proxy URL", func(t *testing.T) { @@ -156,20 +156,20 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { require.Len(t, result.Config.RemoteAgents, 1) remoteAgent := result.Config.RemoteAgents[0] assert.Equal(t, "http://nested-agent.test:8080", remoteAgent.Url) - // X-Host header should not be set when no proxy + // X-Kagent-Host header should not be set when no proxy if remoteAgent.Headers != nil { - _, hasHost := remoteAgent.Headers["X-Host"] - assert.False(t, hasHost, "X-Host header should not be set when no proxy") + _, hasHost := remoteAgent.Headers["X-Kagent-Host"] + assert.False(t, hasHost, "X-Kagent-Host header should not be set when no proxy") } // Verify RemoteMCPServer direct URL (no proxy) require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://test-mcp-server.kagent:8084/mcp", httpTool.Params.Url) - // X-Host header should not be set when no proxy + // X-Kagent-Host header should not be set when no proxy if httpTool.Params.Headers != nil { - _, hasHost := httpTool.Params.Headers["X-Host"] - assert.False(t, hasHost, "X-Host header should not be set when no proxy") + _, hasHost := httpTool.Params.Headers["X-Kagent-Host"] + assert.False(t, hasHost, "X-Kagent-Host header should not be set when no proxy") } }) } @@ -257,10 +257,10 @@ func TestProxyConfiguration_RemoteMCPServer_ExternalURL(t *testing.T) { require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "https://external-mcp.example.com/mcp", httpTool.Params.Url) - // X-Host header should not be set for external URLs (no proxy) + // X-Kagent-Host header should not be set for external URLs (no proxy) if httpTool.Params.Headers != nil { - _, hasHost := httpTool.Params.Headers["X-Host"] - assert.False(t, hasHost, "X-Host header should not be set for RemoteMCPServer with external URL (no proxy)") + _, hasHost := httpTool.Params.Headers["X-Kagent-Host"] + assert.False(t, hasHost, "X-Kagent-Host header should not be set for RemoteMCPServer with external URL (no proxy)") } } @@ -349,9 +349,9 @@ func TestProxyConfiguration_MCPServer(t *testing.T) { require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) - // X-Host header should be set for MCPServer (uses proxy) + // X-Kagent-Host header should be set for MCPServer (uses proxy) require.NotNil(t, httpTool.Params.Headers) - assert.Equal(t, "test-mcp-server.test", httpTool.Params.Headers["X-Host"]) + assert.Equal(t, "test-mcp-server.test", httpTool.Params.Headers["X-Kagent-Host"]) } // TestProxyConfiguration_Service tests that Services as MCP Tools use proxy @@ -446,7 +446,7 @@ func TestProxyConfiguration_Service(t *testing.T) { require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) - // X-Host header should be set for Service (uses proxy) + // X-Kagent-Host header should be set for Service (uses proxy) require.NotNil(t, httpTool.Params.Headers) - assert.Equal(t, "test-service.test", httpTool.Params.Headers["X-Host"]) + assert.Equal(t, "test-service.test", httpTool.Params.Headers["X-Kagent-Host"]) } diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json index 86800e417..0385c82b6 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json @@ -23,7 +23,7 @@ { "params": { "headers": { - "X-Host": "test-mcp-server.kagent" + "X-Kagent-Host": "test-mcp-server.kagent" }, "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" }, @@ -41,7 +41,7 @@ "remote_agents": [ { "headers": { - "X-Host": "nested-agent.test" + "X-Kagent-Host": "nested-agent.test" }, "name": "test__NS__nested_agent", "url": "http://proxy.kagent.svc.cluster.local:8080" @@ -76,7 +76,7 @@ }, "stringData": { "agent-card.json": "{\"name\":\"agent_with_proxy\",\"description\":\"\",\"url\":\"http://agent-with-proxy.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", - "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://proxy.kagent.svc.cluster.local:8080\",\"headers\":{\"X-Host\":\"nested-agent.test\"}}]}" + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Kagent-Host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://proxy.kagent.svc.cluster.local:8080\",\"headers\":{\"X-Kagent-Host\":\"nested-agent.test\"}}]}" } }, { diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json index e97498441..1b5e510bf 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json @@ -23,7 +23,7 @@ { "params": { "headers": { - "X-Host": "test-mcp-server.test" + "X-Kagent-Host": "test-mcp-server.test" }, "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" }, @@ -68,7 +68,7 @@ }, "stringData": { "agent-card.json": "{\"name\":\"agent_with_proxy_mcpserver\",\"description\":\"\",\"url\":\"http://agent-with-proxy-mcpserver.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", - "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Host\":\"test-mcp-server.test\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":null}" + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Kagent-Host\":\"test-mcp-server.test\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":null}" } }, { diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json index 36184ddd1..6dff195fa 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json @@ -23,7 +23,7 @@ { "params": { "headers": { - "X-Host": "toolserver.test" + "X-Kagent-Host": "toolserver.test" }, "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" }, @@ -68,7 +68,7 @@ }, "stringData": { "agent-card.json": "{\"name\":\"agent_with_proxy_service\",\"description\":\"\",\"url\":\"http://agent-with-proxy-service.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", - "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Host\":\"toolserver.test\"}},\"tools\":[\"k8s_get_resources\"]}],\"sse_tools\":null,\"remote_agents\":null}" + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Kagent-Host\":\"toolserver.test\"}},\"tools\":[\"k8s_get_resources\"]}],\"sse_tools\":null,\"remote_agents\":null}" } }, { diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 7694c79b4..c5b0e3121 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -137,18 +137,18 @@ def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugi if remote_agent.headers: client_kwargs["headers"] = remote_agent.headers - # If headers include X-Host header, it means we're using a proxy + # If headers include X-Kagent-Host header, it means we're using a proxy # RemoteA2aAgent may use URLs from agent card response, so we need to - # rewrite all request URLs to use the proxy URL while preserving X-Host header - if remote_agent.headers and "X-Host" in remote_agent.headers: + # rewrite all request URLs to use the proxy URL while preserving X-Kagent-Host header + if remote_agent.headers and "X-Kagent-Host" in remote_agent.headers: # Parse the proxy URL to extract base URL from urllib.parse import urlparse as parse_url parsed_proxy = parse_url(remote_agent.url) proxy_base = f"{parsed_proxy.scheme}://{parsed_proxy.netloc}" - target_host = remote_agent.headers["X-Host"] + target_host = remote_agent.headers["X-Kagent-Host"] - # Event hook to rewrite request URLs to use proxy while preserving X-Host header + # Event hook to rewrite request URLs to use proxy while preserving X-Kagent-Host header def make_rewrite_url_to_proxy(proxy_base: str, target_host: str) -> Callable[[httpx.Request], None]: async def rewrite_url_to_proxy(request: httpx.Request) -> None: parsed = parse_url(str(request.url)) @@ -158,8 +158,8 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: new_url += f"?{parsed.query}" request.url = httpx.URL(new_url) - # Preserve X-Host header for Gateway API routing - request.headers["X-Host"] = target_host + # Preserve X-Kagent-Host header for Gateway API routing + request.headers["X-Kagent-Host"] = target_host return rewrite_url_to_proxy diff --git a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py index b337bfcbb..e2b303c6a 100644 --- a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py +++ b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py @@ -96,10 +96,10 @@ def requests(self) -> list[dict]: @pytest.mark.asyncio async def test_remote_agent_with_proxy_url(): - """Test that RemoteA2aAgent requests go through the proxy URL with correct X-Host header. + """Test that RemoteA2aAgent requests go through the proxy URL with correct X-Kagent-Host header. When proxy is configured, requests should be made to the proxy URL (our test server) - with the X-Host header set for proxy routing. This test uses a real HTTP server + with the X-Kagent-Host header set for proxy routing. This test uses a real HTTP server to verify actual request behavior. """ with TestHTTPServer() as test_server: @@ -112,7 +112,7 @@ async def test_remote_agent_with_proxy_url(): name="remote_agent", url=test_server.url, # Use test server as proxy URL description="Remote agent", - headers={"X-Host": "remote-agent.kagent"}, # X-Host header for proxy routing + headers={"X-Kagent-Host": "remote-agent.kagent"}, # X-Kagent-Host header for proxy routing ) ], ) @@ -138,10 +138,10 @@ async def test_remote_agent_with_proxy_url(): assert len(test_server.requests) > 0, "No requests were received by test server" request = test_server.requests[0] assert request["path"] == AGENT_CARD_WELL_KNOWN_PATH - # Verify X-Host header is set for proxy routing + # Verify X-Kagent-Host header is set for proxy routing assert ( - request["headers"].get("X-Host") == "remote-agent.kagent" - or request["headers"].get("x-host") == "remote-agent.kagent" + request["headers"].get("X-Kagent-Host") == "remote-agent.kagent" + or request["headers"].get("X-Kagent-Host") == "remote-agent.kagent" ) @@ -217,13 +217,13 @@ async def test_remote_agent_direct_url_no_proxy(): assert len(test_server.requests) > 0 assert test_server.requests[0]["path"] == AGENT_CARD_WELL_KNOWN_PATH # Verify Host header is set automatically by httpx based on URL - # (X-Host should not be present when no proxy is configured) + # (X-Kagent-Host should not be present when no proxy is configured) headers = test_server.requests[0]["headers"] assert ( headers.get("Host") == f"localhost:{test_server.port}" or headers.get("host") == f"localhost:{test_server.port}" ) - assert "X-Host" not in headers and "x-host" not in headers + assert "X-Kagent-Host" not in headers and "X-Kagent-Host" not in headers @pytest.mark.asyncio @@ -273,10 +273,10 @@ async def test_remote_agent_with_headers(): @pytest.mark.asyncio async def test_remote_agent_url_rewrite_event_hook(): - """Test that URL rewrite event hook rewrites URLs to proxy when X-Host header is present. + """Test that URL rewrite event hook rewrites URLs to proxy when X-Kagent-Host header is present. - When an X-Host header is present, the event hook rewrites all request URLs to use the proxy - base URL while preserving the X-Host header. This ensures that even if RemoteA2aAgent + When an X-Kagent-Host header is present, the event hook rewrites all request URLs to use the proxy + base URL while preserving the X-Kagent-Host header. This ensures that even if RemoteA2aAgent uses URLs from the agent card response, they still go through the proxy. """ with TestHTTPServer() as test_server: @@ -289,7 +289,7 @@ async def test_remote_agent_url_rewrite_event_hook(): name="remote_agent", url=test_server.url, # Use test server as proxy URL description="Remote agent", - headers={"X-Host": "remote-agent.kagent"}, # X-Host header indicates proxy usage + headers={"X-Kagent-Host": "remote-agent.kagent"}, # X-Kagent-Host header indicates proxy usage ) ], ) @@ -319,13 +319,13 @@ async def test_remote_agent_url_rewrite_event_hook(): # The path should be rewritten to /some/path (proxy base URL + path) assert test_server.requests[0]["path"] == "/some/path" headers = test_server.requests[0]["headers"] - assert headers.get("X-Host") == "remote-agent.kagent" or headers.get("x-host") == "remote-agent.kagent" + assert headers.get("X-Kagent-Host") == "remote-agent.kagent" or headers.get("X-Kagent-Host") == "remote-agent.kagent" def test_mcp_tool_with_proxy_url(): - """Test that MCP tools are configured with proxy URL and X-Host header. + """Test that MCP tools are configured with proxy URL and X-Kagent-Host header. - When proxy is configured, the URL is set to the proxy URL and the X-Host header + When proxy is configured, the URL is set to the proxy URL and the X-Kagent-Host header is included for proxy routing. These are passed through directly to McpToolset. Note: We verify connection_params configuration because McpToolset doesn't expose @@ -337,7 +337,7 @@ def test_mcp_tool_with_proxy_url(): from kagent.adk.types import HttpMcpServerConfig - # Configuration with proxy URL and X-Host header + # Configuration with proxy URL and X-Kagent-Host header config = AgentConfig( model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), description="Test agent", @@ -346,7 +346,7 @@ def test_mcp_tool_with_proxy_url(): HttpMcpServerConfig( params=StreamableHTTPConnectionParams( url="http://proxy.kagent.svc.cluster.local:8080/mcp", # Proxy URL - headers={"X-Host": "test-mcp-server.kagent"}, # X-Host header for proxy routing + headers={"X-Kagent-Host": "test-mcp-server.kagent"}, # X-Kagent-Host header for proxy routing ), tools=["test-tool"], ) @@ -371,7 +371,7 @@ def test_mcp_tool_with_proxy_url(): assert connection_params is not None assert connection_params.url == "http://proxy.kagent.svc.cluster.local:8080/mcp" assert connection_params.headers is not None - assert connection_params.headers["X-Host"] == "test-mcp-server.kagent" + assert connection_params.headers["X-Kagent-Host"] == "test-mcp-server.kagent" def test_mcp_tool_without_proxy(): @@ -418,9 +418,9 @@ def test_mcp_tool_without_proxy(): def test_sse_mcp_tool_with_proxy_url(): - """Test that SSE MCP tools are configured with proxy URL and X-Host header. + """Test that SSE MCP tools are configured with proxy URL and X-Kagent-Host header. - When proxy is configured, the URL is set to the proxy URL and the X-Host header + When proxy is configured, the URL is set to the proxy URL and the X-Kagent-Host header is included for proxy routing. These are passed through directly to McpToolset. Note: We verify connection_params configuration because McpToolset doesn't expose @@ -432,7 +432,7 @@ def test_sse_mcp_tool_with_proxy_url(): from kagent.adk.types import SseMcpServerConfig - # Configuration with proxy URL and X-Host header + # Configuration with proxy URL and X-Kagent-Host header config = AgentConfig( model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), description="Test agent", @@ -441,7 +441,7 @@ def test_sse_mcp_tool_with_proxy_url(): SseMcpServerConfig( params=SseConnectionParams( url="http://proxy.kagent.svc.cluster.local:8080/mcp", # Proxy URL - headers={"X-Host": "test-sse-mcp-server.kagent"}, # X-Host header for proxy routing + headers={"X-Kagent-Host": "test-sse-mcp-server.kagent"}, # X-Kagent-Host header for proxy routing ), tools=["test-sse-tool"], ) @@ -464,7 +464,7 @@ def test_sse_mcp_tool_with_proxy_url(): assert connection_params is not None assert connection_params.url == "http://proxy.kagent.svc.cluster.local:8080/mcp" assert connection_params.headers is not None - assert connection_params.headers["X-Host"] == "test-sse-mcp-server.kagent" + assert connection_params.headers["X-Kagent-Host"] == "test-sse-mcp-server.kagent" def test_sse_mcp_tool_without_proxy(): From 54c9c0710c35159609353e76cb19471529bedd57 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Mon, 5 Jan 2026 13:46:02 -0700 Subject: [PATCH 08/11] Update golden tests Signed-off-by: Jeremy Alvis --- .../translator/agent/testdata/outputs/agent_with_proxy.json | 2 +- .../agent/testdata/outputs/agent_with_proxy_mcpserver.json | 2 +- .../agent/testdata/outputs/agent_with_proxy_service.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json index 0385c82b6..d5bd59216 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json @@ -145,7 +145,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "8542820760737254710" + "kagent.dev/config-hash": "9991414954376340796" }, "labels": { "app": "kagent", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json index 1b5e510bf..84e47bf63 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json @@ -137,7 +137,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "8765961336067912007" + "kagent.dev/config-hash": "2579905671631113766" }, "labels": { "app": "kagent", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json index 6dff195fa..d26bbfe5f 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json @@ -137,7 +137,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "1054793996523090805" + "kagent.dev/config-hash": "6408252146511030563" }, "labels": { "app": "kagent", From 792302155bb623864d43b1dc1986965d1c620091 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Mon, 5 Jan 2026 14:08:36 -0700 Subject: [PATCH 09/11] Fix tests and handle relative paths Signed-off-by: Jeremy Alvis --- .../kagent-adk/src/kagent/adk/types.py | 20 +++++++++++++----- .../tests/unittests/test_proxy_integration.py | 21 ++++++++++++------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index c5b0e3121..0d45d6477 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -148,17 +148,27 @@ def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugi proxy_base = f"{parsed_proxy.scheme}://{parsed_proxy.netloc}" target_host = remote_agent.headers["X-Kagent-Host"] + # Set base_url so relative paths work correctly with httpx + # httpx requires either base_url or absolute URLs - relative paths will fail without base_url + client_kwargs["base_url"] = proxy_base + # Event hook to rewrite request URLs to use proxy while preserving X-Kagent-Host header + # This handles cases where RemoteA2aAgent uses absolute URLs from agent card response + # Note: Relative paths are handled by base_url above, so they'll already point to proxy_base def make_rewrite_url_to_proxy(proxy_base: str, target_host: str) -> Callable[[httpx.Request], None]: async def rewrite_url_to_proxy(request: httpx.Request) -> None: parsed = parse_url(str(request.url)) - new_url = f"{proxy_base}{parsed.path}" + proxy_netloc = parse_url(proxy_base).netloc - if parsed.query: - new_url += f"?{parsed.query}" + # If URL is absolute and points to a different host, rewrite to proxy + if parsed.netloc and parsed.netloc != proxy_netloc: + # This is an absolute URL pointing to the target service, rewrite it + new_url = f"{proxy_base}{parsed.path}" + if parsed.query: + new_url += f"?{parsed.query}" + request.url = httpx.URL(new_url) - request.url = httpx.URL(new_url) - # Preserve X-Kagent-Host header for Gateway API routing + # Always set X-Kagent-Host header for Gateway API routing request.headers["X-Kagent-Host"] = target_host return rewrite_url_to_proxy diff --git a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py index e2b303c6a..1e75493bc 100644 --- a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py +++ b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py @@ -131,8 +131,9 @@ async def test_remote_agent_with_proxy_url(): assert remote_agent_tool is not None # Make a request - this should go through the proxy (test server) + # The client has base_url set to the proxy, so we can use a relative path async with remote_agent_tool._httpx_client as client: - await client.get(f"{AGENT_CARD_WELL_KNOWN_PATH}") + await client.get(AGENT_CARD_WELL_KNOWN_PATH) # Verify that requests were made to the proxy URL (test server) assert len(test_server.requests) > 0, "No requests were received by test server" @@ -141,7 +142,7 @@ async def test_remote_agent_with_proxy_url(): # Verify X-Kagent-Host header is set for proxy routing assert ( request["headers"].get("X-Kagent-Host") == "remote-agent.kagent" - or request["headers"].get("X-Kagent-Host") == "remote-agent.kagent" + or request["headers"].get("x-kagent-host") == "remote-agent.kagent" ) @@ -228,7 +229,7 @@ async def test_remote_agent_direct_url_no_proxy(): @pytest.mark.asyncio async def test_remote_agent_with_headers(): - """Test that RemoteA2aAgent preserves headers including Host header for proxy routing.""" + """Test that RemoteA2aAgent preserves headers including X-Kagent-Host header for proxy routing.""" with TestHTTPServer() as test_server: config = AgentConfig( model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), @@ -241,7 +242,7 @@ async def test_remote_agent_with_headers(): description="Remote agent", headers={ "Authorization": "Bearer token123", - "Host": "remote-agent.kagent", # Host header for proxy routing + "X-Kagent-Host": "remote-agent.kagent", # X-Kagent-Host header for proxy routing }, ) ], @@ -260,7 +261,7 @@ async def test_remote_agent_with_headers(): assert remote_agent_tool is not None - # Make a request using the client + # Make a request using the client - the client has base_url set to the proxy async with remote_agent_tool._httpx_client as client: await client.get("/test") @@ -268,7 +269,10 @@ async def test_remote_agent_with_headers(): assert len(test_server.requests) > 0 headers = test_server.requests[0]["headers"] assert headers.get("Authorization") == "Bearer token123" or headers.get("authorization") == "Bearer token123" - assert headers.get("Host") == "remote-agent.kagent" or headers.get("host") == "remote-agent.kagent" + assert ( + headers.get("X-Kagent-Host") == "remote-agent.kagent" + or headers.get("x-kagent-host") == "remote-agent.kagent" + ) @pytest.mark.asyncio @@ -319,7 +323,10 @@ async def test_remote_agent_url_rewrite_event_hook(): # The path should be rewritten to /some/path (proxy base URL + path) assert test_server.requests[0]["path"] == "/some/path" headers = test_server.requests[0]["headers"] - assert headers.get("X-Kagent-Host") == "remote-agent.kagent" or headers.get("X-Kagent-Host") == "remote-agent.kagent" + assert ( + headers.get("X-Kagent-Host") == "remote-agent.kagent" + or headers.get("x-kagent-host") == "remote-agent.kagent" + ) def test_mcp_tool_with_proxy_url(): From 20c54e479b3bcbdc1862dd6a3b43feead12ea545 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Tue, 6 Jan 2026 08:13:25 -0700 Subject: [PATCH 10/11] Updates based on feedback - Use constants for proxy header - Refactor and simplify logic Signed-off-by: Jeremy Alvis --- .../translator/agent/adk_api_translator.go | 81 +++++++++---------- .../agent/adk_translator_golden_test.go | 42 +++++++--- .../controller/translator/agent/proxy_test.go | 44 +++++----- .../testdata/outputs/agent_with_proxy.json | 8 +- .../outputs/agent_with_proxy_mcpserver.json | 6 +- .../outputs/agent_with_proxy_service.json | 6 +- .../kagent-adk/src/kagent/adk/types.py | 67 +++++++++------ .../tests/unittests/test_proxy_integration.py | 58 ++++++------- 8 files changed, 169 insertions(+), 143 deletions(-) diff --git a/go/internal/controller/translator/agent/adk_api_translator.go b/go/internal/controller/translator/agent/adk_api_translator.go index f170fa5ea..87570055e 100644 --- a/go/internal/controller/translator/agent/adk_api_translator.go +++ b/go/internal/controller/translator/agent/adk_api_translator.go @@ -43,6 +43,8 @@ const ( MCPServicePathDefault = "/mcp" MCPServiceProtocolDefault = v1alpha2.RemoteMCPServerProtocolStreamableHttp + + ProxyHostHeader = "x-kagent-host" ) type ImageConfig struct { @@ -565,25 +567,13 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent *v1al return nil, nil, nil, err } - // If proxy is configured, use proxy URL and set X-Kagent-Host header for Gateway API routing + // If proxy is configured, use proxy URL and set header for Gateway API routing targetURL := originalURL if a.globalProxyURL != "" { - // Parse original URL to extract path and hostname - originalURLParsed, err := url.Parse(originalURL) - if err != nil { - return nil, nil, nil, fmt.Errorf("failed to parse agent URL %q: %w", originalURL, err) - } - proxyURLParsed, err := url.Parse(a.globalProxyURL) + targetURL, headers, err = applyProxyURL(originalURL, a.globalProxyURL, headers) if err != nil { - return nil, nil, nil, fmt.Errorf("failed to parse proxy URL %q: %w", a.globalProxyURL, err) + return nil, nil, nil, err } - // Use proxy URL with original path - targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURLParsed.Path) - // Set X-Kagent-Host header to original hostname (without port) for Gateway API routing - if headers == nil { - headers = make(map[string]string) - } - headers["X-Kagent-Host"] = originalURLParsed.Hostname() } cfg.RemoteAgents = append(cfg.RemoteAgents, adk.RemoteAgentConfig{ @@ -952,25 +942,13 @@ func (a *adkApiTranslator) translateStreamableHttpTool(ctx context.Context, tool return nil, err } - // If proxy is configured, use proxy URL and set X-Kagent-Host header for Gateway API routing + // If proxy is configured, use proxy URL and set header for Gateway API routing targetURL := tool.URL if proxyURL != "" { - // Parse original URL to extract path and hostname - originalURL, err := url.Parse(tool.URL) - if err != nil { - return nil, fmt.Errorf("failed to parse tool URL %q: %w", tool.URL, err) - } - proxyURLParsed, err := url.Parse(proxyURL) + targetURL, headers, err = applyProxyURL(tool.URL, proxyURL, headers) if err != nil { - return nil, fmt.Errorf("failed to parse proxy URL %q: %w", proxyURL, err) - } - // Use proxy URL with original path - targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURL.Path) - // Set X-Kagent-Host header to original hostname (without port) for Gateway API routing - if headers == nil { - headers = make(map[string]string) + return nil, err } - headers["X-Kagent-Host"] = originalURL.Hostname() } params := &adk.StreamableHTTPConnectionParams{ @@ -986,6 +964,7 @@ func (a *adkApiTranslator) translateStreamableHttpTool(ctx context.Context, tool if tool.TerminateOnClose != nil { params.TerminateOnClose = tool.TerminateOnClose } + return params, nil } @@ -995,25 +974,13 @@ func (a *adkApiTranslator) translateSseHttpTool(ctx context.Context, tool *v1alp return nil, err } - // If proxy is configured, use proxy URL and set X-Kagent-Host header for Gateway API routing + // If proxy is configured, use proxy URL and set header for Gateway API routing targetURL := tool.URL if proxyURL != "" { - // Parse original URL to extract path and hostname - originalURL, err := url.Parse(tool.URL) + targetURL, headers, err = applyProxyURL(tool.URL, proxyURL, headers) if err != nil { - return nil, fmt.Errorf("failed to parse tool URL %q: %w", tool.URL, err) - } - proxyURLParsed, err := url.Parse(proxyURL) - if err != nil { - return nil, fmt.Errorf("failed to parse proxy URL %q: %w", proxyURL, err) - } - // Use proxy URL with original path - targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURL.Path) - // Set X-Kagent-Host header to original hostname (without port) for Gateway API routing - if headers == nil { - headers = make(map[string]string) + return nil, err } - headers["X-Kagent-Host"] = originalURL.Hostname() } params := &adk.SseConnectionParams{ @@ -1237,6 +1204,30 @@ func (a *adkApiTranslator) isInternalK8sURL(ctx context.Context, urlStr, namespa return false } +func applyProxyURL(originalURL, proxyURL string, headers map[string]string) (targetURL string, updatedHeaders map[string]string, err error) { + // Parse original URL to extract path and hostname + originalURLParsed, err := url.Parse(originalURL) + if err != nil { + return "", nil, fmt.Errorf("failed to parse original URL %q: %w", originalURL, err) + } + proxyURLParsed, err := url.Parse(proxyURL) + if err != nil { + return "", nil, fmt.Errorf("failed to parse proxy URL %q: %w", proxyURL, err) + } + + // Use proxy URL with original path + targetURL = fmt.Sprintf("%s://%s%s", proxyURLParsed.Scheme, proxyURLParsed.Host, originalURLParsed.Path) + + // Set header to original hostname (without port) for Gateway API routing + updatedHeaders = headers + if updatedHeaders == nil { + updatedHeaders = make(map[string]string) + } + updatedHeaders[ProxyHostHeader] = originalURLParsed.Hostname() + + return targetURL, updatedHeaders, nil +} + func computeConfigHash(agentCfg, agentCard, secretData []byte) uint64 { hasher := sha256.New() hasher.Write(agentCfg) diff --git a/go/internal/controller/translator/agent/adk_translator_golden_test.go b/go/internal/controller/translator/agent/adk_translator_golden_test.go index 97326dca3..562f547f3 100644 --- a/go/internal/controller/translator/agent/adk_translator_golden_test.go +++ b/go/internal/controller/translator/agent/adk_translator_golden_test.go @@ -3,6 +3,7 @@ package agent_test import ( "context" "encoding/json" + "net/url" "os" "path/filepath" "strings" @@ -91,7 +92,7 @@ func runGoldenTest(t *testing.T, inputFile, outputsDir, testName string, updateG // Convert map to unstructured unstrObj := &unstructured.Unstructured{Object: objMap} - // Track namespace if present + // Track namespace from object metadata if metadata, ok := objMap["metadata"].(map[string]any); ok { if ns, ok := metadata["namespace"].(string); ok && ns != "" { namespacesSeen[ns] = true @@ -99,19 +100,12 @@ func runGoldenTest(t *testing.T, inputFile, outputsDir, testName string, updateG } // Extract namespace from URLs in RemoteMCPServer specs + // This is needed because isInternalK8sURL checks if the namespace exists if kind, ok := objMap["kind"].(string); ok && kind == "RemoteMCPServer" { if spec, ok := objMap["spec"].(map[string]any); ok { - if url, ok := spec["url"].(string); ok { - // Parse URL to extract namespace (e.g., http://service.namespace:port/path) - parts := strings.Split(url, "://") - if len(parts) == 2 { - hostPart := strings.Split(parts[1], "/")[0] - hostParts := strings.Split(hostPart, ":") - hostname := hostParts[0] - hostnameParts := strings.Split(hostname, ".") - if len(hostnameParts) == 2 { - namespacesSeen[hostnameParts[1]] = true - } + if urlStr, ok := spec["url"].(string); ok { + if ns := extractNamespaceFromURL(urlStr); ns != "" { + namespacesSeen[ns] = true } } } @@ -262,3 +256,27 @@ func removeNonDeterministicFields(obj any) any { return v } } + +// extractNamespaceFromURL extracts the namespace from a Kubernetes service URL. +// For example, "http://service.namespace:port/path" returns "namespace". +// Returns empty string if the URL is not a valid Kubernetes service URL. +func extractNamespaceFromURL(urlStr string) string { + parsed, err := url.Parse(urlStr) + if err != nil { + return "" + } + + // Split hostname by dots: service.namespace or service.namespace.svc.cluster.local + hostname := parsed.Hostname() + parts := strings.Split(hostname, ".") + + // Valid patterns: + // - service.namespace (2 parts) + // - service.namespace.svc (3 parts) + // - service.namespace.svc.cluster.local (5 parts) + if len(parts) >= 2 { + return parts[1] // namespace is always the second part + } + + return "" +} diff --git a/go/internal/controller/translator/agent/proxy_test.go b/go/internal/controller/translator/agent/proxy_test.go index f3774b1ff..10bca85f4 100644 --- a/go/internal/controller/translator/agent/proxy_test.go +++ b/go/internal/controller/translator/agent/proxy_test.go @@ -13,7 +13,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/kagent-dev/kagent/go/api/v1alpha2" - translator "github.com/kagent-dev/kagent/go/internal/controller/translator/agent" + agenttranslator "github.com/kagent-dev/kagent/go/internal/controller/translator/agent" "github.com/kagent-dev/kmcp/api/v1alpha1" ) @@ -111,7 +111,7 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { Build() t.Run("with proxy URL - RemoteMCPServer with internal k8s URL uses proxy", func(t *testing.T) { - translator := translator.NewAdkApiTranslator( + translator := agenttranslator.NewAdkApiTranslator( kubeClient, types.NamespacedName{Name: "default-model", Namespace: "test"}, nil, @@ -128,19 +128,19 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { remoteAgent := result.Config.RemoteAgents[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080", remoteAgent.Url) assert.NotNil(t, remoteAgent.Headers) - assert.Equal(t, "nested-agent.test", remoteAgent.Headers["X-Kagent-Host"]) + assert.Equal(t, "nested-agent.test", remoteAgent.Headers[agenttranslator.ProxyHostHeader]) // Verify RemoteMCPServer with internal k8s URL DOES use proxy require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) - // X-Kagent-Host header should be set for RemoteMCPServer with internal k8s URL (uses proxy) + // Proxy header should be set for RemoteMCPServer with internal k8s URL (uses proxy) require.NotNil(t, httpTool.Params.Headers) - assert.Equal(t, "test-mcp-server.kagent", httpTool.Params.Headers["X-Kagent-Host"]) + assert.Equal(t, "test-mcp-server.kagent", httpTool.Params.Headers[agenttranslator.ProxyHostHeader]) }) t.Run("without proxy URL", func(t *testing.T) { - translator := translator.NewAdkApiTranslator( + translator := agenttranslator.NewAdkApiTranslator( kubeClient, types.NamespacedName{Name: "default-model", Namespace: "test"}, nil, @@ -156,20 +156,20 @@ func TestProxyConfiguration_ThroughTranslateAgent(t *testing.T) { require.Len(t, result.Config.RemoteAgents, 1) remoteAgent := result.Config.RemoteAgents[0] assert.Equal(t, "http://nested-agent.test:8080", remoteAgent.Url) - // X-Kagent-Host header should not be set when no proxy + // Proxy header should not be set when no proxy if remoteAgent.Headers != nil { - _, hasHost := remoteAgent.Headers["X-Kagent-Host"] - assert.False(t, hasHost, "X-Kagent-Host header should not be set when no proxy") + _, hasHost := remoteAgent.Headers[agenttranslator.ProxyHostHeader] + assert.False(t, hasHost, "Proxy header should not be set when no proxy") } // Verify RemoteMCPServer direct URL (no proxy) require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://test-mcp-server.kagent:8084/mcp", httpTool.Params.Url) - // X-Kagent-Host header should not be set when no proxy + // Proxy header should not be set when no proxy if httpTool.Params.Headers != nil { - _, hasHost := httpTool.Params.Headers["X-Kagent-Host"] - assert.False(t, hasHost, "X-Kagent-Host header should not be set when no proxy") + _, hasHost := httpTool.Params.Headers[agenttranslator.ProxyHostHeader] + assert.False(t, hasHost, "Proxy header should not be set when no proxy") } }) } @@ -241,7 +241,7 @@ func TestProxyConfiguration_RemoteMCPServer_ExternalURL(t *testing.T) { WithObjects(agent, remoteMcpServer, modelConfig, testNamespace). Build() - translator := translator.NewAdkApiTranslator( + translator := agenttranslator.NewAdkApiTranslator( kubeClient, types.NamespacedName{Name: "default-model", Namespace: "test"}, nil, @@ -257,10 +257,10 @@ func TestProxyConfiguration_RemoteMCPServer_ExternalURL(t *testing.T) { require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "https://external-mcp.example.com/mcp", httpTool.Params.Url) - // X-Kagent-Host header should not be set for external URLs (no proxy) + // Proxy header should not be set for external URLs (no proxy) if httpTool.Params.Headers != nil { - _, hasHost := httpTool.Params.Headers["X-Kagent-Host"] - assert.False(t, hasHost, "X-Kagent-Host header should not be set for RemoteMCPServer with external URL (no proxy)") + _, hasHost := httpTool.Params.Headers[agenttranslator.ProxyHostHeader] + assert.False(t, hasHost, "Proxy header should not be set for RemoteMCPServer with external URL (no proxy)") } } @@ -333,7 +333,7 @@ func TestProxyConfiguration_MCPServer(t *testing.T) { WithObjects(agent, mcpServer, modelConfig, testNamespace). Build() - translator := translator.NewAdkApiTranslator( + translator := agenttranslator.NewAdkApiTranslator( kubeClient, types.NamespacedName{Name: "default-model", Namespace: "test"}, nil, @@ -349,9 +349,9 @@ func TestProxyConfiguration_MCPServer(t *testing.T) { require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) - // X-Kagent-Host header should be set for MCPServer (uses proxy) + // Proxy header should be set for MCPServer (uses proxy) require.NotNil(t, httpTool.Params.Headers) - assert.Equal(t, "test-mcp-server.test", httpTool.Params.Headers["X-Kagent-Host"]) + assert.Equal(t, "test-mcp-server.test", httpTool.Params.Headers[agenttranslator.ProxyHostHeader]) } // TestProxyConfiguration_Service tests that Services as MCP Tools use proxy @@ -430,7 +430,7 @@ func TestProxyConfiguration_Service(t *testing.T) { WithObjects(agent, service, modelConfig, testNamespace). Build() - translator := translator.NewAdkApiTranslator( + translator := agenttranslator.NewAdkApiTranslator( kubeClient, types.NamespacedName{Name: "default-model", Namespace: "test"}, nil, @@ -446,7 +446,7 @@ func TestProxyConfiguration_Service(t *testing.T) { require.Len(t, result.Config.HttpTools, 1) httpTool := result.Config.HttpTools[0] assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) - // X-Kagent-Host header should be set for Service (uses proxy) + // Proxy header should be set for Service (uses proxy) require.NotNil(t, httpTool.Params.Headers) - assert.Equal(t, "test-service.test", httpTool.Params.Headers["X-Kagent-Host"]) + assert.Equal(t, "test-service.test", httpTool.Params.Headers[agenttranslator.ProxyHostHeader]) } diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json index d5bd59216..6836c37c5 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy.json @@ -23,7 +23,7 @@ { "params": { "headers": { - "X-Kagent-Host": "test-mcp-server.kagent" + "x-kagent-host": "test-mcp-server.kagent" }, "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" }, @@ -41,7 +41,7 @@ "remote_agents": [ { "headers": { - "X-Kagent-Host": "nested-agent.test" + "x-kagent-host": "nested-agent.test" }, "name": "test__NS__nested_agent", "url": "http://proxy.kagent.svc.cluster.local:8080" @@ -76,7 +76,7 @@ }, "stringData": { "agent-card.json": "{\"name\":\"agent_with_proxy\",\"description\":\"\",\"url\":\"http://agent-with-proxy.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", - "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Kagent-Host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://proxy.kagent.svc.cluster.local:8080\",\"headers\":{\"X-Kagent-Host\":\"nested-agent.test\"}}]}" + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"test-mcp-server.kagent\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":[{\"name\":\"test__NS__nested_agent\",\"url\":\"http://proxy.kagent.svc.cluster.local:8080\",\"headers\":{\"x-kagent-host\":\"nested-agent.test\"}}]}" } }, { @@ -145,7 +145,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "9991414954376340796" + "kagent.dev/config-hash": "2171631320400289677" }, "labels": { "app": "kagent", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json index 84e47bf63..5ec06e37a 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_mcpserver.json @@ -23,7 +23,7 @@ { "params": { "headers": { - "X-Kagent-Host": "test-mcp-server.test" + "x-kagent-host": "test-mcp-server.test" }, "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" }, @@ -68,7 +68,7 @@ }, "stringData": { "agent-card.json": "{\"name\":\"agent_with_proxy_mcpserver\",\"description\":\"\",\"url\":\"http://agent-with-proxy-mcpserver.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", - "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Kagent-Host\":\"test-mcp-server.test\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":null}" + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"test-mcp-server.test\"}},\"tools\":[\"test-tool\"]}],\"sse_tools\":null,\"remote_agents\":null}" } }, { @@ -137,7 +137,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "2579905671631113766" + "kagent.dev/config-hash": "11864275370140522422" }, "labels": { "app": "kagent", diff --git a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json index d26bbfe5f..bbe6102c8 100644 --- a/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json +++ b/go/internal/controller/translator/agent/testdata/outputs/agent_with_proxy_service.json @@ -23,7 +23,7 @@ { "params": { "headers": { - "X-Kagent-Host": "toolserver.test" + "x-kagent-host": "toolserver.test" }, "url": "http://proxy.kagent.svc.cluster.local:8080/mcp" }, @@ -68,7 +68,7 @@ }, "stringData": { "agent-card.json": "{\"name\":\"agent_with_proxy_service\",\"description\":\"\",\"url\":\"http://agent-with-proxy-service.test:8080\",\"version\":\"\",\"capabilities\":{\"streaming\":true,\"pushNotifications\":false,\"stateTransitionHistory\":true},\"defaultInputModes\":[\"text\"],\"defaultOutputModes\":[\"text\"],\"skills\":[]}", - "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"X-Kagent-Host\":\"toolserver.test\"}},\"tools\":[\"k8s_get_resources\"]}],\"sse_tools\":null,\"remote_agents\":null}" + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are an agent that uses proxies.\",\"http_tools\":[{\"params\":{\"url\":\"http://proxy.kagent.svc.cluster.local:8080/mcp\",\"headers\":{\"x-kagent-host\":\"toolserver.test\"}},\"tools\":[\"k8s_get_resources\"]}],\"sse_tools\":null,\"remote_agents\":null}" } }, { @@ -137,7 +137,7 @@ "template": { "metadata": { "annotations": { - "kagent.dev/config-hash": "6408252146511030563" + "kagent.dev/config-hash": "16730647920037520731" }, "labels": { "app": "kagent", diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 0d45d6477..a9f3ccabd 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -22,6 +22,9 @@ logger = logging.getLogger(__name__) +# Proxy host header used for Gateway API routing when using a proxy +PROXY_HOST_HEADER = "x-kagent-host" + class HttpMcpServerConfig(BaseModel): params: StreamableHTTPConnectionParams @@ -128,39 +131,31 @@ def to_agent(self, name: str, sts_integration: Optional[ADKTokenPropagationPlugi ) if self.remote_agents: for remote_agent in self.remote_agents: # Add remote agents as tools - # Always create httpx client - client_kwargs: dict[str, Any] = { - "timeout": httpx.Timeout(timeout=remote_agent.timeout), - "trust_env": False, - } - - if remote_agent.headers: - client_kwargs["headers"] = remote_agent.headers + # Prepare httpx client parameters + timeout = httpx.Timeout(timeout=remote_agent.timeout) + headers: dict[str, str] | None = remote_agent.headers + base_url: str | None = None + event_hooks: dict[str, list[Callable[[httpx.Request], None]]] | None = None - # If headers include X-Kagent-Host header, it means we're using a proxy + # If headers includes the proxy host header, it means we're using a proxy # RemoteA2aAgent may use URLs from agent card response, so we need to - # rewrite all request URLs to use the proxy URL while preserving X-Kagent-Host header - if remote_agent.headers and "X-Kagent-Host" in remote_agent.headers: + # rewrite all request URLs to use the proxy URL while preserving the proxy host header + if remote_agent.headers and PROXY_HOST_HEADER in remote_agent.headers: # Parse the proxy URL to extract base URL from urllib.parse import urlparse as parse_url parsed_proxy = parse_url(remote_agent.url) proxy_base = f"{parsed_proxy.scheme}://{parsed_proxy.netloc}" - target_host = remote_agent.headers["X-Kagent-Host"] - - # Set base_url so relative paths work correctly with httpx - # httpx requires either base_url or absolute URLs - relative paths will fail without base_url - client_kwargs["base_url"] = proxy_base + target_host = remote_agent.headers[PROXY_HOST_HEADER] - # Event hook to rewrite request URLs to use proxy while preserving X-Kagent-Host header - # This handles cases where RemoteA2aAgent uses absolute URLs from agent card response - # Note: Relative paths are handled by base_url above, so they'll already point to proxy_base + # Event hook to rewrite request URLs to use proxy while preserving the proxy host header + # Note: Relative paths are handled by base_url below, so they'll already point to proxy_base def make_rewrite_url_to_proxy(proxy_base: str, target_host: str) -> Callable[[httpx.Request], None]: async def rewrite_url_to_proxy(request: httpx.Request) -> None: parsed = parse_url(str(request.url)) proxy_netloc = parse_url(proxy_base).netloc - # If URL is absolute and points to a different host, rewrite to proxy + # If URL is absolute and points to a different host, rewrite to the proxy base URL if parsed.netloc and parsed.netloc != proxy_netloc: # This is an absolute URL pointing to the target service, rewrite it new_url = f"{proxy_base}{parsed.path}" @@ -168,14 +163,36 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: new_url += f"?{parsed.query}" request.url = httpx.URL(new_url) - # Always set X-Kagent-Host header for Gateway API routing - request.headers["X-Kagent-Host"] = target_host + # Always set proxy host header for Gateway API routing + request.headers[PROXY_HOST_HEADER] = target_host return rewrite_url_to_proxy - client_kwargs["event_hooks"] = {"request": [make_rewrite_url_to_proxy(proxy_base, target_host)]} - - client = httpx.AsyncClient(**client_kwargs) + # Set base_url so relative paths work correctly with httpx + # httpx requires either base_url or absolute URLs - relative paths will fail without base_url + base_url = proxy_base + event_hooks = {"request": [make_rewrite_url_to_proxy(proxy_base, target_host)]} + + # Note: httpx doesn't accept None for base_url/event_hooks, so we only pass the parameters if set + if base_url and event_hooks: + client = httpx.AsyncClient( + timeout=timeout, + trust_env=False, + headers=headers, + base_url=base_url, + event_hooks=event_hooks, + ) + elif headers: + client = httpx.AsyncClient( + timeout=timeout, + trust_env=False, + headers=headers, + ) + else: + client = httpx.AsyncClient( + timeout=timeout, + trust_env=False, + ) remote_a2a_agent = RemoteA2aAgent( name=remote_agent.name, diff --git a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py index 1e75493bc..dfdae38cc 100644 --- a/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py +++ b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py @@ -9,7 +9,7 @@ import pytest from google.adk.agents.remote_a2a_agent import AGENT_CARD_WELL_KNOWN_PATH -from kagent.adk.types import AgentConfig, OpenAI, RemoteAgentConfig +from kagent.adk.types import PROXY_HOST_HEADER, AgentConfig, OpenAI, RemoteAgentConfig class RequestRecordingHandler(BaseHTTPRequestHandler): @@ -96,10 +96,10 @@ def requests(self) -> list[dict]: @pytest.mark.asyncio async def test_remote_agent_with_proxy_url(): - """Test that RemoteA2aAgent requests go through the proxy URL with correct X-Kagent-Host header. + """Test that RemoteA2aAgent requests go through the proxy URL with correct proxy host header. When proxy is configured, requests should be made to the proxy URL (our test server) - with the X-Kagent-Host header set for proxy routing. This test uses a real HTTP server + with the proxy host header set for proxy routing. This test uses a real HTTP server to verify actual request behavior. """ with TestHTTPServer() as test_server: @@ -112,7 +112,7 @@ async def test_remote_agent_with_proxy_url(): name="remote_agent", url=test_server.url, # Use test server as proxy URL description="Remote agent", - headers={"X-Kagent-Host": "remote-agent.kagent"}, # X-Kagent-Host header for proxy routing + headers={PROXY_HOST_HEADER: "remote-agent.kagent"}, # Proxy host header for proxy routing ) ], ) @@ -139,10 +139,10 @@ async def test_remote_agent_with_proxy_url(): assert len(test_server.requests) > 0, "No requests were received by test server" request = test_server.requests[0] assert request["path"] == AGENT_CARD_WELL_KNOWN_PATH - # Verify X-Kagent-Host header is set for proxy routing + # Verify proxy host header is set for proxy routing assert ( - request["headers"].get("X-Kagent-Host") == "remote-agent.kagent" - or request["headers"].get("x-kagent-host") == "remote-agent.kagent" + request["headers"].get(PROXY_HOST_HEADER) == "remote-agent.kagent" + or request["headers"].get(PROXY_HOST_HEADER.lower()) == "remote-agent.kagent" ) @@ -218,18 +218,18 @@ async def test_remote_agent_direct_url_no_proxy(): assert len(test_server.requests) > 0 assert test_server.requests[0]["path"] == AGENT_CARD_WELL_KNOWN_PATH # Verify Host header is set automatically by httpx based on URL - # (X-Kagent-Host should not be present when no proxy is configured) + # (proxy host header should not be present when no proxy is configured) headers = test_server.requests[0]["headers"] assert ( headers.get("Host") == f"localhost:{test_server.port}" or headers.get("host") == f"localhost:{test_server.port}" ) - assert "X-Kagent-Host" not in headers and "X-Kagent-Host" not in headers + assert PROXY_HOST_HEADER not in headers and PROXY_HOST_HEADER.lower() not in headers @pytest.mark.asyncio async def test_remote_agent_with_headers(): - """Test that RemoteA2aAgent preserves headers including X-Kagent-Host header for proxy routing.""" + """Test that RemoteA2aAgent preserves headers including the proxy host header for proxy routing.""" with TestHTTPServer() as test_server: config = AgentConfig( model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), @@ -242,7 +242,7 @@ async def test_remote_agent_with_headers(): description="Remote agent", headers={ "Authorization": "Bearer token123", - "X-Kagent-Host": "remote-agent.kagent", # X-Kagent-Host header for proxy routing + PROXY_HOST_HEADER: "remote-agent.kagent", # Proxy host header for proxy routing }, ) ], @@ -270,17 +270,17 @@ async def test_remote_agent_with_headers(): headers = test_server.requests[0]["headers"] assert headers.get("Authorization") == "Bearer token123" or headers.get("authorization") == "Bearer token123" assert ( - headers.get("X-Kagent-Host") == "remote-agent.kagent" - or headers.get("x-kagent-host") == "remote-agent.kagent" + headers.get(PROXY_HOST_HEADER) == "remote-agent.kagent" + or headers.get(PROXY_HOST_HEADER.lower()) == "remote-agent.kagent" ) @pytest.mark.asyncio async def test_remote_agent_url_rewrite_event_hook(): - """Test that URL rewrite event hook rewrites URLs to proxy when X-Kagent-Host header is present. + """Test that URL rewrite event hook rewrites URLs to proxy when the proxy host header is present. - When an X-Kagent-Host header is present, the event hook rewrites all request URLs to use the proxy - base URL while preserving the X-Kagent-Host header. This ensures that even if RemoteA2aAgent + When the proxy host header is present, the event hook rewrites all request URLs to use the proxy + base URL while preserving the proxy host header. This ensures that even if RemoteA2aAgent uses URLs from the agent card response, they still go through the proxy. """ with TestHTTPServer() as test_server: @@ -293,7 +293,7 @@ async def test_remote_agent_url_rewrite_event_hook(): name="remote_agent", url=test_server.url, # Use test server as proxy URL description="Remote agent", - headers={"X-Kagent-Host": "remote-agent.kagent"}, # X-Kagent-Host header indicates proxy usage + headers={PROXY_HOST_HEADER: "remote-agent.kagent"}, # Proxy host header indicates proxy usage ) ], ) @@ -324,15 +324,15 @@ async def test_remote_agent_url_rewrite_event_hook(): assert test_server.requests[0]["path"] == "/some/path" headers = test_server.requests[0]["headers"] assert ( - headers.get("X-Kagent-Host") == "remote-agent.kagent" - or headers.get("x-kagent-host") == "remote-agent.kagent" + headers.get(PROXY_HOST_HEADER) == "remote-agent.kagent" + or headers.get(PROXY_HOST_HEADER.lower()) == "remote-agent.kagent" ) def test_mcp_tool_with_proxy_url(): - """Test that MCP tools are configured with proxy URL and X-Kagent-Host header. + """Test that MCP tools are configured with proxy URL and the proxy host header. - When proxy is configured, the URL is set to the proxy URL and the X-Kagent-Host header + When proxy is configured, the URL is set to the proxy URL and the proxy host header is included for proxy routing. These are passed through directly to McpToolset. Note: We verify connection_params configuration because McpToolset doesn't expose @@ -344,7 +344,7 @@ def test_mcp_tool_with_proxy_url(): from kagent.adk.types import HttpMcpServerConfig - # Configuration with proxy URL and X-Kagent-Host header + # Configuration with proxy URL and proxy host header config = AgentConfig( model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), description="Test agent", @@ -353,7 +353,7 @@ def test_mcp_tool_with_proxy_url(): HttpMcpServerConfig( params=StreamableHTTPConnectionParams( url="http://proxy.kagent.svc.cluster.local:8080/mcp", # Proxy URL - headers={"X-Kagent-Host": "test-mcp-server.kagent"}, # X-Kagent-Host header for proxy routing + headers={PROXY_HOST_HEADER: "test-mcp-server.kagent"}, # Proxy host header for proxy routing ), tools=["test-tool"], ) @@ -378,7 +378,7 @@ def test_mcp_tool_with_proxy_url(): assert connection_params is not None assert connection_params.url == "http://proxy.kagent.svc.cluster.local:8080/mcp" assert connection_params.headers is not None - assert connection_params.headers["X-Kagent-Host"] == "test-mcp-server.kagent" + assert connection_params.headers[PROXY_HOST_HEADER] == "test-mcp-server.kagent" def test_mcp_tool_without_proxy(): @@ -425,9 +425,9 @@ def test_mcp_tool_without_proxy(): def test_sse_mcp_tool_with_proxy_url(): - """Test that SSE MCP tools are configured with proxy URL and X-Kagent-Host header. + """Test that SSE MCP tools are configured with proxy URL and proxy host header. - When proxy is configured, the URL is set to the proxy URL and the X-Kagent-Host header + When proxy is configured, the URL is set to the proxy URL and the proxy host header is included for proxy routing. These are passed through directly to McpToolset. Note: We verify connection_params configuration because McpToolset doesn't expose @@ -439,7 +439,7 @@ def test_sse_mcp_tool_with_proxy_url(): from kagent.adk.types import SseMcpServerConfig - # Configuration with proxy URL and X-Kagent-Host header + # Configuration with proxy URL and proxy host header config = AgentConfig( model=OpenAI(model="gpt-3.5-turbo", type="openai", api_key="fake"), description="Test agent", @@ -448,7 +448,7 @@ def test_sse_mcp_tool_with_proxy_url(): SseMcpServerConfig( params=SseConnectionParams( url="http://proxy.kagent.svc.cluster.local:8080/mcp", # Proxy URL - headers={"X-Kagent-Host": "test-sse-mcp-server.kagent"}, # X-Kagent-Host header for proxy routing + headers={PROXY_HOST_HEADER: "test-sse-mcp-server.kagent"}, # Proxy host header for proxy routing ), tools=["test-sse-tool"], ) @@ -471,7 +471,7 @@ def test_sse_mcp_tool_with_proxy_url(): assert connection_params is not None assert connection_params.url == "http://proxy.kagent.svc.cluster.local:8080/mcp" assert connection_params.headers is not None - assert connection_params.headers["X-Kagent-Host"] == "test-sse-mcp-server.kagent" + assert connection_params.headers[PROXY_HOST_HEADER] == "test-sse-mcp-server.kagent" def test_sse_mcp_tool_without_proxy(): From e4c05d5400bfb0d104ae79b19c14d75d1f581a2c Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Tue, 6 Jan 2026 13:48:42 -0700 Subject: [PATCH 11/11] Remove trust_env config Signed-off-by: Jeremy Alvis --- python/packages/kagent-adk/src/kagent/adk/types.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index a9f3ccabd..5d42f9d47 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -177,7 +177,6 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: if base_url and event_hooks: client = httpx.AsyncClient( timeout=timeout, - trust_env=False, headers=headers, base_url=base_url, event_hooks=event_hooks, @@ -185,13 +184,11 @@ async def rewrite_url_to_proxy(request: httpx.Request) -> None: elif headers: client = httpx.AsyncClient( timeout=timeout, - trust_env=False, headers=headers, ) else: client = httpx.AsyncClient( timeout=timeout, - trust_env=False, ) remote_a2a_agent = RemoteA2aAgent(