diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index 53d8598f..14d8cebc 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -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()) diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 731732a0..b9ff00ca 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -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) } }) } diff --git a/pkg/kubernetes/configuration.go b/pkg/kubernetes/configuration.go index 4b7f8846..3e634bc1 100644 --- a/pkg/kubernetes/configuration.go +++ b/pkg/kubernetes/configuration.go @@ -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) } diff --git a/pkg/kubernetes/events.go b/pkg/kubernetes/events.go index cbe0eb22..e40720a3 100644 --- a/pkg/kubernetes/events.go +++ b/pkg/kubernetes/events.go @@ -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{ +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, "") + }, namespace, ResourceListOptions{}) if err != nil { - return "", err + return eventMap, err } + unstructuredList := raw.(*unstructured.UnstructuredList) if len(unstructuredList.Items) == 0 { - return "No events found", nil + return eventMap, nil } - var eventMap []map[string]any 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 } diff --git a/pkg/kubernetes/namespaces.go b/pkg/kubernetes/namespaces.go index 931de8e7..8c191c1e 100644 --- a/pkg/kubernetes/namespaces.go +++ b/pkg/kubernetes/namespaces.go @@ -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) } diff --git a/pkg/kubernetes/pods.go b/pkg/kubernetes/pods.go index 791641e0..5dfec1e6 100644 --- a/pkg/kubernetes/pods.go +++ b/pkg/kubernetes/pods.go @@ -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) } diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index 4547916c..4ae63b6b 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -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() - } - return k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{ - LabelSelector: labelSelector, - }) + // 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...) + } + 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) { diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go index f56799a0..fff39f2a 100644 --- a/pkg/mcp/common_test.go +++ b/pkg/mcp/common_test.go @@ -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 { diff --git a/pkg/mcp/configuration.go b/pkg/mcp/configuration.go index 69b115cc..641b5d6d 100644 --- a/pkg/mcp/configuration.go +++ b/pkg/mcp/configuration.go @@ -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 } diff --git a/pkg/mcp/events.go b/pkg/mcp/events.go index 889d4291..fae7bb64 100644 --- a/pkg/mcp/events.go +++ b/pkg/mcp/events.go @@ -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 } diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 1e54bd9c..045a2175 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -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 diff --git a/pkg/mcp/mock_server_test.go b/pkg/mcp/mock_server_test.go index a0402099..124e5ab5 100644 --- a/pkg/mcp/mock_server_test.go +++ b/pkg/mcp/mock_server_test.go @@ -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 { diff --git a/pkg/mcp/namespaces.go b/pkg/mcp/namespaces.go index 3f6c1717..32b41fc1 100644 --- a/pkg/mcp/namespaces.go +++ b/pkg/mcp/namespaces.go @@ -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 } diff --git a/pkg/mcp/namespaces_test.go b/pkg/mcp/namespaces_test.go index f2ce4e36..66a9470d 100644 --- a/pkg/mcp/namespaces_test.go +++ b/pkg/mcp/namespaces_test.go @@ -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 := "(?v1)\\s+" + + "(?Namespace)\\s+" + + "(?ns-1)\\s+" + + "(?Active)\\s+" + + "(?\\d+(s|m))\\s+" + + "(?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 := "(?v1)\\s+" + + "(?Namespace)\\s+" + + "(?ns-2)\\s+" + + "(?Active)\\s+" + + "(?\\d+(s|m))\\s+" + + "(?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) diff --git a/pkg/mcp/pods.go b/pkg/mcp/pods.go index a0bf779a..a7d720f7 100644 --- a/pkg/mcp/pods.go +++ b/pkg/mcp/pods.go @@ -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 + resourceListOptions := kubernetes.ResourceListOptions{ + AsTable: s.configuration.ListOutput.AsTable(), + } if labelSelector != nil { - selector = labelSelector.(string) + resourceListOptions.ListOptions.LabelSelector = labelSelector.(string) } - - ret, err := s.k.Derived(ctx).PodsListInAllNamespaces(ctx, selector) + 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 + resourceListOptions := kubernetes.ResourceListOptions{ + AsTable: s.configuration.ListOutput.AsTable(), + } if labelSelector != nil { - selector = labelSelector.(string) + resourceListOptions.ListOptions.LabelSelector = labelSelector.(string) } - ret, err := s.k.Derived(ctx).PodsListInNamespace(ctx, ns.(string), selector) + 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 } diff --git a/pkg/mcp/pods_test.go b/pkg/mcp/pods_test.go index a19330d7..2bbc17b6 100644 --- a/pkg/mcp/pods_test.go +++ b/pkg/mcp/pods_test.go @@ -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 := "(?ns-1)\\s+" + + "(?v1)\\s+" + + "(?Pod)\\s+" + + "(?a-pod-in-ns-1)\\s+" + + "(?0\\/1)\\s+" + + "(?Pending)\\s+" + + "(?0)\\s+" + + "(?\\d+(s|m))\\s+" + + "(?)\\s+" + + "(?)\\s+" + + "(?)\\s+" + + "(?)\\s+" + + "(?)" + 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 := "(?default)\\s+" + + "(?v1)\\s+" + + "(?Pod)\\s+" + + "(?a-pod-in-default)\\s+" + + "(?0\\/1)\\s+" + + "(?Pending)\\s+" + + "(?0)\\s+" + + "(?\\d+(s|m))\\s+" + + "(?)\\s+" + + "(?)\\s+" + + "(?)\\s+" + + "(?)\\s+" + + "(?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 := "(?ns-1)\\s+" + + "(?v1)\\s+" + + "(?Pod)\\s+" + + "(?a-pod-in-ns-1)\\s+" + + "(?0\\/1)\\s+" + + "(?Pending)\\s+" + + "(?0)\\s+" + + "(?\\d+(s|m))\\s+" + + "(?)\\s+" + + "(?)\\s+" + + "(?)\\s+" + + "(?)\\s+" + + "(?)" + if m, e := regexp.MatchString(expectedRow, outPodsListInNamespace); !m || e != nil { + t.Errorf("Expected row '%s' not found in output:\n%s", expectedRow, outPodsListInNamespace) + } }) }) } diff --git a/pkg/mcp/resources.go b/pkg/mcp/resources.go index 8aeeff50..1f0a51d9 100644 --- a/pkg/mcp/resources.go +++ b/pkg/mcp/resources.go @@ -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) { diff --git a/pkg/output/output.go b/pkg/output/output.go index 92c086c3..fe47b103 100644 --- a/pkg/output/output.go +++ b/pkg/output/output.go @@ -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.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: - 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 } diff --git a/pkg/output/output_test.go b/pkg/output/output_test.go new file mode 100644 index 00000000..ecad73c0 --- /dev/null +++ b/pkg/output/output_test.go @@ -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) + } + }) +}