feat(output): table output to minimize resource list verbosity

A new configuration options is available: `--list-output`

There are two modes available:
 - `yaml`: current default (will be changed in subsequent PR), which returns a multi-document YAML
 - `table`: returns a plain-text table as created by the kube-api server when requested with
   `Accept: application/json;as=Table;v=v1;g=meta.k8s.io`

Additional logic has been added to the table format to include the apiVersion and kind.
This is not returned by the server, kubectl doesn't include this either.
However, this is extremely handy for the LLM when using the generic resource tools.
This commit is contained in:
Marc Nuri
2025-06-12 13:26:40 +02:00
committed by GitHub
parent 155fe6847f
commit 7e10e82a3a
19 changed files with 442 additions and 157 deletions

View File

@@ -47,14 +47,14 @@ Kubernetes Model Context Protocol (MCP) server
fmt.Printf("Invalid profile name: %s, valid names are: %s\n", viper.GetString("profile"), strings.Join(mcp.ProfileNames, ", "))
os.Exit(1)
}
o := output.FromString(viper.GetString("output"))
if o == nil {
fmt.Printf("Invalid output name: %s, valid names are: %s\n", viper.GetString("output"), strings.Join(output.Names, ", "))
listOutput := output.FromString(viper.GetString("list-output"))
if listOutput == nil {
fmt.Printf("Invalid output name: %s, valid names are: %s\n", viper.GetString("list-output"), strings.Join(output.Names, ", "))
os.Exit(1)
}
klog.V(1).Info("Starting kubernetes-mcp-server")
klog.V(1).Infof(" - Profile: %s", profile.GetName())
klog.V(1).Infof(" - Output: %s", o.GetName())
klog.V(1).Infof(" - ListOutput: %s", listOutput.GetName())
klog.V(1).Infof(" - Read-only mode: %t", viper.GetBool("read-only"))
klog.V(1).Infof(" - Disable destructive tools: %t", viper.GetBool("disable-destructive"))
if viper.GetBool("version") {
@@ -63,7 +63,7 @@ Kubernetes Model Context Protocol (MCP) server
}
mcpServer, err := mcp.NewSever(mcp.Configuration{
Profile: profile,
Output: o,
ListOutput: listOutput,
ReadOnly: viper.GetBool("read-only"),
DisableDestructive: viper.GetBool("disable-destructive"),
Kubeconfig: viper.GetString("kubeconfig"),
@@ -109,26 +109,6 @@ func initLogging() {
klog.SetLoggerWithOptions(logger)
}
type profileFlag struct {
mcp.Profile
}
func (p *profileFlag) String() string {
return p.GetName()
}
func (p *profileFlag) Set(v string) error {
p.Profile = mcp.ProfileFromString(v)
if p.Profile != nil {
return nil
}
return fmt.Errorf("invalid profile name: %s, valid names are: %s", v, mcp.ProfileNames)
}
func (p *profileFlag) Type() string {
return "profile"
}
// flagInit initializes the flags for the root command.
// Exposed for testing purposes.
func flagInit() {
@@ -137,8 +117,8 @@ func flagInit() {
rootCmd.Flags().IntP("sse-port", "", 0, "Start a SSE server on the specified port")
rootCmd.Flags().StringP("sse-base-url", "", "", "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)")
rootCmd.Flags().StringP("kubeconfig", "", "", "Path to the kubeconfig file to use for authentication")
rootCmd.Flags().String("profile", "full", "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+") default is full")
rootCmd.Flags().String("output", "yaml", "Output format for resources (one of: "+strings.Join(output.Names, ", ")+") default is yaml")
rootCmd.Flags().String("profile", "full", "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+")")
rootCmd.Flags().String("list-output", "yaml", "Output format for resource lists (one of: "+strings.Join(output.Names, ", ")+")")
rootCmd.Flags().Bool("read-only", false, "If true, only tools annotated with readOnlyHint=true are exposed")
rootCmd.Flags().Bool("disable-destructive", false, "If true, tools annotated with destructiveHint=true are disabled")
_ = viper.BindPFlags(rootCmd.Flags())

View File

@@ -42,13 +42,13 @@ func TestProfile(t *testing.T) {
})
}
func TestOutput(t *testing.T) {
func TestListOutput(t *testing.T) {
t.Run("available", func(t *testing.T) {
rootCmd.SetArgs([]string{"--help"})
rootCmd.ResetFlags()
flagInit()
out, err := captureOutput(rootCmd.Execute)
if !strings.Contains(out, "Output format for resources (one of: yaml)") {
if !strings.Contains(out, "Output format for resource lists (one of: yaml, table)") {
t.Fatalf("Expected all available outputs, got %s %v", out, err)
}
})
@@ -57,8 +57,8 @@ func TestOutput(t *testing.T) {
rootCmd.ResetFlags()
flagInit()
out, err := captureOutput(rootCmd.Execute)
if !strings.Contains(out, "- Output: yaml") {
t.Fatalf("Expected output 'yaml', got %s %v", out, err)
if !strings.Contains(out, "- ListOutput: yaml") {
t.Fatalf("Expected list-output 'yaml', got %s %v", out, err)
}
})
}

View File

@@ -1,7 +1,7 @@
package kubernetes
import (
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
@@ -77,7 +77,7 @@ func (k *Kubernetes) ToRawKubeConfigLoader() clientcmd.ClientConfig {
return k.clientCmdConfig
}
func (k *Kubernetes) ConfigurationView(minify bool) (string, error) {
func (k *Kubernetes) ConfigurationView(minify bool) (runtime.Object, error) {
var cfg clientcmdapi.Config
var err error
if k.IsInCluster() {
@@ -95,20 +95,16 @@ func (k *Kubernetes) ConfigurationView(minify bool) (string, error) {
}
cfg.CurrentContext = "context"
} else if cfg, err = k.clientCmdConfig.RawConfig(); err != nil {
return "", err
return nil, err
}
if minify {
if err = clientcmdapi.MinifyConfig(&cfg); err != nil {
return "", err
return nil, err
}
}
if err = clientcmdapi.FlattenConfig(&cfg); err != nil {
// ignore error
//return "", err
}
convertedObj, err := latest.Scheme.ConvertToVersion(&cfg, latest.ExternalVersion)
if err != nil {
return "", err
}
return output.MarshalYaml(convertedObj)
return latest.Scheme.ConvertToVersion(&cfg, latest.ExternalVersion)
}

View File

@@ -2,29 +2,29 @@ package kubernetes
import (
"context"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"strings"
)
func (k *Kubernetes) EventsList(ctx context.Context, namespace string) (string, error) {
unstructuredList, err := k.resourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Event",
}, namespace, "")
if err != nil {
return "", err
}
if len(unstructuredList.Items) == 0 {
return "No events found", nil
}
func (k *Kubernetes) EventsList(ctx context.Context, namespace string) ([]map[string]any, error) {
var eventMap []map[string]any
raw, err := k.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Event",
}, namespace, ResourceListOptions{})
if err != nil {
return eventMap, err
}
unstructuredList := raw.(*unstructured.UnstructuredList)
if len(unstructuredList.Items) == 0 {
return eventMap, nil
}
for _, item := range unstructuredList.Items {
event := &v1.Event{}
if err = runtime.DefaultUnstructuredConverter.FromUnstructured(item.Object, event); err != nil {
return "", err
return eventMap, err
}
timestamp := event.EventTime.Time
if timestamp.IsZero() && event.Series != nil {
@@ -47,9 +47,5 @@ func (k *Kubernetes) EventsList(ctx context.Context, namespace string) (string,
"Message": strings.TrimSpace(event.Message),
})
}
yamlEvents, err := output.MarshalYaml(eventMap)
if err != nil {
return "", err
}
return fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), nil
return eventMap, nil
}

View File

@@ -2,18 +2,18 @@ package kubernetes
import (
"context"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func (k *Kubernetes) NamespacesList(ctx context.Context) ([]unstructured.Unstructured, error) {
func (k *Kubernetes) NamespacesList(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) {
return k.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Namespace",
}, "")
}, "", options)
}
func (k *Kubernetes) ProjectsList(ctx context.Context) ([]unstructured.Unstructured, error) {
func (k *Kubernetes) ProjectsList(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) {
return k.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "project.openshift.io", Version: "v1", Kind: "Project",
}, "")
}, "", options)
}

