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 0eeafb507..57bd3c91f 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,11 +74,12 @@ 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, globalProxyURL string) AdkApiTranslator { return &adkApiTranslator{ kube: kube, defaultModelConfig: defaultModelConfig, plugins: plugins, + globalProxyURL: globalProxyURL, } } @@ -85,6 +87,7 @@ type adkApiTranslator struct { kube client.Client defaultModelConfig types.NamespacedName plugins []TranslatorPlugin + globalProxyURL string } const MAX_DEPTH = 10 @@ -532,7 +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: - err := a.translateMCPServerTarget(ctx, cfg, agent.Namespace, tool.McpServer, tool.HeadersFrom) + // 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 } @@ -555,15 +559,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 X-Host header for Gateway API routing + targetURL := originalURL + if a.globalProxyURL != "" { + // Parse original URL to extract path and hostname + originalURLParsed, err := url.Parse(originalURL) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to parse agent URL %q: %w", originalURL, err) + } + proxyURLParsed, err := url.Parse(a.globalProxyURL) + if err != nil { + 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) + // Set X-Host header to original hostname (without port) for Gateway API routing + if headers == nil { + headers = make(map[string]string) + } + headers["X-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 +946,35 @@ 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 X-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 X-Host header to original hostname (without port) for Gateway API routing + if headers == nil { + headers = make(map[string]string) + } + headers["X-Host"] = originalURL.Hostname() + } + params := &adk.StreamableHTTPConnectionParams{ - Url: tool.URL, + Url: targetURL, Headers: headers, } if tool.Timeout != nil { @@ -943,14 +989,35 @@ 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 X-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 X-Host header to original hostname (without port) for Gateway API routing + if headers == nil { + headers = make(map[string]string) + } + headers["X-Host"] = originalURL.Hostname() + } + params := &adk.SseConnectionParams{ - Url: tool.URL, + Url: targetURL, Headers: headers, } if tool.Timeout != nil { @@ -962,7 +1029,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 +1060,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 +1078,13 @@ 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) + // 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: "", Kind: "Service", @@ -1034,7 +1107,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 +1172,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 +1184,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 } @@ -1125,6 +1198,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 7ba17cde4..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" @@ -28,6 +31,7 @@ type TestInput struct { 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 @@ -74,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 @@ -119,7 +164,9 @@ 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 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 new file mode 100644 index 000000000..c144caf5e --- /dev/null +++ b/go/internal/controller/translator/agent/proxy_test.go @@ -0,0 +1,452 @@ +package agent_test + +import ( + "context" + "testing" + + "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" + "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" + "github.com/kagent-dev/kmcp/api/v1alpha1" +) + +// 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"}, + }, + }, + }, + }, + }, + } + + // 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, kagentNamespace, testNamespace). + Build() + + 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://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 agent tool proxy configuration + require.Len(t, result.Config.RemoteAgents, 1) + remoteAgent := result.Config.RemoteAgents[0] + assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080", remoteAgent.Url) + assert.NotNil(t, remoteAgent.Headers) + assert.Equal(t, "nested-agent.test", remoteAgent.Headers["X-Host"]) + + // Verify RemoteMCPServer with internal k8s URL DOES use proxy + require.Len(t, result.Config.HttpTools, 1) + httpTool := result.Config.HttpTools[0] + assert.Equal(t, "http://proxy.kagent.svc.cluster.local:8080/mcp", httpTool.Params.Url) + // X-Host header should be set for RemoteMCPServer with internal k8s URL (uses proxy) + require.NotNil(t, httpTool.Params.Headers) + assert.Equal(t, "test-mcp-server.kagent", httpTool.Params.Headers["X-Host"]) + }) + + t.Run("without proxy URL", func(t *testing.T) { + translator := translator.NewAdkApiTranslator( + kubeClient, + types.NamespacedName{Name: "default-model", Namespace: "test"}, + nil, + "", // No proxy + ) + + result, err := translator.TranslateAgent(ctx, agent) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Config) + + // 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) + // X-Host header should not be set when no proxy + if remoteAgent.Headers != nil { + _, hasHost := remoteAgent.Headers["X-Host"] + assert.False(t, hasHost, "X-Host header should not be set when no proxy") + } + + // Verify RemoteMCPServer direct URL (no proxy) + require.Len(t, result.Config.HttpTools, 1) + httpTool := result.Config.HttpTools[0] + assert.Equal(t, "http://test-mcp-server.kagent:8084/mcp", httpTool.Params.Url) + // X-Host header should not be set when no proxy + if httpTool.Params.Headers != nil { + _, hasHost := httpTool.Params.Headers["X-Host"] + assert.False(t, hasHost, "X-Host header should not be set when no proxy") + } + }) +} + +// 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) + // X-Host header should not be set for external URLs (no proxy) + if httpTool.Params.Headers != nil { + _, hasHost := httpTool.Params.Headers["X-Host"] + assert.False(t, hasHost, "X-Host header should not be set for RemoteMCPServer with external URL (no proxy)") + } +} + +// 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) + // 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["X-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) + // 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["X-Host"]) +} diff --git a/go/internal/controller/translator/agent/security_context_test.go b/go/internal/controller/translator/agent/security_context_test.go index 71f1d5296..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 new file mode 100644 index 000000000..6b8c84781 --- /dev/null +++ b/go/internal/controller/translator/agent/testdata/inputs/agent_with_proxy.yaml @@ -0,0 +1,63 @@ +operation: translateAgent +targetObject: agent-with-proxy +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: 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/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 new file mode 100644 index 000000000..86800e417 --- /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": { + "X-Host": "test-mcp-server.kagent" + }, + "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": [ + { + "headers": { + "X-Host": "nested-agent.test" + }, + "name": "test__NS__nested_agent", + "url": "http://proxy.kagent.svc.cluster.local:8080" + } + ], + "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://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\"}}]}" + } + }, + { + "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": "8542820760737254710" + }, + "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/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..e97498441 --- /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": { + "X-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\":{\"X-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": "8765961336067912007" + }, + "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..36184ddd1 --- /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": { + "X-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\":{\"X-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": "1054793996523090805" + }, + "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 5127244b3..72794b4a8 100644 --- a/go/internal/httpserver/handlers/agents.go +++ b/go/internal/httpserver/handlers/agents.go @@ -200,6 +200,7 @@ func (h *AgentsHandler) HandleCreateAgent(w ErrorResponseWriter, r *http.Request kubeClientWrapper, h.DefaultModelConfig, nil, + 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 b258f87a5..fab9664f5 100644 --- a/go/internal/httpserver/handlers/agents_test.go +++ b/go/internal/httpserver/handlers/agents_test.go @@ -78,6 +78,7 @@ func setupTestHandler(objects ...client.Object) (*handlers.AgentsHandler, string }, DatabaseService: dbClient, Authorizer: &auth.NoopAuthorizer{}, + ProxyURL: "", } return handlers.NewAgentsHandler(base), userID diff --git a/go/internal/httpserver/handlers/handlers.go b/go/internal/httpserver/handlers/handlers.go index 99bda31a1..c2c4aa785 100644 --- a/go/internal/httpserver/handlers/handlers.go +++ b/go/internal/httpserver/handlers/handlers.go @@ -33,15 +33,17 @@ type Base struct { DefaultModelConfig types.NamespacedName DatabaseService database.Client Authorizer auth.Authorizer // Interface for authorization checks + 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) *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, + ProxyURL: proxyURL, } return &Handlers{ diff --git a/go/internal/httpserver/server.go b/go/internal/httpserver/server.go index 0875cf025..1e7896d57 100644 --- a/go/internal/httpserver/server.go +++ b/go/internal/httpserver/server.go @@ -55,6 +55,7 @@ type ServerConfig struct { DbClient database.Client Authenticator auth.AuthProvider Authorizer auth.Authorizer + ProxyURL string } // HTTPServer is the structure that manages the HTTP server @@ -74,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), + 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 8ee3b9589..8fa6762ee 100644 --- a/go/pkg/app/app.go +++ b/go/pkg/app/app.go @@ -109,6 +109,9 @@ type Config struct { InitialBufSize resource.QuantityValue `default:"4Ki"` Timeout time.Duration `default:"60s"` } + Proxy struct { + URL string + } LeaderElection bool ProbeAddr string SecureMetrics bool @@ -158,6 +161,8 @@ 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.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.") commandLine.StringVar(&agent_translator.DefaultImageConfig.PullPolicy, "image-pull-policy", agent_translator.DefaultImageConfig.PullPolicy, "The pull policy to use for the image.") @@ -372,6 +377,7 @@ func Start(getExtensionConfig GetExtensionConfig) { mgr.GetClient(), cfg.DefaultModelConfig, extensionCfg.AgentPlugins, + cfg.Proxy.URL, ) rcnclr := reconciler.NewKagentReconciler( @@ -484,6 +490,7 @@ func Start(getExtensionConfig GetExtensionConfig) { DbClient: dbClient, Authorizer: extensionCfg.Authorizer, Authenticator: extensionCfg.Authenticator, + 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 a75b06a58..ffb6ea2d7 100644 --- a/go/pkg/app/app_test.go +++ b/go/pkg/app/app_test.go @@ -259,6 +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_URL": "http://proxy.kagent.svc.cluster.local:8080", "DATABASE_TYPE": "postgres", "POSTGRES_DATABASE_URL": "postgres://localhost:5432/testdb", "WATCH_NAMESPACES": "ns1,ns2,ns3", @@ -304,6 +305,9 @@ func TestLoadFromEnvIntegration(t *testing.T) { if cfg.HttpServerAddr != ":9000" { t.Errorf("HttpServerAddr = %v, want :9000", cfg.HttpServerAddr) } + 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 66095fda7..fef9eb80a 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -26,6 +26,9 @@ 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.url }} + PROXY_URL: {{ .Values.proxy.url | 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..2b43c31be 100644 --- a/helm/kagent/tests/controller-deployment_test.yaml +++ b/helm/kagent/tests/controller-deployment_test.yaml @@ -106,6 +106,22 @@ tests: path: data.A2A_BASE_URL value: "https://kagent.example.com" + - it: should set PROXY_URL when set + template: controller-configmap.yaml + set: + proxy: + url: "http://proxy.kagent.svc.cluster.local:8080" + asserts: + - equal: + path: data.PROXY_URL + value: "http://proxy.kagent.svc.cluster.local:8080" + + - it: should not set PROXY_URL when not set + template: controller-configmap.yaml + asserts: + - notExists: + path: data.PROXY_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..5f5d18878 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -227,9 +227,23 @@ 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) +# - 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: + # Proxy URL for internally-built k8s URLs + # Example: "http://proxy.kagent.svc.cluster.local:8080" + url: "" +# ============================================================================== +# AGENTS +# ============================================================================== 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..7694c79b4 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 @@ -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,48 @@ 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 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 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["X-Host"] + + # 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)) + new_url = f"{proxy_base}{parsed.path}" + + if parsed.query: + new_url += f"?{parsed.query}" + + request.url = httpx.URL(new_url) + # Preserve X-Host header for Gateway API routing + request.headers["X-Host"] = target_host + + return rewrite_url_to_proxy + + client_kwargs["event_hooks"] = {"request": [make_rewrite_url_to_proxy(proxy_base, target_host)]} + + 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..b337bfcbb --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_proxy_integration.py @@ -0,0 +1,510 @@ +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 X-Host header. + + When proxy is configured, requests should be made to the proxy URL (our test server) + with the X-Host header set for proxy routing. This test uses a real HTTP server + 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={"X-Host": "remote-agent.kagent"}, # X-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 X-Host header is set for proxy routing + assert ( + request["headers"].get("X-Host") == "remote-agent.kagent" + or request["headers"].get("x-host") == "remote-agent.kagent" + ) + + +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 + # (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 +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 X-Host header is present. + + When an X-Host header is present, the event hook rewrites all request URLs to use the proxy + base URL while preserving the X-Host header. This ensures that even if RemoteA2aAgent + 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={"X-Host": "remote-agent.kagent"}, # X-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("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 X-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 + 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 X-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://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"], + ) + ], + ) + + 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://proxy.kagent.svc.cluster.local:8080/mcp" + assert connection_params.headers is not None + assert connection_params.headers["X-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 X-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 + 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 X-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://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"], + ) + ], + ) + + 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://proxy.kagent.svc.cluster.local:8080/mcp" + assert connection_params.headers is not None + assert connection_params.headers["X-Host"] == "test-sse-mcp-server.kagent" + + +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/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 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 +}