diff --git a/README.md b/README.md index 744a4d73..9a282033 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ uvx kubernetes-mcp-server@latest --help | `--port` | Starts the MCP server in Streamable HTTP mode (path /mcp) and Server-Sent Event (SSE) (path /sse) mode and listens on the specified port . | | `--log-level` | Sets the logging level (values [from 0-9](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-instrumentation/logging.md)). Similar to [kubectl logging levels](https://kubernetes.io/docs/reference/kubectl/quick-reference/#kubectl-output-verbosity-and-debugging). | | `--kubeconfig` | Path to the Kubernetes configuration file. If not provided, it will try to resolve the configuration (in-cluster, default location, etc.). | +| `--kubecontext` | Context name from Kubernetes configuration file. If not provided, it will use current context. | | `--list-output` | Output format for resource list operations (one of: yaml, table) (default "table") | | `--read-only` | If set, the MCP server will run in read-only mode, meaning it will not allow any write operations (create, update, delete) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without making changes. | | `--disable-destructive` | If set, the MCP server will disable all destructive operations (delete, update, etc.) on the Kubernetes cluster. This is useful for debugging or inspecting the cluster without accidentally making changes. This option has no effect when `--read-only` is used. | diff --git a/pkg/config/config.go b/pkg/config/config.go index 970d8753..879a8a6a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -11,11 +11,12 @@ import ( type StaticConfig struct { DeniedResources []GroupVersionKind `toml:"denied_resources"` - LogLevel int `toml:"log_level,omitempty"` - Port string `toml:"port,omitempty"` - SSEBaseURL string `toml:"sse_base_url,omitempty"` - KubeConfig string `toml:"kubeconfig,omitempty"` - ListOutput string `toml:"list_output,omitempty"` + LogLevel int `toml:"log_level,omitempty"` + Port string `toml:"port,omitempty"` + SSEBaseURL string `toml:"sse_base_url,omitempty"` + KubeConfig string `toml:"kubeconfig,omitempty"` + KubeContext string `toml:"kubecontext,omitempty"` + ListOutput string `toml:"list_output,omitempty"` // When true, expose only tools annotated with readOnlyHint=true ReadOnly bool `toml:"read_only,omitempty"` // When true, disable tools annotated with destructiveHint=true diff --git a/pkg/kubernetes-mcp-server/cmd/root.go b/pkg/kubernetes-mcp-server/cmd/root.go index a96ba763..9d4f3409 100644 --- a/pkg/kubernetes-mcp-server/cmd/root.go +++ b/pkg/kubernetes-mcp-server/cmd/root.go @@ -57,6 +57,7 @@ type MCPServerOptions struct { HttpPort int SSEBaseUrl string Kubeconfig string + KubeContext string Profile string ListOutput string ReadOnly bool @@ -114,6 +115,7 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command { cmd.Flags().StringVar(&o.Port, "port", o.Port, "Start a streamable HTTP and SSE HTTP server on the specified port (e.g. 8080)") 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.KubeContext, "kubecontext", o.Kubeconfig, "Context name from kube config") 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, ", ")+"). Defaults to table.") cmd.Flags().BoolVar(&o.ReadOnly, "read-only", o.ReadOnly, "If true, only tools annotated with readOnlyHint=true are exposed") @@ -170,6 +172,9 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) { if cmd.Flag("kubeconfig").Changed { m.StaticConfig.KubeConfig = m.Kubeconfig } + if cmd.Flag("kubecontext").Changed { + m.StaticConfig.KubeContext = m.KubeContext + } if cmd.Flag("list-output").Changed || m.StaticConfig.ListOutput == "" { m.StaticConfig.ListOutput = m.ListOutput } diff --git a/pkg/kubernetes/configuration.go b/pkg/kubernetes/configuration.go index df88530f..acdd7fd0 100644 --- a/pkg/kubernetes/configuration.go +++ b/pkg/kubernetes/configuration.go @@ -27,9 +27,13 @@ func resolveKubernetesConfigurations(kubernetes *Manager) error { if kubernetes.staticConfig.KubeConfig != "" { pathOptions.LoadingRules.ExplicitPath = kubernetes.staticConfig.KubeConfig } + overrides := &clientcmd.ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: ""}} + if kubernetes.staticConfig.KubeContext != "" { + overrides.CurrentContext = kubernetes.staticConfig.KubeContext + } kubernetes.clientCmdConfig = clientcmd.NewNonInteractiveDeferredLoadingClientConfig( pathOptions.LoadingRules, - &clientcmd.ConfigOverrides{ClusterInfo: clientcmdapi.Cluster{Server: ""}}) + overrides) var err error if kubernetes.IsInCluster() { kubernetes.cfg, err = InClusterConfig() @@ -102,6 +106,9 @@ func (m *Manager) ConfigurationView(minify bool) (runtime.Object, error) { return nil, err } if minify { + if m.staticConfig.KubeContext != "" { + cfg.CurrentContext = m.staticConfig.KubeContext + } if err = clientcmdapi.MinifyConfig(&cfg); err != nil { return nil, err } diff --git a/pkg/kubernetes/configuration_test.go b/pkg/kubernetes/configuration_test.go index 084b99d7..9a26aea5 100644 --- a/pkg/kubernetes/configuration_test.go +++ b/pkg/kubernetes/configuration_test.go @@ -153,3 +153,53 @@ users: } }) } + +func TestKubernetes_ResolveKubernetesConfigurations_KubeContext(t *testing.T) { + tempDir := t.TempDir() + kubeconfigPath := path.Join(tempDir, "config") + kubeconfigContent := ` +apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://context1.example.com + name: cluster1 +- cluster: + server: https://context2.example.com + name: cluster2 +contexts: +- context: + cluster: cluster1 + user: user1 + name: context1 +- context: + cluster: cluster2 + user: user2 + name: context2 +current-context: context1 +users: +- name: user1 + user: + token: token1 +- name: user2 + user: + token: token2 +` + if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0644); err != nil { + t.Fatalf("failed to create kubeconfig file: %v", err) + } + m := Manager{staticConfig: &config.StaticConfig{ + KubeConfig: kubeconfigPath, + KubeContext: "context2", + }} + err := resolveKubernetesConfigurations(&m) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if m.cfg == nil { + t.Errorf("expected non-nil config, got nil") + } + if m.cfg.Host != "https://context2.example.com" { + t.Errorf("expected host https://context2.example.com, got %s", m.cfg.Host) + } +}