View File

@@ -18,16 +18,16 @@ import (
"k8s.io/client-go/tools/remotecommand"
)
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, labelSelector string) ([]unstructured.Unstructured, error) {
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) {
return k.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Pod",
}, "", labelSelector)
}, "", options)
}
func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string, labelSelector string) ([]unstructured.Unstructured, error) {
func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string, options ResourceListOptions) (runtime.Unstructured, error) {
return k.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Pod",
}, namespace, labelSelector)
}, namespace, options)
}
func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) {
@@ -95,7 +95,7 @@ func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container str
return string(rawData), nil
}
func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string, port int32) (string, error) {
func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string, port int32) ([]*unstructured.Unstructured, error) {
if name == "" {
name = version.BinaryName + "-run-" + rand.String(5)
}
@@ -164,11 +164,11 @@ func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string,
for _, obj := range resources {
m, err := converter.ToUnstructured(obj)
if err != nil {
return "", err
return nil, err
}
u := &unstructured.Unstructured{}
if err = converter.FromUnstructured(m, u); err != nil {
return "", err
return nil, err
}
toCreate = append(toCreate, u)
}

View File

@@ -2,7 +2,8 @@ package kubernetes
import (
"context"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
"regexp"
"strings"
@@ -10,6 +11,7 @@ import (
authv1 "k8s.io/api/authorization/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/yaml"
)
@@ -21,16 +23,25 @@ const (
AppKubernetesPartOf = "app.kubernetes.io/part-of"
)
func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string, labelSelector ...string) ([]unstructured.Unstructured, error) {
var selector string
if len(labelSelector) > 0 {
selector = labelSelector[0]
}
rl, err := k.resourcesList(ctx, gvk, namespace, selector)
type ResourceListOptions struct {
metav1.ListOptions
AsTable bool
}
func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string, options ResourceListOptions) (runtime.Unstructured, error) {
gvr, err := k.resourceFor(gvk)
if err != nil {
return nil, err
}
return rl.Items, nil
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
isNamespaced, _ := k.isNamespaced(gvk)
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
namespace = k.configuredNamespace()
}
if options.AsTable {
return k.resourcesListAsTable(ctx, gvk, gvr, namespace, options)
}
return k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, options.ListOptions)
}
func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (*unstructured.Unstructured, error) {
@@ -45,14 +56,14 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
return k.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
}
func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource string) (string, error) {
func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource string) ([]*unstructured.Unstructured, error) {
separator := regexp.MustCompile(`\r?\n---\r?\n`)
resources := separator.Split(resource, -1)
var parsedResources []*unstructured.Unstructured
for _, r := range resources {
var obj unstructured.Unstructured
if err := yaml.NewYAMLToJSONDecoder(strings.NewReader(r)).Decode(&obj); err != nil {
return "", err
return nil, err
}
parsedResources = append(parsedResources, &obj)
}
@@ -71,27 +82,59 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
return k.dynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
}
func (k *Kubernetes) resourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string, labelSelector string) (*unstructured.UnstructuredList, error) {
gvr, err := k.resourceFor(gvk)
// resourcesListAsTable retrieves a list of resources in a table format.
// It's almost identical to the dynamic.DynamicClient implementation, but it uses a specific Accept header to request the table format.
// dynamic.DynamicClient does not provide a way to set the HTTP header (TODO: create an issue to request this feature)
func (k *Kubernetes) resourcesListAsTable(ctx context.Context, gvk *schema.GroupVersionKind, gvr *schema.GroupVersionResource, namespace string, options ResourceListOptions) (runtime.Unstructured, error) {
var url []string
if len(gvr.Group) == 0 {
url = append(url, "api")
} else {
url = append(url, "apis", gvr.Group)
}
url = append(url, gvr.Version)
if len(namespace) > 0 {
url = append(url, "namespaces", namespace)
}
url = append(url, gvr.Resource)
var table metav1.Table
err := k.clientSet.CoreV1().RESTClient().
Get().
SetHeader("Accept", strings.Join([]string{
fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName),
fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1beta1.SchemeGroupVersion.Version, metav1beta1.GroupName),
"application/json",
}, ",")).
AbsPath(url...).
SpecificallyVersionedParams(&options.ListOptions, k.parameterCodec, schema.GroupVersion{Version: "v1"}).
Do(ctx).Into(&table)
if err != nil {
return nil, err
}
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
isNamespaced, _ := k.isNamespaced(gvk)
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
namespace = k.configuredNamespace()
// Add metav1.Table apiVersion and kind to the unstructured object (server may not return these fields)
table.SetGroupVersionKind(metav1.SchemeGroupVersion.WithKind("Table"))
// Add additional columns for fields that aren't returned by the server
table.ColumnDefinitions = append([]metav1.TableColumnDefinition{
{Name: "apiVersion", Type: "string"},
{Name: "kind", Type: "string"},
}, table.ColumnDefinitions...)
for i := range table.Rows {
row := &table.Rows[i]
row.Cells = append([]interface{}{
gvr.GroupVersion().String(),
gvk.Kind,
}, row.Cells...)
}
return k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{
LabelSelector: labelSelector,
})
unstructuredObject, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&table)
return &unstructured.Unstructured{Object: unstructuredObject}, err
}
func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*unstructured.Unstructured) (string, error) {
func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*unstructured.Unstructured) ([]*unstructured.Unstructured, error) {
for i, obj := range resources {
gvk := obj.GroupVersionKind()
gvr, rErr := k.resourceFor(&gvk)
if rErr != nil {
return "", rErr
return nil, rErr
}
namespace := obj.GetNamespace()
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
@@ -102,18 +145,14 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
FieldManager: version.BinaryName,
})
if rErr != nil {
return "", rErr
return nil, rErr
}
// Clear the cache to ensure the next operation is performed on the latest exposed APIs
// Clear the cache to ensure the next operation is performed on the latest exposed APIs (will change after the CRD creation)
if gvk.Kind == "CustomResourceDefinition" {
k.deferredDiscoveryRESTMapper.Reset()
}
}
marshalledYaml, err := output.MarshalYaml(resources)
if err != nil {
return "", err
}
return "# The following resources (YAML) have been created or updated successfully\n" + marshalledYaml, nil
return resources, nil
}
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {

View File

@@ -97,7 +97,7 @@ func TestMain(m *testing.M) {
type mcpContext struct {
profile Profile
output output.Output
listOutput output.Output
readOnly bool
disableDestructive bool
clientOptions []transport.ClientOption
@@ -119,15 +119,15 @@ func (c *mcpContext) beforeEach(t *testing.T) {
if c.profile == nil {
c.profile = &FullProfile{}
}
if c.output == nil {
c.output = &output.YamlOutput{}
if c.listOutput == nil {
c.listOutput = output.Yaml
}
if c.before != nil {
c.before(c)
}
if c.mcpServer, err = NewSever(Configuration{
Profile: c.profile,
Output: c.output,
ListOutput: c.listOutput,
ReadOnly: c.readOnly,
DisableDestructive: c.disableDestructive,
}); err != nil {

View File

@@ -3,6 +3,7 @@ package mcp
import (
"context"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -32,8 +33,12 @@ func (s *Server) configurationView(_ context.Context, ctr mcp.CallToolRequest) (
minify = minified.(bool)
}
ret, err := s.k.ConfigurationView(minify)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get configuration: %v", err)), nil
}
configurationYaml, err := output.MarshalYaml(ret)
if err != nil {
err = fmt.Errorf("failed to get configuration: %v", err)
}
return NewTextResult(ret, err), nil
return NewTextResult(configurationYaml, err), nil
}

View File

@@ -3,6 +3,7 @@ package mcp
import (
"context"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -27,9 +28,16 @@ func (s *Server) eventsList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.
if namespace == nil {
namespace = ""
}
ret, err := s.k.Derived(ctx).EventsList(ctx, namespace.(string))
eventMap, err := s.k.Derived(ctx).EventsList(ctx, namespace.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil
}
return NewTextResult(ret, err), nil
if len(eventMap) == 0 {
return NewTextResult("No events found", nil), nil
}
yamlEvents, err := output.MarshalYaml(eventMap)
if err != nil {
err = fmt.Errorf("failed to list events in all namespaces: %v", err)
}
return NewTextResult(fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), err), nil
}

View File

@@ -11,8 +11,8 @@ import (
)
type Configuration struct {
Profile Profile
Output output.Output
Profile Profile
ListOutput output.Output
// When true, expose only tools annotated with readOnlyHint=true
ReadOnly bool
// When true, disable tools annotated with destructiveHint=true

View File

@@ -56,7 +56,6 @@ func writeObject(w http.ResponseWriter, obj runtime.Object) {
if err := json.NewEncoder(w).Encode(obj); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
w.WriteHeader(http.StatusOK)
}
type streamAndReply struct {

View File

@@ -3,6 +3,7 @@ package mcp
import (
"context"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
@@ -35,17 +36,17 @@ func (s *Server) initNamespaces() []server.ServerTool {
}
func (s *Server) namespacesList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ret, err := s.k.Derived(ctx).NamespacesList(ctx)
ret, err := s.k.Derived(ctx).NamespacesList(ctx, kubernetes.ResourceListOptions{AsTable: s.configuration.ListOutput.AsTable()})
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil
}
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}
func (s *Server) projectsList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ret, err := s.k.Derived(ctx).ProjectsList(ctx)
ret, err := s.k.Derived(ctx).ProjectsList(ctx, kubernetes.ResourceListOptions{AsTable: s.configuration.ListOutput.AsTable()})
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list projects: %v", err)), nil
}
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}

View File

@@ -1,11 +1,13 @@
package mcp
import (
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"github.com/mark3labs/mcp-go/mcp"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"regexp"
"sigs.k8s.io/yaml"
"slices"
"testing"
@@ -46,6 +48,51 @@ func TestNamespacesList(t *testing.T) {
})
}
func TestNamespacesListAsTable(t *testing.T) {
testCaseWithContext(t, &mcpContext{listOutput: output.Table}, func(c *mcpContext) {
c.withEnvTest()
toolResult, err := c.callTool("namespaces_list", map[string]interface{}{})
t.Run("namespaces_list returns namespace list", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
})
out := toolResult.Content[0].(mcp.TextContent).Text
t.Run("namespaces_list returns column headers", func(t *testing.T) {
expectedHeaders := "APIVERSION\\s+KIND\\s+NAME\\s+STATUS\\s+AGE\\s+LABELS"
if m, e := regexp.MatchString(expectedHeaders, out); !m || e != nil {
t.Errorf("Expected headers '%s' not found in output:\n%s", expectedHeaders, out)
}
})
t.Run("namespaces_list returns formatted row for ns-1", func(t *testing.T) {
expectedRow := "(?<apiVersion>v1)\\s+" +
"(?<kind>Namespace)\\s+" +
"(?<name>ns-1)\\s+" +
"(?<status>Active)\\s+" +
"(?<age>\\d+(s|m))\\s+" +
"(?<labels>kubernetes.io/metadata.name=ns-1)"
if m, e := regexp.MatchString(expectedRow, out); !m || e != nil {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow, out)
}
})
t.Run("namespaces_list returns formatted row for ns-2", func(t *testing.T) {
expectedRow := "(?<apiVersion>v1)\\s+" +
"(?<kind>Namespace)\\s+" +
"(?<name>ns-2)\\s+" +
"(?<status>Active)\\s+" +
"(?<age>\\d+(s|m))\\s+" +
"(?<labels>kubernetes.io/metadata.name=ns-2)"
if m, e := regexp.MatchString(expectedRow, out); !m || e != nil {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow, out)
}
})
})
}
func TestProjectsListInOpenShift(t *testing.T) {
testCaseWithContext(t, &mcpContext{before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)

View File

@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
@@ -105,16 +107,17 @@ func (s *Server) initPods() []server.ServerTool {
func (s *Server) podsListInAllNamespaces(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
labelSelector := ctr.GetArguments()["labelSelector"]
var selector string
if labelSelector != nil {
selector = labelSelector.(string)
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(),
}
ret, err := s.k.Derived(ctx).PodsListInAllNamespaces(ctx, selector)
if labelSelector != nil {
resourceListOptions.ListOptions.LabelSelector = labelSelector.(string)
}
ret, err := s.k.Derived(ctx).PodsListInAllNamespaces(ctx, resourceListOptions)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list pods in all namespaces: %v", err)), nil
}
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}
func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -123,15 +126,17 @@ func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolReques
return NewTextResult("", errors.New("failed to list pods in namespace, missing argument namespace")), nil
}
labelSelector := ctr.GetArguments()["labelSelector"]
var selector string
if labelSelector != nil {
selector = labelSelector.(string)
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(),
}
ret, err := s.k.Derived(ctx).PodsListInNamespace(ctx, ns.(string), selector)
if labelSelector != nil {
resourceListOptions.ListOptions.LabelSelector = labelSelector.(string)
}
ret, err := s.k.Derived(ctx).PodsListInNamespace(ctx, ns.(string), resourceListOptions)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list pods in namespace %s: %v", ns, err)), nil
}
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}
func (s *Server) podsGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -147,7 +152,7 @@ func (s *Server) podsGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.Cal
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pod %s in namespace %s: %v", name, ns, err)), nil
}
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
return NewTextResult(output.MarshalYaml(ret)), nil
}
func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -238,9 +243,13 @@ func (s *Server) podsRun(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.Cal
if port == nil {
port = float64(0)
}
ret, err := s.k.Derived(ctx).PodsRun(ctx, ns.(string), name.(string), image.(string), int32(port.(float64)))
resources, err := s.k.Derived(ctx).PodsRun(ctx, ns.(string), name.(string), image.(string), int32(port.(float64)))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get pod %s log in namespace %s: %v", name, ns, err)), nil
}
return NewTextResult(ret, err), nil
marshalledYaml, err := output.MarshalYaml(resources)
if err != nil {
err = fmt.Errorf("failed to run pod: %v", err)
}
return NewTextResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil
}

