From 72a4239cb67933c44501e4c98bc298bf74b97cd9 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Tue, 30 Dec 2025 08:14:24 -0700 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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():