diff --git a/api/v1alpha2/agentconfig_types.go b/api/v1alpha2/agentconfig_types.go index e292c134c..ca73626ec 100644 --- a/api/v1alpha2/agentconfig_types.go +++ b/api/v1alpha2/agentconfig_types.go @@ -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. diff --git a/docs/reference.md b/docs/reference.md index 161eb31a5..3ddc66848 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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 diff --git a/internal/controller/job_builder.go b/internal/controller/job_builder.go index 942344a71..0219a1a02 100644 --- a/internal/controller/job_builder.go +++ b/internal/controller/job_builder.go @@ -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 @@ -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 } diff --git a/internal/controller/job_builder_test.go b/internal/controller/job_builder_test.go index f9bf3fdce..9f6012aad 100644 --- a/internal/controller/job_builder_test.go +++ b/internal/controller/job_builder_test.go @@ -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{ diff --git a/internal/conversion/agentconfig.go b/internal/conversion/agentconfig.go index 915cb1c42..220bcf070 100644 --- a/internal/conversion/agentconfig.go +++ b/internal/conversion/agentconfig.go @@ -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" ) @@ -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"` @@ -53,6 +59,9 @@ 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 } @@ -60,6 +69,7 @@ func agentConfigToHub(_ context.Context, src *v1alpha1.AgentConfig, dst *v1alpha return err } deleteAnnotation(dst.Annotations, preservedMCPValueFromEnvAnnotation) + deleteAnnotation(dst.Annotations, preservedMCPAlwaysLoadAnnotation) deleteAnnotation(dst.Annotations, preservedSkillsSecretRefAnnotation) deleteAnnotation(dst.Annotations, preservedSkillsOptionalAnnotation) return nil @@ -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 } @@ -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 { diff --git a/internal/conversion/agentconfig_test.go b/internal/conversion/agentconfig_test.go index cea1f1167..701af8d5e 100644 --- a/internal/conversion/agentconfig_test.go +++ b/internal/conversion/agentconfig_test.go @@ -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{ diff --git a/internal/manifests/charts/kelos/charts/kelos-crds/templates/agentconfig-crd.yaml b/internal/manifests/charts/kelos/charts/kelos-crds/templates/agentconfig-crd.yaml index c85a605e8..c2b9d1300 100644 --- a/internal/manifests/charts/kelos/charts/kelos-crds/templates/agentconfig-crd.yaml +++ b/internal/manifests/charts/kelos/charts/kelos-crds/templates/agentconfig-crd.yaml @@ -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. diff --git a/internal/manifests/install-crd.yaml b/internal/manifests/install-crd.yaml index 9ff0a29c9..590697031 100644 --- a/internal/manifests/install-crd.yaml +++ b/internal/manifests/install-crd.yaml @@ -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.