Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.idea/
.vscode/
.docusaurus/
node_modules/

Expand Down
9 changes: 9 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
})
}

Expand Down
80 changes: 55 additions & 25 deletions pkg/kubernetes-mcp-server/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
}
}

Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
}
}
Expand Down
48 changes: 48 additions & 0 deletions pkg/kubernetes-mcp-server/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions pkg/kubernetes-mcp-server/cmd/testdata/valid-config.toml
Original file line number Diff line number Diff line change
@@ -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"
Loading