diff --git a/README.md b/README.md index 744a4d7..9a28203 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 970d875..879a8a6 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 a96ba76..9d4f340 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 df88530..acdd7fd 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 084b99d..9a26aea 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) + } +}