Skip to content

Commit d5cacb9

Browse files
committed
feat: pods_exec minimal implementation
1 parent 72ede2e commit d5cacb9

File tree

6 files changed

+135
-1
lines changed

6 files changed

+135
-1
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,14 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
1616
- **✅ Configuration**:
1717
- Automatically detect changes in the Kubernetes configuration and update the MCP server.
1818
- **View** and manage the current [Kubernetes `.kube/config`](https://blog.marcnuri.com/where-is-my-default-kubeconfig-file) or in-cluster configuration.
19-
- **✅ Generic Kubernetes Resources**: Perform operations on any Kubernetes resource.
19+
- **✅ Generic Kubernetes Resources**: Perform operations on **any** Kubernetes or OpenShift resource.
2020
- Any CRUD operation (Create or Update, Get, List, Delete).
2121
- **✅ Pods**: Perform Pod-specific operations.
2222
- **List** pods in all namespaces or in a specific namespace.
2323
- **Get** a pod by name from the specified namespace.
2424
- **Delete** a pod by name from the specified namespace.
2525
- **Show logs** for a pod by name from the specified namespace.
26+
- **Exec** into a pod and run a command.
2627
- **Run** a container image in a pod and optionally expose it.
2728
- **✅ Namespaces**: List Kubernetes Namespaces.
2829
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,16 @@ require (
3535
github.com/google/go-cmp v0.6.0 // indirect
3636
github.com/google/gofuzz v1.2.0 // indirect
3737
github.com/google/uuid v1.6.0 // indirect
38+
github.com/gorilla/websocket v1.5.0 // indirect
3839
github.com/inconshreveable/mousetrap v1.1.0 // indirect
3940
github.com/josharian/intern v1.0.0 // indirect
4041
github.com/json-iterator/go v1.1.12 // indirect
4142
github.com/mailru/easyjson v0.7.7 // indirect
43+
github.com/moby/spdystream v0.5.0 // indirect
4244
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
4345
github.com/modern-go/reflect2 v1.0.2 // indirect
4446
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
47+
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
4548
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
4649
github.com/pkg/errors v0.9.1 // indirect
4750
github.com/sagikazarmark/locafero v0.7.0 // indirect

go.sum

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
2+
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
13
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
24
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
35
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -50,6 +52,8 @@ github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/Z
5052
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
5153
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
5254
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
55+
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
56+
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
5357
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
5458
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
5559
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@@ -69,13 +73,17 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
6973
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
7074
github.com/mark3labs/mcp-go v0.16.0 h1:hNOr0EqhSUra5jm1Wv6+BOynzIa+bMtfP3zgde70MvY=
7175
github.com/mark3labs/mcp-go v0.16.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
76+
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
77+
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
7278
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
7379
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
7480
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
7581
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
7682
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
7783
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
7884
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
85+
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
86+
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
7987
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
8088
github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
8189
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=

pkg/kubernetes/kubernetes.go

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

33
import (
44
"github.com/fsnotify/fsnotify"
5+
v1 "k8s.io/api/core/v1"
56
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
7+
"k8s.io/apimachinery/pkg/runtime"
68
"k8s.io/client-go/discovery"
79
"k8s.io/client-go/discovery/cached/memory"
810
"k8s.io/client-go/dynamic"
@@ -24,6 +26,8 @@ type Kubernetes struct {
2426
cfg *rest.Config
2527
kubeConfigFiles []string
2628
CloseWatchKubeConfig CloseWatchKubeConfig
29+
scheme *runtime.Scheme
30+
parameterCodec *runtime.ParameterCodec
2731
clientSet *kubernetes.Clientset
2832
discoveryClient *discovery.DiscoveryClient
2933
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
@@ -47,9 +51,16 @@ func NewKubernetes() (*Kubernetes, error) {
4751
if err != nil {
4852
return nil, err
4953
}
54+
scheme := runtime.NewScheme()
55+
if err = v1.AddToScheme(scheme); err != nil {
56+
return nil, err
57+
}
58+
parameterCodec := runtime.NewParameterCodec(scheme)
5059
return &Kubernetes{
5160
cfg: cfg,
5261
kubeConfigFiles: resolveConfig().ConfigAccess().GetLoadingPrecedence(),
62+
scheme: scheme,
63+
parameterCodec: &parameterCodec,
5364
clientSet: clientSet,
5465
discoveryClient: discoveryClient,
5566
deferredDiscoveryRESTMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)),

pkg/kubernetes/pods.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
package kubernetes
22

33
import (
4+
"bytes"
45
"context"
6+
"fmt"
57
"github.com/manusa/kubernetes-mcp-server/pkg/version"
68
v1 "k8s.io/api/core/v1"
79
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
810
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
911
labelutil "k8s.io/apimachinery/pkg/labels"
1012
"k8s.io/apimachinery/pkg/runtime"
1113
"k8s.io/apimachinery/pkg/runtime/schema"
14+
"k8s.io/apimachinery/pkg/util/httpstream"
1215
"k8s.io/apimachinery/pkg/util/intstr"
1316
"k8s.io/apimachinery/pkg/util/rand"
17+
"k8s.io/client-go/tools/remotecommand"
1418
)
1519

1620
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context) (string, error) {
@@ -168,3 +172,63 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
168172
}
169173
return k.resourcesCreateOrUpdate(ctx, toCreate)
170174
}
175+
176+
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
177+
namespace = namespaceOrDefault(namespace)
178+
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
179+
if err != nil {
180+
return "", err
181+
}
182+
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L350-L352
183+
if pod.Status.Phase == v1.PodSucceeded || pod.Status.Phase == v1.PodFailed {
184+
return "", fmt.Errorf("cannot exec into a container in a completed pod; current phase is %s", pod.Status.Phase)
185+
}
186+
podExecOptions := &v1.PodExecOptions{
187+
Command: command,
188+
Stdout: true,
189+
Stderr: true,
190+
}
191+
executor, err := k.createExecutor(namespace, name, podExecOptions)
192+
if err != nil {
193+
return "", err
194+
}
195+
if container == "" {
196+
container = pod.Spec.Containers[0].Name
197+
}
198+
stdout := bytes.NewBuffer(make([]byte, 0))
199+
stderr := bytes.NewBuffer(make([]byte, 0))
200+
if err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{
201+
Stdout: stdout, Stderr: stderr, Tty: false,
202+
}); err != nil {
203+
return "", err
204+
}
205+
if stdout.Len() > 0 {
206+
return stdout.String(), nil
207+
}
208+
if stderr.Len() > 0 {
209+
return stderr.String(), nil
210+
}
211+
return "", nil
212+
}
213+
214+
func (k *Kubernetes) createExecutor(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) {
215+
// Compute URL
216+
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
217+
req := k.clientSet.CoreV1().RESTClient().Post().
218+
Resource("pods").
219+
Namespace(namespace).
220+
Name(name).
221+
SubResource("exec")
222+
req.VersionedParams(podExecOptions, *k.parameterCodec)
223+
spdyExec, err := remotecommand.NewSPDYExecutor(k.cfg, "POST", req.URL())
224+
if err != nil {
225+
return nil, err
226+
}
227+
webSocketExec, err := remotecommand.NewWebSocketExecutor(k.cfg, "GET", req.URL().String())
228+
if err != nil {
229+
return nil, err
230+
}
231+
return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool {
232+
return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err)
233+
})
234+
}

