Skip to content

Commit b1b9c24

Browse files
committed
feat(openshift-ai): add OpenShift AI toolset
Add comprehensive OpenShift AI / Open Data Hub support with 23 MCP tools across 5 resource categories for managing data science workloads. Features: - Data Science Projects (DataSciencePipelinesApplications): list, get, create, delete - Models: list, get, create, update, delete - Applications (Jupyter notebooks, code servers): list, get, create, delete - Experiments: list, get, create, delete - Pipelines: list, get, create, delete, plus list and get pipeline runs Implementation: - New pkg/openshift-ai/ package with client, dynamic client, and resource handlers - New pkg/toolsets/openshift-ai/ with tool definitions for all resource types - Client caching in Kubernetes manager for performance - OpenShift AI detection via CRD discovery - Comprehensive test coverage with snapshot tests The toolset is enabled by default alongside core, config, and helm toolsets. All tools use namespace-scoped access and support standard Kubernetes RBAC. Fixes resource type mapping issues for applications, models, and experiments.
1 parent 5d43b9e commit b1b9c24

30 files changed

+8966
-219
lines changed

pkg/api/datascience_project.go

Lines changed: 1297 additions & 0 deletions
Large diffs are not rendered by default.

pkg/config/config_default.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99
func Default() *StaticConfig {
1010
defaultConfig := StaticConfig{
1111
ListOutput: "table",
12-
Toolsets: []string{"core", "config", "helm"},
12+
Toolsets: []string{"core", "config", "helm", "openshift-ai"},
1313
}
1414
overrides := defaultOverrides()
1515
mergedConfig := mergeConfig(defaultConfig, overrides)

pkg/config/config_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
167167
s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
168168
})
169169
s.Run("toolsets defaulted correctly", func() {
170-
s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
171-
for _, toolset := range []string{"core", "config", "helm"} {
170+
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
171+
for _, toolset := range []string{"core", "config", "helm", "openshift-ai"} {
172172
s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
173173
}
174174
})

pkg/kubernetes-mcp-server/cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/containers/kubernetes-mcp-server/pkg/mcp"
2828
"github.com/containers/kubernetes-mcp-server/pkg/output"
2929
"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
30+
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/openshift-ai"
3031
"github.com/containers/kubernetes-mcp-server/pkg/version"
3132
)
3233

pkg/kubernetes-mcp-server/cmd/root_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,15 @@ func TestToolsets(t *testing.T) {
137137
rootCmd := NewMCPServer(ioStreams)
138138
rootCmd.SetArgs([]string{"--help"})
139139
o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
140-
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") {
140+
if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm, openshift-ai).") {
141141
t.Fatalf("Expected all available toolsets, got %s %v", o, err)
142142
}
143143
})
144144
t.Run("default", func(t *testing.T) {
145145
ioStreams, out := testStream()
146146
rootCmd := NewMCPServer(ioStreams)
147147
rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
148-
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm") {
148+
if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm, openshift-ai") {
149149
t.Fatalf("Expected toolsets 'full', got %s %v", out, err)
150150
}
151151
})

pkg/kubernetes/kubernetes.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/containers/kubernetes-mcp-server/pkg/helm"
77
"k8s.io/client-go/kubernetes/scheme"
8+
"k8s.io/client-go/rest"
89

910
_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
1011
)
@@ -37,3 +38,14 @@ func (k *Kubernetes) NewHelm() *helm.Helm {
3738
// This is a derived Kubernetes, so it already has the Helm initialized
3839
return helm.NewHelm(k.manager)
3940
}
41+
42+
// ToRESTConfig returns the REST configuration from the underlying manager
43+
func (k *Kubernetes) ToRESTConfig() (*rest.Config, error) {
44+
return k.manager.ToRESTConfig()
45+
}
46+
47+
// GetOrCreateOpenShiftAIClient returns a cached OpenShift AI client instance from the underlying manager
48+
// clientFactory should be a function that creates the OpenShift AI client: func(*rest.Config, interface{}) (interface{}, error)
49+
func (k *Kubernetes) GetOrCreateOpenShiftAIClient(clientFactory func(*rest.Config, interface{}) (interface{}, error)) (interface{}, error) {
50+
return k.manager.GetOrCreateOpenShiftAIClient(clientFactory)
51+
}

