Skip to content

Commit 155fe68

Browse files
authored
feat(output): configurable output architecture
1 parent d070de8 commit 155fe68

File tree

16 files changed

+162
-75
lines changed

16 files changed

+162
-75
lines changed

pkg/kubernetes-mcp-server/cmd/root.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"flag"
66
"fmt"
77
"github.com/manusa/kubernetes-mcp-server/pkg/mcp"
8+
"github.com/manusa/kubernetes-mcp-server/pkg/output"
89
"github.com/manusa/kubernetes-mcp-server/pkg/version"
910
"github.com/mark3labs/mcp-go/server"
1011
"github.com/spf13/cobra"
@@ -46,8 +47,14 @@ Kubernetes Model Context Protocol (MCP) server
4647
fmt.Printf("Invalid profile name: %s, valid names are: %s\n", viper.GetString("profile"), strings.Join(mcp.ProfileNames, ", "))
4748
os.Exit(1)
4849
}
50+
o := output.FromString(viper.GetString("output"))
51+
if o == nil {
52+
fmt.Printf("Invalid output name: %s, valid names are: %s\n", viper.GetString("output"), strings.Join(output.Names, ", "))
53+
os.Exit(1)
54+
}
4955
klog.V(1).Info("Starting kubernetes-mcp-server")
5056
klog.V(1).Infof(" - Profile: %s", profile.GetName())
57+
klog.V(1).Infof(" - Output: %s", o.GetName())
5158
klog.V(1).Infof(" - Read-only mode: %t", viper.GetBool("read-only"))
5259
klog.V(1).Infof(" - Disable destructive tools: %t", viper.GetBool("disable-destructive"))
5360
if viper.GetBool("version") {
@@ -56,6 +63,7 @@ Kubernetes Model Context Protocol (MCP) server
5663
}
5764
mcpServer, err := mcp.NewSever(mcp.Configuration{
5865
Profile: profile,
66+
Output: o,
5967
ReadOnly: viper.GetBool("read-only"),
6068
DisableDestructive: viper.GetBool("disable-destructive"),
6169
Kubeconfig: viper.GetString("kubeconfig"),
@@ -121,14 +129,21 @@ func (p *profileFlag) Type() string {
121129
return "profile"
122130
}
123131

124-
func init() {
132+
// flagInit initializes the flags for the root command.
133+
// Exposed for testing purposes.
134+
func flagInit() {
125135
rootCmd.Flags().BoolP("version", "v", false, "Print version information and quit")
126136
rootCmd.Flags().IntP("log-level", "", 0, "Set the log level (from 0 to 9)")
127137
rootCmd.Flags().IntP("sse-port", "", 0, "Start a SSE server on the specified port")
128138
rootCmd.Flags().StringP("sse-base-url", "", "", "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)")
129139
rootCmd.Flags().StringP("kubeconfig", "", "", "Path to the kubeconfig file to use for authentication")
130-
rootCmd.Flags().String("profile", "full", "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+")")
140+
rootCmd.Flags().String("profile", "full", "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+") default is full")
141+
rootCmd.Flags().String("output", "yaml", "Output format for resources (one of: "+strings.Join(output.Names, ", ")+") default is yaml")
131142
rootCmd.Flags().Bool("read-only", false, "If true, only tools annotated with readOnlyHint=true are exposed")
132143
rootCmd.Flags().Bool("disable-destructive", false, "If true, tools annotated with destructiveHint=true are disabled")
133144
_ = viper.BindPFlags(rootCmd.Flags())
134145
}
146+
147+
func init() {
148+
flagInit()
149+
}

pkg/kubernetes-mcp-server/cmd/root_test.go

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,51 @@ func captureOutput(f func() error) (string, error) {
2222

2323
func TestVersion(t *testing.T) {
2424
rootCmd.SetArgs([]string{"--version"})
25+
rootCmd.ResetFlags()
26+
flagInit()
2527
version, err := captureOutput(rootCmd.Execute)
2628
if version != "0.0.0\n" {
2729
t.Fatalf("Expected version 0.0.0, got %s %v", version, err)
2830
}
2931
}
3032

31-
func TestDefaultProfile(t *testing.T) {
32-
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
33-
out, err := captureOutput(rootCmd.Execute)
34-
if !strings.Contains(out, "- Profile: full") {
35-
t.Fatalf("Expected profile 'full', got %s %v", out, err)
36-
}
33+
func TestProfile(t *testing.T) {
34+
t.Run("default", func(t *testing.T) {
35+
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
36+
rootCmd.ResetFlags()
37+
flagInit()
38+
out, err := captureOutput(rootCmd.Execute)
39+
if !strings.Contains(out, "- Profile: full") {
40+
t.Fatalf("Expected profile 'full', got %s %v", out, err)
41+
}
42+
})
43+
}
44+
45+
func TestOutput(t *testing.T) {
46+
t.Run("available", func(t *testing.T) {
47+
rootCmd.SetArgs([]string{"--help"})
48+
rootCmd.ResetFlags()
49+
flagInit()
50+
out, err := captureOutput(rootCmd.Execute)
51+
if !strings.Contains(out, "Output format for resources (one of: yaml)") {
52+
t.Fatalf("Expected all available outputs, got %s %v", out, err)
53+
}
54+
})
55+
t.Run("default", func(t *testing.T) {
56+
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
57+
rootCmd.ResetFlags()
58+
flagInit()
59+
out, err := captureOutput(rootCmd.Execute)
60+
if !strings.Contains(out, "- Output: yaml") {
61+
t.Fatalf("Expected output 'yaml', got %s %v", out, err)
62+
}
63+
})
3764
}
3865

3966
func TestDefaultReadOnly(t *testing.T) {
4067
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
68+
rootCmd.ResetFlags()
69+
flagInit()
4170
out, err := captureOutput(rootCmd.Execute)
4271
if !strings.Contains(out, " - Read-only mode: false") {
4372
t.Fatalf("Expected read-only mode false, got %s %v", out, err)
@@ -46,6 +75,8 @@ func TestDefaultReadOnly(t *testing.T) {
4675

4776
func TestDefaultDisableDestructive(t *testing.T) {
4877
rootCmd.SetArgs([]string{"--version", "--log-level=1"})
78+
rootCmd.ResetFlags()
79+
flagInit()
4980
out, err := captureOutput(rootCmd.Execute)
5081
if !strings.Contains(out, " - Disable destructive tools: false") {
5182
t.Fatalf("Expected disable destructive false, got %s %v", out, err)

pkg/kubernetes/configuration.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package kubernetes
22

33
import (
4+
"github.com/manusa/kubernetes-mcp-server/pkg/output"
45
"k8s.io/client-go/rest"
56
"k8s.io/client-go/tools/clientcmd"
67
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
@@ -109,5 +110,5 @@ func (k *Kubernetes) ConfigurationView(minify bool) (string, error) {
109110
if err != nil {
110111
return "", err
111112
}
112-
return marshal(convertedObj)
113+
return output.MarshalYaml(convertedObj)
113114
}

pkg/kubernetes/events.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package kubernetes
33
import (
44
"context"
55
"fmt"
6+
"github.com/manusa/kubernetes-mcp-server/pkg/output"
67
v1 "k8s.io/api/core/v1"
78
"k8s.io/apimachinery/pkg/runtime"
89
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -46,7 +47,7 @@ func (k *Kubernetes) EventsList(ctx context.Context, namespace string) (string,
4647
"Message": strings.TrimSpace(event.Message),
4748
})
4849
}
49-
yamlEvents, err := marshal(eventMap)
50+
yamlEvents, err := output.MarshalYaml(eventMap)
5051
if err != nil {
5152
return "", err
5253
}

pkg/kubernetes/kubernetes.go

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"github.com/manusa/kubernetes-mcp-server/pkg/helm"
77
v1 "k8s.io/api/core/v1"
88
"k8s.io/apimachinery/pkg/api/meta"
9-
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
109
"k8s.io/apimachinery/pkg/runtime"
1110
"k8s.io/client-go/discovery"
1211
"k8s.io/client-go/discovery/cached/memory"
@@ -18,7 +17,6 @@ import (
1817
"k8s.io/client-go/tools/clientcmd"
1918
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
2019
"k8s.io/klog/v2"
21-
"sigs.k8s.io/yaml"
2220
"strings"
2321
)
2422

@@ -164,25 +162,3 @@ func (k *Kubernetes) Derived(ctx context.Context) *Kubernetes {
164162
derived.Helm = helm.NewHelm(derived)
165163
return derived
166164
}
167-
168-
func marshal(v any) (string, error) {
169-
switch t := v.(type) {
170-
case []unstructured.Unstructured:
171-
for i := range t {
172-
t[i].SetManagedFields(nil)
173-
}
174-
case []*unstructured.Unstructured:
175-
for i := range t {
176-
t[i].SetManagedFields(nil)
177-
}
178-
case unstructured.Unstructured:
179-
t.SetManagedFields(nil)
180-
case *unstructured.Unstructured:
181-
t.SetManagedFields(nil)
182-
}
183-
ret, err := yaml.Marshal(v)
184-
if err != nil {
185-
return "", err
186-
}
187-
return string(ret), nil
188-
}

pkg/kubernetes/namespaces.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,17 @@ package kubernetes
22

33
import (
44
"context"
5+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
56
"k8s.io/apimachinery/pkg/runtime/schema"
67
)
78

8-
func (k *Kubernetes) NamespacesList(ctx context.Context) (string, error) {
9+
func (k *Kubernetes) NamespacesList(ctx context.Context) ([]unstructured.Unstructured, error) {
910
return k.ResourcesList(ctx, &schema.GroupVersionKind{
1011
Group: "", Version: "v1", Kind: "Namespace",
1112
}, "")
1213
}
1314

14-
func (k *Kubernetes) ProjectsList(ctx context.Context) (string, error) {
15+
func (k *Kubernetes) ProjectsList(ctx context.Context) ([]unstructured.Unstructured, error) {
1516
return k.ResourcesList(ctx, &schema.GroupVersionKind{
1617
Group: "project.openshift.io", Version: "v1", Kind: "Project",
1718
}, "")

pkg/kubernetes/pods.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,19 @@ import (
1818
"k8s.io/client-go/tools/remotecommand"
1919
)
2020

21-
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, labelSelector string) (string, error) {
21+
func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, labelSelector string) ([]unstructured.Unstructured, error) {
2222
return k.ResourcesList(ctx, &schema.GroupVersionKind{
2323
Group: "", Version: "v1", Kind: "Pod",
2424
}, "", labelSelector)
2525
}
2626

27-
func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string, labelSelector string) (string, error) {
27+
func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string, labelSelector string) ([]unstructured.Unstructured, error) {
2828
return k.ResourcesList(ctx, &schema.GroupVersionKind{
2929
Group: "", Version: "v1", Kind: "Pod",
3030
}, namespace, labelSelector)
3131
}
3232

33-
func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (string, error) {
33+
func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) {
3434
return k.ResourcesGet(ctx, &schema.GroupVersionKind{
3535
Group: "", Version: "v1", Kind: "Pod",
3636
}, k.NamespaceOrDefault(namespace), name)

pkg/kubernetes/resources.go

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package kubernetes
22

33
import (
44
"context"
5+
"github.com/manusa/kubernetes-mcp-server/pkg/output"
56
"regexp"
67
"strings"
78

@@ -20,32 +21,28 @@ const (
2021
AppKubernetesPartOf = "app.kubernetes.io/part-of"
2122
)
2223

23-
func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string, labelSelector ...string) (string, error) {
24+
func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string, labelSelector ...string) ([]unstructured.Unstructured, error) {
2425
var selector string
2526
if len(labelSelector) > 0 {
2627
selector = labelSelector[0]
2728
}
2829
rl, err := k.resourcesList(ctx, gvk, namespace, selector)
2930
if err != nil {
30-
return "", err
31+
return nil, err
3132
}
32-
return marshal(rl.Items)
33+
return rl.Items, nil
3334
}
3435

35-
func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (string, error) {
36+
func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (*unstructured.Unstructured, error) {
3637
gvr, err := k.resourceFor(gvk)
3738
if err != nil {
38-
return "", err
39+
return nil, err
3940
}
4041
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
4142
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
4243
namespace = k.NamespaceOrDefault(namespace)
4344
}
44-
rg, err := k.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
45-
if err != nil {
46-
return "", err
47-
}
48-
return marshal(rg)
45+
return k.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
4946
}
5047

5148
func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource string) (string, error) {
@@ -112,7 +109,7 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
112109
k.deferredDiscoveryRESTMapper.Reset()
113110
}
114111
}
115-
marshalledYaml, err := marshal(resources)
112+
marshalledYaml, err := output.MarshalYaml(resources)
116113
if err != nil {
117114
return "", err
118115
}

pkg/mcp/common_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"github.com/manusa/kubernetes-mcp-server/pkg/output"
78
"github.com/mark3labs/mcp-go/client"
89
"github.com/mark3labs/mcp-go/client/transport"
910
"github.com/mark3labs/mcp-go/mcp"
@@ -96,6 +97,7 @@ func TestMain(m *testing.M) {
9697

9798
type mcpContext struct {
9899
profile Profile
100+
output output.Output
99101
readOnly bool
100102
disableDestructive bool
101103
clientOptions []transport.ClientOption
@@ -117,11 +119,17 @@ func (c *mcpContext) beforeEach(t *testing.T) {
117119
if c.profile == nil {
118120
c.profile = &FullProfile{}
119121
}
122+
if c.output == nil {
123+
c.output = &output.YamlOutput{}
124+
}
120125
if c.before != nil {
121126
c.before(c)
122127
}
123128
if c.mcpServer, err = NewSever(Configuration{
124-
Profile: c.profile, ReadOnly: c.readOnly, DisableDestructive: c.disableDestructive,
129+
Profile: c.profile,
130+
Output: c.output,
131+
ReadOnly: c.readOnly,
132+
DisableDestructive: c.disableDestructive,
125133
}); err != nil {
126134
t.Fatal(err)
127135
return

pkg/mcp/mcp.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package mcp
33
import (
44
"context"
55
"github.com/manusa/kubernetes-mcp-server/pkg/kubernetes"
6+
"github.com/manusa/kubernetes-mcp-server/pkg/output"
67
"github.com/manusa/kubernetes-mcp-server/pkg/version"
78
"github.com/mark3labs/mcp-go/mcp"
89
"github.com/mark3labs/mcp-go/server"
@@ -11,6 +12,7 @@ import (
1112

1213
type Configuration struct {
1314
Profile Profile
15+
Output output.Output
1416
// When true, expose only tools annotated with readOnlyHint=true
1517
ReadOnly bool
1618
// When true, disable tools annotated with destructiveHint=true

0 commit comments

Comments
 (0)