mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
feat(kubernetes): pods_get for explicit or nil namespace
This commit is contained in:
@@ -1,25 +1,23 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||
"k8s.io/client-go/tools/clientcmd/api/latest"
|
||||
)
|
||||
|
||||
func ConfigurationView() (string, error) {
|
||||
// TODO: consider in cluster run mode (current approach only shows kubeconfig)
|
||||
pathOptions := clientcmd.NewDefaultPathOptions()
|
||||
cfg, err := pathOptions.GetStartingConfig()
|
||||
cfg, err := resolveConfig().RawConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = clientcmdapi.MinifyConfig(cfg); err != nil {
|
||||
if err = clientcmdapi.MinifyConfig(&cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = clientcmdapi.FlattenConfig(cfg); err != nil {
|
||||
if err = clientcmdapi.FlattenConfig(&cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
convertedObj, err := latest.Scheme.ConvertToVersion(cfg, latest.ExternalVersion)
|
||||
convertedObj, err := latest.Scheme.ConvertToVersion(&cfg, latest.ExternalVersion)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/restmapper"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
@@ -37,11 +38,28 @@ func marshal(v any) (string, error) {
|
||||
return string(ret), nil
|
||||
}
|
||||
|
||||
func resolveConfig() clientcmd.ClientConfig {
|
||||
pathOptions := clientcmd.NewDefaultPathOptions()
|
||||
//cfg, err := pathOptions.GetStartingConfig()
|
||||
//if err != nil {
|
||||
// return nil, err
|
||||
//}
|
||||
//if err = clientcmdapi.MinifyConfig(cfg); err != nil {
|
||||
// return nil, err
|
||||
//}
|
||||
//if err = clientcmdapi.FlattenConfig(cfg); err != nil {
|
||||
// return nil, err
|
||||
//}
|
||||
//return cfg, nil
|
||||
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||
&clientcmd.ClientConfigLoadingRules{ExplicitPath: pathOptions.GetDefaultFilename()},
|
||||
&clientcmd.ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: ""}})
|
||||
}
|
||||
|
||||
func resolveClientConfig() (*rest.Config, error) {
|
||||
inClusterConfig, err := rest.InClusterConfig()
|
||||
if err == nil && inClusterConfig != nil {
|
||||
return inClusterConfig, nil
|
||||
}
|
||||
pathOptions := clientcmd.NewDefaultPathOptions()
|
||||
return clientcmd.BuildConfigFromFlags("", pathOptions.GetDefaultFilename())
|
||||
return resolveConfig().ClientConfig()
|
||||
}
|
||||
|
||||
@@ -16,3 +16,14 @@ func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string)
|
||||
Group: "", Version: "v1", Kind: "Pod",
|
||||
}, namespace)
|
||||
}
|
||||
|
||||
func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (string, error) {
|
||||
if namespace == "" {
|
||||
if ns, _, nsErr := resolveConfig().Namespace(); nsErr == nil {
|
||||
namespace = ns
|
||||
}
|
||||
}
|
||||
return k.ResourcesGet(ctx, &schema.GroupVersionKind{
|
||||
Group: "", Version: "v1", Kind: "Pod",
|
||||
}, namespace, name)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,22 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
|
||||
return marshal(rl.Items)
|
||||
}
|
||||
|
||||
func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (string, error) {
|
||||
client, err := dynamic.NewForConfig(k.cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gvr, err := k.resourceFor(gvk)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rg, err := client.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return marshal(rg)
|
||||
}
|
||||
|
||||
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {
|
||||
if k.deferredDiscoveryRESTMapper == nil {
|
||||
d, err := discovery.NewDiscoveryClientForConfig(k.cfg)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
"github.com/mark3labs/mcp-go/server"
|
||||
"github.com/spf13/afero"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
@@ -55,6 +57,8 @@ func TestMain(m *testing.M) {
|
||||
BinaryAssetsDirectory: filepath.Join(envTestDir, "k8s", versionDir),
|
||||
}
|
||||
envTestRestConfig, _ = envTest.Start()
|
||||
kc, _ := kubernetes.NewForConfig(envTestRestConfig)
|
||||
createTestData(context.Background(), kc)
|
||||
|
||||
// Test!
|
||||
code := m.Run()
|
||||
@@ -111,6 +115,7 @@ func testCase(t *testing.T, test func(c *mcpContext)) {
|
||||
test(mcpCtx)
|
||||
}
|
||||
|
||||
// withKubeConfig sets up a fake kubeconfig in the temp directory based on the provided rest.Config
|
||||
func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {
|
||||
fakeConfig := api.NewConfig()
|
||||
fakeConfig.CurrentContext = "fake-context"
|
||||
@@ -132,10 +137,12 @@ func (c *mcpContext) withKubeConfig(rc *rest.Config) *api.Config {
|
||||
return fakeConfig
|
||||
}
|
||||
|
||||
// withEnvTest sets up the environment for kubeconfig to be used with envTest
|
||||
func (c *mcpContext) withEnvTest() {
|
||||
c.withKubeConfig(envTestRestConfig)
|
||||
}
|
||||
|
||||
// newKubernetesClient creates a new Kubernetes client with the current kubeconfig
|
||||
func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
|
||||
c.withEnvTest()
|
||||
pathOptions := clientcmd.NewDefaultPathOptions()
|
||||
@@ -147,9 +154,44 @@ func (c *mcpContext) newKubernetesClient() *kubernetes.Clientset {
|
||||
return kubernetesClient
|
||||
}
|
||||
|
||||
// callTool helper function to call a tool by name with arguments
|
||||
func (c *mcpContext) callTool(name string, args map[string]interface{}) (*mcp.CallToolResult, error) {
|
||||
callToolRequest := mcp.CallToolRequest{}
|
||||
callToolRequest.Params.Name = name
|
||||
callToolRequest.Params.Arguments = args
|
||||
return c.mcpClient.CallTool(c.ctx, callToolRequest)
|
||||
}
|
||||
|
||||
func createTestData(ctx context.Context, kc *kubernetes.Clientset) {
|
||||
_, _ = kc.CoreV1().Namespaces().
|
||||
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-1"}}, metav1.CreateOptions{})
|
||||
_, _ = kc.CoreV1().Namespaces().
|
||||
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-2"}}, metav1.CreateOptions{})
|
||||
_, _ = kc.CoreV1().Pods("default").
|
||||
Create(ctx, &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-default"},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{Name: "nginx", Image: "nginx"},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
_, _ = kc.CoreV1().Pods("ns-1").
|
||||
Create(ctx, &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-ns-1"},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{Name: "nginx", Image: "nginx"},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
_, _ = kc.CoreV1().Pods("ns-2").
|
||||
Create(ctx, &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-ns-2"},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{Name: "nginx", Image: "nginx"},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,17 @@ func (s *Sever) initPods() {
|
||||
mcp.Required(),
|
||||
),
|
||||
), podsListInNamespace)
|
||||
s.server.AddTool(mcp.NewTool(
|
||||
"pods_get",
|
||||
mcp.WithDescription("Get a Kubernetes Pod in the current namespace with the provided name"),
|
||||
mcp.WithString("namespace",
|
||||
mcp.Description("Namespace to get the Pod from"),
|
||||
),
|
||||
mcp.WithString("name",
|
||||
mcp.Description("Name of the Pod"),
|
||||
mcp.Required(),
|
||||
),
|
||||
), podsGet)
|
||||
}
|
||||
|
||||
func podsListInAllNamespaces(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
@@ -50,3 +61,23 @@ func podsListInNamespace(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.Cal
|
||||
}
|
||||
return NewTextResult(ret, err), nil
|
||||
}
|
||||
|
||||
func podsGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
k, err := kubernetes.NewKubernetes()
|
||||
if err != nil {
|
||||
return NewTextResult("", fmt.Errorf("failed to get pod: %v", err)), nil
|
||||
}
|
||||
ns := ctr.Params.Arguments["namespace"]
|
||||
if ns == nil {
|
||||
ns = ""
|
||||
}
|
||||
name := ctr.Params.Arguments["name"]
|
||||
if name == nil {
|
||||
return NewTextResult("", errors.New("failed to get pod, missing argument name")), nil
|
||||
}
|
||||
ret, err := k.PodsGet(ctx, ns.(string), name.(string))
|
||||
if err != nil {
|
||||
return NewTextResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil
|
||||
}
|
||||
return NewTextResult(ret, err), nil
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"sigs.k8s.io/yaml"
|
||||
"testing"
|
||||
)
|
||||
@@ -13,7 +9,6 @@ import (
|
||||
func TestPodsListInAllNamespaces(t *testing.T) {
|
||||
testCase(t, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
createTestData(c.ctx, c.newKubernetesClient())
|
||||
toolResult, err := c.callTool("pods_list", map[string]interface{}{})
|
||||
t.Run("pods_list returns pods list", func(t *testing.T) {
|
||||
if err != nil {
|
||||
@@ -29,34 +24,34 @@ func TestPodsListInAllNamespaces(t *testing.T) {
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_list returns 2 items", func(t *testing.T) {
|
||||
if len(decoded) != 2 {
|
||||
t.Fatalf("invalid pods count, expected 2, got %v", len(decoded))
|
||||
t.Run("pods_list returns 3 items", func(t *testing.T) {
|
||||
if len(decoded) != 3 {
|
||||
t.Fatalf("invalid pods count, expected 3, got %v", len(decoded))
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_list returns pod in ns-1", func(t *testing.T) {
|
||||
if decoded[0].GetName() != "a-pod-in-ns-1" {
|
||||
t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decoded[0].GetName())
|
||||
if decoded[1].GetName() != "a-pod-in-ns-1" {
|
||||
t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decoded[1].GetName())
|
||||
return
|
||||
}
|
||||
if decoded[0].GetNamespace() != "ns-1" {
|
||||
t.Fatalf("invalid pod namespace, expected ns-1, got %v", decoded[0].GetNamespace())
|
||||
if decoded[1].GetNamespace() != "ns-1" {
|
||||
t.Fatalf("invalid pod namespace, expected ns-1, got %v", decoded[1].GetNamespace())
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_list returns pod in ns-2", func(t *testing.T) {
|
||||
if decoded[1].GetName() != "a-pod-in-ns-2" {
|
||||
t.Fatalf("invalid pod name, expected a-pod-in-ns-2, got %v", decoded[1].GetName())
|
||||
if decoded[2].GetName() != "a-pod-in-ns-2" {
|
||||
t.Fatalf("invalid pod name, expected a-pod-in-ns-2, got %v", decoded[2].GetName())
|
||||
return
|
||||
}
|
||||
if decoded[1].GetNamespace() != "ns-2" {
|
||||
t.Fatalf("invalid pod namespace, expected ns-2, got %v", decoded[1].GetNamespace())
|
||||
if decoded[2].GetNamespace() != "ns-2" {
|
||||
t.Fatalf("invalid pod namespace, expected ns-2, got %v", decoded[2].GetNamespace())
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_list omits managed fields", func(t *testing.T) {
|
||||
if decoded[0].GetManagedFields() != nil {
|
||||
if decoded[1].GetManagedFields() != nil {
|
||||
t.Fatalf("managed fields should be omitted, got %v", decoded[0].GetManagedFields())
|
||||
return
|
||||
}
|
||||
@@ -67,7 +62,7 @@ func TestPodsListInAllNamespaces(t *testing.T) {
|
||||
func TestPodsListInNamespace(t *testing.T) {
|
||||
testCase(t, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
t.Run("pods_list_in_namespace with nil namespace returns pods list", func(t *testing.T) {
|
||||
t.Run("pods_list_in_namespace with nil namespace returns error", func(t *testing.T) {
|
||||
toolResult, _ := c.callTool("pods_list_in_namespace", map[string]interface{}{})
|
||||
if toolResult.IsError != true {
|
||||
t.Fatalf("call tool should fail")
|
||||
@@ -78,7 +73,6 @@ func TestPodsListInNamespace(t *testing.T) {
|
||||
return
|
||||
}
|
||||
})
|
||||
createTestData(c.ctx, c.newKubernetesClient())
|
||||
toolResult, err := c.callTool("pods_list_in_namespace", map[string]interface{}{
|
||||
"namespace": "ns-1",
|
||||
})
|
||||
@@ -123,30 +117,84 @@ func TestPodsListInNamespace(t *testing.T) {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func createTestData(ctx context.Context, kc *kubernetes.Clientset) {
|
||||
_, _ = kc.CoreV1().Namespaces().
|
||||
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-1"}}, metav1.CreateOptions{})
|
||||
_, _ = kc.CoreV1().Namespaces().
|
||||
Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns-2"}}, metav1.CreateOptions{})
|
||||
_, _ = kc.CoreV1().Pods("ns-1").
|
||||
Create(ctx, &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-ns-1"},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{Name: "nginx", Image: "nginx"},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
_, _ = kc.CoreV1().Pods("ns-2").
|
||||
Create(ctx, &corev1.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: "a-pod-in-ns-2"},
|
||||
Spec: corev1.PodSpec{
|
||||
Containers: []corev1.Container{
|
||||
{Name: "nginx", Image: "nginx"},
|
||||
},
|
||||
},
|
||||
}, metav1.CreateOptions{})
|
||||
func TestPodsGet(t *testing.T) {
|
||||
testCase(t, func(c *mcpContext) {
|
||||
c.withEnvTest()
|
||||
t.Run("pods_get with nil name returns error", func(t *testing.T) {
|
||||
toolResult, _ := c.callTool("pods_get", map[string]interface{}{})
|
||||
if toolResult.IsError != true {
|
||||
t.Fatalf("call tool should fail")
|
||||
return
|
||||
}
|
||||
if toolResult.Content[0].(map[string]interface{})["text"].(string) != "failed to get pod, missing argument name" {
|
||||
t.Fatalf("invalid error message, got %v", toolResult.Content[0].(map[string]interface{})["text"].(string))
|
||||
return
|
||||
}
|
||||
})
|
||||
podsGetNilNamespace, err := c.callTool("pods_get", map[string]interface{}{
|
||||
"name": "a-pod-in-default",
|
||||
})
|
||||
t.Run("pods_get with name and nil namespace returns pod", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("call tool failed %v", err)
|
||||
return
|
||||
}
|
||||
if podsGetNilNamespace.IsError {
|
||||
t.Fatalf("call tool failed")
|
||||
return
|
||||
}
|
||||
})
|
||||
var decodedNilNamespace unstructured.Unstructured
|
||||
err = yaml.Unmarshal([]byte(podsGetNilNamespace.Content[0].(map[string]interface{})["text"].(string)), &decodedNilNamespace)
|
||||
t.Run("pods_get with name and nil namespace has yaml content", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("invalid tool result content %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_get with name and nil namespace returns pod in default", func(t *testing.T) {
|
||||
if decodedNilNamespace.GetName() != "a-pod-in-default" {
|
||||
t.Fatalf("invalid pod name, expected a-pod-in-default, got %v", decodedNilNamespace.GetName())
|
||||
return
|
||||
}
|
||||
if decodedNilNamespace.GetNamespace() != "default" {
|
||||
t.Fatalf("invalid pod namespace, expected default, got %v", decodedNilNamespace.GetNamespace())
|
||||
return
|
||||
}
|
||||
})
|
||||
podsGetInNamespace, err := c.callTool("pods_get", map[string]interface{}{
|
||||
"namespace": "ns-1",
|
||||
"name": "a-pod-in-ns-1",
|
||||
})
|
||||
t.Run("pods_get with name and namespace returns pod", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("call tool failed %v", err)
|
||||
return
|
||||
}
|
||||
if podsGetInNamespace.IsError {
|
||||
t.Fatalf("call tool failed")
|
||||
return
|
||||
}
|
||||
})
|
||||
var decodedInNamespace unstructured.Unstructured
|
||||
err = yaml.Unmarshal([]byte(podsGetInNamespace.Content[0].(map[string]interface{})["text"].(string)), &decodedInNamespace)
|
||||
t.Run("pods_get with name and namespace has yaml content", func(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("invalid tool result content %v", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
t.Run("pods_get with name and namespace returns pod in ns-1", func(t *testing.T) {
|
||||
if decodedInNamespace.GetName() != "a-pod-in-ns-1" {
|
||||
t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decodedInNamespace.GetName())
|
||||
return
|
||||
}
|
||||
if decodedInNamespace.GetNamespace() != "ns-1" {
|
||||
t.Fatalf("invalid pod namespace, ns-1 ns-1, got %v", decodedInNamespace.GetNamespace())
|
||||
return
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user