Skip to content

Commit b894b79

Browse files
committed
MCP: Allow override config
Currently there is no way to modify the MCP server configuration, which means we cannot even configure the logs to be in debug mode. In this commit we add a section in the `dev` field (named `rhosMCPConfig`) to allow configuring anything within the lightspeed-mcp service except for enabling the tools, which is still done by the operator itself. Configuring the logging has not been added to the standard location in the CRD because the feature is still experimental, so we can't set its configuration in the CRD as if it were a normal feature. This is to avoid having to make non-compatible changes to the CRD down the road. Added a small comment to the openstacklightspeed_types.go file establishing this rule. It is the users responsibility to ensure that configuration makes sense and works. The operator will not be responsible if the service cannot start. For example, if we increase the number of workers the container will need more memory than it has currently assigned and the container will be restarted.
1 parent 738e6eb commit b894b79

12 files changed

Lines changed: 248 additions & 8 deletions

api/v1beta1/openstacklightspeed_types.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,15 @@ const (
5757
// May change at any time without backward compatibility.
5858
//
5959
// Supported fields:
60-
// - featureFlags: list of experimental feature flags to enable
60+
// - featureFlags: list of experimental feature flags to enable. Configuration options for experimental features must also live within the `DevSpec`.
6161
// - okpChunkFilterQuery: Solr filter query for OKP searches (default: version-aware query combining detected OpenStack and OCP versions)
6262
// - okpRagOnly: when true, only OKP is used as a RAG source (default: true)
63+
// - rhosMCPConfig: custom YAML configuration for the rhos-mcps service; deep-merged on top of the operator defaults, openstack.enabled and openshift.enabled are always overridden by the operator
6364
type DevSpec struct {
6465
FeatureFlags []string `json:"featureFlags,omitempty"`
6566
OKPChunkFilterQuery string `json:"okpChunkFilterQuery,omitempty"`
6667
OKPRagOnly *bool `json:"okpRagOnly,omitempty"`
68+
RhosMCPConfig string `json:"rhosMCPConfig,omitempty"`
6769
}
6870

6971
// OKPSpec defines configuration for the Offline Knowledge Portal (OKP).

config/samples/api_v1beta1_openstacklightspeed.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,10 @@ spec:
2626
# - rhoso_mcps
2727
# okpChunkFilterQuery: "product:(*openstack* OR *openshift*)"
2828
# okpRagOnly: true
29+
# # Custom MCP server config (deep-merged with operator defaults;
30+
# # openstack.enabled and openshift.enabled are always overridden):
31+
# rhosMCPConfig: |
32+
# debug: true
33+
# workers: 4
2934
# okp:
3035
# accessKey: okp-access-key-secret

internal/controller/mcp_server.go

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,29 @@ type mcpServerConfigParams struct {
7676
OpenShiftEnabled bool
7777
}
7878

79-
// buildMCPServerConfigData renders the MCP server config template with the
80-
// enabled flags for each platform section.
81-
func buildMCPServerConfigData(openStackReady bool) (string, error) {
79+
// deepMerge recursively merges override into base. When both sides have a map
80+
// for the same key the maps are merged; otherwise the override value wins.
81+
func deepMerge(base, override map[string]interface{}) map[string]interface{} {
82+
result := make(map[string]interface{}, len(base))
83+
for k, v := range base {
84+
result[k] = v
85+
}
86+
for k, v := range override {
87+
if baseMap, ok := result[k].(map[string]interface{}); ok {
88+
if overrideMap, ok := v.(map[string]interface{}); ok {
89+
result[k] = deepMerge(baseMap, overrideMap)
90+
continue
91+
}
92+
}
93+
result[k] = v
94+
}
95+
return result
96+
}
97+
98+
// buildMCPServerConfigData renders the MCP server config template and, when
99+
// rhosMCPConfig is provided, deep-merges the user config on top. The
100+
// openstack.enabled and openshift.enabled flags are always enforced.
101+
func buildMCPServerConfigData(openStackReady bool, rhosMCPConfig string) (string, error) {
82102
var buf bytes.Buffer
83103
err := mcpServerConfigTmpl.Execute(&buf, mcpServerConfigParams{
84104
OpenStackEnabled: openStackReady,
@@ -88,15 +108,50 @@ func buildMCPServerConfigData(openStackReady bool) (string, error) {
88108
return "", fmt.Errorf("failed to render MCP server config template: %w", err)
89109
}
90110

91-
return buf.String(), nil
111+
if rhosMCPConfig == "" {
112+
return buf.String(), nil
113+
}
114+
115+
var baseConfig map[string]interface{}
116+
if err := yaml.Unmarshal(buf.Bytes(), &baseConfig); err != nil {
117+
return "", fmt.Errorf("failed to parse base MCP server config: %w", err)
118+
}
119+
120+
var userConfig map[string]interface{}
121+
if err := yaml.Unmarshal([]byte(rhosMCPConfig), &userConfig); err != nil {
122+
return "", fmt.Errorf("failed to parse rhosMCPConfig: %w", err)
123+
}
124+
125+
merged := deepMerge(baseConfig, userConfig)
126+
127+
osSection, ok := merged["openstack"].(map[string]interface{})
128+
if !ok {
129+
osSection = make(map[string]interface{})
130+
merged["openstack"] = osSection
131+
}
132+
osSection["enabled"] = openStackReady
133+
134+
ocpSection, ok := merged["openshift"].(map[string]interface{})
135+
if !ok {
136+
ocpSection = make(map[string]interface{})
137+
merged["openshift"] = ocpSection
138+
}
139+
ocpSection["enabled"] = true
140+
141+
result, err := yaml.Marshal(merged)
142+
if err != nil {
143+
return "", fmt.Errorf("failed to marshal merged MCP server config: %w", err)
144+
}
145+
return string(result), nil
92146
}
93147

94148
// BuildMCPServerConfigMap creates the ConfigMap for the MCP server configuration.
95149
func BuildMCPServerConfigMap(
96150
instance *apiv1beta1.OpenStackLightspeed,
97151
openStackReady bool,
98152
) (corev1.ConfigMap, error) {
99-
configData, err := buildMCPServerConfigData(openStackReady)
153+
devConfig, _ := parseDevConfig(instance)
154+
configData, err := buildMCPServerConfigData(openStackReady, devConfig.RhosMCPConfig)
100155
if err != nil {
101156
return corev1.ConfigMap{}, err
102157
}

internal/controller/mcp_server_test.go

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ import (
2020
"fmt"
2121
"strings"
2222
"testing"
23+
24+
"sigs.k8s.io/yaml"
2325
)
2426

2527
func TestBuildMCPServerConfigData_OpenStackNotReady(t *testing.T) {
26-
result, err := buildMCPServerConfigData(false)
28+
result, err := buildMCPServerConfigData(false, "")
2729
if err != nil {
2830
t.Fatalf("unexpected error: %v", err)
2931
}
@@ -36,7 +38,7 @@ func TestBuildMCPServerConfigData_OpenStackNotReady(t *testing.T) {
3638
}
3739

3840
func TestBuildMCPServerConfigData_OpenStackReady(t *testing.T) {
39-
result, err := buildMCPServerConfigData(true)
41+
result, err := buildMCPServerConfigData(true, "")
4042
if err != nil {
4143
t.Fatalf("unexpected error: %v", err)
4244
}
@@ -48,6 +50,101 @@ func TestBuildMCPServerConfigData_OpenStackReady(t *testing.T) {
4850
}
4951
}
5052

53+
func TestBuildMCPServerConfigData_CustomConfig_OverridesEnabled(t *testing.T) {
54+
customConfig := `
55+
openstack:
56+
enabled: true
57+
allow_write: true
58+
openshift:
59+
enabled: false
60+
`
61+
result, err := buildMCPServerConfigData(false, customConfig)
62+
if err != nil {
63+
t.Fatalf("unexpected error: %v", err)
64+
}
65+
66+
var parsed map[string]interface{}
67+
if err := yaml.Unmarshal([]byte(result), &parsed); err != nil {
68+
t.Fatalf("failed to parse result: %v", err)
69+
}
70+
71+
osSection := parsed["openstack"].(map[string]interface{})
72+
if osSection["enabled"] != false {
73+
t.Errorf("expected openstack.enabled=false (operator override), got %v", osSection["enabled"])
74+
}
75+
if osSection["allow_write"] != true {
76+
t.Errorf("expected openstack.allow_write=true (user value), got %v", osSection["allow_write"])
77+
}
78+
79+
ocpSection := parsed["openshift"].(map[string]interface{})
80+
if ocpSection["enabled"] != true {
81+
t.Errorf("expected openshift.enabled=true (operator override), got %v", ocpSection["enabled"])
82+
}
83+
}
84+
85+
func TestBuildMCPServerConfigData_CustomConfig_DeepMergesWithDefaults(t *testing.T) {
86+
customConfig := `
87+
debug: true
88+
workers: 4
89+
`
90+
result, err := buildMCPServerConfigData(false, customConfig)
91+
if err != nil {
92+
t.Fatalf("unexpected error: %v", err)
93+
}
94+
95+
var parsed map[string]interface{}
96+
if err := yaml.Unmarshal([]byte(result), &parsed); err != nil {
97+
t.Fatalf("failed to parse result: %v", err)
98+
}
99+
100+
if parsed["debug"] != true {
101+
t.Errorf("expected debug=true (user override), got %v", parsed["debug"])
102+
}
103+
if parsed["workers"] != float64(4) {
104+
t.Errorf("expected workers=4 (user override), got %v", parsed["workers"])
105+
}
106+
if parsed["port"] != float64(8080) {
107+
t.Errorf("expected port=8080 (template default preserved), got %v", parsed["port"])
108+
}
109+
110+
osSection := parsed["openstack"].(map[string]interface{})
111+
if osSection["ca_cert"] != "./tls-ca-bundle.pem" {
112+
t.Errorf("expected openstack.ca_cert preserved from template, got %v", osSection["ca_cert"])
113+
}
114+
}
115+
116+
func TestBuildMCPServerConfigData_CustomConfig_AddsNewFields(t *testing.T) {
117+
customConfig := `
118+
custom_section:
119+
key1: value1
120+
key2: 42
121+
`
122+
result, err := buildMCPServerConfigData(true, customConfig)
123+
if err != nil {
124+
t.Fatalf("unexpected error: %v", err)
125+
}
126+
127+
var parsed map[string]interface{}
128+
if err := yaml.Unmarshal([]byte(result), &parsed); err != nil {
129+
t.Fatalf("failed to parse result: %v", err)
130+
}
131+
132+
custom := parsed["custom_section"].(map[string]interface{})
133+
if custom["key1"] != "value1" {
134+
t.Errorf("expected custom_section.key1=value1, got %v", custom["key1"])
135+
}
136+
if custom["key2"] != float64(42) {
137+
t.Errorf("expected custom_section.key2=42, got %v", custom["key2"])
138+
}
139+
}
140+
141+
func TestBuildMCPServerConfigData_CustomConfig_InvalidYAML(t *testing.T) {
142+
_, err := buildMCPServerConfigData(false, "not: valid: yaml: [")
143+
if err == nil {
144+
t.Error("expected error for invalid YAML")
145+
}
146+
}
147+
51148
func TestBuildLCoreMCPServersConfig_WithOpenStack(t *testing.T) {
52149
servers := buildLCoreMCPServersConfig(true)
53150

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
apiVersion: lightspeed.openstack.org/v1beta1
3+
kind: OpenStackLightspeed
4+
metadata:
5+
name: openstack-lightspeed
6+
namespace: openstack-lightspeed
7+
spec:
8+
llmEndpoint: http://mock-llm-api-server-pod:8000/v1
9+
llmEndpointType: openai
10+
llmCredentials: openstack-lightspeed-apitoken
11+
modelName: ibm-granite/granite-3.1-8b-instruct
12+
tlsCACertBundle: openstack-lightspeed-cert
13+
llmProjectID: test-project-id
14+
llmDeploymentName: test-deployment-name
15+
llmAPIVersion: v1
16+
logging:
17+
ogxLogLevel: DEBUG
18+
lightspeedStackLogLevel: WARNING
19+
dataverseExporterLogLevel: DEBUG
20+
dev:
21+
featureFlags:
22+
- rhoso_mcps
23+
rhosMCPConfig: |
24+
debug: true
25+
workers: 4
26+
openstack:
27+
enabled: true
28+
allow_write: true
29+
openshift:
30+
enabled: false
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
##############################################################################
2+
# Assert that rhosMCPConfig is deep-merged with operator defaults #
3+
##############################################################################
4+
apiVersion: kuttl.dev/v1beta1
5+
kind: TestAssert
6+
commands:
7+
- script: |
8+
#!/bin/bash
9+
set -euo pipefail
10+
source ../../common/shared_funcs.sh
11+
12+
mcp_data=$(oc get configmap mcp-config -n "$NAMESPACE" \
13+
-o jsonpath='{.data.config\.yaml}')
14+
15+
assert_yaml_field() {
16+
local field="$1" expected="$2"
17+
local actual
18+
actual=$(echo "$mcp_data" | python3 -c "
19+
import sys, yaml
20+
data = yaml.safe_load(sys.stdin)
21+
keys = '$field'.split('.')
22+
val = data
23+
for k in keys:
24+
val = val[k]
25+
print(val)
26+
")
27+
if [ "$actual" != "$expected" ]; then
28+
echo "ERROR: Expected $field=$expected, got $actual"
29+
echo "$mcp_data"
30+
exit 1
31+
fi
32+
}
33+
34+
# User values should be merged
35+
assert_yaml_field "debug" "True"
36+
assert_yaml_field "workers" "4"
37+
38+
# Template defaults should be preserved
39+
assert_yaml_field "port" "8080"
40+
assert_yaml_field "ip" "127.0.0.1"
41+
assert_yaml_field "openstack.ca_cert" "./tls-ca-bundle.pem"
42+
assert_yaml_field "openstack.insecure" "False"
43+
assert_yaml_field "openshift.insecure" "False"
44+
45+
# User value in nested section should be merged
46+
assert_yaml_field "openstack.allow_write" "True"
47+
48+
# Operator must override enabled fields
49+
assert_yaml_field "openstack.enabled" "False"
50+
assert_yaml_field "openshift.enabled" "True"
51+
timeout: 180

test/kuttl/tests/rhoso-mcps-configuration/04-disable-rhoso-mcps.yaml renamed to test/kuttl/tests/rhoso-mcps-configuration/06-disable-rhoso-mcps.yaml

File renamed without changes.

test/kuttl/tests/rhoso-mcps-configuration/05-errors-rhoso-mcps-cleanup.yaml renamed to test/kuttl/tests/rhoso-mcps-configuration/07-errors-rhoso-mcps-cleanup.yaml

File renamed without changes.

test/kuttl/tests/rhoso-mcps-configuration/06-cleanup-openstack-lightspeed-instance.yaml renamed to test/kuttl/tests/rhoso-mcps-configuration/08-cleanup-openstack-lightspeed-instance.yaml

File renamed without changes.

test/kuttl/tests/rhoso-mcps-configuration/07-errors-openstack-lightspeed-instance.yaml renamed to test/kuttl/tests/rhoso-mcps-configuration/09-errors-openstack-lightspeed-instance.yaml

File renamed without changes.

0 commit comments

Comments
 (0)