mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
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:
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
32
pkg/output/output_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user