feat(output): configurable output architecture

This commit is contained in:
Marc Nuri
2025-06-11 12:13:23 +02:00
committed by GitHub
parent d070de86eb
commit 155fe6847f
16 changed files with 162 additions and 75 deletions

View File

@@ -5,6 +5,7 @@ import (
"flag"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/mcp"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"github.com/manusa/kubernetes-mcp-server/pkg/version"
"github.com/mark3labs/mcp-go/server"
"github.com/spf13/cobra"
@@ -46,8 +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, ", "))
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(" - Read-only mode: %t", viper.GetBool("read-only"))
klog.V(1).Infof(" - Disable destructive tools: %t", viper.GetBool("disable-destructive"))
if viper.GetBool("version") {
@@ -56,6 +63,7 @@ Kubernetes Model Context Protocol (MCP) server
}
mcpServer, err := mcp.NewSever(mcp.Configuration{
Profile: profile,
Output: o,
ReadOnly: viper.GetBool("read-only"),
DisableDestructive: viper.GetBool("disable-destructive"),
Kubeconfig: viper.GetString("kubeconfig"),
@@ -121,14 +129,21 @@ func (p *profileFlag) Type() string {
return "profile"
}
func init() {
// flagInit initializes the flags for the root command.
// Exposed for testing purposes.
func flagInit() {
rootCmd.Flags().BoolP("version", "v", false, "Print version information and quit")
rootCmd.Flags().IntP("log-level", "", 0, "Set the log level (from 0 to 9)")
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, ", ")+")")
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().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())
}
func init() {
flagInit()
}

View File

@@ -22,22 +22,51 @@ func captureOutput(f func() error) (string, error) {
func TestVersion(t *testing.T) {
rootCmd.SetArgs([]string{"--version"})
rootCmd.ResetFlags()
flagInit()
version, err := captureOutput(rootCmd.Execute)
if version != "0.0.0\n" {
t.Fatalf("Expected version 0.0.0, got %s %v", version, err)
}
}
func TestDefaultProfile(t *testing.T) {
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
out, err := captureOutput(rootCmd.Execute)
if !strings.Contains(out, "- Profile: full") {
t.Fatalf("Expected profile 'full', got %s %v", out, err)
}
func TestProfile(t *testing.T) {
t.Run("default", func(t *testing.T) {
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
rootCmd.ResetFlags()
flagInit()
out, err := captureOutput(rootCmd.Execute)
if !strings.Contains(out, "- Profile: full") {
t.Fatalf("Expected profile 'full', got %s %v", out, err)
}
})
}
func TestOutput(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)") {
t.Fatalf("Expected all available outputs, got %s %v", out, err)
}
})
t.Run("default", func(t *testing.T) {
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
rootCmd.ResetFlags()
flagInit()
out, err := captureOutput(rootCmd.Execute)
if !strings.Contains(out, "- Output: yaml") {
t.Fatalf("Expected output 'yaml', got %s %v", out, err)
}
})
}
func TestDefaultReadOnly(t *testing.T) {
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
rootCmd.ResetFlags()
flagInit()
out, err := captureOutput(rootCmd.Execute)
if !strings.Contains(out, " - Read-only mode: false") {
t.Fatalf("Expected read-only mode false, got %s %v", out, err)
@@ -46,6 +75,8 @@ func TestDefaultReadOnly(t *testing.T) {
func TestDefaultDisableDestructive(t *testing.T) {
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
rootCmd.ResetFlags()
flagInit()
out, err := captureOutput(rootCmd.Execute)
if !strings.Contains(out, " - Disable destructive tools: false") {
t.Fatalf("Expected disable destructive false, got %s %v", out, err)

View File

@@ -1,6 +1,7 @@
package kubernetes
import (
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
@@ -109,5 +110,5 @@ func (k *Kubernetes) ConfigurationView(minify bool) (string, error) {
if err != nil {
return "", err
}
return marshal(convertedObj)
return output.MarshalYaml(convertedObj)
}

View File

@@ -3,6 +3,7 @@ package kubernetes
import (
"context"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -46,7 +47,7 @@ func (k *Kubernetes) EventsList(ctx context.Context, namespace string) (string,
"Message": strings.TrimSpace(event.Message),
})
}
yamlEvents, err := marshal(eventMap)
yamlEvents, err := output.MarshalYaml(eventMap)
if err != nil {
return "", err
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/manusa/kubernetes-mcp-server/pkg/helm"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/discovery"
"k8s.io/client-go/discovery/cached/memory"
@@ -18,7 +17,6 @@ import (
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
"k8s.io/klog/v2"
"sigs.k8s.io/yaml"
"strings"
)
@@ -164,25 +162,3 @@ func (k *Kubernetes) Derived(ctx context.Context) *Kubernetes {
derived.Helm = helm.NewHelm(derived)
return derived
}
func marshal(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.Unstructured:
t.SetManagedFields(nil)
case *unstructured.Unstructured:
t.SetManagedFields(nil)
}
ret, err := yaml.Marshal(v)
if err != nil {
return "", err
}
return string(ret), nil
}

View File

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

View File

@@ -18,19 +18,19 @@ import (
"k8s.io/client-go/tools/remotecommand"
)
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, labelSelector string) (string, error) {
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, labelSelector string) ([]unstructured.Unstructured, error) {
return k.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Pod",
}, "", labelSelector)
}
func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string, labelSelector string) (string, error) {
func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string, labelSelector string) ([]unstructured.Unstructured, error) {
return k.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Pod",
}, namespace, labelSelector)
}
func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (string, error) {
func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) {
return k.ResourcesGet(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Pod",
}, k.NamespaceOrDefault(namespace), name)

