From 7de85c4ddf8bcf0687544bb6749b1236dc35bb92 Mon Sep 17 00:00:00 2001 From: Hans Knecht Date: Wed, 28 Oct 2026 11:23:44 +0100 Subject: [PATCH] Add alwaysLoad to AgentConfig MCP servers Claude Code defers MCP tool definitions behind tool search and loads a server's tools on demand. An agent that uses one server on nearly every run pays a ToolSearch call for it, and on some providers that load breaks the prompt cache for the rest of the run. MCPServerSpec gains an optional alwaysLoad. When true it is written into the server's entry in KELOS_MCP_SERVERS, and Claude Code loads that server's tools at session start while other servers stay deferred. Codex builds its config from named fields, so it never sees the key. v1alpha1 is still served and has no alwaysLoad, so conversion to v1alpha1 records each flagged server in the kelos.dev/v1alpha2-mcp-always-load annotation, and conversion back restores it by index and name, as the other v1alpha2-only MCP and skills settings do. Co-Authored-By: Claude Opus 5.5 --- api/v1alpha2/agentconfig_types.go | 5 ++ docs/reference.md | 1 + internal/controller/job_builder.go | 26 ++++---- internal/controller/job_builder_test.go | 63 +++++++++++++++++++ internal/conversion/agentconfig.go | 55 ++++++++++++++++ internal/conversion/agentconfig_test.go | 56 +++++++++++++++++ .../kelos-crds/templates/agentconfig-crd.yaml | 5 ++ internal/manifests/install-crd.yaml | 5 ++ 8 files changed, 204 insertions(+), 12 deletions(-) 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.