Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/registry/runtimes/kubernetes/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,14 @@ func (a *kubernetesDeploymentAdapter) buildDesiredStateFromV1Alpha1(

switch target := in.Target.(type) {
case *v1alpha1.MCPServer:
envFrom := in.Deployment.Spec.EnvFrom
server, err := utils.SpecToRuntimeMCPServer(ctx, target.Metadata, target.Spec, utils.MCPServerTranslateOpts{
DeploymentID: deploymentID,
Namespace: namespace,
EnvValues: envValues,
ArgValues: argValues,
HeaderValues: headerValues,
EnvFrom: envFrom,
})
if err != nil {
return nil, err
Expand Down
75 changes: 75 additions & 0 deletions internal/registry/runtimes/kubernetes/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package kubernetes

import (
"context"
"reflect"
"testing"

v1alpha2 "github.com/kagent-dev/kagent/go/api/v1alpha2"
kmcpv1alpha1 "github.com/kagent-dev/kmcp/api/v1alpha1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
k8stypes "k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
Expand Down Expand Up @@ -219,3 +221,76 @@ func TestK8sV1Alpha1Logs_ReturnsClosedChannel(t *testing.T) {
t.Fatalf("expected closed channel")
}
}

// TestK8sV1Alpha1Apply_BundledMCPServerEnvFrom_CreatesResourceWithSecretRefs
// walks the adapter's full apply path for a bundled MCPServer Deployment
// carrying spec.envFrom and asserts the created kmcp MCPServer resource
// carries spec.deployment.secretRefs (which the kmcp controller renders as
// container envFrom).
func TestK8sV1Alpha1Apply_BundledMCPServerEnvFrom_CreatesResourceWithSecretRefs(t *testing.T) {
fakeClient := withFakeKubeClient(t)

runtime := &v1alpha1.Runtime{
TypeMeta: v1alpha1.TypeMeta{APIVersion: v1alpha1.GroupVersion, Kind: v1alpha1.KindRuntime},
Metadata: v1alpha1.ObjectMeta{Namespace: "default", Name: "kube-local"},
Spec: v1alpha1.RuntimeSpec{
Type: v1alpha1.TypeKubernetes,
Config: map[string]any{"namespace": "kagent"},
},
}
target := &v1alpha1.MCPServer{
TypeMeta: v1alpha1.TypeMeta{APIVersion: v1alpha1.GroupVersion, Kind: v1alpha1.KindMCPServer},
Metadata: v1alpha1.ObjectMeta{Namespace: "default", Name: "weather"},
Spec: v1alpha1.MCPServerSpec{
Source: &v1alpha1.MCPServerSource{
Package: &v1alpha1.MCPPackage{
Origin: v1alpha1.MCPPackageOrigin{
Type: v1alpha1.MCPPackageOriginTypeOCI,
Identifier: "docker.io/example/weather:1.0",
OCI: &v1alpha1.MCPPackageOriginOCI{ServerName: "io.example/weather"},
},
Transport: v1alpha1.MCPTransport{Type: "stdio"},
Launch: &v1alpha1.MCPPackageLaunch{Command: "python"},
},
},
},
}
deployment := &v1alpha1.Deployment{
TypeMeta: v1alpha1.TypeMeta{APIVersion: v1alpha1.GroupVersion, Kind: v1alpha1.KindDeployment},
Metadata: v1alpha1.ObjectMeta{Namespace: "default", Name: "weather-kube"},
Spec: v1alpha1.DeploymentSpec{
TargetRef: v1alpha1.ResourceRef{Kind: v1alpha1.KindMCPServer, Name: "weather"},
RuntimeRef: v1alpha1.ResourceRef{Kind: v1alpha1.KindRuntime, Name: "kube-local"},
DesiredState: v1alpha1.DesiredStateDeployed,
Env: map[string]string{"env": "fromenv"},
EnvFrom: []v1alpha1.EnvFromSource{
{SecretRef: &v1alpha1.SecretEnvSource{Name: "mcp-secrets"}},
},
},
}

adapter := NewKubernetesDeploymentAdapter()
if _, err := adapter.Apply(context.Background(), adapterpkgtypes.ApplyInput{
Deployment: deployment,
Target: target,
Runtime: runtime,
}); err != nil {
t.Fatalf("Apply: %v", err)
}

mcps := &kmcpv1alpha1.MCPServerList{}
if err := fakeClient.List(context.Background(), mcps); err != nil {
t.Fatalf("list MCPServers: %v", err)
}
if len(mcps.Items) != 1 {
t.Fatalf("expected 1 MCPServer, got %d", len(mcps.Items))
}
dep := mcps.Items[0].Spec.Deployment
wantRefs := []corev1.LocalObjectReference{{Name: "mcp-secrets"}}
if !reflect.DeepEqual(dep.SecretRefs, wantRefs) {
t.Fatalf("secretRefs = %+v, want %+v", dep.SecretRefs, wantRefs)
}
if dep.Env["env"] != "fromenv" {
t.Fatalf("env env = %q, want fromenv (explicit env forwarded beside the refs)", dep.Env["env"])
}
}
30 changes: 26 additions & 4 deletions internal/registry/runtimes/kubernetes/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,27 @@ func kubernetesTranslateRemoteMCPServer(server *runtimetypes.MCPServer) (*v1alph
}, nil
}

// secretLocalObjectRefs converts Secret names into the LocalObjectReference
// list kmcp expects under spec.deployment.secretRefs (rendered as container
// envFrom by the kmcp controller). Returns nil when no names are configured
// so the key is omitted from the CRD entirely.
func secretLocalObjectRefs(names []string) []corev1.LocalObjectReference {
if len(names) == 0 {
return nil
}
refs := make([]corev1.LocalObjectReference, 0, len(names))
for _, n := range names {
if n == "" {
continue
}
refs = append(refs, corev1.LocalObjectReference{Name: n})
}
if len(refs) == 0 {
return nil
}
return refs
}

func kubernetesTranslateLocalMCPServer(server *runtimetypes.MCPServer) (*kmcpv1alpha1.MCPServer, error) {
if server.Local == nil {
return nil, fmt.Errorf("local MCP server config missing for %s", server.Name)
Expand All @@ -461,10 +482,11 @@ func kubernetesTranslateLocalMCPServer(server *runtimetypes.MCPServer) (*kmcpv1a
namespace = server.Local.Deployment.Env[constants.EnvKagentNamespace]
}
deployment := kmcpv1alpha1.MCPServerDeployment{
Image: server.Local.Deployment.Image,
Cmd: server.Local.Deployment.Cmd,
Args: server.Local.Deployment.Args,
Env: server.Local.Deployment.Env,
Image: server.Local.Deployment.Image,
Cmd: server.Local.Deployment.Cmd,
Args: server.Local.Deployment.Args,
Env: server.Local.Deployment.Env,
SecretRefs: secretLocalObjectRefs(server.Local.Deployment.SecretRefs),
}

spec := kmcpv1alpha1.MCPServerSpec{Deployment: deployment}
Expand Down
66 changes: 66 additions & 0 deletions internal/registry/runtimes/kubernetes/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -656,3 +657,68 @@ func TestKubernetesDeploymentScopedName_TruncatesLongBaseButPreservesSuffix(t *t
t.Fatalf("expected uuid short suffix to be preserved, got %s", got)
}
}

func TestKubernetesTranslateRuntimeConfig_LocalMCPSecretRefs(t *testing.T) {
ctx := context.Background()

desired := &runtimetypes.DesiredState{
MCPServers: []*runtimetypes.MCPServer{{
Name: "secret-server",
MCPServerType: runtimetypes.MCPServerTypeLocal,
Namespace: "kagent",
Local: &runtimetypes.LocalMCPServer{
TransportType: runtimetypes.TransportTypeStdio,
Deployment: runtimetypes.MCPServerDeployment{
Image: "mcp-image:latest",
Cmd: "python",
Env: map[string]string{"env": "fromenv"},
SecretRefs: []string{"mcp-secrets", "shared-tokens"},
},
},
}},
}

config, err := kubernetesTranslateRuntimeConfig(ctx, desired)
if err != nil {
t.Fatalf("kubernetesTranslateRuntimeConfig failed: %v", err)
}
if len(config.MCPServers) != 1 {
t.Fatalf("expected 1 MCPServer, got %d", len(config.MCPServers))
}
got := config.MCPServers[0].Spec.Deployment.SecretRefs
want := []corev1.LocalObjectReference{{Name: "mcp-secrets"}, {Name: "shared-tokens"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("secretRefs = %+v, want %+v", got, want)
}
// The explicit env entry rides alongside the refs untouched; the kubelet
// resolves collisions (env wins over envFrom).
if config.MCPServers[0].Spec.Deployment.Env["env"] != "fromenv" {
t.Fatalf("env env = %q, want fromenv", config.MCPServers[0].Spec.Deployment.Env["env"])
}
}

func TestKubernetesTranslateRuntimeConfig_LocalMCPNoSecretRefsOmitted(t *testing.T) {
ctx := context.Background()

desired := &runtimetypes.DesiredState{
MCPServers: []*runtimetypes.MCPServer{{
Name: "plain-server",
MCPServerType: runtimetypes.MCPServerTypeLocal,
Local: &runtimetypes.LocalMCPServer{
TransportType: runtimetypes.TransportTypeStdio,
Deployment: runtimetypes.MCPServerDeployment{
Image: "mcp-image:latest",
Cmd: "python",
},
},
}},
}

config, err := kubernetesTranslateRuntimeConfig(ctx, desired)
if err != nil {
t.Fatalf("kubernetesTranslateRuntimeConfig failed: %v", err)
}
if refs := config.MCPServers[0].Spec.Deployment.SecretRefs; refs != nil {
t.Fatalf("secretRefs = %+v, want nil so the CRD omits the key", refs)
}
}
5 changes: 5 additions & 0 deletions internal/registry/runtimes/local/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ func (a *localDeploymentAdapter) Apply(ctx context.Context, in types.ApplyInput)
if in.Deployment == nil {
return nil, fmt.Errorf("apply: deployment is required")
}
// spec.envFrom references a runtime-resolved Secret; the local runtime
// has no secret store to resolve it against, so reject.
if len(in.Deployment.Spec.EnvFrom) > 0 {
return nil, fmt.Errorf("apply: spec.envFrom is not supported by the %s runtime", v1alpha1.TypeLocal)
}
desired, err := a.buildDesiredStateFromV1Alpha1(ctx, in)
if err != nil {
return nil, err
Expand Down
27 changes: 27 additions & 0 deletions internal/registry/runtimes/local/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"

"github.com/agentregistry-dev/agentregistry/pkg/api/v1alpha1"
Expand Down Expand Up @@ -233,3 +234,29 @@ func contains(s, sub string) bool {
}
return false
}

// TestV1Alpha1Apply_RejectsEnvFrom: the local docker-compose runtime has no
// Secret store to resolve spec.envFrom against, so Apply must fail.
func TestV1Alpha1Apply_RejectsEnvFrom(t *testing.T) {
adapter := NewLocalDeploymentAdapter(t.TempDir(), 21212)

_, err := adapter.Apply(context.Background(), types.ApplyInput{
Deployment: &v1alpha1.Deployment{
TypeMeta: v1alpha1.TypeMeta{APIVersion: v1alpha1.GroupVersion, Kind: v1alpha1.KindDeployment},
Metadata: v1alpha1.ObjectMeta{Namespace: "default", Name: "weather-local"},
Spec: v1alpha1.DeploymentSpec{
TargetRef: v1alpha1.ResourceRef{Kind: v1alpha1.KindMCPServer, Name: "weather"},
RuntimeRef: v1alpha1.ResourceRef{Kind: v1alpha1.KindRuntime, Name: "local"},
EnvFrom: []v1alpha1.EnvFromSource{
{SecretRef: &v1alpha1.SecretEnvSource{Name: "mcp-secrets"}},
},
},
},
})
if err == nil {
t.Fatal("expected error for local runtime deployment with envFrom")
}
if want := "spec.envFrom is not supported by the Local runtime"; !strings.Contains(err.Error(), want) {
t.Fatalf("error = %q, want it to contain %q", err, want)
}
}
9 changes: 5 additions & 4 deletions internal/registry/runtimes/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,11 @@ const (
)

type MCPServerDeployment struct {
Image string `json:"image,omitempty"`
Cmd string `json:"cmd,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
Image string `json:"image,omitempty"`
Cmd string `json:"cmd,omitempty"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
SecretRefs []string `json:"secretRefs,omitempty"`
}

type AgentDeployment struct {
Expand Down
20 changes: 15 additions & 5 deletions internal/registry/runtimes/utils/deployment_adapter_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ type MCPServerRunRequest struct {
// HeaderValues are per-deployment header overrides resolved against
// Spec.Remote.Headers when the server is remote. Ignored for bundled.
HeaderValues map[string]string
// EnvFromSecretRefs names Kubernetes Secrets whose keys the runtime
// injects as container env vars, flattened from Deployment.Spec.EnvFrom.
// Only valid for bundled servers.
EnvFromSecretRefs []string
}

// TranslateMCPServer maps a v1alpha1 MCPServerSpec onto the runtime-internal
Expand All @@ -59,12 +63,16 @@ func TranslateMCPServer(ctx context.Context, req *MCPServerRunRequest) (*runtime
return nil, fmt.Errorf("mcp server run request is required")
}
if req.Spec.Remote != nil {
// A remote MCP has no pod, so a Secret reference cannot be honored.
if len(req.EnvFromSecretRefs) > 0 {
return nil, fmt.Errorf("spec.envFrom is not supported for remote MCP server %s", req.Name)
}
return translateRemoteMCPServer(req.Name, req.Spec.Remote, req.DeploymentID, req.HeaderValues)
}
if req.Spec.Source == nil || req.Spec.Source.Package == nil {
return nil, fmt.Errorf("no valid deployment method found for server: %s (no package or remote)", req.Name)
}
return translateLocalMCPServer(ctx, req.Name, req.Spec, req.DeploymentID, req.EnvValues, req.ArgValues)
return translateLocalMCPServer(ctx, req.Name, req.Spec, req.DeploymentID, req.EnvValues, req.ArgValues, req.EnvFromSecretRefs)
}

// translateRemoteMCPServer emits a runtimetypes.MCPServer for a
Expand Down Expand Up @@ -115,6 +123,7 @@ func translateLocalMCPServer(
deploymentID string,
envValues map[string]string,
argValues map[string]string,
envFromSecretRefs []string,
) (*runtimetypes.MCPServer, error) {
pkg := *spec.Source.Package

Expand Down Expand Up @@ -165,10 +174,11 @@ func translateLocalMCPServer(
MCPServerType: runtimetypes.MCPServerTypeLocal,
Local: &runtimetypes.LocalMCPServer{
Deployment: runtimetypes.MCPServerDeployment{
Image: config.Image,
Cmd: cmd,
Args: args,
Env: envValues,
Image: config.Image,
Cmd: cmd,
Args: args,
Env: envValues,
SecretRefs: envFromSecretRefs,
},
TransportType: transportType,
HTTP: httpTransport,
Expand Down
Loading
Loading