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
5 changes: 5 additions & 0 deletions api/v1alpha2/agentconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ type MCPServerSpec struct {
// where env overrides envFrom).
// +optional
EnvFrom *SecretValuesSource `json:"envFrom,omitempty"`

// AlwaysLoad makes Claude Code load this server's tools at session start
// instead of deferring them behind tool search. Only Claude Code acts on it.
// +optional
AlwaysLoad bool `json:"alwaysLoad,omitempty"`
}

// SecretValuesSource selects a Secret to populate values from.
Expand Down
1 change: 1 addition & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,7 @@ to receive refreshed credentials during long-running work.
| `spec.mcpServers[].env[].valueFrom.configMapKeyRef` | ConfigMap key reference for an MCP env value. Set `name` and `key`; when `optional: true`, a missing ConfigMap or key omits the variable instead of failing the Task | No |
| `spec.mcpServers[].env[].valueFrom` | Only `secretKeyRef` and `configMapKeyRef` are supported for MCP server env. Other Kubernetes `EnvVarSource` variants are rejected when a Task consumes the AgentConfig | No |
| `spec.mcpServers[].envFrom.secretRef.name` | Secret whose data keys become stdio MCP environment variable names and values. Values from `envFrom` override inline `env` on key conflicts | No |
| `spec.mcpServers[].alwaysLoad` | When `true`, Claude Code loads this server's tools at session start instead of deferring them behind tool search. Only Claude Code acts on it | No |

## TaskSpawner

Expand Down
26 changes: 14 additions & 12 deletions internal/controller/job_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -1467,12 +1467,13 @@ func envFromVar(name, valueVar string) string {
// format used by Claude Code and other agents. Fields are omitted when
// empty so that the resulting JSON stays minimal.
type mcpServerJSON struct {
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Env map[string]string `json:"env,omitempty"`
Type string `json:"type,omitempty"`
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Env map[string]string `json:"env,omitempty"`
AlwaysLoad bool `json:"alwaysLoad,omitempty"`
}

// buildMCPServersJSON converts MCPServerSpec entries into a JSON string
Expand All @@ -1496,12 +1497,13 @@ func buildMCPServersJSON(servers []kelos.MCPServerSpec) (string, error) {
return "", err
}
entry := mcpServerJSON{
Type: s.Type,
Command: s.Command,
Args: s.Args,
URL: s.URL,
Headers: s.Headers,
Env: envMap,
Type: s.Type,
Command: s.Command,
Args: s.Args,
URL: s.URL,
Headers: s.Headers,
Env: envMap,
AlwaysLoad: s.AlwaysLoad,
}
mcpMap[s.Name] = entry
}
Expand Down
63 changes: 63 additions & 0 deletions internal/controller/job_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4242,6 +4242,69 @@ func TestBuildJob_AgentConfigMCPServers(t *testing.T) {
}
}

func TestBuildJob_AgentConfigMCPServersAlwaysLoad(t *testing.T) {
builder := NewJobBuilder()
task := &kelos.Task{
ObjectMeta: metav1.ObjectMeta{
Name: "test-mcp-always-load",
Namespace: "default",
},
Spec: kelos.TaskSpec{
Type: AgentTypeClaudeCode,
Prompt: "Fix issue",
Credentials: &kelos.Credentials{
Type: kelos.CredentialTypeAPIKey,
SecretRef: &kelos.SecretReference{Name: "my-secret"},
},
},
}

agentConfig := &kelos.AgentConfigSpec{
MCPServers: []kelos.MCPServerSpec{
{
Name: "memory",
Type: "stdio",
Command: "mcp-server-qdrant",
AlwaysLoad: true,
},
{
Name: "github",
Type: "http",
URL: "https://api.githubcopilot.com/mcp/",
},
},
}

job, err := builder.Build(task, nil, agentConfig, task.Spec.Prompt)
if err != nil {
t.Fatalf("Build() returned error: %v", err)
}

var mcpJSON string
for _, env := range job.Spec.Template.Spec.Containers[0].Env {
if env.Name == "KELOS_MCP_SERVERS" {
mcpJSON = env.Value
}
}
if mcpJSON == "" {
t.Fatal("Expected KELOS_MCP_SERVERS env var to be set")
}

var parsed struct {
MCPServers map[string]map[string]json.RawMessage `json:"mcpServers"`
}
if err := json.Unmarshal([]byte(mcpJSON), &parsed); err != nil {
t.Fatalf("Failed to parse KELOS_MCP_SERVERS JSON: %v", err)
}

if got := string(parsed.MCPServers["memory"]["alwaysLoad"]); got != "true" {
t.Errorf("Expected memory alwaysLoad true, got %q", got)
}
if raw, ok := parsed.MCPServers["github"]["alwaysLoad"]; ok {
t.Errorf("Expected github to omit alwaysLoad, got %s", raw)
}
}

