mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
feat: pods_exec minimal implementation
This commit is contained in:
@@ -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: ¶meterCodec,
|
||||
clientSet: clientSet,
|
||||
discoveryClient: discoveryClient,
|
||||
deferredDiscoveryRESTMapper: restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(discoveryClient)),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user