mirror of
https://github.com/openshift/openshift-mcp-server.git
synced 2025-10-17 14:27:48 +03:00
feat: pods_exec minimal implementation
This commit is contained in:
@@ -16,13 +16,14 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
|
||||
- **✅ Configuration**:
|
||||
- Automatically detect changes in the Kubernetes configuration and update the MCP server.
|
||||
- **View** and manage the current [Kubernetes `.kube/config`](https://blog.marcnuri.com/where-is-my-default-kubeconfig-file) or in-cluster configuration.
|
||||
- **✅ Generic Kubernetes Resources**: Perform operations on any Kubernetes resource.
|
||||
- **✅ Generic Kubernetes Resources**: Perform operations on **any** Kubernetes or OpenShift resource.
|
||||
- Any CRUD operation (Create or Update, Get, List, Delete).
|
||||
- **✅ Pods**: Perform Pod-specific operations.
|
||||
- **List** pods in all namespaces or in a specific namespace.
|
||||
- **Get** a pod by name from the specified namespace.
|
||||
- **Delete** a pod by name from the specified namespace.
|
||||
- **Show logs** for a pod by name from the specified namespace.
|
||||
- **Exec** into a pod and run a command.
|
||||
- **Run** a container image in a pod and optionally expose it.
|
||||
- **✅ Namespaces**: List Kubernetes Namespaces.
|
||||
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
|
||||
|
||||
3
go.mod
3
go.mod
@@ -35,13 +35,16 @@ require (
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/gofuzz v1.2.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/moby/spdystream v0.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.7.0 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -1,3 +1,5 @@
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
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
|
||||
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
@@ -69,6 +73,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mark3labs/mcp-go v0.16.0 h1:hNOr0EqhSUra5jm1Wv6+BOynzIa+bMtfP3zgde70MvY=
|
||||
github.com/mark3labs/mcp-go v0.16.0/go.mod h1:KmJndYv7GIgcPVwEKJjNcbhVQ+hJGJhrCCB/9xITzpE=
|
||||
github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU=
|
||||
github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -76,6 +82,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU=
|
||||
github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk=
|
||||
github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8=
|
||||
|
||||
@@ -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