Skip to content

Commit a98e691

Browse files
committed
feat: watch for configuration changes
Watch kube config files for changes. Automatically reload kubernetes client and list of tools. Useful for logins or context changes after an MCP session has started.
1 parent c9def7d commit a98e691

File tree

7 files changed

+97
-6
lines changed

7 files changed

+97
-6
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ https://github.com/user-attachments/assets/be2b67b3-fc1c-4d11-ae46-93deba8ed98e
1313

1414
A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.marcnuri.com/model-context-protocol-mcp-introduction) server implementation with support for OpenShift.
1515

16-
- **✅ Configuration**: View and manage the [Kubernetes `.kube/config`](https://blog.marcnuri.com/where-is-my-default-kubeconfig-file).
17-
- **View** the current configuration.
16+
- **✅ Configuration**:
17+
- Automatically detect changes in the Kubernetes configuration and update the MCP server.
18+
- **View** and manage the current [Kubernetes `.kube/config`](https://blog.marcnuri.com/where-is-my-default-kubeconfig-file) or in-cluster configuration.
1819
- **✅ Generic Kubernetes Resources**: Perform operations on any Kubernetes resource.
1920
- Any CRUD operation (Create or Update, Get, List, Delete).
2021
- **✅ Pods**: Perform Pod-specific operations.

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/manusa/kubernetes-mcp-server
33
go 1.23.5
44

55
require (
6+
github.com/fsnotify/fsnotify v1.8.0
67
github.com/mark3labs/mcp-go v0.15.0
78
github.com/spf13/afero v1.14.0
89
github.com/spf13/cobra v1.9.1
@@ -22,7 +23,6 @@ require (
2223
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
2324
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
2425
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
25-
github.com/fsnotify/fsnotify v1.8.0 // indirect
2626
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
2727
github.com/go-logr/logr v1.4.2 // indirect
2828
github.com/go-openapi/jsonpointer v0.21.0 // indirect

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,19 @@ Kubernetes Model Context Protocol (MCP) server
4242
if err != nil {
4343
panic(err)
4444
}
45+
defer mcpServer.Close()
4546

4647
var sseServer *server.SSEServer
4748
if ssePort := viper.GetInt("sse-port"); ssePort > 0 {
4849
sseServer = mcpServer.ServeSse(viper.GetString("sse-base-url"))
4950
if err := sseServer.Start(fmt.Sprintf(":%d", ssePort)); err != nil {
5051
panic(err)
5152
}
53+
defer sseServer.Shutdown(cmd.Context())
5254
}
5355
if err := mcpServer.ServeStdio(); err != nil && !errors.Is(err, context.Canceled) {
5456
panic(err)
5557
}
56-
if sseServer != nil {
57-
_ = sseServer.Shutdown(cmd.Context())
58-
}
5958
},
6059
}
6160

pkg/kubernetes/kubernetes.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package kubernetes
22

33
import (
4+
"github.com/fsnotify/fsnotify"
45
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
56
"k8s.io/client-go/discovery"
67
"k8s.io/client-go/discovery/cached/memory"
@@ -17,8 +18,12 @@ import (
1718
// Exposed for testing
1819
var InClusterConfig = rest.InClusterConfig
1920

21+
type CloseWatchKubeConfig func() error
22+
2023
type Kubernetes struct {
2124
cfg *rest.Config
25+
kubeConfigFiles []string
26+
CloseWatchKubeConfig CloseWatchKubeConfig
2227
clientSet *kubernetes.Clientset
2328
discoveryClient *discovery.DiscoveryClient
2429
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
@@ -44,13 +49,52 @@ func NewKubernetes() (*Kubernetes, error) {
4449
}
4550
return &Kubernetes{
4651
cfg: cfg,
52+
kubeConfigFiles: resolveConfig().ConfigAccess().GetLoadingPrecedence(),
4753
clientSet: clientSet,
4854
discoveryClient: discoveryClient,
4955
deferredDiscoveryRESTMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)),
5056
dynamicClient: dynamicClient,
5157
}, nil
5258
}
5359

60+
func (k *Kubernetes) WatchKubeConfig(onKubeConfigChange func() error) {
61+
if len(k.kubeConfigFiles) == 0 {
62+
return
63+
}
64+
watcher, err := fsnotify.NewWatcher()
65+
if err != nil {
66+
return
67+
}
68+
for _, file := range k.kubeConfigFiles {
69+
_ = watcher.Add(file)
70+
}
71+
go func() {
72+
for {
73+
select {
74+
case _, ok := <-watcher.Events:
75+
if !ok {
76+
return
77+
}
78+
_ = onKubeConfigChange()
79+
case _, ok := <-watcher.Errors:
80+
if !ok {
81+
return
82+
}
83+
}
84+
}
85+
}()
86+
if k.CloseWatchKubeConfig != nil {
87+
_ = k.CloseWatchKubeConfig()
88+
}
89+
k.CloseWatchKubeConfig = watcher.Close
90+
}
91+
92+
func (k *Kubernetes) Close() {
93+
if k.CloseWatchKubeConfig != nil {
94+
_ = k.CloseWatchKubeConfig()
95+
}
96+
}
97+
5498
func marshal(v any) (string, error) {
5599
switch t := v.(type) {
56100
case []unstructured.Unstructured:

pkg/mcp/common_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ func (c *mcpContext) beforeEach(t *testing.T) {
129129

130130
func (c *mcpContext) afterEach() {
131131
c.cancel()
132+
c.mcpServer.Close()
132133
_ = c.mcpClient.Close()
133134
c.mcpHttpServer.Close()
134135
}

pkg/mcp/mcp.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ func NewSever() (*Server, error) {
2727
if err := s.reloadKubernetesClient(); err != nil {
2828
return nil, err
2929
}
30+
s.k.WatchKubeConfig(s.reloadKubernetesClient)
3031
return s, nil
3132
}
3233

@@ -57,6 +58,12 @@ func (s *Server) ServeSse(baseUrl string) *server.SSEServer {
5758
return server.NewSSEServer(s.server, options...)
5859
}
5960

61+
func (s *Server) Close() {
62+
if s.k != nil {
63+
s.k.Close()
64+
}
65+
}
66+
6067
func NewTextResult(content string, err error) *mcp.CallToolResult {
6168
if err != nil {
6269
return &mcp.CallToolResult{

pkg/mcp/mcp_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,51 @@
11
package mcp
22

33
import (
4+
"context"
45
"github.com/mark3labs/mcp-go/mcp"
6+
"os"
7+
"path/filepath"
58
"slices"
69
"strings"
710
"testing"
11+
"time"
812
)
913

14+
func TestWatchKubeConfig(t *testing.T) {
15+
testCase(t, func(c *mcpContext) {
16+
// Given
17+
withTimeout, cancel := context.WithTimeout(c.ctx, 5*time.Second)
18+
defer cancel()
19+
var notification *mcp.JSONRPCNotification
20+
c.mcpClient.OnNotification(func(n mcp.JSONRPCNotification) {
21+
notification = &n
22+
})
23+
// When
24+
f, _ := os.OpenFile(filepath.Join(c.tempDir, "config"), os.O_APPEND|os.O_WRONLY, 0644)
25+
_, _ = f.WriteString("\n")
26+
for {
27+
if notification != nil {
28+
break
29+
}
30+
select {
31+
case <-withTimeout.Done():
32+
break
33+
default:
34+
time.Sleep(100 * time.Millisecond)
35+
}
36+
}
37+
// Then
38+
t.Run("WatchKubeConfig notifies tools change", func(t *testing.T) {
39+
if notification == nil {
40+
t.Fatalf("WatchKubeConfig did not notify")
41+
}
42+
if notification.Method != "notifications/tools/list_changed" {
43+
t.Fatalf("WatchKubeConfig did not notify tools change, got %s", notification.Method)
44+
}
45+
})
46+
})
47+
}
48+
1049
func TestTools(t *testing.T) {
1150
expectedNames := []string{
1251
"configuration_view",

0 commit comments

Comments
 (0)