Skip to content
Open
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
6 changes: 4 additions & 2 deletions charts/kubernetes-mcp-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Each container accepts any valid Kubernetes container field including `image`, `
| configFilePath | string | `"/etc/kubernetes-mcp-server/config.toml"` | |
| defaultPodSecurityContext | object | `{"seccompProfile":{"type":"RuntimeDefault"}}` | Default Security Context for the Pod when one is not provided |
| defaultSecurityContext | object | `{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]},"runAsNonRoot":true}` | Default Security Context for the Container when one is not provided |
| extraArgs | list | `[]` | Useful for passing TLS keys or other configuration options. It can also be configured using ConfigMap. |
| extraContainers | list | `[]` | Each container is defined as a complete container spec. |
| extraVolumeMounts | list | `[]` | Additional volumeMounts on the output Deployment definition. |
| extraVolumes | list | `[]` | Additional volumes on the output Deployment definition. |
Expand Down Expand Up @@ -121,9 +122,10 @@ Each container accepts any valid Kubernetes container field including `image`, `
| replicaCount | int | `1` | This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ |
| resources | object | `{"limits":{"cpu":"100m","memory":"128Mi"},"requests":{"cpu":"100m","memory":"128Mi"}}` | Resource requests and limits for the container. |
| securityContext | object | `{}` | Define the Security Context for the Container |
| service | object | `{"port":8080,"targetPort":"http","type":"ClusterIP"}` | This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ |
| service | object | `{"annotations":{},"port":8080,"targetPort":"http","type":"ClusterIP"}` | This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ |
| service.annotations | object | `{}` | Annotations to add to the service |
| service.port | int | `8080` | This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports |
| service.targetPort | string | `"http"` | Target port for the service. Useful when deploying with an proxy sidecar. Set this to the sidecar's port to route traffic through the proxy before reaching the main container. |
| service.targetPort | string | `"http"` | Target port for the service. Useful when deploying with an proxy sidecar or exposing a different port. Set this to the sidecar's port to route traffic through the proxy before reaching the main container. |
| service.type | string | `"ClusterIP"` | This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types |
| serviceAccount | object | `{"annotations":{},"create":true,"name":""}` | This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ |
| serviceAccount.annotations | object | `{}` | Annotations to add to the service account |
Expand Down
3 changes: 3 additions & 0 deletions charts/kubernetes-mcp-server/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ spec:
args:
- "--config"
- "{{ .Values.configFilePath }}"
{{- with .Values.extraArgs }}
{{- toYaml . | nindent 12 }}
{{- end }}
env:
- name: POD_NAMESPACE
valueFrom:
Expand Down
4 changes: 4 additions & 0 deletions charts/kubernetes-mcp-server/templates/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ metadata:
namespace: {{ .Release.Namespace }}
labels:
{{- include "kubernetes-mcp-server.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
Expand Down
11 changes: 10 additions & 1 deletion charts/kubernetes-mcp-server/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,11 @@ service:
type: ClusterIP
# -- This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports
port: 8080
# -- Target port for the service. Useful when deploying with an proxy sidecar. Set this to the sidecar's port to route traffic through the proxy before reaching the main container.
# -- This sets the target port on the pod. Defaults to "http" (the container port name).
# -- Target port for the service. Useful when deploying with an proxy sidecar or exposing a different port. Set this to the sidecar's port to route traffic through the proxy before reaching the main container.
targetPort: http
# -- Annotations to add to the service
annotations: {}

# -- This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/
ingress:
Expand Down Expand Up @@ -166,6 +169,12 @@ extraVolumeMounts: []
# mountPath: "/etc/foo"
# readOnly: true

# -- Extra arguments to pass to the kubernetes-mcp-server command line.
# -- Useful for passing TLS keys or other configuration options. It can also be configured using ConfigMap.
extraArgs: []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When TLS is enabled via extraArgs, the default livenessProbe and readinessProbe in values.yaml use httpGet with port: http. These HTTP probes will fail against an HTTPS endpoint since the server won't respond to plain HTTP.

Since TLS is configured through opaque extraArgs, the chart has no way to auto-set scheme: HTTPS on the probes. Users would need to manually override probes, volumes, and volume mounts — which is error-prone.

Minimal fix: Document the required probe override in the comments here (e.g., show that users must also set livenessProbe.httpGet.scheme: HTTPS).

Better approach: Add a first-class tls section to the chart values instead of relying on extraArgs for TLS:

tls:
  enabled: false
  secretName: ""
  mountPath: /etc/tls

When tls.enabled is true, the chart template would automatically:

  • Inject --tls-cert and --tls-key args
  • Mount the TLS secret as a volume
  • Set scheme: HTTPS on health probes

This is the standard pattern used by most Helm charts (cert-manager webhook, ingress-nginx, etc.) and provides a much better UX — users just set tls.enabled: true and tls.secretName: my-cert.

The extraArgs value is still useful as a generic escape hatch for other flags.

# - "--tls-cert=/etc/tls/tls.crt"
# - "--tls-key=/etc/tls/tls.key"

# -- Additional containers to add to the pod (sidecars).
# -- Each container is defined as a complete container spec.
extraContainers: []
Expand Down
7 changes: 7 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ type StaticConfig struct {
StsScopes []string `toml:"sts_scopes,omitempty"`
CertificateAuthority string `toml:"certificate_authority,omitempty"`
ServerURL string `toml:"server_url,omitempty"`

// TLS configuration for the HTTP server
// TLSCert is the path to the TLS certificate file for HTTPS
TLSCert string `toml:"tls_cert,omitempty"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new TOML options should also be documented in docs/configuration.md. Other similar fields like certificate_authority and server_url are already documented in the configuration reference table there.

// TLSKey is the path to the TLS private key file for HTTPS
TLSKey string `toml:"tls_key,omitempty"`

// ClusterProviderStrategy is how the server finds clusters.
// If set to "kubeconfig", the clusters will be loaded from those in the kubeconfig.
// If set to "in-cluster", the server will use the in cluster config
Expand Down
10 changes: 10 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ func (s *ConfigSuite) TestReadConfigValid() {
{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
]

# TLS configuration
tls_cert = "/path/to/cert.pem"
tls_key = "/path/to/key.pem"

[[prompts]]
name = "k8s-troubleshoot"
title = "Troubleshoot Kubernetes"
Expand Down Expand Up @@ -132,6 +136,12 @@ func (s *ConfigSuite) TestReadConfigValid() {
s.Run("stateless parsed correctly", func() {
s.Truef(config.Stateless, "Expected Stateless to be true, got %v", config.Stateless)
})
s.Run("tls_cert parsed correctly", func() {
s.Equalf("/path/to/cert.pem", config.TLSCert, "Expected TLSCert to be /path/to/cert.pem, got %s", config.TLSCert)
})
s.Run("tls_key parsed correctly", func() {
s.Equalf("/path/to/key.pem", config.TLSKey, "Expected TLSKey to be /path/to/key.pem, got %s", config.TLSKey)
})
s.Run("toolsets", func() {
s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
for _, toolset := range []string{"core", "config", "helm", "metrics"} {
Expand Down
38 changes: 36 additions & 2 deletions pkg/http/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import (
"context"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

Expand All @@ -18,6 +21,24 @@ import (
"github.com/containers/kubernetes-mcp-server/pkg/mcp"
)

// tlsErrorFilterWriter filters out noisy TLS handshake errors from health checks
type tlsErrorFilterWriter struct {
underlying io.Writer
}

func (w *tlsErrorFilterWriter) Write(p []byte) (n int, err error) {
msg := string(p)
// Filter TLS handshake EOF errors - these are typically from
// load balancer health checks that just do TCP connects.
// Log at V(4) instead of discarding silently so they can still be seen
// when debugging with higher verbosity.
if strings.Contains(msg, "TLS handshake error") && strings.Contains(msg, "EOF") {
klog.V(4).Infof("TLS handshake error (likely health check): %s", strings.TrimSpace(msg))
return len(p), nil
}
return w.underlying.Write(p)
}

const (
healthEndpoint = "/healthz"
statsEndpoint = "/stats"
Expand Down Expand Up @@ -92,6 +113,12 @@ func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.Stat
Handler: instrumentedHandler,
}

// Only set up custom error logger for TLS mode to filter noisy TLS handshake errors
// from load balancer health checks
if staticConfig.TLSCert != "" && staticConfig.TLSKey != "" {
httpServer.ErrorLog = log.New(&tlsErrorFilterWriter{underlying: os.Stderr}, "", 0)
}

sseServer := mcpServer.ServeSse()
streamableHttpServer := mcpServer.ServeHTTP()
mux.Handle(sseEndpoint, sseServer)
Expand All @@ -112,8 +139,15 @@ func Serve(ctx context.Context, mcpServer *mcp.Server, staticConfig *config.Stat

serverErr := make(chan error, 1)
go func() {
klog.V(0).Infof("HTTP server starting on port %s (endpoints: /mcp, /sse, /message, /healthz, /stats, /metrics)", staticConfig.Port)
if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
var err error
if staticConfig.TLSCert != "" && staticConfig.TLSKey != "" {
klog.V(0).Infof("HTTPS server starting on port %s (endpoints: /mcp, /sse, /message, /healthz, /stats, /metrics)", staticConfig.Port)
err = httpServer.ListenAndServeTLS(staticConfig.TLSCert, staticConfig.TLSKey)
} else {
klog.V(0).Infof("HTTP server starting on port %s (endpoints: /mcp, /sse, /message, /healthz, /stats, /metrics)", staticConfig.Port)
err = httpServer.ListenAndServe()
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
serverErr <- err
}
}()
Expand Down
28 changes: 28 additions & 0 deletions pkg/kubernetes-mcp-server/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const (
flagCertificateAuthority = "certificate-authority"
flagDisableMultiCluster = "disable-multi-cluster"
flagClusterProvider = "cluster-provider"
flagTLSCert = "tls-cert"
flagTLSKey = "tls-key"
)

type MCPServerOptions struct {
Expand All @@ -106,6 +108,8 @@ type MCPServerOptions struct {
ServerURL string
DisableMultiCluster bool
ClusterProvider string
TLSCert string
TLSKey string

ConfigPath string
ConfigDir string
Expand Down Expand Up @@ -167,6 +171,8 @@ func NewMCPServer(streams genericiooptions.IOStreams) *cobra.Command {
_ = cmd.Flags().MarkHidden(flagCertificateAuthority)
cmd.Flags().BoolVar(&o.DisableMultiCluster, flagDisableMultiCluster, o.DisableMultiCluster, "Disable multi cluster tools. Optional. If true, all tools will be run against the default cluster/context.")
cmd.Flags().StringVar(&o.ClusterProvider, flagClusterProvider, o.ClusterProvider, "Cluster provider strategy to use (one of: kubeconfig, in-cluster, kcp, disabled). If not set, the server will auto-detect based on the environment.")
cmd.Flags().StringVar(&o.TLSCert, flagTLSCert, o.TLSCert, "Path to TLS certificate file for HTTPS. Must be used together with --tls-key.")
cmd.Flags().StringVar(&o.TLSKey, flagTLSKey, o.TLSKey, "Path to TLS private key file for HTTPS. Must be used together with --tls-cert.")

return cmd
}
Expand Down Expand Up @@ -241,6 +247,12 @@ func (m *MCPServerOptions) loadFlags(cmd *cobra.Command) {
if cmd.Flag(flagDisableMultiCluster).Changed && m.DisableMultiCluster {
m.StaticConfig.ClusterProviderStrategy = api.ClusterProviderDisabled
}
if cmd.Flag(flagTLSCert).Changed {
m.StaticConfig.TLSCert = m.TLSCert
}
if cmd.Flag(flagTLSKey).Changed {
m.StaticConfig.TLSKey = m.TLSKey
}
}

func (m *MCPServerOptions) initializeLogging() {
Expand Down Expand Up @@ -296,6 +308,22 @@ func (m *MCPServerOptions) Validate() error {
return fmt.Errorf("certificate-authority must be a valid file path: %w", err)
}
}
// Validate TLS configuration
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: TLS is only meaningful in HTTP mode (when port is set). Consider also validating that port is configured when TLS cert/key are provided:

if tlsCert != "" && m.StaticConfig.Port == "" {
    return fmt.Errorf("--tls-cert and --tls-key require --port to be set (TLS is only supported in HTTP mode)")
}

This would give users a clear early error instead of silently ignoring the TLS config in STDIO mode.

tlsCert := strings.TrimSpace(m.StaticConfig.TLSCert)
tlsKey := strings.TrimSpace(m.StaticConfig.TLSKey)
if (tlsCert != "" && tlsKey == "") || (tlsCert == "" && tlsKey != "") {
return fmt.Errorf("both --tls-cert and --tls-key must be provided together")
}
if tlsCert != "" {
if _, err := os.Stat(tlsCert); err != nil {
return fmt.Errorf("tls-cert must be a valid file path: %w", err)
}
}
if tlsKey != "" {
if _, err := os.Stat(tlsKey); err != nil {
return fmt.Errorf("tls-key must be a valid file path: %w", err)
}
}
return nil
}

Expand Down
68 changes: 68 additions & 0 deletions pkg/kubernetes-mcp-server/cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -482,3 +482,71 @@ func TestStateless(t *testing.T) {
}
})
}

func TestTLSValidation(t *testing.T) {
t.Run("tls-cert without tls-key returns error", func(t *testing.T) {
tempDir := t.TempDir()
certPath := filepath.Join(tempDir, "cert.pem")
require.NoError(t, os.WriteFile(certPath, []byte("cert content"), 0644))

ioStreams, _ := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--port=8080", "--tls-cert", certPath})
err := rootCmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "both --tls-cert and --tls-key must be provided together")
})

t.Run("tls-key without tls-cert returns error", func(t *testing.T) {
tempDir := t.TempDir()
keyPath := filepath.Join(tempDir, "key.pem")
require.NoError(t, os.WriteFile(keyPath, []byte("key content"), 0644))

ioStreams, _ := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--port=8080", "--tls-key", keyPath})
err := rootCmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "both --tls-cert and --tls-key must be provided together")
})

t.Run("invalid tls-cert path returns error", func(t *testing.T) {
tempDir := t.TempDir()
keyPath := filepath.Join(tempDir, "key.pem")
require.NoError(t, os.WriteFile(keyPath, []byte("key content"), 0644))

ioStreams, _ := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--port=8080", "--tls-cert", "/nonexistent/cert.pem", "--tls-key", keyPath})
err := rootCmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "tls-cert must be a valid file path")
})

t.Run("invalid tls-key path returns error", func(t *testing.T) {
tempDir := t.TempDir()
certPath := filepath.Join(tempDir, "cert.pem")
require.NoError(t, os.WriteFile(certPath, []byte("cert content"), 0644))

ioStreams, _ := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--port=8080", "--tls-cert", certPath, "--tls-key", "/nonexistent/key.pem"})
err := rootCmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "tls-key must be a valid file path")
})

t.Run("valid tls-cert and tls-key paths succeed", func(t *testing.T) {
tempDir := t.TempDir()
certPath := filepath.Join(tempDir, "cert.pem")
keyPath := filepath.Join(tempDir, "key.pem")
require.NoError(t, os.WriteFile(certPath, []byte("cert content"), 0644))
require.NoError(t, os.WriteFile(keyPath, []byte("key content"), 0644))

ioStreams, _ := testStream()
rootCmd := NewMCPServer(ioStreams)
rootCmd.SetArgs([]string{"--version", "--port=8080", "--tls-cert", certPath, "--tls-key", keyPath})
err := rootCmd.Execute()
require.NoError(t, err)
})
}