pkg/mcp/pods.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,24 @@ func (s *Server) initPods() []server.ServerTool {
2727
mcp.WithString("namespace", mcp.Description("Namespace to delete the Pod from")),
2828
mcp.WithString("name", mcp.Description("Name of the Pod to delete"), mcp.Required()),
2929
), s.podsDelete},
30+
{mcp.NewTool("pods_exec",
31+
mcp.WithDescription("Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command"),
32+
mcp.WithString("namespace", mcp.Description("Namespace to get the Pod logs from")),
33+
mcp.WithString("name", mcp.Description("Name of the Pod to get the logs from"), mcp.Required()),
34+
mcp.WithArray("command", mcp.Description("Command to execute in the Pod container. "+
35+
"The first item is the command to be run, and the rest are the arguments to that command. "+
36+
`Example: ["ls", "-l", "/tmp"]`),
37+
// TODO: manual fix to ensure that the items property gets initialized (Gemini)
38+
// https://www.googlecloudcommunity.com/gc/AI-ML/Gemini-API-400-Bad-Request-Array-fields-breaks-function-calling/m-p/769835?nobounce
39+
func(schema map[string]interface{}) {
40+
schema["type"] = "array"
41+
schema["items"] = map[string]interface{}{
42+
"type": "string",
43+
}
44+
},
45+
mcp.Required(),
46+
),
47+
), s.podsExec},
3048
{mcp.NewTool("pods_log",
3149
mcp.WithDescription("Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name"),
3250
mcp.WithString("namespace", mcp.Description("Namespace to get the Pod logs from")),
@@ -94,6 +112,35 @@ func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.
94112
return NewTextResult(ret, err), nil
95113
}
96114

115+
func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
116+
ns := ctr.Params.Arguments["namespace"]
117+
if ns == nil {
118+
ns = ""
119+
}
120+
name := ctr.Params.Arguments["name"]
121+
if name == nil {
122+
return NewTextResult("", errors.New("failed to exec in pod, missing argument name")), nil
123+
}
124+
commandArg := ctr.Params.Arguments["command"]
125+
command := make([]string, 0)
126+
if _, ok := commandArg.([]interface{}); ok {
127+
for _, cmd := range commandArg.([]interface{}) {
128+
if _, ok := cmd.(string); ok {
129+
command = append(command, cmd.(string))
130+
}
131+
}
132+
} else {
133+
return NewTextResult("", errors.New("failed to exec in pod, invalid command argument")), nil
134+
}
135+
ret, err := s.k.PodsExec(ctx, ns.(string), name.(string), "", command)
136+
if err != nil {
137+
return NewTextResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil
138+
} else if ret == "" {
139+
ret = fmt.Sprintf("The executed command in pod %s in namespace %s has not produced any output", name, ns)
140+
}
141+
return NewTextResult(ret, err), nil
142+
}
143+
97144
func (s *Server) podsLog(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
98145
ns := ctr.Params.Arguments["namespace"]
99146
if ns == nil {

0 commit comments

Comments
 (0)