Skip to content
Merged
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
34 changes: 34 additions & 0 deletions api/v1alpha1/claw_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const (
ConditionTypeCredentialsResolved = "CredentialsResolved"
ConditionTypeProxyConfigured = "ProxyConfigured"
ConditionTypeDevicePairingConfigured = "DevicePairingConfigured"
ConditionTypeMcpServersConfigured = "McpServersConfigured"
)

// Annotation keys used on pod templates to trigger rollouts on config changes.
Expand Down Expand Up @@ -214,6 +215,34 @@ type CredentialSpec struct {
AllowedPaths []string `json:"allowedPaths,omitempty"`
}

// McpServerSpec defines an MCP server the operator injects into OpenClaw's config.
// +kubebuilder:validation:XValidation:rule="has(self.command) || has(self.url)",message="either command (stdio) or url (HTTP) must be set"
// +kubebuilder:validation:XValidation:rule="!has(self.command) || !has(self.url)",message="command and url are mutually exclusive"
type McpServerSpec struct {
// Command is the executable for a stdio MCP server.
// +optional
Command string `json:"command,omitempty"`

// Args are command-line arguments for the stdio server.
// +optional
Args []string `json:"args,omitempty"`

// URL is the endpoint for an HTTP MCP server.
// +optional
URL string `json:"url,omitempty"`

// Transport selects the HTTP transport type ("streamable-http" or "sse").
// Only valid when url is set.
// +optional
Transport string `json:"transport,omitempty"`

// Env are plain environment variables passed to the stdio server process
// and written into the MCP server config in operator.json.
// Use for non-secret values and tier-2 placeholder tokens.
// +optional
Env map[string]string `json:"env,omitempty"`
}

// ClawSpec defines the desired state of Claw
type ClawSpec struct {
// ConfigMode controls how operator config is applied on pod start.
Expand All @@ -227,6 +256,11 @@ type ClawSpec struct {
// Credentials configures proxy credential injection per domain.
// +optional
Credentials []CredentialSpec `json:"credentials,omitempty"`

// McpServers declares MCP servers injected into OpenClaw's config.
// Map keys are server names as they appear in the mcp.servers config.
// +optional
McpServers map[string]McpServerSpec `json:"mcpServers,omitempty"`
}

// ClawStatus defines the observed state of Claw
Expand Down
34 changes: 34 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions config/crd/bases/claw.sandbox.redhat.com_claws.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,45 @@ spec:
inferred defaults
rule: self.type != 'oauth2' || has(self.oauth2) || has(self.channel)
type: array
mcpServers:
additionalProperties:
description: McpServerSpec defines an MCP server the operator injects
into OpenClaw's config.
properties:
args:
description: Args are command-line arguments for the stdio server.
items:
type: string
type: array
command:
description: Command is the executable for a stdio MCP server.
type: string
env:
additionalProperties:
type: string
description: |-
Env are plain environment variables passed to the stdio server process
and written into the MCP server config in operator.json.
Use for non-secret values and tier-2 placeholder tokens.
type: object
transport:
description: |-
Transport selects the HTTP transport type ("streamable-http" or "sse").
Only valid when url is set.
type: string
url:
description: URL is the endpoint for an HTTP MCP server.
type: string
type: object
x-kubernetes-validations:
- message: either command (stdio) or url (HTTP) must be set
rule: has(self.command) || has(self.url)
- message: command and url are mutually exclusive
rule: '!has(self.command) || !has(self.url)'
description: |-
McpServers declares MCP servers injected into OpenClaw's config.
Map keys are server names as they appear in the mcp.servers config.
type: object
type: object
status:
description: ClawStatus defines the observed state of Claw
Expand Down
2 changes: 1 addition & 1 deletion docs/proposals/mcp-support-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ Failures also set `Ready=False`.

Each phase corresponds to a single PR. Phases must be merged in order.

### Phase 1: HTTP/SSE and basic stdio MCP support (tiers 1 & 2)
### Phase 1: HTTP/SSE and basic stdio MCP support (tiers 1 & 2) - DONE

Delivers working MCP for HTTP servers (auto-allowlisted) and stdio servers with static/placeholder env vars. No `envFrom` field yet — the CRD only advertises capabilities the reconciler supports.

Expand Down
6 changes: 3 additions & 3 deletions internal/controller/claw_channels_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,7 @@ func TestGenerateProxyConfigWithChannelCompanions(t *testing.T) {
},
}