func TestBuildJob_AgentConfigMCPServersWithHTTPHeaders(t *testing.T) {
builder := NewJobBuilder()
task := &kelos.Task{
Expand Down
55 changes: 55 additions & 0 deletions internal/conversion/agentconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

const (
preservedMCPValueFromEnvAnnotation = "kelos.dev/v1alpha2-mcp-value-from-env"
preservedMCPAlwaysLoadAnnotation = "kelos.dev/v1alpha2-mcp-always-load"
preservedSkillsSecretRefAnnotation = "kelos.dev/v1alpha2-skills-secret-ref"
preservedSkillsOptionalAnnotation = "kelos.dev/v1alpha2-skills-optional"
)
Expand All @@ -23,6 +24,11 @@ type preservedMCPValueFromEnv struct {
Env []corev1.EnvVar `json:"env"`
}

type preservedMCPAlwaysLoad struct {
Index int `json:"index"`
Name string `json:"name"`
}

type preservedSkillsSecretRef struct {
Index int `json:"index"`
Source string `json:"source"`
Expand Down Expand Up @@ -53,13 +59,17 @@ func agentConfigToHub(_ context.Context, src *v1alpha1.AgentConfig, dst *v1alpha
if err := restorePreservedMCPValueFromEnv(src.Annotations, dst.Spec.MCPServers); err != nil {
return err
}
if err := restorePreservedMCPAlwaysLoad(src.Annotations, dst.Spec.MCPServers); err != nil {
return err
}
if err := restorePreservedSkillsSecretRefs(src.Annotations, dst.Spec.Skills); err != nil {
return err
}
if err := restorePreservedSkillsOptional(src.Annotations, dst.Spec.Skills); err != nil {
return err
}
deleteAnnotation(dst.Annotations, preservedMCPValueFromEnvAnnotation)
deleteAnnotation(dst.Annotations, preservedMCPAlwaysLoadAnnotation)
deleteAnnotation(dst.Annotations, preservedSkillsSecretRefAnnotation)
deleteAnnotation(dst.Annotations, preservedSkillsOptionalAnnotation)
return nil
Expand All @@ -74,6 +84,9 @@ func agentConfigFromHub(_ context.Context, src *v1alpha2.AgentConfig, dst *v1alp
if err := setPreservedMCPValueFromEnvAnnotation(dst, src.Spec.MCPServers); err != nil {
return err
}
if err := setPreservedMCPAlwaysLoadAnnotation(dst, src.Spec.MCPServers); err != nil {
return err
}
if err := setPreservedSkillsSecretRefAnnotation(dst, src.Spec.Skills); err != nil {
return err
}
Expand Down Expand Up @@ -308,6 +321,48 @@ func restorePreservedSkillsSecretRefs(annotations map[string]string, skills []v1
return nil
}

func setPreservedMCPAlwaysLoadAnnotation(dst *v1alpha1.AgentConfig, servers []v1alpha2.MCPServerSpec) error {
var preserved []preservedMCPAlwaysLoad
for i, server := range servers {
if !server.AlwaysLoad {
continue
}
preserved = append(preserved, preservedMCPAlwaysLoad{Index: i, Name: server.Name})
}
if len(preserved) == 0 {
deleteAnnotation(dst.Annotations, preservedMCPAlwaysLoadAnnotation)
return nil
}
data, err := json.Marshal(preserved)
if err != nil {
return err
}
if dst.Annotations == nil {
dst.Annotations = map[string]string{}
}
dst.Annotations[preservedMCPAlwaysLoadAnnotation] = string(data)
return nil
}

func restorePreservedMCPAlwaysLoad(annotations map[string]string, servers []v1alpha2.MCPServerSpec) error {
raw := annotations[preservedMCPAlwaysLoadAnnotation]
if raw == "" {
return nil
}
var preserved []preservedMCPAlwaysLoad
if err := json.Unmarshal([]byte(raw), &preserved); err != nil {
// Conversion must not be blocked by malformed preservation metadata.
return nil
}
for _, item := range preserved {
index, ok := preservedServerIndex(servers, preservedMCPValueFromEnv{Index: item.Index, Name: item.Name})
if ok {
servers[index].AlwaysLoad = true
}
}
return nil
}

func setPreservedSkillsOptionalAnnotation(dst *v1alpha1.AgentConfig, skills []v1alpha2.SkillsShSpec) error {
var preserved []preservedSkillsOptional
for i, skill := range skills {
Expand Down
56 changes: 56 additions & 0 deletions internal/conversion/agentconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,62 @@ func TestAgentConfigRoundTrip_PreservesOptionalSkill(t *testing.T) {
}
}

func TestAgentConfigRoundTrip_PreservesMCPAlwaysLoad(t *testing.T) {
src := &v1alpha2.AgentConfig{
ObjectMeta: metav1.ObjectMeta{Name: "cfg", Namespace: "default"},
Spec: v1alpha2.AgentConfigSpec{
MCPServers: []v1alpha2.MCPServerSpec{
{Name: "memory", Type: "stdio", Command: "mcp-server-qdrant", AlwaysLoad: true},
{Name: "github", Type: "http", URL: "https://api.githubcopilot.com/mcp/"},
},
},
}

spoke := &v1alpha1.AgentConfig{}
if err := agentConfigFromHub(context.Background(), src, spoke); err != nil {
t.Fatalf("agentConfigFromHub() error = %v", err)
}
if spoke.Annotations[preservedMCPAlwaysLoadAnnotation] == "" {
t.Fatalf("spoke annotation %q should carry alwaysLoad", preservedMCPAlwaysLoadAnnotation)
}
hub := &v1alpha2.AgentConfig{}
if err := agentConfigToHub(context.Background(), spoke, hub); err != nil {
t.Fatalf("agentConfigToHub() error = %v", err)
}

if len(hub.Spec.MCPServers) != 2 || !hub.Spec.MCPServers[0].AlwaysLoad || hub.Spec.MCPServers[1].AlwaysLoad {
t.Fatalf("MCPServers = %#v, want alwaysLoad on memory only", hub.Spec.MCPServers)
}
if _, ok := hub.Annotations[preservedMCPAlwaysLoadAnnotation]; ok {
t.Errorf("hub annotation %q should be removed after restore", preservedMCPAlwaysLoadAnnotation)
}
}

func TestAgentConfigRoundTrip_RestoresMCPAlwaysLoadAfterServerReorder(t *testing.T) {
spoke := &v1alpha1.AgentConfig{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
preservedMCPAlwaysLoadAnnotation: `[{"index":0,"name":"memory"}]`,
},
},
Spec: v1alpha1.AgentConfigSpec{
MCPServers: []v1alpha1.MCPServerSpec{
{Name: "github", Type: "http", URL: "https://api.githubcopilot.com/mcp/"},
{Name: "memory", Type: "stdio", Command: "mcp-server-qdrant"},
},
},
}

hub := &v1alpha2.AgentConfig{}
if err := agentConfigToHub(context.Background(), spoke, hub); err != nil {
t.Fatalf("agentConfigToHub() error = %v", err)
}

if hub.Spec.MCPServers[0].AlwaysLoad || !hub.Spec.MCPServers[1].AlwaysLoad {
t.Fatalf("MCPServers = %#v, want alwaysLoad on memory only", hub.Spec.MCPServers)
}
}

func TestAgentConfigRoundTrip_DoesNotRestoreReorderedDuplicateOptionalSkill(t *testing.T) {
spoke := &v1alpha1.AgentConfig{
ObjectMeta: metav1.ObjectMeta{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,11 @@ spec:
items:
description: MCPServerSpec defines an MCP server configuration.
properties:
alwaysLoad:
description: |-
AlwaysLoad makes Claude Code load this server's tools at session start
instead of deferring them behind tool search. Only Claude Code acts on it.
type: boolean
args:
description: |-
Args are command-line arguments for the server process.
Expand Down
5 changes: 5 additions & 0 deletions internal/manifests/install-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@ spec:
items:
description: MCPServerSpec defines an MCP server configuration.
properties:
alwaysLoad:
description: |-
AlwaysLoad makes Claude Code load this server's tools at session start
instead of deferring them behind tool search. Only Claude Code acts on it.
type: boolean
args:
description: |-
Args are command-line arguments for the server process.
Expand Down
Loading