View File

@@ -1,6 +1,8 @@
package mcp
import (
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"regexp"
"strings"
"testing"
@@ -141,11 +143,9 @@ func TestPodsListInNamespace(t *testing.T) {
t.Run("pods_list_in_namespace returns pods list", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if toolResult.IsError {
t.Fatalf("call tool failed")
return
}
})
var decoded []unstructured.Unstructured
@@ -153,30 +153,132 @@ func TestPodsListInNamespace(t *testing.T) {
t.Run("pods_list_in_namespace has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
return
}
})
t.Run("pods_list_in_namespace returns 1 items", func(t *testing.T) {
if len(decoded) != 1 {
t.Fatalf("invalid pods count, expected 1, got %v", len(decoded))
return
}
})
t.Run("pods_list_in_namespace 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())
return
t.Errorf("invalid pod name, expected a-pod-in-ns-1, got %v", decoded[0].GetName())
}
if decoded[0].GetNamespace() != "ns-1" {
t.Fatalf("invalid pod namespace, expected ns-1, got %v", decoded[0].GetNamespace())
return
t.Errorf("invalid pod namespace, expected ns-1, got %v", decoded[0].GetNamespace())
}
})
t.Run("pods_list_in_namespace omits managed fields", func(t *testing.T) {
if decoded[0].GetManagedFields() != nil {
t.Fatalf("managed fields should be omitted, got %v", decoded[0].GetManagedFields())
}
})
})
}
func TestPodsListAsTable(t *testing.T) {
testCaseWithContext(t, &mcpContext{listOutput: output.Table}, func(c *mcpContext) {
c.withEnvTest()
podsList, err := c.callTool("pods_list", map[string]interface{}{})
t.Run("pods_list returns pods list", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if podsList.IsError {
t.Fatalf("call tool failed")
}
})
outPodsList := podsList.Content[0].(mcp.TextContent).Text
t.Run("pods_list returns table with 1 header and 3 rows", func(t *testing.T) {
lines := strings.Count(outPodsList, "\n")
if lines != 4 {
t.Fatalf("invalid line count, expected 4 (1 header, 3 row), got %v", lines)
}
})
t.Run("pods_list_in_namespace returns column headers", func(t *testing.T) {
expectedHeaders := "NAMESPACE\\s+APIVERSION\\s+KIND\\s+NAME\\s+READY\\s+STATUS\\s+RESTARTS\\s+AGE\\s+IP\\s+NODE\\s+NOMINATED NODE\\s+READINESS GATES\\s+LABELS"
if m, e := regexp.MatchString(expectedHeaders, outPodsList); !m || e != nil {
t.Errorf("Expected headers '%s' not found in output:\n%s", expectedHeaders, outPodsList)
}
})
t.Run("pods_list_in_namespace returns formatted row for a-pod-in-ns-1", func(t *testing.T) {
expectedRow := "(?<namespace>ns-1)\\s+" +
"(?<apiVersion>v1)\\s+" +
"(?<kind>Pod)\\s+" +
"(?<name>a-pod-in-ns-1)\\s+" +
"(?<ready>0\\/1)\\s+" +
"(?<status>Pending)\\s+" +
"(?<restarts>0)\\s+" +
"(?<age>\\d+(s|m))\\s+" +
"(?<ip><none>)\\s+" +
"(?<node><none>)\\s+" +
"(?<nominated_node><none>)\\s+" +
"(?<readiness_gates><none>)\\s+" +
"(?<labels><none>)"
if m, e := regexp.MatchString(expectedRow, outPodsList); !m || e != nil {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow, outPodsList)
}
})
t.Run("pods_list_in_namespace returns formatted row for a-pod-in-default", func(t *testing.T) {
expectedRow := "(?<namespace>default)\\s+" +
"(?<apiVersion>v1)\\s+" +
"(?<kind>Pod)\\s+" +
"(?<name>a-pod-in-default)\\s+" +
"(?<ready>0\\/1)\\s+" +
"(?<status>Pending)\\s+" +
"(?<restarts>0)\\s+" +
"(?<age>\\d+(s|m))\\s+" +
"(?<ip><none>)\\s+" +
"(?<node><none>)\\s+" +
"(?<nominated_node><none>)\\s+" +
"(?<readiness_gates><none>)\\s+" +
"(?<labels>app=nginx)"
if m, e := regexp.MatchString(expectedRow, outPodsList); !m || e != nil {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow, outPodsList)
}
})
podsListInNamespace, err := c.callTool("pods_list_in_namespace", map[string]interface{}{
"namespace": "ns-1",
})
t.Run("pods_list_in_namespace returns pods list", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
return
}
if podsListInNamespace.IsError {
t.Fatalf("call tool failed")
}
})
outPodsListInNamespace := podsListInNamespace.Content[0].(mcp.TextContent).Text
t.Run("pods_list_in_namespace returns table with 1 header and 1 row", func(t *testing.T) {
lines := strings.Count(outPodsListInNamespace, "\n")
if lines != 2 {
t.Fatalf("invalid line count, expected 2 (1 header, 1 row), got %v", lines)
}
})
t.Run("pods_list_in_namespace returns column headers", func(t *testing.T) {
expectedHeaders := "NAMESPACE\\s+APIVERSION\\s+KIND\\s+NAME\\s+READY\\s+STATUS\\s+RESTARTS\\s+AGE\\s+IP\\s+NODE\\s+NOMINATED NODE\\s+READINESS GATES\\s+LABELS"
if m, e := regexp.MatchString(expectedHeaders, outPodsListInNamespace); !m || e != nil {
t.Errorf("Expected headers '%s' not found in output:\n%s", expectedHeaders, outPodsListInNamespace)
}
})
t.Run("pods_list_in_namespace returns formatted row", func(t *testing.T) {
expectedRow := "(?<namespace>ns-1)\\s+" +
"(?<apiVersion>v1)\\s+" +
"(?<kind>Pod)\\s+" +
"(?<name>a-pod-in-ns-1)\\s+" +
"(?<ready>0\\/1)\\s+" +
"(?<status>Pending)\\s+" +
"(?<restarts>0)\\s+" +
"(?<age>\\d+(s|m))\\s+" +
"(?<ip><none>)\\s+" +
"(?<node><none>)\\s+" +
"(?<nominated_node><none>)\\s+" +
"(?<readiness_gates><none>)\\s+" +
"(?<labels><none>)"
if m, e := regexp.MatchString(expectedRow, outPodsListInNamespace); !m || e != nil {
t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow, outPodsListInNamespace)
}
})
})
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
@@ -104,18 +106,21 @@ func (s *Server) resourcesList(ctx context.Context, ctr mcp.CallToolRequest) (*m
namespace = ""
}
labelSelector := ctr.GetArguments()["labelSelector"]
if labelSelector == nil {
labelSelector = ""
resourceListOptions := kubernetes.ResourceListOptions{
AsTable: s.configuration.ListOutput.AsTable(),
}
if labelSelector != nil {
resourceListOptions.ListOptions.LabelSelector = labelSelector.(string)
}
gvk, err := parseGroupVersionKind(ctr.GetArguments())
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list resources, %s", err)), nil
}
ret, err := s.k.Derived(ctx).ResourcesList(ctx, gvk, namespace.(string), labelSelector.(string))
ret, err := s.k.Derived(ctx).ResourcesList(ctx, gvk, namespace.(string), resourceListOptions)
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list resources: %v", err)), nil
}
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
return NewTextResult(s.configuration.ListOutput.PrintObj(ret)), nil
}
func (s *Server) resourcesGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -135,7 +140,7 @@ func (s *Server) resourcesGet(ctx context.Context, ctr mcp.CallToolRequest) (*mc
if err != nil {
return NewTextResult("", fmt.Errorf("failed to get resource: %v", err)), nil
}
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
return NewTextResult(output.MarshalYaml(ret)), nil
}
func (s *Server) resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -143,11 +148,15 @@ func (s *Server) resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRe
if resource == nil || resource == "" {
return NewTextResult("", errors.New("failed to create or update resources, missing argument resource")), nil
}
ret, err := s.k.Derived(ctx).ResourcesCreateOrUpdate(ctx, resource.(string))
resources, err := s.k.Derived(ctx).ResourcesCreateOrUpdate(ctx, resource.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
}
return NewTextResult(ret, err), nil
marshalledYaml, err := output.MarshalYaml(resources)
if err != nil {
err = fmt.Errorf("failed to create or update resources:: %v", err)
}
return NewTextResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil
}
func (s *Server) resourcesDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {

View File

@@ -1,17 +1,30 @@
package output
import (
"bytes"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/yaml"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/cli-runtime/pkg/printers"
yml "sigs.k8s.io/yaml"
)
var Yaml = &yaml{}
var Table = &table{}
type Output interface {
// GetName returns the name of the output format, will be used by the CLI to identify the output format.
GetName() string
PrintObj(obj any) (string, error)
// AsTable true if the kubernetes request should be made with the `application/json;as=Table;v=0.1` header.
AsTable() bool
// PrintObj prints the given object as a string.
PrintObj(obj runtime.Unstructured) (string, error)
}
var Outputs = []Output{
&YamlOutput{},
Yaml,
Table,
}
var Names []string
@@ -25,31 +38,80 @@ func FromString(name string) Output {
return nil
}
type YamlOutput struct{}
type yaml struct{}
func (p *YamlOutput) GetName() string {
func (p *yaml) GetName() string {
return "yaml"
}
func (p *YamlOutput) PrintObj(obj any) (string, error) {
func (p *yaml) AsTable() bool {
return false
}
func (p *yaml) PrintObj(obj runtime.Unstructured) (string, error) {
return MarshalYaml(obj)
}
type table struct{}
func (p *table) GetName() string {
return "table"
}
func (p *table) AsTable() bool {
return true
}
func (p *table) PrintObj(obj runtime.Unstructured) (string, error) {
var objectToPrint runtime.Object = obj
withNamespace := false
if obj.GetObjectKind().GroupVersionKind() == metav1.SchemeGroupVersion.WithKind("Table") {
t := &metav1.Table{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), t); err == nil {
objectToPrint = t
// Process the Raw object to retrieve the complete metadata (see kubectl/pkg/printers/table_printer.go)
for i := range t.Rows {
row := &t.Rows[i]
if row.Object.Raw == nil || row.Object.Object != nil {
continue
}
row.Object.Object, err = runtime.Decode(unstructured.UnstructuredJSONScheme, row.Object.Raw)
// Print namespace if at least one row has it (object is namespaced)
if err == nil && !withNamespace {
switch rowObject := row.Object.Object.(type) {
case *unstructured.Unstructured:
withNamespace = rowObject.GetNamespace() != ""
}
}
}
}
}
buf := new(bytes.Buffer)
// TablePrinter is mutable and not thread-safe, must create a new instance each time.
printer := printers.NewTablePrinter(printers.PrintOptions{
WithNamespace: withNamespace,
WithKind: true,
Wide: true,
ShowLabels: true,
})
err := printer.PrintObj(objectToPrint, buf)
return buf.String(), err
}
func MarshalYaml(v any) (string, error) {
switch t := v.(type) {
case []unstructured.Unstructured:
for i := range t {
t[i].SetManagedFields(nil)
//case unstructured.UnstructuredList:
// for i := range t.Items {
// t.Items[i].SetManagedFields(nil)
// }
// v = t.Items
case *unstructured.UnstructuredList:
for i := range t.Items {
t.Items[i].SetManagedFields(nil)
}
case []*unstructured.Unstructured:
for i := range t {
t[i].SetManagedFields(nil)
}
case unstructured.Unstructured:
t.SetManagedFields(nil)
v = t.Items
//case unstructured.Unstructured:
// t.SetManagedFields(nil)
case *unstructured.Unstructured:
t.SetManagedFields(nil)
}
ret, err := yaml.Marshal(v)
ret, err := yml.Marshal(v)
if err != nil {
return "", err
}

32
pkg/output/output_test.go Normal file
View File

@@ -0,0 +1,32 @@
package output
import (
"encoding/json"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"regexp"
"testing"
)
func TestPlainTextUnstructuredList(t *testing.T) {
var podList unstructured.UnstructuredList
_ = json.Unmarshal([]byte(`
{ "apiVersion": "v1", "kind": "PodList", "items": [{
"apiVersion": "v1", "kind": "Pod",
"metadata": {
"name": "pod-1", "namespace": "default", "creationTimestamp": "2023-10-01T00:00:00Z", "labels": { "app": "nginx" }
},
"spec": { "containers": [{ "name": "container-1", "image": "marcnuri/chuck-norris" }] } }
]}`), &podList)
out, err := Table.PrintObj(&podList)
t.Run("processes the list", func(t *testing.T) {
if err != nil {
t.Fatalf("Error printing pod list: %v", err)
}
})
t.Run("prints headers", func(t *testing.T) {
expectedHeaders := "NAME\\s+AGE\\s+LABELS"
if m, e := regexp.MatchString(expectedHeaders, out); !m || e != nil {
t.Errorf("Expected headers '%s' not found in output: %s", expectedHeaders, out)
}
})
}