feat: pods_exec minimal implementation

This commit is contained in:
Marc Nuri
2025-03-28 10:49:21 +01:00
parent 72ede2ea10
commit d5cacb9527
6 changed files with 135 additions and 1 deletions

View File

@@ -2,7 +2,9 @@ package kubernetes
import (
"github.com/fsnotify/fsnotify"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/discovery"
"k8s.io/client-go/discovery/cached/memory"
"k8s.io/client-go/dynamic"
@@ -24,6 +26,8 @@ type Kubernetes struct {
cfg *rest.Config
kubeConfigFiles []string
CloseWatchKubeConfig CloseWatchKubeConfig
scheme *runtime.Scheme
parameterCodec *runtime.ParameterCodec
clientSet *kubernetes.Clientset
discoveryClient *discovery.DiscoveryClient
deferredDiscoveryRESTMapper *restmapper.DeferredDiscoveryRESTMapper
@@ -47,9 +51,16 @@ func NewKubernetes() (*Kubernetes, error) {
if err != nil {
return nil, err
}
scheme := runtime.NewScheme()
if err = v1.AddToScheme(scheme); err != nil {
return nil, err
}
parameterCodec := runtime.NewParameterCodec(scheme)
return &Kubernetes{
cfg: cfg,
kubeConfigFiles: resolveConfig().ConfigAccess().GetLoadingPrecedence(),
scheme: scheme,
parameterCodec: &parameterCodec,
clientSet: clientSet,
discoveryClient: discoveryClient,
deferredDiscoveryRESTMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)),

View File

@@ -1,7 +1,9 @@
package kubernetes
import (
"bytes"
"context"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/version"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -9,8 +11,10 @@ import (
labelutil "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/httpstream"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/apimachinery/pkg/util/rand"
"k8s.io/client-go/tools/remotecommand"
)
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context) (string, error) {
@@ -168,3 +172,63 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
}
return k.resourcesCreateOrUpdate(ctx, toCreate)
}
func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
namespace = namespaceOrDefault(namespace)
pod, err := k.clientSet.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err
}
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L350-L352
if pod.Status.Phase == v1.PodSucceeded || pod.Status.Phase == v1.PodFailed {
return "", fmt.Errorf("cannot exec into a container in a completed pod; current phase is %s", pod.Status.Phase)
}
podExecOptions := &v1.PodExecOptions{
Command: command,
Stdout: true,
Stderr: true,
}
executor, err := k.createExecutor(namespace, name, podExecOptions)
if err != nil {
return "", err
}
if container == "" {
container = pod.Spec.Containers[0].Name
}
stdout := bytes.NewBuffer(make([]byte, 0))
stderr := bytes.NewBuffer(make([]byte, 0))
if err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{
Stdout: stdout, Stderr: stderr, Tty: false,
}); err != nil {
return "", err
}
if stdout.Len() > 0 {
return stdout.String(), nil
}
if stderr.Len() > 0 {
return stderr.String(), nil
}
return "", nil
}
func (k *Kubernetes) createExecutor(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) {
// Compute URL
// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
req := k.clientSet.CoreV1().RESTClient().Post().
Resource("pods").
Namespace(namespace).
Name(name).
SubResource("exec")
req.VersionedParams(podExecOptions, *k.parameterCodec)
spdyExec, err := remotecommand.NewSPDYExecutor(k.cfg, "POST", req.URL())
if err != nil {
return nil, err
}
webSocketExec, err := remotecommand.NewWebSocketExecutor(k.cfg, "GET", req.URL().String())
if err != nil {
return nil, err
}
return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool {
return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err)
})
}

View File

@@ -27,6 +27,24 @@ func (s *Server) initPods() []server.ServerTool {
mcp.WithString("namespace", mcp.Description("Namespace to delete the Pod from")),
mcp.WithString("name", mcp.Description("Name of the Pod to delete"), mcp.Required()),
), s.podsDelete},
{mcp.NewTool("pods_exec",
mcp.WithDescription("Execute a command in a Kubernetes Pod in the current or provided namespace with the provided name and command"),
mcp.WithString("namespace", mcp.Description("Namespace to get the Pod logs from")),
mcp.WithString("name", mcp.Description("Name of the Pod to get the logs from"), mcp.Required()),
mcp.WithArray("command", mcp.Description("Command to execute in the Pod container. "+
"The first item is the command to be run, and the rest are the arguments to that command. "+
`Example: ["ls", "-l", "/tmp"]`),
// TODO: manual fix to ensure that the items property gets initialized (Gemini)
// https://www.googlecloudcommunity.com/gc/AI-ML/Gemini-API-400-Bad-Request-Array-fields-breaks-function-calling/m-p/769835?nobounce
func(schema map[string]interface{}) {
schema["type"] = "array"
schema["items"] = map[string]interface{}{
"type": "string",
}
},
mcp.Required(),
),
), s.podsExec},
{mcp.NewTool("pods_log",
mcp.WithDescription("Get the logs of a Kubernetes Pod in the current or provided namespace with the provided name"),
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.
return NewTextResult(ret, err), nil
}
func (s *Server) podsExec(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.Params.Arguments["namespace"]
if ns == nil {
ns = ""
}
name := ctr.Params.Arguments["name"]
if name == nil {
return NewTextResult("", errors.New("failed to exec in pod, missing argument name")), nil
}
commandArg := ctr.Params.Arguments["command"]
command := make([]string, 0)
if _, ok := commandArg.([]interface{}); ok {
for _, cmd := range commandArg.([]interface{}) {
if _, ok := cmd.(string); ok {
command = append(command, cmd.(string))
}
}
} else {
return NewTextResult("", errors.New("failed to exec in pod, invalid command argument")), nil
}
ret, err := s.k.PodsExec(ctx, ns.(string), name.(string), "", command)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to exec in pod %s in namespace %s: %v", name, ns, err)), nil
} else if ret == "" {
ret = fmt.Sprintf("The executed command in pod %s in namespace %s has not produced any output", name, ns)
}
return NewTextResult(ret, err), nil
}
func (s *Server) podsLog(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ns := ctr.Params.Arguments["namespace"]
if ns == nil {