data, err := generateProxyConfig(toResolved([]clawv1alpha1.CredentialSpec{cred}))
data, err := generateProxyConfig(toResolved([]clawv1alpha1.CredentialSpec{cred}), nil)
require.NoError(t, err)

var cfg proxyConfig
Expand All @@ -484,7 +484,7 @@ func TestGenerateProxyConfigWithChannelCompanions(t *testing.T) {
Type: clawv1alpha1.CredentialTypeNone,
}

data, err := generateProxyConfig(toResolved([]clawv1alpha1.CredentialSpec{cred}))
data, err := generateProxyConfig(toResolved([]clawv1alpha1.CredentialSpec{cred}), nil)
require.NoError(t, err)

var cfg proxyConfig
Expand Down Expand Up @@ -513,7 +513,7 @@ func TestGenerateProxyConfigWithChannelCompanions(t *testing.T) {
},
}

data, err := generateProxyConfig(toResolved([]clawv1alpha1.CredentialSpec{cred}))
data, err := generateProxyConfig(toResolved([]clawv1alpha1.CredentialSpec{cred}), nil)
require.NoError(t, err)

var cfg proxyConfig
Expand Down
96 changes: 96 additions & 0 deletions internal/controller/claw_mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
Copyright 2026 Red Hat.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package controller

import (
"encoding/json"
"fmt"

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"

clawv1alpha1 "github.com/codeready-toolchain/claw-operator/api/v1alpha1"
)

// injectMcpServersIntoConfigMap injects MCP server configuration into operator.json
// for all entries in spec.mcpServers. Stdio servers get command/args/env; HTTP servers
// get url/transport.
func injectMcpServersIntoConfigMap(objects []*unstructured.Unstructured, instance *clawv1alpha1.Claw) error {
if len(instance.Spec.McpServers) == 0 {
return nil
}

servers := make(map[string]any, len(instance.Spec.McpServers))
for name, spec := range instance.Spec.McpServers {
servers[name] = buildMcpServerConfig(spec)
}

configMapName := getConfigMapName(instance.Name)
for _, obj := range objects {
if obj.GetKind() != ConfigMapKind || obj.GetName() != configMapName {
continue
}

operatorJSON, found, err := unstructured.NestedString(obj.Object, "data", "operator.json")
if err != nil {
return fmt.Errorf("failed to extract operator.json from ConfigMap: %w", err)
}
if !found {
return fmt.Errorf("operator.json not found in ConfigMap data")
}

var config map[string]any
if err := json.Unmarshal([]byte(operatorJSON), &config); err != nil {
return fmt.Errorf("failed to parse operator.json: %w", err)
}

config["mcp"] = map[string]any{"servers": servers}

updatedJSON, err := json.MarshalIndent(config, " ", " ")
if err != nil {
return fmt.Errorf("failed to marshal operator.json: %w", err)
}

if err := unstructured.SetNestedField(obj.Object, string(updatedJSON), "data", "operator.json"); err != nil {
return fmt.Errorf("failed to set updated operator.json in ConfigMap: %w", err)
}
return nil
}

return fmt.Errorf("ConfigMap %q not found in manifests", configMapName)
}

// buildMcpServerConfig builds the JSON-ready config for a single MCP server entry.
func buildMcpServerConfig(spec clawv1alpha1.McpServerSpec) map[string]any {
entry := map[string]any{}

if spec.Command != "" {
entry["command"] = spec.Command
if len(spec.Args) > 0 {
entry["args"] = spec.Args
}
if len(spec.Env) > 0 {
entry["env"] = spec.Env
}
} else {
entry["url"] = spec.URL
if spec.Transport != "" {
entry["transport"] = spec.Transport
}
}

return entry
}
Loading
Loading