View File

@@ -2,6 +2,7 @@ package kubernetes
import (
"context"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"regexp"
"strings"
@@ -20,32 +21,28 @@ const (
AppKubernetesPartOf = "app.kubernetes.io/part-of"
)
func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string, labelSelector ...string) (string, error) {
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)
if err != nil {
return "", err
return nil, err
}
return marshal(rl.Items)
return rl.Items, nil
}
func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (string, error) {
func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (*unstructured.Unstructured, error) {
gvr, err := k.resourceFor(gvk)
if err != nil {
return "", err
return nil, err
}
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
namespace = k.NamespaceOrDefault(namespace)
}
rg, err := k.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return "", err
}
return marshal(rg)
return k.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
}
func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource string) (string, error) {
@@ -112,7 +109,7 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
k.deferredDiscoveryRESTMapper.Reset()
}
}
marshalledYaml, err := marshal(resources)
marshalledYaml, err := output.MarshalYaml(resources)
if err != nil {
return "", err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
@@ -96,6 +97,7 @@ func TestMain(m *testing.M) {
type mcpContext struct {
profile Profile
output output.Output
readOnly bool
disableDestructive bool
clientOptions []transport.ClientOption
@@ -117,11 +119,17 @@ func (c *mcpContext) beforeEach(t *testing.T) {
if c.profile == nil {
c.profile = &FullProfile{}
}
if c.output == nil {
c.output = &output.YamlOutput{}
}
if c.before != nil {
c.before(c)
}
if c.mcpServer, err = NewSever(Configuration{
Profile: c.profile, ReadOnly: c.readOnly, DisableDestructive: c.disableDestructive,
Profile: c.profile,
Output: c.output,
ReadOnly: c.readOnly,
DisableDestructive: c.disableDestructive,
}); err != nil {
t.Fatal(err)
return

View File

@@ -3,6 +3,7 @@ package mcp
import (
"context"
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
"github.com/manusa/kubernetes-mcp-server/pkg/output"
"github.com/manusa/kubernetes-mcp-server/pkg/version"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
@@ -11,6 +12,7 @@ import (
type Configuration struct {
Profile Profile
Output output.Output
// When true, expose only tools annotated with readOnlyHint=true
ReadOnly bool
// When true, disable tools annotated with destructiveHint=true

View File

@@ -37,15 +37,15 @@ 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)
if err != nil {
err = fmt.Errorf("failed to list namespaces: %v", err)
return NewTextResult("", fmt.Errorf("failed to list namespaces: %v", err)), nil
}
return NewTextResult(ret, err), nil
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
}
func (s *Server) projectsList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ret, err := s.k.Derived(ctx).ProjectsList(ctx)
if err != nil {
err = fmt.Errorf("failed to list projects: %v", err)
return NewTextResult("", fmt.Errorf("failed to list projects: %v", err)), nil
}
return NewTextResult(ret, err), nil
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
}

View File

@@ -114,7 +114,7 @@ func (s *Server) podsListInAllNamespaces(ctx context.Context, ctr mcp.CallToolRe
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list pods in all namespaces: %v", err)), nil
}
return NewTextResult(ret, err), nil
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
}
func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -131,7 +131,7 @@ func (s *Server) podsListInNamespace(ctx context.Context, ctr mcp.CallToolReques
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list pods in namespace %s: %v", ns, err)), nil
}
return NewTextResult(ret, err), nil
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
}
func (s *Server) podsGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -147,7 +147,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(ret, err), nil
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
}
func (s *Server) podsDelete(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {

View File

@@ -21,11 +21,9 @@ func TestPodsListInAllNamespaces(t *testing.T) {
t.Run("pods_list 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
@@ -33,39 +31,32 @@ func TestPodsListInAllNamespaces(t *testing.T) {
t.Run("pods_list has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
return
}
})
t.Run("pods_list returns 3 items", func(t *testing.T) {
if len(decoded) != 3 {
t.Fatalf("invalid pods count, expected 3, got %v", len(decoded))
return
}
})
t.Run("pods_list returns pod in ns-1", func(t *testing.T) {
if decoded[1].GetName() != "a-pod-in-ns-1" {
t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decoded[1].GetName())
return
}
if decoded[1].GetNamespace() != "ns-1" {
t.Fatalf("invalid pod namespace, expected ns-1, got %v", decoded[1].GetNamespace())
return
}
})
t.Run("pods_list returns pod in ns-2", func(t *testing.T) {
if decoded[2].GetName() != "a-pod-in-ns-2" {
t.Fatalf("invalid pod name, expected a-pod-in-ns-2, got %v", decoded[2].GetName())
return
}
if decoded[2].GetNamespace() != "ns-2" {
t.Fatalf("invalid pod namespace, expected ns-2, got %v", decoded[2].GetNamespace())
return
}
})
t.Run("pods_list omits managed fields", func(t *testing.T) {
if decoded[1].GetManagedFields() != nil {
t.Fatalf("managed fields should be omitted, got %v", decoded[0].GetManagedFields())
return
}
})
})

View File

@@ -5,18 +5,18 @@ import (
"slices"
)
var Profiles = []Profile{
&FullProfile{},
}
var ProfileNames []string
type Profile interface {
GetName() string
GetDescription() string
GetTools(s *Server) []server.ServerTool
}
var Profiles = []Profile{
&FullProfile{},
}
var ProfileNames []string
func ProfileFromString(name string) Profile {
for _, profile := range Profiles {
if profile.GetName() == name {

View File

@@ -115,7 +115,7 @@ func (s *Server) resourcesList(ctx context.Context, ctr mcp.CallToolRequest) (*m
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list resources: %v", err)), nil
}
return NewTextResult(ret, err), nil
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
}
func (s *Server) resourcesGet(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -135,7 +135,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(ret, err), nil
return NewTextResult(s.configuration.Output.PrintObj(ret)), nil
}
func (s *Server) resourcesCreateOrUpdate(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {

64
pkg/output/output.go Normal file
View File

@@ -0,0 +1,64 @@
package output
import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"sigs.k8s.io/yaml"
)
type Output interface {
GetName() string
PrintObj(obj any) (string, error)
}
var Outputs = []Output{
&YamlOutput{},
}
var Names []string
func FromString(name string) Output {
for _, output := range Outputs {
if output.GetName() == name {
return output
}
}
return nil
}
type YamlOutput struct{}
func (p *YamlOutput) GetName() string {
return "yaml"
}
func (p *YamlOutput) PrintObj(obj any) (string, error) {
return MarshalYaml(obj)
}
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.Unstructured:
t.SetManagedFields(nil)
case *unstructured.Unstructured:
t.SetManagedFields(nil)
}
ret, err := yaml.Marshal(v)
if err != nil {
return "", err
}
return string(ret), nil
}
func init() {
Names = make([]string, 0)
for _, output := range Outputs {
Names = append(Names, output.GetName())
}
}