diff --git a/.gitignore b/.gitignore index 1d4fb59..8b36a25 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea/ +.vscode/ .docusaurus/ node_modules/ diff --git a/pkg/config/config.go b/pkg/config/config.go index f6471ea..af3f0ae 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -8,6 +8,15 @@ import ( type StaticConfig struct { DeniedResources []GroupVersionKind `toml:"denied_resources"` + + LogLevel int `toml:"log_level,omitempty"` + SSEPort int `toml:"sse_port,omitempty"` + HTTPPort int `toml:"http_port,omitempty"` + SSEBaseURL string `toml:"sse_base_url,omitempty"` + KubeConfig string `toml:"kubeconfig,omitempty"` + ListOutput string `toml:"list_output,omitempty"` + ReadOnly bool `toml:"read_only,omitempty"` + DisableDestructive bool `toml:"disable_destructive,omitempty"` } type GroupVersionKind struct { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index bf862ef..1aac25f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -50,6 +50,13 @@ kind = "Role func TestReadConfigValid(t *testing.T) { validConfigPath := writeConfig(t, ` +log_level = 1 +sse_port = 9999 +kubeconfig = "test" +list_output = "yaml" +read_only = true +disable_destructive = false + [[denied_resources]] group = "apps" version = "v1" @@ -78,6 +85,30 @@ version = "v1" config.DeniedResources[0].Kind != "Deployment" { t.Errorf("Unexpected denied resources: %v", config.DeniedResources[0]) } + if config.LogLevel != 1 { + t.Fatalf("Unexpected log level: %v", config.LogLevel) + } + if config.SSEPort != 9999 { + t.Fatalf("Unexpected sse_port value: %v", config.SSEPort) + } + if config.SSEBaseURL != "" { + t.Fatalf("Unexpected sse_base_url value: %v", config.SSEBaseURL) + } + if config.HTTPPort != 0 { + t.Fatalf("Unexpected http_port value: %v", config.HTTPPort) + } + if config.KubeConfig != "test" { + t.Fatalf("Unexpected kubeconfig value: %v", config.KubeConfig) + } + if config.ListOutput != "yaml" { + t.Fatalf("Unexpected list_output value: %v", config.ListOutput) + } + if !config.ReadOnly { + t.Fatalf("Unexpected read-only mode: %v", config.ReadOnly) + } + if config.DisableDestructive { + t.Fatalf("Unexpected disable destructive: %v", config.DisableDestructive) + } }) } diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index e59b7c2..9d36fe3 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -62,9 +62,10 @@ type MCPServerOptions struct { func NewMCPServerOptions(streams genericiooptions.IOStreams) *MCPServerOptions { return &MCPServerOptions{ - IOStreams: streams, - Profile: "full", - ListOutput: "table", + IOStreams: streams, + Profile: "full", + ListOutput: "table", + StaticConfig: &config.StaticConfig{}, } } @@ -76,7 +77,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { Long: long, Example: examples, RunE: func(c *cobra.Command, args []string) error { - if err := o.Complete(); err != nil { + if err := o.Complete(c); err != nil { return err } if err := o.Validate(); err != nil { @@ -98,16 +99,14 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { cmd.Flags().StringVar(&o.SSEBaseUrl, "sse-base-url", o.SSEBaseUrl, "SSE public base URL to use when sending the endpoint message (e.g. https://example.com)") cmd.Flags().StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, "Path to the kubeconfig file to use for authentication") cmd.Flags().StringVar(&o.Profile, "profile", o.Profile, "MCP profile to use (one of: "+strings.Join(mcp.ProfileNames, ", ")+")") - cmd.Flags().StringVar(&o.ListOutput, "list-output", o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+")") + cmd.Flags().StringVar(&o.ListOutput, "list-output", o.ListOutput, "Output format for resource list operations (one of: "+strings.Join(output.Names, ", ")+"). Defaults to table.") cmd.Flags().BoolVar(&o.ReadOnly, "read-only", o.ReadOnly, "If true, only tools annotated with readOnlyHint=true are exposed") cmd.Flags().BoolVar(&o.DisableDestructive, "disable-destructive", o.DisableDestructive, "If true, tools annotated with destructiveHint=true are disabled") return cmd } -func (m *MCPServerOptions) Complete() error { - m.initializeLogging() - +func (m *MCPServerOptions) Complete(cmd *cobra.Command) error { if m.ConfigPath != "" { cnf, err := config.ReadConfig(m.ConfigPath) if err != nil { @@ -116,16 +115,47 @@ func (m *MCPServerOptions) Complete() error { m.StaticConfig = cnf } + m.loadFlags(cmd) + + m.initializeLogging() + return nil } +func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) { + if cmd.Flag("log-level").Changed { + m.StaticConfig.LogLevel = m.LogLevel + } + if cmd.Flag("sse-port").Changed { + m.StaticConfig.SSEPort = m.SSEPort + } + if cmd.Flag("http-port").Changed { + m.StaticConfig.HTTPPort = m.HttpPort + } + if cmd.Flag("sse-base-url").Changed { + m.StaticConfig.SSEBaseURL = m.SSEBaseUrl + } + if cmd.Flag("kubeconfig").Changed { + m.StaticConfig.KubeConfig = m.Kubeconfig + } + if cmd.Flag("list-output").Changed || m.StaticConfig.ListOutput == "" { + m.StaticConfig.ListOutput = m.ListOutput + } + if cmd.Flag("read-only").Changed { + m.StaticConfig.ReadOnly = m.ReadOnly + } + if cmd.Flag("disable-destructive").Changed { + m.StaticConfig.DisableDestructive = m.DisableDestructive + } +} + func (m *MCPServerOptions) initializeLogging() { flagSet := flag.NewFlagSet("klog", flag.ContinueOnError) klog.InitFlags(flagSet) loggerOptions := []textlogger.ConfigOption{textlogger.Output(m.Out)} - if m.LogLevel >= 0 { - loggerOptions = append(loggerOptions, textlogger.Verbosity(m.LogLevel)) - _ = flagSet.Parse([]string{"--v", strconv.Itoa(m.LogLevel)}) + if m.StaticConfig.LogLevel >= 0 { + loggerOptions = append(loggerOptions, textlogger.Verbosity(m.StaticConfig.LogLevel)) + _ = flagSet.Parse([]string{"--v", strconv.Itoa(m.StaticConfig.LogLevel)}) } logger := textlogger.NewLogger(textlogger.NewConfig(loggerOptions...)) klog.SetLoggerWithOptions(logger) @@ -140,16 +170,16 @@ func (m *MCPServerOptions) Run() error { if profile == nil { return fmt.Errorf("Invalid profile name: %s, valid names are: %s\n", m.Profile, strings.Join(mcp.ProfileNames, ", ")) } - listOutput := output.FromString(m.ListOutput) + listOutput := output.FromString(m.StaticConfig.ListOutput) if listOutput == nil { - return fmt.Errorf("Invalid output name: %s, valid names are: %s\n", m.ListOutput, strings.Join(output.Names, ", ")) + return fmt.Errorf("Invalid output name: %s, valid names are: %s\n", m.StaticConfig.ListOutput, strings.Join(output.Names, ", ")) } klog.V(1).Info("Starting kubernetes-mcp-server") klog.V(1).Infof(" - Config: %s", m.ConfigPath) klog.V(1).Infof(" - Profile: %s", profile.GetName()) klog.V(1).Infof(" - ListOutput: %s", listOutput.GetName()) - klog.V(1).Infof(" - Read-only mode: %t", m.ReadOnly) - klog.V(1).Infof(" - Disable destructive tools: %t", m.DisableDestructive) + klog.V(1).Infof(" - Read-only mode: %t", m.StaticConfig.ReadOnly) + klog.V(1).Infof(" - Disable destructive tools: %t", m.StaticConfig.DisableDestructive) if m.Version { _, _ = fmt.Fprintf(m.Out, "%s\n", version.Version) @@ -158,9 +188,9 @@ func (m *MCPServerOptions) Run() error { mcpServer, err := mcp.NewServer(mcp.Configuration{ Profile: profile, ListOutput: listOutput, - ReadOnly: m.ReadOnly, - DisableDestructive: m.DisableDestructive, - Kubeconfig: m.Kubeconfig, + ReadOnly: m.StaticConfig.ReadOnly, + DisableDestructive: m.StaticConfig.DisableDestructive, + Kubeconfig: m.StaticConfig.KubeConfig, StaticConfig: m.StaticConfig, }) if err != nil { @@ -170,19 +200,19 @@ func (m *MCPServerOptions) Run() error { ctx := context.Background() - if m.SSEPort > 0 { - sseServer := mcpServer.ServeSse(m.SSEBaseUrl) + if m.StaticConfig.SSEPort > 0 { + sseServer := mcpServer.ServeSse(m.StaticConfig.SSEBaseURL) defer func() { _ = sseServer.Shutdown(ctx) }() - klog.V(0).Infof("SSE server starting on port %d and path /sse", m.SSEPort) - if err := sseServer.Start(fmt.Sprintf(":%d", m.SSEPort)); err != nil { + klog.V(0).Infof("SSE server starting on port %d and path /sse", m.StaticConfig.SSEPort) + if err := sseServer.Start(fmt.Sprintf(":%d", m.StaticConfig.SSEPort)); err != nil { return fmt.Errorf("failed to start SSE server: %w\n", err) } } - if m.HttpPort > 0 { + if m.StaticConfig.HTTPPort > 0 { httpServer := mcpServer.ServeHTTP() - klog.V(0).Infof("Streaming HTTP server starting on port %d and path /mcp", m.HttpPort) - if err := httpServer.Start(fmt.Sprintf(":%d", m.HttpPort)); err != nil { + klog.V(0).Infof("Streaming HTTP server starting on port %d and path /mcp", m.StaticConfig.HTTPPort) + if err := httpServer.Start(fmt.Sprintf(":%d", m.StaticConfig.HTTPPort)); err != nil { return fmt.Errorf("failed to start streaming HTTP server: %w\n", err) } } diff --git a/pkg/kubernetes-mcp-server/cmd/root_test.go b/pkg/kubernetes-mcp-server/cmd/root_test.go index 85b1861..13bd5c7 100644 --- a/pkg/kubernetes-mcp-server/cmd/root_test.go +++ b/pkg/kubernetes-mcp-server/cmd/root_test.go @@ -79,6 +79,54 @@ func TestConfig(t *testing.T) { t.Fatalf("Expected error to be %s, got %s", expected, err.Error()) } }) + t.Run("set with valid --config", func(t *testing.T) { + ioStreams, out := testStream() + rootCmd := NewMCPServer(ioStreams) + _, file, _, _ := runtime.Caller(0) + validConfigPath := filepath.Join(filepath.Dir(file), "testdata", "valid-config.toml") + rootCmd.SetArgs([]string{"--version", "--config", validConfigPath}) + _ = rootCmd.Execute() + expectedConfig := `(?m)\" - Config\:[^\"]+valid-config\.toml\"` + if m, err := regexp.MatchString(expectedConfig, out.String()); !m || err != nil { + t.Fatalf("Expected config to be %s, got %s %v", expectedConfig, out.String(), err) + } + expectedListOutput := `(?m)\" - ListOutput\: yaml"` + if m, err := regexp.MatchString(expectedListOutput, out.String()); !m || err != nil { + t.Fatalf("Expected config to be %s, got %s %v", expectedListOutput, out.String(), err) + } + expectedReadOnly := `(?m)\" - Read-only mode: true"` + if m, err := regexp.MatchString(expectedReadOnly, out.String()); !m || err != nil { + t.Fatalf("Expected config to be %s, got %s %v", expectedReadOnly, out.String(), err) + } + expectedDisableDestruction := `(?m)\" - Disable destructive tools: true"` + if m, err := regexp.MatchString(expectedDisableDestruction, out.String()); !m || err != nil { + t.Fatalf("Expected config to be %s, got %s %v", expectedDisableDestruction, out.String(), err) + } + }) + t.Run("set with valid --config, flags override", func(t *testing.T) { + ioStreams, out := testStream() + rootCmd := NewMCPServer(ioStreams) + _, file, _, _ := runtime.Caller(0) + validConfigPath := filepath.Join(filepath.Dir(file), "testdata", "valid-config.toml") + rootCmd.SetArgs([]string{"--version", "--list-output=table", "--disable-destructive=false", "--read-only=false", "--config", validConfigPath}) + _ = rootCmd.Execute() + expected := `(?m)\" - Config\:[^\"]+valid-config\.toml\"` + if m, err := regexp.MatchString(expected, out.String()); !m || err != nil { + t.Fatalf("Expected config to be %s, got %s %v", expected, out.String(), err) + } + expectedListOutput := `(?m)\" - ListOutput\: table"` + if m, err := regexp.MatchString(expectedListOutput, out.String()); !m || err != nil { + t.Fatalf("Expected config to be %s, got %s %v", expectedListOutput, out.String(), err) + } + expectedReadOnly := `(?m)\" - Read-only mode: false"` + if m, err := regexp.MatchString(expectedReadOnly, out.String()); !m || err != nil { + t.Fatalf("Expected config to be %s, got %s %v", expectedReadOnly, out.String(), err) + } + expectedDisableDestruction := `(?m)\" - Disable destructive tools: false"` + if m, err := regexp.MatchString(expectedDisableDestruction, out.String()); !m || err != nil { + t.Fatalf("Expected config to be %s, got %s %v", expectedDisableDestruction, out.String(), err) + } + }) } func TestProfile(t *testing.T) { diff --git a/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml b/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml new file mode 100644 index 0000000..bb8da16 --- /dev/null +++ b/pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml @@ -0,0 +1,15 @@ +log_level = 1 +sse_port = 9999 +kubeconfig = "test" +list_output = "yaml" +read_only = true +disable_destructive = true + +[[denied_resources]] +group = "apps" +version = "v1" +kind = "Deployment" + +[[denied_resources]] +group = "rbac.authorization.k8s.io" +version = "v1" \ No newline at end of file