pkg/kubernetes/manager.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"strings"
8+
"sync"
89

910
"github.com/containers/kubernetes-mcp-server/pkg/config"
1011
"github.com/containers/kubernetes-mcp-server/pkg/helm"
@@ -32,6 +33,11 @@ type Manager struct {
3233

3334
staticConfig *config.StaticConfig
3435
CloseWatchKubeConfig CloseWatchKubeConfig
36+
37+
// OpenShift AI client (lazy-initialized) - using interface{} to avoid import cycle
38+
openshiftAIClient interface{}
39+
openshiftAIClientOnce sync.Once
40+
openshiftAIClientErr error
3541
}
3642

3743
var _ helm.Kubernetes = (*Manager)(nil)
@@ -202,6 +208,21 @@ func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig {
202208
return m.clientCmdConfig
203209
}
204210

211+
// GetRESTConfig returns the REST config for OpenShift AI client creation
212+
func (m *Manager) GetRESTConfig() *rest.Config {
213+
return m.cfg
214+
}
215+
216+
// GetDiscoveryClient returns the discovery client for OpenShift AI operations
217+
func (m *Manager) GetDiscoveryClient() discovery.CachedDiscoveryInterface {
218+
return m.discoveryClient
219+
}
220+
221+
// GetDynamicClient returns the dynamic client for OpenShift AI operations
222+
func (m *Manager) GetDynamicClient() *dynamic.DynamicClient {
223+
return m.dynamicClient
224+
}
225+
205226
func (m *Manager) VerifyToken(ctx context.Context, token, audience string) (*authenticationv1api.UserInfo, []string, error) {
206227
tokenReviewClient, err := m.accessControlClientSet.TokenReview()
207228
if err != nil {
@@ -299,3 +320,21 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) {
299320
}
300321
return derived, nil
301322
}
323+
324+
// GetOrCreateOpenShiftAIClient returns a cached OpenShift AI client instance.
325+
// The client is created lazily on first access and reused for all subsequent calls.
326+
// This avoids the overhead of creating new dynamic and discovery clients on every tool invocation.
327+
// Thread-safe via sync.Once.
328+
// clientFactory should be a function that creates the OpenShift AI client: func(*rest.Config, interface{}) (interface{}, error)
329+
func (m *Manager) GetOrCreateOpenShiftAIClient(clientFactory func(*rest.Config, interface{}) (interface{}, error)) (interface{}, error) {
330+
m.openshiftAIClientOnce.Do(func() {
331+
m.openshiftAIClient, m.openshiftAIClientErr = clientFactory(m.cfg, nil)
332+
if m.openshiftAIClientErr == nil {
333+
klog.V(2).InfoS("OpenShift AI client initialized and cached")
334+
} else {
335+
klog.ErrorS(m.openshiftAIClientErr, "Failed to initialize OpenShift AI client")
336+
}
337+
})
338+
339+
return m.openshiftAIClient, m.openshiftAIClientErr
340+
}

