diff --git a/e2e/service_provisioning_test.go b/e2e/service_provisioning_test.go index e0c1586c..658c6d52 100644 --- a/e2e/service_provisioning_test.go +++ b/e2e/service_provisioning_test.go @@ -53,6 +53,7 @@ func TestProvisionMCPService(t *testing.T) { Version: "latest", HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-test-key-12345", @@ -184,6 +185,7 @@ func TestProvisionMultiHostMCPService(t *testing.T) { controlplane.Identifier(host3), }, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "openai", "llm_model": "gpt-4", "openai_api_key": "sk-test-key-67890", @@ -285,6 +287,7 @@ func TestUpdateDatabaseAddService(t *testing.T) { Version: "latest", HostIds: []controlplane.Identifier{controlplane.Identifier(host2)}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "ollama", "llm_model": "llama2", "ollama_url": "http://localhost:11434", @@ -350,6 +353,7 @@ func TestProvisionMCPServiceUnsupportedVersion(t *testing.T) { Version: "99.99.99", // Valid semver but not registered in ServiceVersions HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-test-key-12345", @@ -454,6 +458,7 @@ func TestProvisionMCPServiceRecovery(t *testing.T) { Version: "99.99.99", // Unsupported version - workflow will fail HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-test-key-12345", @@ -541,6 +546,7 @@ func TestProvisionMCPServiceRecovery(t *testing.T) { Version: "latest", // Corrected version HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-test-key-12345", @@ -650,6 +656,7 @@ func TestUpdateDatabaseServiceStable(t *testing.T) { Version: "latest", HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-test-key-stable", @@ -718,6 +725,7 @@ func TestUpdateDatabaseServiceStable(t *testing.T) { Version: "latest", HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-test-key-stable", @@ -788,6 +796,7 @@ func TestUpdateMCPServiceConfig(t *testing.T) { Version: "latest", HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-test-key-config", @@ -850,6 +859,7 @@ func TestUpdateMCPServiceConfig(t *testing.T) { Version: "latest", HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-haiku-4-5", "anthropic_api_key": "sk-ant-test-key-config", @@ -929,6 +939,7 @@ func TestUpdateDatabaseRemoveService(t *testing.T) { Version: "latest", HostIds: []controlplane.Identifier{controlplane.Identifier(host1)}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-test", diff --git a/server/internal/api/apiv1/validate_test.go b/server/internal/api/apiv1/validate_test.go index dd8e16f2..c75023c8 100644 --- a/server/internal/api/apiv1/validate_test.go +++ b/server/internal/api/apiv1/validate_test.go @@ -577,6 +577,7 @@ func TestValidateDatabaseSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -605,6 +606,7 @@ func TestValidateDatabaseSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -616,6 +618,7 @@ func TestValidateDatabaseSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-2"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -647,6 +650,7 @@ func TestValidateDatabaseSpec(t *testing.T) { Version: "v1.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "unknown", }, }, @@ -677,6 +681,7 @@ func TestValidateDatabaseSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "unknown", }, }, @@ -771,6 +776,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1", "host-2"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -785,6 +791,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "2.1.3", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "openai", "llm_model": "gpt-4", "openai_api_key": "sk-...", @@ -799,6 +806,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.5.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "ollama", "llm_model": "llama2", "ollama_url": "http://localhost:11434", @@ -813,6 +821,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "latest", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -827,6 +836,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -843,6 +853,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -945,6 +956,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "v1.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -962,6 +974,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1", "host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -979,6 +992,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host 1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -996,7 +1010,8 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ - "llm_model": "claude-sonnet-4-5", + "llm_enabled": true, + "llm_model": "claude-sonnet-4-5", }, }, expected: []string{ @@ -1011,6 +1026,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", }, }, @@ -1026,6 +1042,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "unknown", "llm_model": "some-model", }, @@ -1042,6 +1059,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", }, @@ -1058,6 +1076,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "openai", "llm_model": "gpt-4", }, @@ -1074,6 +1093,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "ollama", "llm_model": "llama2", }, @@ -1090,6 +1110,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -1108,6 +1129,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -1126,6 +1148,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -1148,6 +1171,7 @@ func TestValidateServiceSpec(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -1288,6 +1312,7 @@ func TestValidateServiceSpec_DatabaseConnectionCrossValidation(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -1308,6 +1333,7 @@ func TestValidateServiceSpec_DatabaseConnectionCrossValidation(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -1328,6 +1354,7 @@ func TestValidateServiceSpec_DatabaseConnectionCrossValidation(t *testing.T) { Version: "1.0.0", HostIds: []api.Identifier{"host-1"}, Config: map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", @@ -1421,6 +1448,7 @@ func TestValidateOrchestratorOpts(t *testing.T) { func TestValidateDatabaseUpdate_ServiceBootstrapFields(t *testing.T) { // A minimal valid MCP config shared across test cases. validMCPConfig := map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-sonnet-4-5", "anthropic_api_key": "sk-ant-...", diff --git a/server/internal/database/mcp_service_config.go b/server/internal/database/mcp_service_config.go index c6d53a91..7dae332c 100644 --- a/server/internal/database/mcp_service_config.go +++ b/server/internal/database/mcp_service_config.go @@ -17,7 +17,10 @@ type MCPServiceUser struct { // MCPServiceConfig is the typed internal representation of MCP service configuration. // It is parsed from the ServiceSpec.Config map[string]any and validated. type MCPServiceConfig struct { - // Required + // Optional - LLM proxy for web client (default: false) + LLMEnabled *bool `json:"llm_enabled,omitempty"` + + // Required when llm_enabled is true LLMProvider string `json:"llm_provider"` LLMModel string `json:"llm_model"` AnthropicAPIKey *string `json:"anthropic_api_key,omitempty"` @@ -53,6 +56,7 @@ type MCPServiceConfig struct { // mcpKnownKeys is the set of all valid config keys for MCP service configuration. var mcpKnownKeys = map[string]bool{ + "llm_enabled": true, "llm_provider": true, "llm_model": true, "anthropic_api_key": true, @@ -97,39 +101,105 @@ func ParseMCPServiceConfig(config map[string]any, isUpdate bool) (*MCPServiceCon } } - // Parse required string fields - llmProvider, providerErrs := requireString(config, "llm_provider") - errs = append(errs, providerErrs...) + // Parse llm_enabled (optional bool, default false) + llmEnabled, leErrs := optionalBool(config, "llm_enabled") + errs = append(errs, leErrs...) - llmModel, modelErrs := requireString(config, "llm_model") - errs = append(errs, modelErrs...) + isLLMEnabled := llmEnabled != nil && *llmEnabled - // Validate llm_provider enum - if llmProvider != "" && !slices.Contains(validLLMProviders, llmProvider) { - errs = append(errs, fmt.Errorf("llm_provider must be one of: %s", strings.Join(validLLMProviders, ", "))) - } + // Parse embedding config early — needed for ollama_url shared field logic + embeddingProvider, epErrs := optionalString(config, "embedding_provider") + errs = append(errs, epErrs...) - // Provider-specific API key cross-validation + embeddingModel, emErrs := optionalString(config, "embedding_model") + errs = append(errs, emErrs...) + + embeddingAPIKey, eakErrs := optionalString(config, "embedding_api_key") + errs = append(errs, eakErrs...) + + // LLM fields: conditionally required when llm_enabled is true, + // rejected when llm_enabled is false. + var llmProvider string + var llmModel string var anthropicKey, openaiKey, ollamaURL *string - if llmProvider != "" && slices.Contains(validLLMProviders, llmProvider) { - switch llmProvider { - case "anthropic": - key, keyErrs := requireStringForProvider(config, "anthropic_api_key", "anthropic") - errs = append(errs, keyErrs...) - if key != "" { - anthropicKey = &key + var llmTemperature *float64 + var llmMaxTokens *int + + if isLLMEnabled { + // llm_provider and llm_model are required + var providerErrs, modelErrs []error + llmProvider, providerErrs = requireString(config, "llm_provider") + errs = append(errs, providerErrs...) + + llmModel, modelErrs = requireString(config, "llm_model") + errs = append(errs, modelErrs...) + + // Validate llm_provider enum + if llmProvider != "" && !slices.Contains(validLLMProviders, llmProvider) { + errs = append(errs, fmt.Errorf("llm_provider must be one of: %s", strings.Join(validLLMProviders, ", "))) + } + + // Provider-specific API key cross-validation + if llmProvider != "" && slices.Contains(validLLMProviders, llmProvider) { + switch llmProvider { + case "anthropic": + key, keyErrs := requireStringForProvider(config, "anthropic_api_key", "anthropic") + errs = append(errs, keyErrs...) + if key != "" { + anthropicKey = &key + } + case "openai": + key, keyErrs := requireStringForProvider(config, "openai_api_key", "openai") + errs = append(errs, keyErrs...) + if key != "" { + openaiKey = &key + } + case "ollama": + url, urlErrs := requireStringForProvider(config, "ollama_url", "ollama") + errs = append(errs, urlErrs...) + if url != "" { + ollamaURL = &url + } } - case "openai": - key, keyErrs := requireStringForProvider(config, "openai_api_key", "openai") - errs = append(errs, keyErrs...) - if key != "" { - openaiKey = &key + } + + // LLM tuning fields (optional) + var ltErrs, lmtErrs []error + llmTemperature, ltErrs = optionalFloat64(config, "llm_temperature") + errs = append(errs, ltErrs...) + llmMaxTokens, lmtErrs = optionalInt(config, "llm_max_tokens") + errs = append(errs, lmtErrs...) + + // Range validations + if llmTemperature != nil { + if *llmTemperature < 0.0 || *llmTemperature > 2.0 { + errs = append(errs, fmt.Errorf("llm_temperature must be between 0.0 and 2.0")) } - case "ollama": - url, urlErrs := requireStringForProvider(config, "ollama_url", "ollama") - errs = append(errs, urlErrs...) - if url != "" { - ollamaURL = &url + } + if llmMaxTokens != nil { + if *llmMaxTokens <= 0 { + errs = append(errs, fmt.Errorf("llm_max_tokens must be a positive integer")) + } + } + } else { + // LLM is disabled — reject LLM-specific fields if present + llmOnlyFields := []string{"llm_provider", "llm_model", "anthropic_api_key", "openai_api_key", "llm_temperature", "llm_max_tokens"} + for _, key := range llmOnlyFields { + if _, ok := config[key]; ok { + errs = append(errs, fmt.Errorf("%s must not be set unless llm_enabled is true", key)) + } + } + // ollama_url is shared with embedding — only reject if not needed for embedding + if _, ok := config["ollama_url"]; ok { + embIsOllama := embeddingProvider != nil && *embeddingProvider == "ollama" + if !embIsOllama { + errs = append(errs, fmt.Errorf("ollama_url must not be set unless llm_enabled is true")) + } else { + // Parse ollama_url for embedding use — it's optional here because + // the embedding cross-validation block below enforces it when required. + url, urlErrs := optionalString(config, "ollama_url") + errs = append(errs, urlErrs...) + ollamaURL = url } } } @@ -144,21 +214,6 @@ func ParseMCPServiceConfig(config map[string]any, isUpdate bool) (*MCPServiceCon initUsers, iuErrs := parseInitUsers(config) errs = append(errs, iuErrs...) - embeddingProvider, epErrs := optionalString(config, "embedding_provider") - errs = append(errs, epErrs...) - - embeddingModel, emErrs := optionalString(config, "embedding_model") - errs = append(errs, emErrs...) - - embeddingAPIKey, eakErrs := optionalString(config, "embedding_api_key") - errs = append(errs, eakErrs...) - - llmTemperature, ltErrs := optionalFloat64(config, "llm_temperature") - errs = append(errs, ltErrs...) - - llmMaxTokens, lmtErrs := optionalInt(config, "llm_max_tokens") - errs = append(errs, lmtErrs...) - poolMaxConns, pmcErrs := optionalInt(config, "pool_max_conns") errs = append(errs, pmcErrs...) @@ -178,17 +233,6 @@ func ParseMCPServiceConfig(config map[string]any, isUpdate bool) (*MCPServiceCon disableCountRows, dcrErrs := optionalBool(config, "disable_count_rows") errs = append(errs, dcrErrs...) - // Range validations - if llmTemperature != nil { - if *llmTemperature < 0.0 || *llmTemperature > 2.0 { - errs = append(errs, fmt.Errorf("llm_temperature must be between 0.0 and 2.0")) - } - } - if llmMaxTokens != nil { - if *llmMaxTokens <= 0 { - errs = append(errs, fmt.Errorf("llm_max_tokens must be a positive integer")) - } - } if poolMaxConns != nil { if *poolMaxConns <= 0 { errs = append(errs, fmt.Errorf("pool_max_conns must be a positive integer")) @@ -211,12 +255,16 @@ func ParseMCPServiceConfig(config map[string]any, isUpdate bool) (*MCPServiceCon if embeddingModel == nil { errs = append(errs, fmt.Errorf("embedding_model is required when embedding_provider is set")) } - // Providers that require an API key + // Provider-specific credential requirements switch *embeddingProvider { case "voyage", "openai": if embeddingAPIKey == nil { errs = append(errs, fmt.Errorf("embedding_api_key is required when embedding_provider is %q", *embeddingProvider)) } + case "ollama": + if ollamaURL == nil { + errs = append(errs, fmt.Errorf("ollama_url is required when embedding_provider is %q", *embeddingProvider)) + } } } } @@ -226,6 +274,7 @@ func ParseMCPServiceConfig(config map[string]any, isUpdate bool) (*MCPServiceCon } return &MCPServiceConfig{ + LLMEnabled: llmEnabled, LLMProvider: llmProvider, LLMModel: llmModel, AnthropicAPIKey: anthropicKey, diff --git a/server/internal/database/mcp_service_config_test.go b/server/internal/database/mcp_service_config_test.go index de7fb87a..65ac2800 100644 --- a/server/internal/database/mcp_service_config_test.go +++ b/server/internal/database/mcp_service_config_test.go @@ -16,33 +16,41 @@ func boolPtr(b bool) *bool { return &b } func intPtr(i int) *int { return &i } func float64Ptr(f float64) *float64 { return &f } -// anthropicBase returns a minimal valid config for the anthropic provider. +// anthropicBase returns a minimal valid config for the anthropic provider with LLM enabled. func anthropicBase() map[string]any { return map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-3-5-sonnet-20241022", "anthropic_api_key": "sk-ant-key", } } -// openaiBase returns a minimal valid config for the openai provider. +// openaiBase returns a minimal valid config for the openai provider with LLM enabled. func openaiBase() map[string]any { return map[string]any{ + "llm_enabled": true, "llm_provider": "openai", "llm_model": "gpt-4o", "openai_api_key": "sk-openai-key", } } -// ollamaBase returns a minimal valid config for the ollama provider. +// ollamaBase returns a minimal valid config for the ollama provider with LLM enabled. func ollamaBase() map[string]any { return map[string]any{ + "llm_enabled": true, "llm_provider": "ollama", "llm_model": "llama3.2", "ollama_url": "http://localhost:11434", } } +// noLLMBase returns a minimal valid config with no LLM (the new default). +func noLLMBase() map[string]any { + return map[string]any{} +} + // joinedErr joins a []error into a single error for assertion convenience. func joinedErr(errs []error) error { return errors.Join(errs...) @@ -83,8 +91,25 @@ func TestParseMCPServiceConfig(t *testing.T) { assert.Nil(t, cfg.OpenAIAPIKey) }) + t.Run("minimal no-LLM config", func(t *testing.T) { + cfg, errs := database.ParseMCPServiceConfig(noLLMBase(), false) + require.Empty(t, errs) + assert.Nil(t, cfg.LLMEnabled) + assert.Empty(t, cfg.LLMProvider) + assert.Empty(t, cfg.LLMModel) + }) + + t.Run("explicit llm_enabled false", func(t *testing.T) { + config := map[string]any{"llm_enabled": false} + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.LLMEnabled) + assert.False(t, *cfg.LLMEnabled) + }) + t.Run("all optional fields populated", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-3-5-sonnet-20241022", "anthropic_api_key": "sk-ant-key", @@ -146,9 +171,10 @@ func TestParseMCPServiceConfig(t *testing.T) { }) }) - t.Run("required fields", func(t *testing.T) { + t.Run("required fields when llm_enabled is true", func(t *testing.T) { t.Run("missing llm_provider", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_model": "claude-3-5-sonnet-20241022", "anthropic_api_key": "sk-ant-key", } @@ -159,6 +185,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("missing llm_model", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "anthropic_api_key": "sk-ant-key", } @@ -169,6 +196,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("empty llm_provider", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "", "llm_model": "claude-3-5-sonnet-20241022", "anthropic_api_key": "sk-ant-key", @@ -180,6 +208,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("empty llm_model", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "", "anthropic_api_key": "sk-ant-key", @@ -188,11 +217,21 @@ func TestParseMCPServiceConfig(t *testing.T) { require.NotEmpty(t, errs) assert.Contains(t, joinedErr(errs).Error(), "llm_model must not be empty") }) + + t.Run("llm_enabled true with no other fields", func(t *testing.T) { + config := map[string]any{ + "llm_enabled": true, + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_provider is required") + }) }) t.Run("provider cross-validation", func(t *testing.T) { t.Run("anthropic without anthropic_api_key", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-3-5-sonnet-20241022", } @@ -203,6 +242,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("anthropic with empty anthropic_api_key", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-3-5-sonnet-20241022", "anthropic_api_key": "", @@ -214,6 +254,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("openai without openai_api_key", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "openai", "llm_model": "gpt-4o", } @@ -224,6 +265,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("openai with empty openai_api_key", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "openai", "llm_model": "gpt-4o", "openai_api_key": "", @@ -235,6 +277,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("ollama without ollama_url", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "ollama", "llm_model": "llama3.2", } @@ -245,6 +288,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("ollama with empty ollama_url", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "ollama", "llm_model": "llama3.2", "ollama_url": "", @@ -258,6 +302,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("invalid provider", func(t *testing.T) { t.Run("unknown llm_provider value", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "bedrock", "llm_model": "some-model", } @@ -274,6 +319,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("type errors", func(t *testing.T) { t.Run("llm_provider wrong type", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": 42, "llm_model": "some-model", } @@ -284,6 +330,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("llm_model wrong type", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": true, "anthropic_api_key": "sk-ant-key", @@ -295,6 +342,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("anthropic_api_key wrong type", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "llm_model": "claude-3-5-sonnet-20241022", "anthropic_api_key": 12345, @@ -462,8 +510,8 @@ func TestParseMCPServiceConfig(t *testing.T) { assert.Equal(t, "openai", *cfg.EmbeddingProvider) }) - t.Run("valid ollama embedding config (no api key required)", func(t *testing.T) { - config := anthropicBase() + t.Run("valid ollama embedding config with LLM enabled (ollama_url from LLM)", func(t *testing.T) { + config := ollamaBase() config["embedding_provider"] = "ollama" config["embedding_model"] = "nomic-embed-text" cfg, errs := database.ParseMCPServiceConfig(config, false) @@ -473,6 +521,30 @@ func TestParseMCPServiceConfig(t *testing.T) { assert.Nil(t, cfg.EmbeddingAPIKey) }) + t.Run("valid ollama embedding config without LLM (ollama_url explicit)", func(t *testing.T) { + config := map[string]any{ + "embedding_provider": "ollama", + "embedding_model": "nomic-embed-text", + "ollama_url": "http://localhost:11434", + } + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.EmbeddingProvider) + assert.Equal(t, "ollama", *cfg.EmbeddingProvider) + require.NotNil(t, cfg.OllamaURL) + assert.Equal(t, "http://localhost:11434", *cfg.OllamaURL) + }) + + t.Run("ollama embedding without ollama_url", func(t *testing.T) { + config := map[string]any{ + "embedding_provider": "ollama", + "embedding_model": "nomic-embed-text", + } + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), `ollama_url is required when embedding_provider is "ollama"`) + }) + t.Run("embedding_provider without embedding_model", func(t *testing.T) { config := anthropicBase() config["embedding_provider"] = "voyage" @@ -686,8 +758,8 @@ func TestParseMCPServiceConfig(t *testing.T) { }) t.Run("multiple errors", func(t *testing.T) { - t.Run("missing both required fields returns multiple errors", func(t *testing.T) { - config := map[string]any{} + t.Run("llm_enabled true missing both required fields returns multiple errors", func(t *testing.T) { + config := map[string]any{"llm_enabled": true} _, errs := database.ParseMCPServiceConfig(config, false) require.NotEmpty(t, errs) // Both errors are separate entries in the slice @@ -699,6 +771,7 @@ func TestParseMCPServiceConfig(t *testing.T) { t.Run("unknown key plus missing required field accumulates errors", func(t *testing.T) { config := map[string]any{ + "llm_enabled": true, "llm_provider": "anthropic", "mystery_field": "oops", // llm_model missing, anthropic_api_key missing @@ -795,6 +868,87 @@ func TestParseMCPServiceConfig(t *testing.T) { }) }) + t.Run("llm_enabled false rejects LLM fields", func(t *testing.T) { + t.Run("llm_provider rejected", func(t *testing.T) { + config := map[string]any{"llm_provider": "anthropic"} + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_provider must not be set unless llm_enabled is true") + }) + + t.Run("llm_model rejected", func(t *testing.T) { + config := map[string]any{"llm_model": "claude-sonnet-4-5"} + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_model must not be set unless llm_enabled is true") + }) + + t.Run("anthropic_api_key rejected", func(t *testing.T) { + config := map[string]any{"anthropic_api_key": "sk-ant-key"} + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "anthropic_api_key must not be set unless llm_enabled is true") + }) + + t.Run("openai_api_key rejected", func(t *testing.T) { + config := map[string]any{"openai_api_key": "sk-openai-key"} + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "openai_api_key must not be set unless llm_enabled is true") + }) + + t.Run("ollama_url rejected when no ollama embedding", func(t *testing.T) { + config := map[string]any{"ollama_url": "http://localhost:11434"} + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "ollama_url must not be set unless llm_enabled is true") + }) + + t.Run("ollama_url allowed for ollama embedding without LLM", func(t *testing.T) { + config := map[string]any{ + "embedding_provider": "ollama", + "embedding_model": "nomic-embed-text", + "ollama_url": "http://localhost:11434", + } + cfg, errs := database.ParseMCPServiceConfig(config, false) + require.Empty(t, errs) + require.NotNil(t, cfg.OllamaURL) + assert.Equal(t, "http://localhost:11434", *cfg.OllamaURL) + }) + + t.Run("llm_temperature rejected", func(t *testing.T) { + config := map[string]any{"llm_temperature": 0.5} + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_temperature must not be set unless llm_enabled is true") + }) + + t.Run("llm_max_tokens rejected", func(t *testing.T) { + config := map[string]any{"llm_max_tokens": float64(1024)} + _, errs := database.ParseMCPServiceConfig(config, false) + require.NotEmpty(t, errs) + assert.Contains(t, joinedErr(errs).Error(), "llm_max_tokens must not be set unless llm_enabled is true") + }) + }) + + t.Run("llm_enabled updatable", func(t *testing.T) { + t.Run("disable LLM on update", func(t *testing.T) { + config := map[string]any{"llm_enabled": false} + cfg, errs := database.ParseMCPServiceConfig(config, true) + require.Empty(t, errs) + require.NotNil(t, cfg.LLMEnabled) + assert.False(t, *cfg.LLMEnabled) + }) + + t.Run("enable LLM on update", func(t *testing.T) { + cfg, errs := database.ParseMCPServiceConfig(anthropicBase(), true) + require.Empty(t, errs) + require.NotNil(t, cfg.LLMEnabled) + assert.True(t, *cfg.LLMEnabled) + assert.Equal(t, "anthropic", cfg.LLMProvider) + }) + }) + t.Run("json.Number types", func(t *testing.T) { t.Run("llm_temperature as json.Number", func(t *testing.T) { config := anthropicBase() diff --git a/server/internal/orchestrator/swarm/mcp_config.go b/server/internal/orchestrator/swarm/mcp_config.go index a5c22c7c..487f0d37 100644 --- a/server/internal/orchestrator/swarm/mcp_config.go +++ b/server/internal/orchestrator/swarm/mcp_config.go @@ -12,7 +12,7 @@ import ( type mcpYAMLConfig struct { HTTP mcpHTTPConfig `yaml:"http"` Databases []mcpDatabaseConfig `yaml:"databases"` - LLM mcpLLMConfig `yaml:"llm"` + LLM *mcpLLMConfig `yaml:"llm,omitempty"` Embedding *mcpEmbeddingConfig `yaml:"embedding,omitempty"` Builtins mcpBuiltinsConfig `yaml:"builtins"` } @@ -101,14 +101,6 @@ func GenerateMCPConfig(params *MCPConfigParams) ([]byte, error) { cfg := params.Config // Apply defaults for overridable fields - temperature := 0.7 - if cfg.LLMTemperature != nil { - temperature = *cfg.LLMTemperature - } - maxTokens := 4096 - if cfg.LLMMaxTokens != nil { - maxTokens = *cfg.LLMMaxTokens - } poolMaxConns := 4 if cfg.PoolMaxConns != nil { poolMaxConns = *cfg.PoolMaxConns @@ -118,27 +110,39 @@ func GenerateMCPConfig(params *MCPConfigParams) ([]byte, error) { allowWrites = *cfg.AllowWrites } - // Build LLM config - llm := mcpLLMConfig{ - Enabled: true, - Provider: cfg.LLMProvider, - Model: cfg.LLMModel, - Temperature: temperature, - MaxTokens: maxTokens, - } - switch cfg.LLMProvider { - case "anthropic": - if cfg.AnthropicAPIKey != nil { - llm.AnthropicAPIKey = *cfg.AnthropicAPIKey + // Build LLM config (only when llm_enabled is true) + var llm *mcpLLMConfig + if cfg.LLMEnabled != nil && *cfg.LLMEnabled { + temperature := 0.7 + if cfg.LLMTemperature != nil { + temperature = *cfg.LLMTemperature + } + maxTokens := 4096 + if cfg.LLMMaxTokens != nil { + maxTokens = *cfg.LLMMaxTokens } - case "openai": - if cfg.OpenAIAPIKey != nil { - llm.OpenAIAPIKey = *cfg.OpenAIAPIKey + l := &mcpLLMConfig{ + Enabled: true, + Provider: cfg.LLMProvider, + Model: cfg.LLMModel, + Temperature: temperature, + MaxTokens: maxTokens, } - case "ollama": - if cfg.OllamaURL != nil { - llm.OllamaURL = *cfg.OllamaURL + switch cfg.LLMProvider { + case "anthropic": + if cfg.AnthropicAPIKey != nil { + l.AnthropicAPIKey = *cfg.AnthropicAPIKey + } + case "openai": + if cfg.OpenAIAPIKey != nil { + l.OpenAIAPIKey = *cfg.OpenAIAPIKey + } + case "ollama": + if cfg.OllamaURL != nil { + l.OllamaURL = *cfg.OllamaURL + } } + llm = l } // Build embedding config (only if provider is set) diff --git a/server/internal/orchestrator/swarm/mcp_config_test.go b/server/internal/orchestrator/swarm/mcp_config_test.go index fffa44df..3400fe21 100644 --- a/server/internal/orchestrator/swarm/mcp_config_test.go +++ b/server/internal/orchestrator/swarm/mcp_config_test.go @@ -6,6 +6,7 @@ import ( "github.com/goccy/go-yaml" "github.com/pgEdge/control-plane/server/internal/database" + "github.com/pgEdge/control-plane/server/internal/utils" ) func strPtr(s string) *string { return &s } @@ -21,12 +22,9 @@ func parseYAML(t *testing.T, data []byte) *mcpYAMLConfig { } func TestGenerateMCPConfig_MinimalConfig(t *testing.T) { + // Minimal config: no LLM, no embedding — just database. params := &MCPConfigParams{ - Config: &database.MCPServiceConfig{ - LLMProvider: "anthropic", - LLMModel: "claude-sonnet-4-5", - AnthropicAPIKey: strPtr("sk-ant-api03-test"), - }, + Config: &database.MCPServiceConfig{}, DatabaseName: "mydb", DatabaseHosts: []database.ServiceHostEntry{{Host: "db-host", Port: 5432}}, Username: "appuser", @@ -62,12 +60,9 @@ func TestGenerateMCPConfig_MinimalConfig(t *testing.T) { t.Fatalf("databases len = %d, want 1", len(cfg.Databases)) } - // llm section - if !cfg.LLM.Enabled { - t.Error("llm.enabled should be true") - } - if cfg.LLM.Provider != "anthropic" { - t.Errorf("llm.provider = %q, want %q", cfg.LLM.Provider, "anthropic") + // llm section should be absent + if cfg.LLM != nil { + t.Errorf("llm section should be absent when llm_enabled is not set, got %+v", cfg.LLM) } // builtins.tools.llm_connection_selection must be false @@ -79,14 +74,33 @@ func TestGenerateMCPConfig_MinimalConfig(t *testing.T) { } } -func TestGenerateMCPConfig_DefaultValues(t *testing.T) { - // No optional fields set — defaults should apply. +func TestGenerateMCPConfig_LLMDisabled_SectionOmitted(t *testing.T) { + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{LLMEnabled: utils.PointerTo(false)}, + DatabaseName: "mydb", + DatabaseHosts: []database.ServiceHostEntry{{Host: "db-host", Port: 5432}}, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + if cfg.LLM != nil { + t.Errorf("llm section should be absent when llm_enabled is false, got %+v", cfg.LLM) + } +} + +func TestGenerateMCPConfig_LLMEnabled_SectionPresent(t *testing.T) { params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ + LLMEnabled: utils.PointerTo(true), LLMProvider: "anthropic", LLMModel: "claude-sonnet-4-5", AnthropicAPIKey: strPtr("sk-ant-api03-test"), - // LLMTemperature, LLMMaxTokens, PoolMaxConns, AllowWrites all nil }, DatabaseName: "mydb", DatabaseHosts: []database.ServiceHostEntry{{Host: "db-host", Port: 5432}}, @@ -100,13 +114,72 @@ func TestGenerateMCPConfig_DefaultValues(t *testing.T) { } cfg := parseYAML(t, data) + if cfg.LLM == nil { + t.Fatal("llm section should be present when llm_enabled is true") + } + if !cfg.LLM.Enabled { + t.Error("llm.enabled should be true") + } + if cfg.LLM.Provider != "anthropic" { + t.Errorf("llm.provider = %q, want %q", cfg.LLM.Provider, "anthropic") + } + if cfg.LLM.Model != "claude-sonnet-4-5" { + t.Errorf("llm.model = %q, want %q", cfg.LLM.Model, "claude-sonnet-4-5") + } +} +func TestGenerateMCPConfig_LLMEnabled_DefaultTuning(t *testing.T) { + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{ + LLMEnabled: utils.PointerTo(true), + LLMProvider: "anthropic", + LLMModel: "claude-sonnet-4-5", + AnthropicAPIKey: strPtr("sk-ant-api03-test"), + }, + DatabaseName: "mydb", + DatabaseHosts: []database.ServiceHostEntry{{Host: "db-host", Port: 5432}}, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + if cfg.LLM == nil { + t.Fatal("llm section should be present") + } if cfg.LLM.Temperature != 0.7 { t.Errorf("llm.temperature = %v, want 0.7", cfg.LLM.Temperature) } if cfg.LLM.MaxTokens != 4096 { t.Errorf("llm.max_tokens = %d, want 4096", cfg.LLM.MaxTokens) } +} + +func TestGenerateMCPConfig_DefaultValues(t *testing.T) { + // No LLM, no optional fields — defaults should apply for database fields. + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{}, + DatabaseName: "mydb", + DatabaseHosts: []database.ServiceHostEntry{{Host: "db-host", Port: 5432}}, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + + // LLM section absent + if cfg.LLM != nil { + t.Errorf("llm section should be absent, got %+v", cfg.LLM) + } if len(cfg.Databases) != 1 { t.Fatalf("databases len = %d, want 1", len(cfg.Databases)) } @@ -126,6 +199,7 @@ func TestGenerateMCPConfig_CustomValues(t *testing.T) { params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ + LLMEnabled: utils.PointerTo(true), LLMProvider: "anthropic", LLMModel: "claude-opus-4-6", AnthropicAPIKey: strPtr("sk-ant-api03-test"), @@ -147,6 +221,9 @@ func TestGenerateMCPConfig_CustomValues(t *testing.T) { cfg := parseYAML(t, data) + if cfg.LLM == nil { + t.Fatal("llm section should be present") + } if cfg.LLM.Temperature != 1.2 { t.Errorf("llm.temperature = %v, want 1.2", cfg.LLM.Temperature) } @@ -168,6 +245,7 @@ func TestGenerateMCPConfig_ProviderKeys_Anthropic(t *testing.T) { apiKey := "sk-ant-api03-test" params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ + LLMEnabled: utils.PointerTo(true), LLMProvider: "anthropic", LLMModel: "claude-sonnet-4-5", AnthropicAPIKey: &apiKey, @@ -185,6 +263,9 @@ func TestGenerateMCPConfig_ProviderKeys_Anthropic(t *testing.T) { cfg := parseYAML(t, data) + if cfg.LLM == nil { + t.Fatal("llm section should be present") + } if cfg.LLM.AnthropicAPIKey != apiKey { t.Errorf("llm.anthropic_api_key = %q, want %q", cfg.LLM.AnthropicAPIKey, apiKey) } @@ -200,6 +281,7 @@ func TestGenerateMCPConfig_ProviderKeys_OpenAI(t *testing.T) { apiKey := "sk-openai-test" params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ + LLMEnabled: utils.PointerTo(true), LLMProvider: "openai", LLMModel: "gpt-4", OpenAIAPIKey: &apiKey, @@ -217,6 +299,9 @@ func TestGenerateMCPConfig_ProviderKeys_OpenAI(t *testing.T) { cfg := parseYAML(t, data) + if cfg.LLM == nil { + t.Fatal("llm section should be present") + } if cfg.LLM.OpenAIAPIKey != apiKey { t.Errorf("llm.openai_api_key = %q, want %q", cfg.LLM.OpenAIAPIKey, apiKey) } @@ -232,6 +317,7 @@ func TestGenerateMCPConfig_ProviderKeys_Ollama(t *testing.T) { ollamaURL := "http://localhost:11434" params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ + LLMEnabled: utils.PointerTo(true), LLMProvider: "ollama", LLMModel: "llama3", OllamaURL: &ollamaURL, @@ -249,6 +335,9 @@ func TestGenerateMCPConfig_ProviderKeys_Ollama(t *testing.T) { cfg := parseYAML(t, data) + if cfg.LLM == nil { + t.Fatal("llm section should be present") + } if cfg.LLM.OllamaURL != ollamaURL { t.Errorf("llm.ollama_url = %q, want %q", cfg.LLM.OllamaURL, ollamaURL) } @@ -267,6 +356,7 @@ func TestGenerateMCPConfig_EmbeddingPresent(t *testing.T) { params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ + LLMEnabled: utils.PointerTo(true), LLMProvider: "anthropic", LLMModel: "claude-sonnet-4-5", AnthropicAPIKey: strPtr("sk-ant-api03-test"), @@ -304,13 +394,17 @@ func TestGenerateMCPConfig_EmbeddingPresent(t *testing.T) { } } -func TestGenerateMCPConfig_EmbeddingAbsent(t *testing.T) { - // No embedding_provider set — embedding section must be omitted. +func TestGenerateMCPConfig_EmbeddingWithoutLLM(t *testing.T) { + // Embedding enabled without LLM — LLM section absent, embedding present. + embProvider := "voyage" + embModel := "voyage-3" + embAPIKey := "pa-voyage-key" + params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ - LLMProvider: "anthropic", - LLMModel: "claude-sonnet-4-5", - AnthropicAPIKey: strPtr("sk-ant-api03-test"), + EmbeddingProvider: &embProvider, + EmbeddingModel: &embModel, + EmbeddingAPIKey: &embAPIKey, }, DatabaseName: "mydb", DatabaseHosts: []database.ServiceHostEntry{{Host: "db-host", Port: 5432}}, @@ -325,6 +419,37 @@ func TestGenerateMCPConfig_EmbeddingAbsent(t *testing.T) { cfg := parseYAML(t, data) + if cfg.LLM != nil { + t.Errorf("llm section should be absent when llm_enabled is not set, got %+v", cfg.LLM) + } + if cfg.Embedding == nil { + t.Fatal("embedding section should be present") + } + if !cfg.Embedding.Enabled { + t.Error("embedding.enabled should be true") + } + if cfg.Embedding.Provider != "voyage" { + t.Errorf("embedding.provider = %q, want %q", cfg.Embedding.Provider, "voyage") + } +} + +func TestGenerateMCPConfig_EmbeddingAbsent(t *testing.T) { + // No embedding_provider set — embedding section must be omitted. + params := &MCPConfigParams{ + Config: &database.MCPServiceConfig{}, + DatabaseName: "mydb", + DatabaseHosts: []database.ServiceHostEntry{{Host: "db-host", Port: 5432}}, + Username: "appuser", + Password: "secret", + } + + data, err := GenerateMCPConfig(params) + if err != nil { + t.Fatalf("GenerateMCPConfig() error = %v", err) + } + + cfg := parseYAML(t, data) + if cfg.Embedding != nil { t.Errorf("embedding section should be absent when embedding_provider is not set, got %+v", cfg.Embedding) } @@ -337,6 +462,7 @@ func TestGenerateMCPConfig_EmbeddingOpenAI(t *testing.T) { params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ + LLMEnabled: utils.PointerTo(true), LLMProvider: "openai", LLMModel: "gpt-4", OpenAIAPIKey: strPtr("sk-openai-llm"), @@ -375,6 +501,7 @@ func TestGenerateMCPConfig_EmbeddingOllama(t *testing.T) { params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ + LLMEnabled: utils.PointerTo(true), LLMProvider: "ollama", LLMModel: "llama3", OllamaURL: &ollamaURL, @@ -406,9 +533,6 @@ func TestGenerateMCPConfig_ToolToggles_AllDisabled(t *testing.T) { trueVal := true params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ - LLMProvider: "anthropic", - LLMModel: "claude-sonnet-4-5", - AnthropicAPIKey: strPtr("sk-ant-api03-test"), DisableQueryDatabase: &trueVal, DisableGetSchemaInfo: &trueVal, DisableSimilaritySearch: &trueVal, @@ -458,11 +582,7 @@ func TestGenerateMCPConfig_ToolToggles_NoneDisabled(t *testing.T) { // No disable_* flags set — all tool fields should be omitted (nil), except // llm_connection_selection which is always explicitly false. params := &MCPConfigParams{ - Config: &database.MCPServiceConfig{ - LLMProvider: "anthropic", - LLMModel: "claude-sonnet-4-5", - AnthropicAPIKey: strPtr("sk-ant-api03-test"), - }, + Config: &database.MCPServiceConfig{}, DatabaseName: "mydb", DatabaseHosts: []database.ServiceHostEntry{{Host: "db-host", Port: 5432}}, Username: "appuser", @@ -506,9 +626,6 @@ func TestGenerateMCPConfig_ToolToggles_DisableFalseIsNoop(t *testing.T) { falseVal := false params := &MCPConfigParams{ Config: &database.MCPServiceConfig{ - LLMProvider: "anthropic", - LLMModel: "claude-sonnet-4-5", - AnthropicAPIKey: strPtr("sk-ant-api03-test"), DisableQueryDatabase: &falseVal, }, DatabaseName: "mydb", @@ -532,11 +649,7 @@ func TestGenerateMCPConfig_ToolToggles_DisableFalseIsNoop(t *testing.T) { func TestGenerateMCPConfig_DatabaseConfig(t *testing.T) { params := &MCPConfigParams{ - Config: &database.MCPServiceConfig{ - LLMProvider: "anthropic", - LLMModel: "claude-sonnet-4-5", - AnthropicAPIKey: strPtr("sk-ant-api03-test"), - }, + Config: &database.MCPServiceConfig{}, DatabaseName: "myspecialdb", DatabaseHosts: []database.ServiceHostEntry{{Host: "pg-primary.internal", Port: 5433}}, Username: "svc_myspecialdb", @@ -594,14 +707,9 @@ func TestGenerateMCPConfig_MultiHostTopology(t *testing.T) { } } - // baseMCPConfig returns a minimal MCPServiceConfig to avoid nil-pointer - // issues in GenerateMCPConfig. + // baseMCPConfig returns a minimal MCPServiceConfig (no LLM). baseMCPConfig := func() *database.MCPServiceConfig { - return &database.MCPServiceConfig{ - LLMProvider: "anthropic", - LLMModel: "claude-sonnet-4-5", - AnthropicAPIKey: strPtr("sk-ant-api03-test"), - } + return &database.MCPServiceConfig{} } // assertHostEntries verifies the YAML hosts array matches the expected