pkg/mcp/generate_snapshots_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"strconv"
8+
"testing"
9+
10+
"github.com/containers/kubernetes-mcp-server/internal/test"
11+
configuration "github.com/containers/kubernetes-mcp-server/pkg/config"
12+
"github.com/mark3labs/mcp-go/mcp"
13+
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
14+
)
15+
16+
func TestGenerateSnapshots(t *testing.T) {
17+
if os.Getenv("GENERATE_SNAPSHOTS") != "true" {
18+
t.Skip("Skipping snapshot generation test. Set GENERATE_SNAPSHOTS=true to run")
19+
}
20+
21+
// Test 1: Default toolsets
22+
t.Run("Generate toolsets-full-tools.json", func(t *testing.T) {
23+
mockServer := test.NewMockServer()
24+
defer mockServer.Close()
25+
cfg := configuration.Default()
26+
cfg.KubeConfig = mockServer.KubeconfigFile(t)
27+
mcpServer, err := NewServer(Configuration{StaticConfig: cfg})
28+
if err != nil {
29+
t.Fatal(err)
30+
}
31+
defer mcpServer.Close()
32+
client := test.NewMcpClient(t, mcpServer.ServeHTTP(nil))
33+
defer client.Close()
34+
tools, err := client.ListTools(context.Background(), mcp.ListToolsRequest{})
35+
if err != nil {
36+
t.Fatal(err)
37+
}
38+
writeJSON(t, "testdata/toolsets-full-tools.json", tools.Tools)
39+
})
40+
41+
// Test 2: OpenShift
42+
t.Run("Generate toolsets-full-tools-openshift.json", func(t *testing.T) {
43+
mockServer := test.NewMockServer()
44+
mockServer.Handle(&test.InOpenShiftHandler{})
45+
defer mockServer.Close()
46+
cfg := configuration.Default()
47+
cfg.KubeConfig = mockServer.KubeconfigFile(t)
48+
mcpServer, err := NewServer(Configuration{StaticConfig: cfg})
49+
if err != nil {
50+
t.Fatal(err)
51+
}
52+
defer mcpServer.Close()
53+
client := test.NewMcpClient(t, mcpServer.ServeHTTP(nil))
54+
defer client.Close()
55+
tools, err := client.ListTools(context.Background(), mcp.ListToolsRequest{})
56+
if err != nil {
57+
t.Fatal(err)
58+
}
59+
writeJSON(t, "testdata/toolsets-full-tools-openshift.json", tools.Tools)
60+
})
61+
62+
// Test 3: Multi-cluster (11 clusters)
63+
t.Run("Generate toolsets-full-tools-multicluster.json", func(t *testing.T) {
64+
mockServer := test.NewMockServer()
65+
defer mockServer.Close()
66+
kubeconfig := mockServer.Kubeconfig()
67+
for i := 0; i < 10; i++ {
68+
kubeconfig.Contexts[strconv.Itoa(i)] = clientcmdapi.NewContext()
69+
}
70+
cfg := configuration.Default()
71+
cfg.KubeConfig = test.KubeconfigFile(t, kubeconfig)
72+
mcpServer, err := NewServer(Configuration{StaticConfig: cfg})
73+
if err != nil {
74+
t.Fatal(err)
75+
}
76+
defer mcpServer.Close()
77+
client := test.NewMcpClient(t, mcpServer.ServeHTTP(nil))
78+
defer client.Close()
79+
tools, err := client.ListTools(context.Background(), mcp.ListToolsRequest{})
80+
if err != nil {
81+
t.Fatal(err)
82+
}
83+
writeJSON(t, "testdata/toolsets-full-tools-multicluster.json", tools.Tools)
84+
})
85+
86+
// Test 4: Multi-cluster enum (2 clusters)
87+
t.Run("Generate toolsets-full-tools-multicluster-enum.json", func(t *testing.T) {
88+
mockServer := test.NewMockServer()
89+
defer mockServer.Close()
90+
kubeconfig := mockServer.Kubeconfig()
91+
kubeconfig.Contexts["extra-cluster"] = clientcmdapi.NewContext()
92+
cfg := configuration.Default()
93+
cfg.KubeConfig = test.KubeconfigFile(t, kubeconfig)
94+
mcpServer, err := NewServer(Configuration{StaticConfig: cfg})
95+
if err != nil {
96+
t.Fatal(err)
97+
}
98+
defer mcpServer.Close()
99+
client := test.NewMcpClient(t, mcpServer.ServeHTTP(nil))
100+
defer client.Close()
101+
tools, err := client.ListTools(context.Background(), mcp.ListToolsRequest{})
102+
if err != nil {
103+
t.Fatal(err)
104+
}
105+
writeJSON(t, "testdata/toolsets-full-tools-multicluster-enum.json", tools.Tools)
106+
})
107+
}
108+
109+
func writeJSON(t *testing.T, filename string, data interface{}) {
110+
jsonData, err := json.MarshalIndent(data, "", " ")
111+
if err != nil {
112+
t.Fatal(err)
113+
}
114+
err = os.WriteFile(filename, jsonData, 0644)
115+
if err != nil {
116+
t.Fatal(err)
117+
}
118+
t.Logf("Written %s", filename)
119+
}

pkg/mcp/modules.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ package mcp
33
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
44
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
55
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
6+
import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/openshift-ai"

0 commit comments

Comments
 (0)