From 4265e9ebbe2ca1d5bace7c5d5f3c2280dfbdc88b Mon Sep 17 00:00:00 2001 From: Muhammad Faizan Date: Mon, 24 Nov 2025 14:46:47 +0100 Subject: [PATCH 01/21] adding auth based authentication instead of kubeconfigs Signed-off-by: Muhammad Faizan --- .gitignore | 2 + docs/AUTH_HEADERS_PROVIDER.md | 282 +++++++++++++++++++ pkg/config/config.go | 7 +- pkg/kubernetes/auth_headers.go | 106 +++++++ pkg/kubernetes/kubernetes.go | 10 +- pkg/kubernetes/provider_auth_headers.go | 160 +++++++++++ pkg/kubernetes/provider_auth_headers_test.go | 127 +++++++++ pkg/kubernetes/resources.go | 3 +- pkg/mcp/mcp.go | 3 + pkg/mcp/middleware.go | 38 +++ 10 files changed, 733 insertions(+), 5 deletions(-) create mode 100644 docs/AUTH_HEADERS_PROVIDER.md create mode 100644 pkg/kubernetes/auth_headers.go create mode 100644 pkg/kubernetes/provider_auth_headers.go create mode 100644 pkg/kubernetes/provider_auth_headers_test.go diff --git a/.gitignore b/.gitignore index 12b624e7..5db09f82 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,5 @@ python/build/ python/dist/ python/kubernetes_mcp_server.egg-info/ !python/kubernetes-mcp-server + +config-dev.toml diff --git a/docs/AUTH_HEADERS_PROVIDER.md b/docs/AUTH_HEADERS_PROVIDER.md new file mode 100644 index 00000000..f480679f --- /dev/null +++ b/docs/AUTH_HEADERS_PROVIDER.md @@ -0,0 +1,282 @@ +# Auth-Headers Provider + +The `auth-headers` cluster provider strategy enables multi-tenant Kubernetes MCP server deployments where each user authenticates with their own Kubernetes token via HTTP request headers. + +## Overview + +This provider: +- **Requires authentication via request headers** (`Authorization` or `kubernetes-authorization`) +- **Extracts cluster connection details** from kubeconfig (server URL, CA certificates) +- **Strips all authentication credentials** from the kubeconfig +- **Creates dynamic Kubernetes clients** per request using the provided bearer tokens + +## Use Cases + +- **Multi-tenant SaaS deployments** - Single MCP server instance serving multiple users +- **Zero-trust architectures** - No stored credentials, authentication per request +- **OIDC/OAuth integration** - Users authenticate via identity provider, tokens forwarded to Kubernetes +- **Auditing & compliance** - Each request uses the user's actual identity for Kubernetes RBAC + +## Configuration + +### Basic Setup + +```bash +kubernetes-mcp-server \ + --port 8080 \ + --kubeconfig /path/to/kubeconfig \ + --cluster-provider-strategy auth-headers +``` + +The server will: +1. Read cluster connection details from the kubeconfig +2. Automatically enable `--require-oauth` +3. Reject any requests without valid bearer tokens + +### TOML Configuration + +```toml +cluster_provider_strategy = "auth-headers" +kubeconfig = "/path/to/kubeconfig" +require_oauth = true +validate_token = true # Optional: validate tokens against Kubernetes API +``` + +### With Token Validation + +```bash +kubernetes-mcp-server \ + --port 8080 \ + --kubeconfig /path/to/kubeconfig \ + --cluster-provider-strategy auth-headers \ + --validate-token +``` + +This validates each token using Kubernetes TokenReview API before allowing operations. + +## How It Works + +### 1. Initialization + +When the server starts: +``` +Kubeconfig → Extract cluster info (server URL, CA cert) → Create base manager + ↓ + Strip all auth credentials + ↓ + Ready to accept requests +``` + +### 2. Request Processing + +For each MCP request: +``` +HTTP Request → Extract Authorization header → Create derived Kubernetes client + ↓ ↓ + "Bearer " Uses token for authentication + ↓ + Execute Kubernetes operation +``` + +### 3. Security Model + +``` +┌──────────────────┐ +│ MCP Client │ (User's application) +│ (Claude, etc) │ +└────────┬─────────┘ + │ Bearer + ↓ +┌──────────────────┐ +│ MCP Server │ +│ (auth-headers) │ +└────────┬─────────┘ + │ Uses user's token + ↓ +┌──────────────────┐ +│ Kubernetes API │ +│ Server │ +└──────────────────┘ + ↓ + RBAC enforced with + user's actual identity +``` + +## Client Usage + +### Using the Go MCP Client + +```go +import "github.com/mark3labs/mcp-go/client/transport" + +// Get user's Kubernetes token (from OIDC, service account, etc.) +userToken := getUserKubernetesToken() + +client := NewMCPClient( + transport.WithHTTPHeaders(map[string]string{ + "Authorization": "Bearer " + userToken + }) +) +``` + +### Using Claude Desktop + +```json +{ + "mcpServers": { + "kubernetes": { + "url": "https://mcp-server.example.com/sse", + "headers": { + "Authorization": "Bearer YOUR_KUBERNETES_TOKEN" + } + } + } +} +``` + +### Using cURL + +```bash +curl -X POST https://mcp-server.example.com/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer eyJhbGci..." \ + -d '{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "pods_list", + "arguments": {"namespace": "default"} + }, + "id": 1 + }' +``` + +## Comparison with Other Providers + +| Feature | auth-headers | kubeconfig | in-cluster | disabled | +|---------|--------------|------------|------------|----------| +| **Multi-tenant** | ✅ Yes | ❌ No | ❌ No | ❌ No | +| **Multi-cluster** | ❌ No | ✅ Yes | ❌ No | ❌ No | +| **Per-request auth** | ✅ Yes | ❌ No | ❌ No | ❌ No | +| **Requires headers** | ✅ Required | ❌ Optional | ❌ Optional | ❌ Optional | +| **Stored credentials** | ❌ None | ✅ Kubeconfig | ✅ SA token | ✅ Kubeconfig | +| **Use case** | SaaS/Multi-user | Local dev | In-cluster | Single cluster | + +## Security Considerations + +### ✅ Advantages + +- **No stored credentials** - Server doesn't store any Kubernetes authentication +- **Per-request authentication** - Each request uses fresh, user-specific token +- **RBAC enforcement** - Kubernetes enforces permissions using actual user identity +- **Token expiration** - Short-lived tokens automatically expire +- **Audit trails** - Kubernetes audit logs show actual user, not service account + +### ⚠️ Important Notes + +1. **Tokens in transit** - Use HTTPS to protect tokens in HTTP headers +2. **Token validation** - Enable `--validate-token` for additional security +3. **Rate limiting** - Consider implementing rate limiting per token/user +4. **Token rotation** - Clients must handle token refresh/expiration +5. **Network security** - Ensure MCP server can reach Kubernetes API + +## Example Deployment + +### Docker Compose + +```yaml +version: '3.8' +services: + kubernetes-mcp-server: + image: quay.io/containers/kubernetes_mcp_server:latest + ports: + - "8080:8080" + command: + - --port=8080 + - --kubeconfig=/kubeconfig/config + - --cluster-provider-strategy=auth-headers + - --validate-token + volumes: + - ./kubeconfig:/kubeconfig:ro + environment: + - LOG_LEVEL=1 +``` + +### Kubernetes Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kubernetes-mcp-server +spec: + replicas: 3 + selector: + matchLabels: + app: kubernetes-mcp-server + template: + metadata: + labels: + app: kubernetes-mcp-server + spec: + containers: + - name: server + image: quay.io/containers/kubernetes_mcp_server:latest + args: + - --port=8080 + - --kubeconfig=/kubeconfig/config + - --cluster-provider-strategy=auth-headers + - --validate-token + ports: + - containerPort: 8080 + volumeMounts: + - name: kubeconfig + mountPath: /kubeconfig + readOnly: true + volumes: + - name: kubeconfig + configMap: + name: cluster-kubeconfig +--- +apiVersion: v1 +kind: Service +metadata: + name: kubernetes-mcp-server +spec: + selector: + app: kubernetes-mcp-server + ports: + - port: 80 + targetPort: 8080 +``` + +## Troubleshooting + +### Error: "bearer token required in Authorization header" + +**Cause**: Request missing authentication header + +**Solution**: Include `Authorization: Bearer ` header in all requests + +### Error: "auth-headers ClusterProviderStrategy cannot be used in in-cluster deployments" + +**Cause**: Trying to use auth-headers provider from within a Kubernetes cluster + +**Solution**: Use `in-cluster` or `disabled` strategy for in-cluster deployments, or explicitly set a kubeconfig path + +### Error: "token-based authentication required" + +**Cause**: `RequireOAuth` is enabled but no token provided + +**Solution**: Ensure client sends bearer token in Authorization header + +### Warning: "auth-headers ClusterProviderStrategy requires OAuth authentication, enabling RequireOAuth" + +**Info**: This is expected - auth-headers provider automatically enables OAuth requirement + +## Related Documentation + +- [OIDC/OAuth Setup Guide](./KEYCLOAK_OIDC_SETUP.md) +- [Getting Started](./GETTING_STARTED_KUBERNETES.md) +- [Claude Integration](./GETTING_STARTED_CLAUDE_CODE.md) + diff --git a/pkg/config/config.go b/pkg/config/config.go index 5601e7f0..433a8df7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -11,9 +11,10 @@ import ( ) const ( - ClusterProviderKubeConfig = "kubeconfig" - ClusterProviderInCluster = "in-cluster" - ClusterProviderDisabled = "disabled" + ClusterProviderKubeConfig = "kubeconfig" + ClusterProviderInCluster = "in-cluster" + ClusterProviderDisabled = "disabled" + ClusterProviderAuthHeaders = "auth-headers" ) // StaticConfig is the configuration for the server. diff --git a/pkg/kubernetes/auth_headers.go b/pkg/kubernetes/auth_headers.go new file mode 100644 index 00000000..04d0f7b8 --- /dev/null +++ b/pkg/kubernetes/auth_headers.go @@ -0,0 +1,106 @@ +package kubernetes + +import ( + "encoding/base64" + "fmt" +) + +// AuthType represents the type of Kubernetes authentication. +type AuthType string + +const ( + // AuthTypeToken represents token-based authentication. + AuthTypeToken AuthType = "token" + // AuthTypeClientCertificate represents client certificate authentication. + AuthTypeClientCertificate AuthType = "client_certificate" + // AuthTypeUnknown represents unknown or unsupported authentication type. + AuthTypeUnknown AuthType = "unknown" + AuthHeadersContextKey string = "k8s_auth_headers" +) + +// K8sAuthHeaders represents Kubernetes API authentication headers. +type K8sAuthHeaders struct { + // ClusterURL is the Kubernetes cluster URL. + ClusterURL string + // ClusterCertificateAuthorityData is the base64-encoded CA certificate. + ClusterCertificateAuthorityData string + // AuthorizationToken is the optional bearer token for authentication. + AuthorizationToken string + // ClientCertificateData is the optional base64-encoded client certificate. + ClientCertificateData string + // ClientKeyData is the optional base64-encoded client key. + ClientKeyData string +} + +func NewK8sAuthHeadersFromHeaders(data map[string]any) (*K8sAuthHeaders, error) { + authHeaders := &K8sAuthHeaders{} + var ok bool + authHeaders.ClusterURL, ok = data[string(CustomClusterURLHeader)].(string) + if !ok || authHeaders.ClusterURL == "" { + return nil, fmt.Errorf("%s header is required", CustomClusterURLHeader) + } + + authHeaders.ClusterCertificateAuthorityData, ok = data[string(CustomCertificateAuthorityDataHeader)].(string) + if !ok || authHeaders.ClusterCertificateAuthorityData == "" { + return nil, fmt.Errorf("%s header is required", CustomCertificateAuthorityDataHeader) + } + + // Token or client certificate and key data (optional). + authHeaders.AuthorizationToken, _ = data[string(CustomAuthorizationHeader)].(string) + authHeaders.ClientCertificateData, _ = data[string(CustomClientCertificateDataHeader)].(string) + authHeaders.ClientKeyData, _ = data[string(CustomClientKeyDataHeader)].(string) + + // Check if either token auth or client certificate auth is provided + hasTokenAuth := authHeaders.AuthorizationToken != "" + hasClientCertAuth := authHeaders.ClientCertificateData != "" && authHeaders.ClientKeyData != "" + + if !hasTokenAuth && !hasClientCertAuth { + return nil, fmt.Errorf("either %s header or (%s and %s) headers are required", CustomAuthorizationHeader, CustomClientCertificateDataHeader, CustomClientKeyDataHeader) + } + + return authHeaders, nil +} + +// GetAuthType returns the authentication type based on the provided headers. +func (h *K8sAuthHeaders) GetAuthType() AuthType { + if h.AuthorizationToken != "" { + return AuthTypeToken + } + if h.ClientCertificateData != "" && h.ClientKeyData != "" { + return AuthTypeClientCertificate + } + return AuthTypeUnknown +} + +// GetDecodedCertificateAuthorityData decodes and returns the CA certificate data. +func (h *K8sAuthHeaders) GetDecodedCertificateAuthorityData() ([]byte, error) { + data, err := base64.StdEncoding.DecodeString(h.ClusterCertificateAuthorityData) + if err != nil { + return nil, fmt.Errorf("failed to decode certificate authority data: %w", err) + } + return data, nil +} + +// // GetDecodedClientCertificateData decodes and returns the client certificate data. +// func (h *K8sAuthHeaders) GetDecodedClientCertificateData() ([]byte, error) { +// if h.ClientCertificateData == nil || *h.ClientCertificateData == "" { +// return nil, errors.New("client certificate data is not available") +// } +// data, err := base64.StdEncoding.DecodeString(*h.ClientCertificateData) +// if err != nil { +// return nil, fmt.Errorf("failed to decode client certificate data: %w", err) +// } +// return data, nil +// } + +// // GetDecodedClientKeyData decodes and returns the client key data. +// func (h *K8sAuthHeaders) GetDecodedClientKeyData() ([]byte, error) { +// if h.ClientKeyData == nil || *h.ClientKeyData == "" { +// return nil, errors.New("client key data is not available") +// } +// data, err := base64.StdEncoding.DecodeString(*h.ClientKeyData) +// if err != nil { +// return nil, fmt.Errorf("failed to decode client key data: %w", err) +// } +// return data, nil +// } diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 7de8d6ff..e4dc33c3 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -13,8 +13,16 @@ import ( type HeaderKey string const ( + CustomClusterURLHeader = HeaderKey("kubernetes-cluster-url") CustomAuthorizationHeader = HeaderKey("kubernetes-authorization") - OAuthAuthorizationHeader = HeaderKey("Authorization") + // CustomCertificateAuthorityData is the base64-encoded CA certificate. + CustomCertificateAuthorityDataHeader = HeaderKey("kubernetes-certificate-authority-data") + // CustomClientCertificateData is the base64-encoded client certificate. + CustomClientCertificateDataHeader = HeaderKey("kubernetes-client-certificate-data") + // CustomClientKeyData is the base64-encoded client key. + CustomClientKeyDataHeader = HeaderKey("kubernetes-client-key-data") + + OAuthAuthorizationHeader = HeaderKey("Authorization") CustomUserAgent = "kubernetes-mcp-server/bearer-token-auth" ) diff --git a/pkg/kubernetes/provider_auth_headers.go b/pkg/kubernetes/provider_auth_headers.go new file mode 100644 index 00000000..1917a34b --- /dev/null +++ b/pkg/kubernetes/provider_auth_headers.go @@ -0,0 +1,160 @@ +package kubernetes + +import ( + "context" + "fmt" + + "github.com/containers/kubernetes-mcp-server/pkg/config" + authenticationv1api "k8s.io/api/authentication/v1" + "k8s.io/klog/v2" +) + +// AuthHeadersClusterProvider implements Provider for authentication via request headers. +// This provider requires users to provide authentication tokens via request headers. +// It uses cluster connection details from configuration but does not use any +// authentication credentials from kubeconfig files. +type AuthHeadersClusterProvider struct{} + +var _ Provider = &AuthHeadersClusterProvider{} + +func init() { + RegisterProvider(config.ClusterProviderAuthHeaders, newAuthHeadersClusterProvider) +} + +// newAuthHeadersClusterProvider creates a provider that requires header-based authentication. +// Users must provide tokens via request headers (server URL, CA cert). +func newAuthHeadersClusterProvider(cfg *config.StaticConfig) (Provider, error) { + // // Create a base manager using kubeconfig for cluster connection details + // m, err := NewKubeconfigManager(cfg, "") + // if err != nil { + // return nil, fmt.Errorf("failed to create auth-headers provider: %w", err) + // } + + // // Create a minimal kubeconfig with only cluster connection info (no auth) + // rawConfig, err := m.clientCmdConfig.RawConfig() + // if err != nil { + // return nil, fmt.Errorf("failed to read kubeconfig: %w", err) + // } + + // // Get the current context to extract cluster info + // currentContext := rawConfig.Contexts[rawConfig.CurrentContext] + // if currentContext == nil { + // return nil, fmt.Errorf("current context not found in kubeconfig") + // } + + // cluster := rawConfig.Clusters[currentContext.Cluster] + // if cluster == nil { + // return nil, fmt.Errorf("cluster %s not found in kubeconfig", currentContext.Cluster) + // } + + // // Create a REST config with only cluster connection details (no auth) + // restConfig := &rest.Config{ + // Host: cluster.Server, + // APIPath: m.cfg.APIPath, + // TLSClientConfig: rest.TLSClientConfig{ + // Insecure: cluster.InsecureSkipTLSVerify, + // ServerName: cluster.TLSServerName, + // CAData: cluster.CertificateAuthorityData, + // CAFile: cluster.CertificateAuthority, + // }, + // UserAgent: rest.DefaultKubernetesUserAgent(), + // QPS: m.cfg.QPS, + // Burst: m.cfg.Burst, + // Timeout: m.cfg.Timeout, + // } + + // // Create a minimal clientcmd config without any authentication + // minimalConfig := clientcmdapi.NewConfig() + // minimalConfig.Clusters["cluster"] = &clientcmdapi.Cluster{ + // Server: cluster.Server, + // InsecureSkipTLSVerify: cluster.InsecureSkipTLSVerify, + // CertificateAuthority: cluster.CertificateAuthority, + // CertificateAuthorityData: cluster.CertificateAuthorityData, + // TLSServerName: cluster.TLSServerName, + // } + // minimalConfig.Contexts["auth-headers-context"] = &clientcmdapi.Context{ + // Cluster: "cluster", + // } + // minimalConfig.CurrentContext = "auth-headers-context" + + // // Create a new manager with the stripped-down config + // baseManager, err := newManager(cfg, restConfig, clientcmd.NewDefaultClientConfig(*minimalConfig, nil)) + // if err != nil { + // return nil, fmt.Errorf("failed to create base manager for auth-headers provider: %w", err) + // } + + klog.V(1).Infof("Auth-headers provider initialized - all requests must include valid headers") + + return &AuthHeadersClusterProvider{}, nil +} + +func (p *AuthHeadersClusterProvider) IsOpenShift(ctx context.Context) bool { + return false +} + +func (p *AuthHeadersClusterProvider) VerifyToken(ctx context.Context, target, token, audience string) (*authenticationv1api.UserInfo, []string, error) { + return nil, nil, fmt.Errorf("auth-headers VerifyToken not implemented") +} + +func (p *AuthHeadersClusterProvider) GetTargets(_ context.Context) ([]string, error) { + // Single cluster mode + return []string{""}, nil +} + +func (p *AuthHeadersClusterProvider) GetTargetParameterName() string { + return "" +} + +func (p *AuthHeadersClusterProvider) GetDerivedKubernetes(ctx context.Context, target string) (*Kubernetes, error) { + // _, err := New(ctx) + // if err != nil { + // return nil, err + // } + + // derivedCfg := &rest.Config{ + // Host: authHeaders.ClusterURL, + // APIPath: m.cfg.APIPath, + // // Copy only server verification TLS settings (CA bundle and server name) + // TLSClientConfig: rest.TLSClientConfig{ + // Insecure: m.cfg.Insecure, + // ServerName: m.cfg.ServerName, + // CAFile: m.cfg.CAFile, + // CAData: m.cfg.CAData, + // }, + // BearerToken: strings.TrimPrefix(authorization, "Bearer "), + // // pass custom UserAgent to identify the client + // UserAgent: CustomUserAgent, + // QPS: m.cfg.QPS, + // Burst: m.cfg.Burst, + // Timeout: m.cfg.Timeout, + // Impersonate: rest.ImpersonationConfig{}, + // } + + // type Manager struct { + // cfg *rest.Config + // clientCmdConfig clientcmd.ClientConfig + // discoveryClient discovery.CachedDiscoveryInterface + // accessControlClientSet *AccessControlClientset + // accessControlRESTMapper *AccessControlRESTMapper + // dynamicClient *dynamic.DynamicClient + + // staticConfig *config.StaticConfig + // CloseWatchKubeConfig CloseWatchKubeConfig + // } + + // k := &Kubernetes{ + // manager: p.baseManager, + // } + + return nil, nil +} + +func (p *AuthHeadersClusterProvider) GetDefaultTarget() string { + return "" +} + +func (p *AuthHeadersClusterProvider) WatchTargets(watch func() error) { +} + +func (p *AuthHeadersClusterProvider) Close() { +} diff --git a/pkg/kubernetes/provider_auth_headers_test.go b/pkg/kubernetes/provider_auth_headers_test.go new file mode 100644 index 00000000..b8a48836 --- /dev/null +++ b/pkg/kubernetes/provider_auth_headers_test.go @@ -0,0 +1,127 @@ +package kubernetes + +import ( + "context" + "testing" + + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/client-go/rest" +) + +func TestAuthHeadersProviderFactory(t *testing.T) { + t.Run("auth-headers provider requires kubeconfig for cluster connection info", func(t *testing.T) { + cfg := &config.StaticConfig{ + KubeConfig: "", + ClusterProviderStrategy: config.ClusterProviderAuthHeaders, + } + _, err := newAuthHeadersClusterProvider(cfg) + require.Error(t, err) + }) + + t.Run("auth-headers provider initializes with valid kubeconfig", func(t *testing.T) { + mockServer := test.NewMockServer() + defer mockServer.Close() + + cfg := &config.StaticConfig{ + KubeConfig: mockServer.KubeconfigFile(t), + ClusterProviderStrategy: config.ClusterProviderAuthHeaders, + RequireOAuth: true, + } + + provider, err := newAuthHeadersClusterProvider(cfg) + require.NoError(t, err) + require.NotNil(t, provider) + assert.IsType(t, &AuthHeadersClusterProvider{}, provider) + }) + + t.Run("auth-headers provider automatically enables RequireOAuth", func(t *testing.T) { + mockServer := test.NewMockServer() + defer mockServer.Close() + + cfg := &config.StaticConfig{ + KubeConfig: mockServer.KubeconfigFile(t), + ClusterProviderStrategy: config.ClusterProviderAuthHeaders, + RequireOAuth: false, // Will be forced to true + } + + provider, err := newAuthHeadersClusterProvider(cfg) + require.NoError(t, err) + require.NotNil(t, provider) + assert.True(t, cfg.RequireOAuth, "RequireOAuth should be forced to true") + }) + + t.Run("auth-headers provider rejects in-cluster config", func(t *testing.T) { + cfg := &config.StaticConfig{ + ClusterProviderStrategy: config.ClusterProviderAuthHeaders, + } + + // Temporarily mock IsInCluster to return true + originalInClusterConfig := InClusterConfig + InClusterConfig = func() (*rest.Config, error) { + return &rest.Config{Host: "https://kubernetes.default.svc"}, nil + } + defer func() { InClusterConfig = originalInClusterConfig }() + + _, err := newAuthHeadersClusterProvider(cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be used in in-cluster deployments") + }) +} + +func TestAuthHeadersProviderInterface(t *testing.T) { + mockServer := test.NewMockServer() + defer mockServer.Close() + + cfg := &config.StaticConfig{ + KubeConfig: mockServer.KubeconfigFile(t), + ClusterProviderStrategy: config.ClusterProviderAuthHeaders, + RequireOAuth: true, + } + + provider, err := newAuthHeadersClusterProvider(cfg) + require.NoError(t, err) + + t.Run("GetTargets returns single empty target", func(t *testing.T) { + targets, err := provider.GetTargets(context.Background()) + require.NoError(t, err) + assert.Equal(t, []string{""}, targets) + }) + + t.Run("GetTargetParameterName returns empty string", func(t *testing.T) { + assert.Equal(t, "", provider.GetTargetParameterName()) + }) + + t.Run("GetDefaultTarget returns empty string", func(t *testing.T) { + assert.Equal(t, "", provider.GetDefaultTarget()) + }) + + t.Run("GetDerivedKubernetes requires token in context", func(t *testing.T) { + ctx := context.Background() + _, err := provider.GetDerivedKubernetes(ctx, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "required") + }) + + t.Run("GetDerivedKubernetes works with valid bearer token", func(t *testing.T) { + ctx := context.WithValue(context.Background(), OAuthAuthorizationHeader, "Bearer test-token") + k, err := provider.GetDerivedKubernetes(ctx, "") + require.NoError(t, err) + require.NotNil(t, k) + }) + + t.Run("GetDerivedKubernetes rejects non-empty target", func(t *testing.T) { + ctx := context.WithValue(context.Background(), OAuthAuthorizationHeader, "Bearer test-token") + _, err := provider.GetDerivedKubernetes(ctx, "some-cluster") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support multiple targets") + }) + + t.Run("VerifyToken rejects non-empty target", func(t *testing.T) { + _, _, err := provider.VerifyToken(context.Background(), "some-cluster", "token", "audience") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not support multiple targets") + }) +} diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index 1f559e12..31e59a5f 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -3,10 +3,11 @@ package kubernetes import ( "context" "fmt" - "k8s.io/apimachinery/pkg/runtime" "regexp" "strings" + "k8s.io/apimachinery/pkg/runtime" + "github.com/containers/kubernetes-mcp-server/pkg/version" authv1 "k8s.io/api/authorization/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 6a4a6d2f..06e5a78d 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -82,6 +82,9 @@ func NewServer(configuration Configuration) (*Server, error) { }), } + if configuration.ClusterProviderStrategy == config.ClusterProviderAuthHeaders { + s.server.AddReceivingMiddleware(customAuthHeadersPropagationMiddleware) + } s.server.AddReceivingMiddleware(authHeaderPropagationMiddleware) s.server.AddReceivingMiddleware(toolCallLoggingMiddleware) if configuration.RequireOAuth && false { // TODO: Disabled scope auth validation for now diff --git a/pkg/mcp/middleware.go b/pkg/mcp/middleware.go index ec6f4d42..b1181696 100644 --- a/pkg/mcp/middleware.go +++ b/pkg/mcp/middleware.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "slices" + "strings" internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -30,6 +31,43 @@ func authHeaderPropagationMiddleware(next mcp.MethodHandler) mcp.MethodHandler { } } +func customAuthHeadersPropagationMiddleware(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + + var authHeaders *internalk8s.K8sAuthHeaders = nil + var err error = nil + if req.GetParams() != nil { + if toolParams, ok := req.GetParams().(*mcp.CallToolParamsRaw); ok { + toolParamsMeta := toolParams.GetMeta() + authHeaders, err = internalk8s.NewK8sAuthHeadersFromHeaders(toolParamsMeta) + if err != nil { + klog.V(4).ErrorS(err, "failed to parse custom auth headers from tool params meta", "tool", req.GetParams().(*mcp.CallToolParamsRaw).Name) + } + } + } + + if authHeaders == nil && req.GetExtra() != nil && req.GetExtra().Header != nil { + // Convert http.Header to map[string]any + headerMap := make(map[string]any) + for key, values := range req.GetExtra().Header { + if len(values) > 0 { + headerMap[strings.ToLower(key)] = values[0] + } + } + authHeaders, err = internalk8s.NewK8sAuthHeadersFromHeaders(headerMap) + if err != nil { + return nil, err + } + } + + // add auth headers to context + if authHeaders != nil { + ctx = context.WithValue(ctx, internalk8s.AuthHeadersContextKey, authHeaders) + } + return next(ctx, method, req) + } +} + func toolCallLoggingMiddleware(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { switch params := req.GetParams().(type) { From 5891f1555c75622317bdd964ac9785cf25f98ae3 Mon Sep 17 00:00:00 2001 From: Muhammad Faizan Date: Mon, 24 Nov 2025 15:48:35 +0100 Subject: [PATCH 02/21] updated Signed-off-by: Muhammad Faizan --- pkg/kubernetes/provider_auth_headers.go | 56 +++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/pkg/kubernetes/provider_auth_headers.go b/pkg/kubernetes/provider_auth_headers.go index 1917a34b..2e42ba97 100644 --- a/pkg/kubernetes/provider_auth_headers.go +++ b/pkg/kubernetes/provider_auth_headers.go @@ -13,7 +13,9 @@ import ( // This provider requires users to provide authentication tokens via request headers. // It uses cluster connection details from configuration but does not use any // authentication credentials from kubeconfig files. -type AuthHeadersClusterProvider struct{} +type AuthHeadersClusterProvider struct { + staticConfig *config.StaticConfig +} var _ Provider = &AuthHeadersClusterProvider{} @@ -85,7 +87,7 @@ func newAuthHeadersClusterProvider(cfg *config.StaticConfig) (Provider, error) { klog.V(1).Infof("Auth-headers provider initialized - all requests must include valid headers") - return &AuthHeadersClusterProvider{}, nil + return &AuthHeadersClusterProvider{staticConfig: cfg}, nil } func (p *AuthHeadersClusterProvider) IsOpenShift(ctx context.Context) bool { @@ -106,11 +108,57 @@ func (p *AuthHeadersClusterProvider) GetTargetParameterName() string { } func (p *AuthHeadersClusterProvider) GetDerivedKubernetes(ctx context.Context, target string) (*Kubernetes, error) { - // _, err := New(ctx) + // authHeaders, ok := ctx.Value(AuthHeadersContextKey).(*K8sAuthHeaders) + // if !ok { + // return nil, errors.New("authHeaders required") + // } + + // decodedCA, err := authHeaders.GetDecodedCertificateAuthorityData() // if err != nil { - // return nil, err + // return nil, fmt.Errorf("failed to decode certificate authority data: %w", err) + // } + + // restConfig := &rest.Config{ + // Host: authHeaders.ClusterURL, + // BearerToken: authHeaders.AuthorizationToken, + // TLSClientConfig: rest.TLSClientConfig{ + // Insecure: false, + // CAData: decodedCA, + // }, // } + // _ := clientcmd.NewDefaultClientConfig(*restConfig, nil) + + // // Create a REST config with only cluster connection details (no auth) + // restConfig := &rest.Config{ + // Host: cluster.Server, + // APIPath: m.cfg.APIPath, + // TLSClientConfig: rest.TLSClientConfig{ + // Insecure: cluster.InsecureSkipTLSVerify, + // ServerName: cluster.TLSServerName, + // CAData: cluster.CertificateAuthorityData, + // CAFile: cluster.CertificateAuthority, + // }, + // UserAgent: rest.DefaultKubernetesUserAgent(), + // QPS: m.cfg.QPS, + // Burst: m.cfg.Burst, + // Timeout: m.cfg.Timeout, + // } + + // // Create a minimal clientcmd config without any authentication + // minimalConfig := clientcmdapi.NewConfig() + // minimalConfig.Clusters["cluster"] = &clientcmdapi.Cluster{ + // Server: cluster.Server, + // InsecureSkipTLSVerify: cluster.InsecureSkipTLSVerify, + // CertificateAuthority: cluster.CertificateAuthority, + // CertificateAuthorityData: cluster.CertificateAuthorityData, + // TLSServerName: cluster.TLSServerName, + // } + // minimalConfig.Contexts["auth-headers-context"] = &clientcmdapi.Context{ + // Cluster: "cluster", + // } + // minimalConfig.CurrentContext = "auth-headers-context" + // derivedCfg := &rest.Config{ // Host: authHeaders.ClusterURL, // APIPath: m.cfg.APIPath, From b87b3cfe67dec6eea6cf6bb48112eab02d5ff60d Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Fri, 21 Nov 2025 13:45:54 +0100 Subject: [PATCH 03/21] test(http): try to reduce test flakiness (#489) Signed-off-by: Marc Nuri Signed-off-by: Muhammad Faizan --- internal/test/mcp.go | 4 +++- internal/test/test.go | 22 ++++++++++++++++++++++ pkg/http/http_authorization_test.go | 13 +++++++++++-- pkg/http/http_mcp_test.go | 4 ++-- pkg/http/http_test.go | 15 ++++++++------- 5 files changed, 46 insertions(+), 12 deletions(-) diff --git a/internal/test/mcp.go b/internal/test/mcp.go index 174fe4eb..b330fd13 100644 --- a/internal/test/mcp.go +++ b/internal/test/mcp.go @@ -13,7 +13,9 @@ import ( ) func McpInitRequest() mcp.InitializeRequest { - initRequest := mcp.InitializeRequest{} + initRequest := mcp.InitializeRequest{ + Request: mcp.Request{Method: "initialize"}, + } initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION initRequest.Params.ClientInfo = mcp.Implementation{Name: "test", Version: "1.33.7"} return initRequest diff --git a/internal/test/test.go b/internal/test/test.go index c2ccec4e..3fb39626 100644 --- a/internal/test/test.go +++ b/internal/test/test.go @@ -3,6 +3,7 @@ package test import ( "fmt" "net" + "net/http" "os" "path/filepath" "runtime" @@ -49,3 +50,24 @@ func WaitForServer(tcpAddr *net.TCPAddr) error { } return err } + +// WaitForHealthz waits for the /healthz endpoint to return a non-404 response +func WaitForHealthz(tcpAddr *net.TCPAddr) error { + url := fmt.Sprintf("http://%s/healthz", tcpAddr.String()) + var resp *http.Response + var err error + for i := 0; i < 100; i++ { + resp, err = http.Get(url) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode != http.StatusNotFound { + return nil + } + } + time.Sleep(50 * time.Millisecond) + } + if err != nil { + return err + } + return fmt.Errorf("healthz endpoint returned 404 after retries") +} diff --git a/pkg/http/http_authorization_test.go b/pkg/http/http_authorization_test.go index 68987c00..ed7bf4b0 100644 --- a/pkg/http/http_authorization_test.go +++ b/pkg/http/http_authorization_test.go @@ -31,6 +31,7 @@ func (s *AuthorizationSuite) SetupTest() { s.BaseHttpSuite.SetupTest() // Capture logs + s.logBuffer.Reset() s.klogState = klog.CaptureState() flags := flag.NewFlagSet("test", flag.ContinueOnError) klog.InitFlags(flags) @@ -59,14 +60,14 @@ func (s *AuthorizationSuite) TearDownTest() { func (s *AuthorizationSuite) StartClient(options ...transport.StreamableHTTPCOption) { var err error - s.mcpClient, err = client.NewStreamableHttpClient(fmt.Sprintf("http://127.0.0.1:%d/mcp", s.TcpAddr.Port), options...) + s.mcpClient, err = client.NewStreamableHttpClient(fmt.Sprintf("http://127.0.0.1:%s/mcp", s.StaticConfig.Port), options...) s.Require().NoError(err, "Expected no error creating Streamable HTTP MCP client") err = s.mcpClient.Start(s.T().Context()) s.Require().NoError(err, "Expected no error starting Streamable HTTP MCP client") } func (s *AuthorizationSuite) HttpGet(authHeader string) *http.Response { - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/mcp", s.TcpAddr.Port), nil) + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%s/mcp", s.StaticConfig.Port), nil) s.Require().NoError(err, "Failed to create request") if authHeader != "" { req.Header.Set("Authorization", authHeader) @@ -339,6 +340,7 @@ func (s *AuthorizationSuite) TestAuthorizationRawToken() { for _, c := range cases { s.StaticConfig.OAuthAudience = c.audience s.StaticConfig.ValidateToken = c.validateToken + s.logBuffer.Reset() s.StartServer() s.StartClient(transport.WithHTTPHeaders(map[string]string{ "Authorization": "Bearer " + tokenBasicNotExpired, @@ -362,7 +364,9 @@ func (s *AuthorizationSuite) TestAuthorizationRawToken() { }) }) _ = s.mcpClient.Close() + s.mcpClient = nil s.StopServer() + s.Require().NoError(s.WaitForShutdown()) } } @@ -407,7 +411,9 @@ func (s *AuthorizationSuite) TestAuthorizationOidcToken() { }) }) _ = s.mcpClient.Close() + s.mcpClient = nil s.StopServer() + s.Require().NoError(s.WaitForShutdown()) } } @@ -440,6 +446,7 @@ func (s *AuthorizationSuite) TestAuthorizationOidcTokenExchange() { s.StaticConfig.StsClientSecret = "test-sts-client-secret" s.StaticConfig.StsAudience = "backend-audience" s.StaticConfig.StsScopes = []string{"backend-scope"} + s.logBuffer.Reset() s.StartServer() s.StartClient(transport.WithHTTPHeaders(map[string]string{ "Authorization": "Bearer " + validOidcClientToken, @@ -463,7 +470,9 @@ func (s *AuthorizationSuite) TestAuthorizationOidcTokenExchange() { }) }) _ = s.mcpClient.Close() + s.mcpClient = nil s.StopServer() + s.Require().NoError(s.WaitForShutdown()) } } diff --git a/pkg/http/http_mcp_test.go b/pkg/http/http_mcp_test.go index 2a79b4be..0bd5cd9b 100644 --- a/pkg/http/http_mcp_test.go +++ b/pkg/http/http_mcp_test.go @@ -25,7 +25,7 @@ func (s *McpTransportSuite) TearDownTest() { } func (s *McpTransportSuite) TestSseTransport() { - sseClient, sseClientErr := client.NewSSEMCPClient(fmt.Sprintf("http://127.0.0.1:%d/sse", s.TcpAddr.Port)) + sseClient, sseClientErr := client.NewSSEMCPClient(fmt.Sprintf("http://127.0.0.1:%s/sse", s.StaticConfig.Port)) s.Require().NoError(sseClientErr, "Expected no error creating SSE MCP client") startErr := sseClient.Start(s.T().Context()) s.Require().NoError(startErr, "Expected no error starting SSE MCP client") @@ -44,7 +44,7 @@ func (s *McpTransportSuite) TestSseTransport() { } func (s *McpTransportSuite) TestStreamableHttpTransport() { - httpClient, httpClientErr := client.NewStreamableHttpClient(fmt.Sprintf("http://127.0.0.1:%d/mcp", s.TcpAddr.Port), transport.WithContinuousListening()) + httpClient, httpClientErr := client.NewStreamableHttpClient(fmt.Sprintf("http://127.0.0.1:%s/mcp", s.StaticConfig.Port), transport.WithContinuousListening()) s.Require().NoError(httpClientErr, "Expected no error creating Streamable HTTP MCP client") startErr := httpClient.Start(s.T().Context()) s.Require().NoError(startErr, "Expected no error starting Streamable HTTP MCP client") diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go index 64c3355e..091fca2e 100644 --- a/pkg/http/http_test.go +++ b/pkg/http/http_test.go @@ -33,7 +33,6 @@ import ( type BaseHttpSuite struct { suite.Suite MockServer *test.MockServer - TcpAddr *net.TCPAddr StaticConfig *config.StaticConfig mcpServer *mcp.Server OidcProvider *oidc.Provider @@ -43,18 +42,19 @@ type BaseHttpSuite struct { } func (s *BaseHttpSuite) SetupTest() { - var err error http.DefaultClient.Timeout = 10 * time.Second s.MockServer = test.NewMockServer() - s.TcpAddr, err = test.RandomPortAddress() - s.Require().NoError(err, "Expected no error getting random port address") + s.MockServer.Handle(&test.DiscoveryClientHandler{}) s.StaticConfig = config.Default() s.StaticConfig.KubeConfig = s.MockServer.KubeconfigFile(s.T()) - s.StaticConfig.Port = strconv.Itoa(s.TcpAddr.Port) } func (s *BaseHttpSuite) StartServer() { - var err error + + tcpAddr, err := test.RandomPortAddress() + s.Require().NoError(err, "Expected no error getting random port address") + s.StaticConfig.Port = strconv.Itoa(tcpAddr.Port) + s.mcpServer, err = mcp.NewServer(mcp.Configuration{StaticConfig: s.StaticConfig}) s.Require().NoError(err, "Expected no error creating MCP server") s.Require().NotNil(s.mcpServer, "MCP server should not be nil") @@ -64,7 +64,8 @@ func (s *BaseHttpSuite) StartServer() { cancelCtx, s.StopServer = context.WithCancel(gc) group.Go(func() error { return Serve(cancelCtx, s.mcpServer, s.StaticConfig, s.OidcProvider, nil) }) s.WaitForShutdown = group.Wait - s.Require().NoError(test.WaitForServer(s.TcpAddr), "HTTP server did not start in time") + s.Require().NoError(test.WaitForServer(tcpAddr), "HTTP server did not start in time") + s.Require().NoError(test.WaitForHealthz(tcpAddr), "HTTP server /healthz endpoint did not respond with non-404 in time") } func (s *BaseHttpSuite) TearDownTest() { From c21d5050d45e200c757cb689d64e3c20ab54cde8 Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Mon, 17 Nov 2025 16:15:39 -0500 Subject: [PATCH 04/21] feat: add accesscontrol rest client Signed-off-by: Calum Murray Signed-off-by: Muhammad Faizan --- pkg/kubernetes/accesscontrol_restclient.go | 61 ++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 pkg/kubernetes/accesscontrol_restclient.go diff --git a/pkg/kubernetes/accesscontrol_restclient.go b/pkg/kubernetes/accesscontrol_restclient.go new file mode 100644 index 00000000..79a2390d --- /dev/null +++ b/pkg/kubernetes/accesscontrol_restclient.go @@ -0,0 +1,61 @@ +package kubernetes + +import ( + "fmt" + "net/http" + "strings" + + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type AccessControlRoundTripper struct { + delegate http.RoundTripper + accessControlRESTMapper *AccessControlRESTMapper +} + +func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + gvr, err := parseURLToGVR(req.URL.Path) + if err != nil { + return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to parse url: %w", err) + } + + _, err = rt.accessControlRESTMapper.KindFor(gvr) + if err != nil { + return nil, fmt.Errorf("not allowed to access resource: %v", gvr) + } + + return rt.delegate.RoundTrip(req) +} + +func parseURLToGVR(path string) (schema.GroupVersionResource, error) { + parts := strings.Split(strings.Trim(path, "/"), "/") + + if len(parts) < 3 { + return schema.GroupVersionResource{}, fmt.Errorf("not an api path: %s", path) + } + + gvr := schema.GroupVersionResource{} + + switch parts[0] { + case "api": + gvr.Group = "" + gvr.Version = parts[1] + if parts[2] == "namespaces" && len(parts) > 4 { + gvr.Resource = parts[4] + } else { + gvr.Resource = parts[2] + } + case "apis": + gvr.Group = parts[1] + gvr.Version = parts[2] + if parts[3] == "namespaces" && len(parts) > 5 { + gvr.Resource = parts[5] + } else { + gvr.Resource = parts[3] + } + default: + return schema.GroupVersionResource{}, fmt.Errorf("unknown prefix: %s", parts[0]) + } + + return gvr, nil +} From ec739d1e9c85aadb3f8c49db7dad1ed8bf518186 Mon Sep 17 00:00:00 2001 From: Calum Murray Date: Mon, 17 Nov 2025 16:21:32 -0500 Subject: [PATCH 05/21] feat: expose accesscontrol rest client through Kubernetes interface Signed-off-by: Calum Murray Signed-off-by: Muhammad Faizan --- pkg/kubernetes/kubernetes.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index e4dc33c3..84003c28 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -1,13 +1,16 @@ package kubernetes import ( - "k8s.io/apimachinery/pkg/runtime" + "net/http" - "github.com/containers/kubernetes-mcp-server/pkg/helm" - "github.com/containers/kubernetes-mcp-server/pkg/kiali" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" + + "github.com/containers/kubernetes-mcp-server/pkg/helm" + "github.com/containers/kubernetes-mcp-server/pkg/kiali" ) type HeaderKey string @@ -33,6 +36,28 @@ type Kubernetes struct { manager *Manager } +// AccessControlRestClient returns the access-controlled rest.Interface +// This ensures that any denied resources configured in the system are properly enforced +func (k *Kubernetes) AccessControlRestClient() (rest.Interface, error) { + config, err := k.manager.ToRESTConfig() + if err != nil { + return nil, err + } + config.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { + return &AccessControlRoundTripper{ + delegate: rt, + accessControlRESTMapper: k.manager.accessControlRESTMapper, + } + } + + client, err := rest.RESTClientFor(config) + if err != nil { + return nil, err + } + + return client, nil +} + // AccessControlClientset returns the access-controlled clientset // This ensures that any denied resources configured in the system are properly enforced func (k *Kubernetes) AccessControlClientset() *AccessControlClientset { From 0806afb6c7921ec92330435822054f55c7a31590 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Wed, 19 Nov 2025 11:57:19 +0100 Subject: [PATCH 06/21] feat(kubernetes): access control round tripper Signed-off-by: Marc Nuri Signed-off-by: Muhammad Faizan --- pkg/kubernetes/accesscontrol_restclient.go | 61 ----- pkg/kubernetes/accesscontrol_restmapper.go | 80 ------ pkg/kubernetes/accesscontrol_round_tripper.go | 70 +++++ .../accesscontrol_round_tripper_test.go | 247 ++++++++++++++++++ pkg/kubernetes/kubernetes.go | 26 -- pkg/kubernetes/kubernetes_derived_test.go | 4 +- pkg/kubernetes/manager.go | 37 +-- pkg/kubernetes/resources.go | 4 +- pkg/mcp/helm_test.go | 35 +++ 9 files changed, 376 insertions(+), 188 deletions(-) delete mode 100644 pkg/kubernetes/accesscontrol_restclient.go delete mode 100644 pkg/kubernetes/accesscontrol_restmapper.go create mode 100644 pkg/kubernetes/accesscontrol_round_tripper.go create mode 100644 pkg/kubernetes/accesscontrol_round_tripper_test.go diff --git a/pkg/kubernetes/accesscontrol_restclient.go b/pkg/kubernetes/accesscontrol_restclient.go deleted file mode 100644 index 79a2390d..00000000 --- a/pkg/kubernetes/accesscontrol_restclient.go +++ /dev/null @@ -1,61 +0,0 @@ -package kubernetes - -import ( - "fmt" - "net/http" - "strings" - - "k8s.io/apimachinery/pkg/runtime/schema" -) - -type AccessControlRoundTripper struct { - delegate http.RoundTripper - accessControlRESTMapper *AccessControlRESTMapper -} - -func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - gvr, err := parseURLToGVR(req.URL.Path) - if err != nil { - return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to parse url: %w", err) - } - - _, err = rt.accessControlRESTMapper.KindFor(gvr) - if err != nil { - return nil, fmt.Errorf("not allowed to access resource: %v", gvr) - } - - return rt.delegate.RoundTrip(req) -} - -func parseURLToGVR(path string) (schema.GroupVersionResource, error) { - parts := strings.Split(strings.Trim(path, "/"), "/") - - if len(parts) < 3 { - return schema.GroupVersionResource{}, fmt.Errorf("not an api path: %s", path) - } - - gvr := schema.GroupVersionResource{} - - switch parts[0] { - case "api": - gvr.Group = "" - gvr.Version = parts[1] - if parts[2] == "namespaces" && len(parts) > 4 { - gvr.Resource = parts[4] - } else { - gvr.Resource = parts[2] - } - case "apis": - gvr.Group = parts[1] - gvr.Version = parts[2] - if parts[3] == "namespaces" && len(parts) > 5 { - gvr.Resource = parts[5] - } else { - gvr.Resource = parts[3] - } - default: - return schema.GroupVersionResource{}, fmt.Errorf("unknown prefix: %s", parts[0]) - } - - return gvr, nil -} diff --git a/pkg/kubernetes/accesscontrol_restmapper.go b/pkg/kubernetes/accesscontrol_restmapper.go deleted file mode 100644 index 06269480..00000000 --- a/pkg/kubernetes/accesscontrol_restmapper.go +++ /dev/null @@ -1,80 +0,0 @@ -package kubernetes - -import ( - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/restmapper" - - "github.com/containers/kubernetes-mcp-server/pkg/config" -) - -type AccessControlRESTMapper struct { - delegate *restmapper.DeferredDiscoveryRESTMapper - staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice -} - -var _ meta.RESTMapper = &AccessControlRESTMapper{} - -func (a AccessControlRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) { - gvk, err := a.delegate.KindFor(resource) - if err != nil { - return schema.GroupVersionKind{}, err - } - if !isAllowed(a.staticConfig, &gvk) { - return schema.GroupVersionKind{}, isNotAllowedError(&gvk) - } - return gvk, nil -} - -func (a AccessControlRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) { - gvks, err := a.delegate.KindsFor(resource) - if err != nil { - return nil, err - } - for i := range gvks { - if !isAllowed(a.staticConfig, &gvks[i]) { - return nil, isNotAllowedError(&gvks[i]) - } - } - return gvks, nil -} - -func (a AccessControlRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) { - return a.delegate.ResourceFor(input) -} - -func (a AccessControlRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) { - return a.delegate.ResourcesFor(input) -} - -func (a AccessControlRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) { - for _, version := range versions { - gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - } - return a.delegate.RESTMapping(gk, versions...) -} - -func (a AccessControlRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) { - for _, version := range versions { - gvk := &schema.GroupVersionKind{Group: gk.Group, Version: version, Kind: gk.Kind} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - } - return a.delegate.RESTMappings(gk, versions...) -} - -func (a AccessControlRESTMapper) ResourceSingularizer(resource string) (singular string, err error) { - return a.delegate.ResourceSingularizer(resource) -} - -func (a AccessControlRESTMapper) Reset() { - a.delegate.Reset() -} - -func NewAccessControlRESTMapper(delegate *restmapper.DeferredDiscoveryRESTMapper, staticConfig *config.StaticConfig) *AccessControlRESTMapper { - return &AccessControlRESTMapper{delegate: delegate, staticConfig: staticConfig} -} diff --git a/pkg/kubernetes/accesscontrol_round_tripper.go b/pkg/kubernetes/accesscontrol_round_tripper.go new file mode 100644 index 00000000..909568c2 --- /dev/null +++ b/pkg/kubernetes/accesscontrol_round_tripper.go @@ -0,0 +1,70 @@ +package kubernetes + +import ( + "fmt" + "net/http" + "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/config" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +type AccessControlRoundTripper struct { + delegate http.RoundTripper + staticConfig *config.StaticConfig + restMapper meta.RESTMapper +} + +func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + gvr, ok := parseURLToGVR(req.URL.Path) + // Not an API resource request, just pass through + if !ok { + return rt.delegate.RoundTrip(req) + } + + gvk, err := rt.restMapper.KindFor(gvr) + if err != nil { + return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to get kind for gvr %v: %w", gvr, err) + } + if !isAllowed(rt.staticConfig, &gvk) { + return nil, isNotAllowedError(&gvk) + } + + return rt.delegate.RoundTrip(req) +} + +func parseURLToGVR(path string) (gvr schema.GroupVersionResource, ok bool) { + parts := strings.Split(strings.Trim(path, "/"), "/") + + gvr = schema.GroupVersionResource{} + switch parts[0] { + case "api": + // /api or /api/v1 are discovery endpoints + if len(parts) < 3 { + return + } + gvr.Group = "" + gvr.Version = parts[1] + if parts[2] == "namespaces" && len(parts) > 4 { + gvr.Resource = parts[4] + } else { + gvr.Resource = parts[2] + } + case "apis": + // /apis, /apis/apps, or /apis/apps/v1 are discovery endpoints + if len(parts) < 4 { + return + } + gvr.Group = parts[1] + gvr.Version = parts[2] + if parts[3] == "namespaces" && len(parts) > 5 { + gvr.Resource = parts[5] + } else { + gvr.Resource = parts[3] + } + default: + return + } + return gvr, true +} diff --git a/pkg/kubernetes/accesscontrol_round_tripper_test.go b/pkg/kubernetes/accesscontrol_round_tripper_test.go new file mode 100644 index 00000000..8706df20 --- /dev/null +++ b/pkg/kubernetes/accesscontrol_round_tripper_test.go @@ -0,0 +1,247 @@ +package kubernetes + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/BurntSushi/toml" + "github.com/containers/kubernetes-mcp-server/internal/test" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/stretchr/testify/suite" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/restmapper" +) + +type mockRoundTripper struct { + called *bool + onRequest func(w http.ResponseWriter, r *http.Request) +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + *m.called = true + rec := httptest.NewRecorder() + m.onRequest(rec, req) + return rec.Result(), nil +} + +type AccessControlRoundTripperTestSuite struct { + suite.Suite + mockServer *test.MockServer + restMapper *restmapper.DeferredDiscoveryRESTMapper +} + +func (s *AccessControlRoundTripperTestSuite) SetupTest() { + s.mockServer = test.NewMockServer() + s.mockServer.Handle(&test.DiscoveryClientHandler{}) + + clientSet, err := kubernetes.NewForConfig(s.mockServer.Config()) + s.Require().NoError(err, "Expected no error creating clientset") + + s.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(memory.NewMemCacheClient(clientSet.Discovery())) +} + +func (s *AccessControlRoundTripperTestSuite) TearDownTest() { + s.mockServer.Close() +} + +func (s *AccessControlRoundTripperTestSuite) TestRoundTripForNonAPIResources() { + delegateCalled := false + mockDelegate := &mockRoundTripper{ + called: &delegateCalled, + onRequest: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + } + + rt := &AccessControlRoundTripper{ + delegate: mockDelegate, + staticConfig: nil, + restMapper: s.restMapper, + } + + testCases := []string{"healthz", "readyz", "livez", "metrics", "version"} + for _, testCase := range testCases { + s.Run("/"+testCase+" check endpoint bypasses access control", func() { + delegateCalled = false + resp, err := rt.RoundTrip(httptest.NewRequest("GET", "/"+testCase, nil)) + s.NoError(err) + s.NotNil(resp) + s.Equal(http.StatusOK, resp.StatusCode) + s.Truef(delegateCalled, "Expected delegate to be called for /%s", testCase) + }) + } +} + +func (s *AccessControlRoundTripperTestSuite) TestRoundTripForDiscoveryRequests() { + delegateCalled := false + mockDelegate := &mockRoundTripper{ + called: &delegateCalled, + onRequest: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + } + + rt := &AccessControlRoundTripper{ + delegate: mockDelegate, + staticConfig: nil, + restMapper: s.restMapper, + } + + testCases := []string{"/api", "/apis", "/api/v1", "/api/v1/", "/apis/apps", "/apis/apps/v1", "/apis/batch/v1"} + for _, testCase := range testCases { + s.Run("API Discovery endpoint "+testCase+" bypasses access control", func() { + delegateCalled = false + resp, err := rt.RoundTrip(httptest.NewRequest("GET", testCase, nil)) + s.NoError(err) + s.NotNil(resp) + s.Equal(http.StatusOK, resp.StatusCode) + s.True(delegateCalled, "Expected delegate to be called for /api") + }) + } +} + +func (s *AccessControlRoundTripperTestSuite) TestRoundTripForAllowedAPIResources() { + delegateCalled := false + mockDelegate := &mockRoundTripper{ + called: &delegateCalled, + onRequest: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + } + + rt := &AccessControlRoundTripper{ + delegate: mockDelegate, + staticConfig: nil, // nil config allows all resources + restMapper: s.restMapper, + } + + s.Run("List all pods is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/pods", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.Equal(http.StatusOK, resp.StatusCode) + s.True(delegateCalled, "Expected delegate to be called for listing pods") + }) + + s.Run("List pods in namespace is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/namespaces/default/pods", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for namespaced pods list") + }) + + s.Run("Get specific pod is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/my-pod", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for getting specific pod") + }) + + s.Run("Resource path with trailing slash is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/pods/", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for path with trailing slash") + }) + + s.Run("List Deployments is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/apis/apps/v1/deployments", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for listing deployments") + }) + + s.Run("List Deployments in namespace is allowed", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/apis/apps/v1/namespaces/default/deployments", nil) + resp, err := rt.RoundTrip(req) + s.NoError(err) + s.NotNil(resp) + s.True(delegateCalled, "Expected delegate to be called for namespaced deployments list") + }) +} + +func (s *AccessControlRoundTripperTestSuite) TestRoundTripForDeniedAPIResources() { + delegateCalled := false + mockDelegate := &mockRoundTripper{ + called: &delegateCalled, + onRequest: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }, + } + rt := &AccessControlRoundTripper{ + delegate: mockDelegate, + staticConfig: config.Default(), + restMapper: s.restMapper, + } + + s.Run("Specific resource kind is denied", func() { + s.Require().NoError(toml.Unmarshal([]byte(` + denied_resources = [ { version = "v1", kind = "Pod" } ] + `), rt.staticConfig), "Expected to parse denied resources config") + + s.Run("List pods is denied", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/pods", nil) + resp, err := rt.RoundTrip(req) + s.Error(err) + s.Nil(resp) + s.False(delegateCalled, "Expected delegate not to be called for denied resource") + s.Contains(err.Error(), "resource not allowed") + s.Contains(err.Error(), "Pod") + }) + + s.Run("Get specific pod is denied", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/namespaces/default/pods/my-pod", nil) + resp, err := rt.RoundTrip(req) + s.Error(err) + s.Nil(resp) + s.False(delegateCalled) + s.Contains(err.Error(), "resource not allowed") + }) + }) + + s.Run("Entire group/version is denied", func() { + s.Require().NoError(toml.Unmarshal([]byte(` + denied_resources = [ { version = "v1", kind = "" } ] + `), rt.staticConfig), "Expected to v1 denied resources config") + + s.Run("Pods in core/v1 are denied", func() { + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/pods", nil) + resp, err := rt.RoundTrip(req) + s.Error(err) + s.Nil(resp) + s.False(delegateCalled) + }) + + }) + + s.Run("RESTMapper error for unknown resource", func() { + rt.staticConfig = nil + delegateCalled = false + req := httptest.NewRequest("GET", "/api/v1/unknownresources", nil) + resp, err := rt.RoundTrip(req) + s.Error(err) + s.Nil(resp) + s.False(delegateCalled, "Expected delegate not to be called when RESTMapper fails") + s.Contains(err.Error(), "failed to make request") + }) +} + +func TestAccessControlRoundTripper(t *testing.T) { + suite.Run(t, new(AccessControlRoundTripperTestSuite)) +} diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 84003c28..8ccdef2c 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -1,12 +1,8 @@ package kubernetes import ( - "net/http" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - _ "k8s.io/client-go/plugin/pkg/client/auth/oidc" "github.com/containers/kubernetes-mcp-server/pkg/helm" @@ -36,28 +32,6 @@ type Kubernetes struct { manager *Manager } -// AccessControlRestClient returns the access-controlled rest.Interface -// This ensures that any denied resources configured in the system are properly enforced -func (k *Kubernetes) AccessControlRestClient() (rest.Interface, error) { - config, err := k.manager.ToRESTConfig() - if err != nil { - return nil, err - } - config.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { - return &AccessControlRoundTripper{ - delegate: rt, - accessControlRESTMapper: k.manager.accessControlRESTMapper, - } - } - - client, err := rest.RESTClientFor(config) - if err != nil { - return nil, err - } - - return client, nil -} - // AccessControlClientset returns the access-controlled clientset // This ensures that any denied resources configured in the system are properly enforced func (k *Kubernetes) AccessControlClientset() *AccessControlClientset { diff --git a/pkg/kubernetes/kubernetes_derived_test.go b/pkg/kubernetes/kubernetes_derived_test.go index 69d4ef33..7d0dd90d 100644 --- a/pkg/kubernetes/kubernetes_derived_test.go +++ b/pkg/kubernetes/kubernetes_derived_test.go @@ -124,8 +124,8 @@ users: s.NotNilf(derived.manager.accessControlClientSet, "expected accessControlClientSet to be initialized") s.Equalf(testStaticConfig, derived.manager.accessControlClientSet.staticConfig, "staticConfig not properly wired to derived manager") s.NotNilf(derived.manager.discoveryClient, "expected discoveryClient to be initialized") - s.NotNilf(derived.manager.accessControlRESTMapper, "expected accessControlRESTMapper to be initialized") - s.Equalf(testStaticConfig, derived.manager.accessControlRESTMapper.staticConfig, "staticConfig not properly wired to derived manager") + s.NotNilf(derived.manager.restMapper, "expected accessControlRESTMapper to be initialized") + //s.Equalf(testStaticConfig, derived.manager.re.staticConfig, "staticConfig not properly wired to derived manager") s.NotNilf(derived.manager.dynamicClient, "expected dynamicClient to be initialized") }) }) diff --git a/pkg/kubernetes/manager.go b/pkg/kubernetes/manager.go index d09b8790..d2e5d0fe 100644 --- a/pkg/kubernetes/manager.go +++ b/pkg/kubernetes/manager.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "github.com/containers/kubernetes-mcp-server/pkg/config" @@ -23,12 +24,12 @@ import ( ) type Manager struct { - cfg *rest.Config - clientCmdConfig clientcmd.ClientConfig - discoveryClient discovery.CachedDiscoveryInterface - accessControlClientSet *AccessControlClientset - accessControlRESTMapper *AccessControlRESTMapper - dynamicClient *dynamic.DynamicClient + cfg *rest.Config + clientCmdConfig clientcmd.ClientConfig + discoveryClient discovery.CachedDiscoveryInterface + restMapper *restmapper.DeferredDiscoveryRESTMapper + accessControlClientSet *AccessControlClientset + dynamicClient *dynamic.DynamicClient staticConfig *config.StaticConfig CloseWatchKubeConfig CloseWatchKubeConfig @@ -117,10 +118,14 @@ func newManager(config *config.StaticConfig, restConfig *rest.Config, clientCmdC return nil, err } k8s.discoveryClient = memory.NewMemCacheClient(k8s.accessControlClientSet.DiscoveryClient()) - k8s.accessControlRESTMapper = NewAccessControlRESTMapper( - restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient), - k8s.staticConfig, - ) + k8s.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient) + k8s.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper { + return &AccessControlRoundTripper{ + delegate: original, + staticConfig: k8s.staticConfig, + restMapper: k8s.restMapper, + } + }) k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg) if err != nil { return nil, err @@ -189,7 +194,7 @@ func (m *Manager) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error } func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) { - return m.accessControlRESTMapper, nil + return m.restMapper, nil } // ToRESTConfig returns the rest.Config object (genericclioptions.RESTClientGetter) @@ -243,8 +248,9 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { } klog.V(5).Infof("%s header found (Bearer), using provided bearer token", OAuthAuthorizationHeader) derivedCfg := &rest.Config{ - Host: m.cfg.Host, - APIPath: m.cfg.APIPath, + Host: m.cfg.Host, + APIPath: m.cfg.APIPath, + WrapTransport: m.cfg.WrapTransport, // Copy only server verification TLS settings (CA bundle and server name) TLSClientConfig: rest.TLSClientConfig{ Insecure: m.cfg.Insecure, @@ -285,10 +291,7 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { return &Kubernetes{manager: m}, nil } derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient()) - derived.manager.accessControlRESTMapper = NewAccessControlRESTMapper( - restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient), - derived.manager.staticConfig, - ) + derived.manager.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient) derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg) if err != nil { if m.staticConfig.RequireOAuth { diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index 31e59a5f..35fc1a93 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -154,14 +154,14 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u } // Clear the cache to ensure the next operation is performed on the latest exposed APIs (will change after the CRD creation) if gvk.Kind == "CustomResourceDefinition" { - k.manager.accessControlRESTMapper.Reset() + k.manager.restMapper.Reset() } } return resources, nil } func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) { - m, err := k.manager.accessControlRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) + m, err := k.manager.restMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) if err != nil { return nil, err } diff --git a/pkg/mcp/helm_test.go b/pkg/mcp/helm_test.go index f2af3d23..b2b67a08 100644 --- a/pkg/mcp/helm_test.go +++ b/pkg/mcp/helm_test.go @@ -202,6 +202,41 @@ func (s *HelmSuite) TestHelmList() { }) } +func (s *HelmSuite) TestHelmListDenied() { + s.Require().NoError(toml.Unmarshal([]byte(` + denied_resources = [ { version = "v1", kind = "Secret" } ] + `), s.Cfg), "Expected to parse denied resources config") + kc := kubernetes.NewForConfigOrDie(envTestRestConfig) + _, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sh.helm.release.v1.release-to-list-denied", + Labels: map[string]string{"owner": "helm", "name": "release-to-list-denied"}, + }, + Data: map[string][]byte{ + "release": []byte(base64.StdEncoding.EncodeToString([]byte("{" + + "\"name\":\"release-to-list-denied\"," + + "\"info\":{\"status\":\"deployed\"}" + + "}"))), + }, + }, metav1.CreateOptions{}) + s.Require().NoError(err) + s.InitMcpClient() + s.Run("helm_list() with deployed release (denied)", func() { + toolResult, err := s.CallTool("helm_list", map[string]interface{}{}) + s.Run("has error", func() { + s.Truef(toolResult.IsError, "call tool should fail") + s.Nilf(err, "call tool should not return error object") + }) + s.Run("describes denial", func() { + msg := toolResult.Content[0].(mcp.TextContent).Text + s.Contains(msg, "resource not allowed:") + s.Truef(strings.HasPrefix(msg, "failed to list helm releases"), "expected descriptive error, got %v", toolResult.Content[0].(mcp.TextContent).Text) + expectedMessage := ": resource not allowed: /v1, Kind=Secret" + s.Truef(strings.HasSuffix(msg, expectedMessage), "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text) + }) + }) +} + func (s *HelmSuite) TestHelmUninstallNoReleases() { s.InitMcpClient() s.Run("helm_uninstall(name=release-to-uninstall) with no releases", func() { From a4692b42014533dae3cef8b7f9c484afc13c33e8 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Thu, 20 Nov 2025 15:47:54 +0100 Subject: [PATCH 07/21] feat(kubernetes)!: simplified Kubernetes client access for toolsets Signed-off-by: Marc Nuri Signed-off-by: Muhammad Faizan --- pkg/http/http_authorization_test.go | 3 + pkg/kubernetes/accesscontrol.go | 39 --- pkg/kubernetes/accesscontrol_clientset.go | 223 ++++++------------ pkg/kubernetes/accesscontrol_round_tripper.go | 30 ++- pkg/kubernetes/kubernetes.go | 2 +- pkg/kubernetes/kubernetes_derived_test.go | 17 +- pkg/kubernetes/manager.go | 71 ++---- pkg/kubernetes/manager_test.go | 10 +- pkg/kubernetes/nodes.go | 35 ++- pkg/kubernetes/openshift.go | 8 +- pkg/kubernetes/pods.go | 58 +++-- pkg/kubernetes/resources.go | 23 +- 12 files changed, 212 insertions(+), 307 deletions(-) diff --git a/pkg/http/http_authorization_test.go b/pkg/http/http_authorization_test.go index ed7bf4b0..29b1b736 100644 --- a/pkg/http/http_authorization_test.go +++ b/pkg/http/http_authorization_test.go @@ -325,6 +325,7 @@ func (s *AuthorizationSuite) TestAuthorizationRequireOAuthFalse() { } func (s *AuthorizationSuite) TestAuthorizationRawToken() { + s.MockServer.ResetHandlers() tokenReviewHandler := test.NewTokenReviewHandler() s.MockServer.Handle(tokenReviewHandler) @@ -371,6 +372,7 @@ func (s *AuthorizationSuite) TestAuthorizationRawToken() { } func (s *AuthorizationSuite) TestAuthorizationOidcToken() { + s.MockServer.ResetHandlers() tokenReviewHandler := test.NewTokenReviewHandler() s.MockServer.Handle(tokenReviewHandler) @@ -418,6 +420,7 @@ func (s *AuthorizationSuite) TestAuthorizationOidcToken() { } func (s *AuthorizationSuite) TestAuthorizationOidcTokenExchange() { + s.MockServer.ResetHandlers() tokenReviewHandler := test.NewTokenReviewHandler() s.MockServer.Handle(tokenReviewHandler) diff --git a/pkg/kubernetes/accesscontrol.go b/pkg/kubernetes/accesscontrol.go index e35b5dfb..276009a4 100644 --- a/pkg/kubernetes/accesscontrol.go +++ b/pkg/kubernetes/accesscontrol.go @@ -1,40 +1 @@ package kubernetes - -import ( - "fmt" - - "k8s.io/apimachinery/pkg/runtime/schema" - - "github.com/containers/kubernetes-mcp-server/pkg/config" -) - -// isAllowed checks the resource is in denied list or not. -// If it is in denied list, this function returns false. -func isAllowed( - staticConfig *config.StaticConfig, // TODO: maybe just use the denied resource slice - gvk *schema.GroupVersionKind, -) bool { - if staticConfig == nil { - return true - } - - for _, val := range staticConfig.DeniedResources { - // If kind is empty, that means Group/Version pair is denied entirely - if val.Kind == "" { - if gvk.Group == val.Group && gvk.Version == val.Version { - return false - } - } - if gvk.Group == val.Group && - gvk.Version == val.Version && - gvk.Kind == val.Kind { - return false - } - } - - return true -} - -func isNotAllowedError(gvk *schema.GroupVersionKind) error { - return fmt.Errorf("resource not allowed: %s", gvk.String()) -} diff --git a/pkg/kubernetes/accesscontrol_clientset.go b/pkg/kubernetes/accesscontrol_clientset.go index a6c3fccd..e871bd96 100644 --- a/pkg/kubernetes/accesscontrol_clientset.go +++ b/pkg/kubernetes/accesscontrol_clientset.go @@ -1,204 +1,113 @@ package kubernetes import ( - "context" "fmt" + "net/http" - authenticationv1api "k8s.io/api/authentication/v1" - authorizationv1api "k8s.io/api/authorization/v1" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/httpstream" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "k8s.io/apimachinery/pkg/api/meta" "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" authenticationv1 "k8s.io/client-go/kubernetes/typed/authentication/v1" authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1" corev1 "k8s.io/client-go/kubernetes/typed/core/v1" "k8s.io/client-go/rest" - "k8s.io/client-go/tools/remotecommand" - "k8s.io/metrics/pkg/apis/metrics" - metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1" + "k8s.io/client-go/restmapper" metricsv1beta1 "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1" - - "github.com/containers/kubernetes-mcp-server/pkg/config" ) // AccessControlClientset is a limited clientset delegating interface to the standard kubernetes.Clientset // Only a limited set of functions are implemented with a single point of access to the kubernetes API where // apiVersion and kinds are checked for allowed access type AccessControlClientset struct { - cfg *rest.Config - delegate kubernetes.Interface - discoveryClient discovery.DiscoveryInterface + cfg *rest.Config + kubernetes.Interface + restMapper meta.ResettableRESTMapper + discoveryClient discovery.CachedDiscoveryInterface + dynamicClient dynamic.Interface metricsV1beta1 *metricsv1beta1.MetricsV1beta1Client - staticConfig *config.StaticConfig // TODO: maybe just store the denied resource slice -} - -func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface { - return a.discoveryClient } -func (a *AccessControlClientset) Nodes() (corev1.NodeInterface, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) +func NewAccessControlClientset(staticConfig *config.StaticConfig, restConfig *rest.Config) (*AccessControlClientset, error) { + rest.CopyConfig(restConfig) + acc := &AccessControlClientset{ + cfg: rest.CopyConfig(restConfig), } - return a.delegate.CoreV1().Nodes(), nil -} - -func (a *AccessControlClientset) NodesLogs(ctx context.Context, name string) (*rest.Request, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) + if acc.cfg.UserAgent == "" { + acc.cfg.UserAgent = rest.DefaultKubernetesUserAgent() } - - if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil { - return nil, fmt.Errorf("failed to get node %s: %w", name, err) + acc.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper { + return &AccessControlRoundTripper{ + delegate: original, + staticConfig: staticConfig, + restMapper: acc.restMapper, + } + }) + discoveryClient, err := discovery.NewDiscoveryClientForConfig(acc.cfg) + if err != nil { + return nil, fmt.Errorf("failed to create discovery client: %v", err) } - - url := []string{"api", "v1", "nodes", name, "proxy", "logs"} - return a.delegate.CoreV1().RESTClient(). - Get(). - AbsPath(url...), nil -} - -func (a *AccessControlClientset) NodesMetricses(ctx context.Context, name string, listOptions metav1.ListOptions) (*metrics.NodeMetricsList, error) { - gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "NodeMetrics"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) + acc.discoveryClient = memory.NewMemCacheClient(discoveryClient) + acc.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(acc.discoveryClient) + acc.Interface, err = kubernetes.NewForConfig(acc.cfg) + if err != nil { + return nil, err } - versionedMetrics := &metricsv1beta1api.NodeMetricsList{} - var err error - if name != "" { - m, err := a.metricsV1beta1.NodeMetricses().Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to get metrics for node %s: %w", name, err) - } - versionedMetrics.Items = []metricsv1beta1api.NodeMetrics{*m} - } else { - versionedMetrics, err = a.metricsV1beta1.NodeMetricses().List(ctx, listOptions) - if err != nil { - return nil, fmt.Errorf("failed to list node metrics: %w", err) - } + acc.dynamicClient, err = dynamic.NewForConfig(acc.cfg) + if err != nil { + return nil, err } - convertedMetrics := &metrics.NodeMetricsList{} - return convertedMetrics, metricsv1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, convertedMetrics, nil) + acc.metricsV1beta1, err = metricsv1beta1.NewForConfig(acc.cfg) + if err != nil { + return nil, err + } + return acc, nil } -func (a *AccessControlClientset) NodesStatsSummary(ctx context.Context, name string) (*rest.Request, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } +func (a *AccessControlClientset) RESTMapper() meta.ResettableRESTMapper { + return a.restMapper +} - if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil { - return nil, fmt.Errorf("failed to get node %s: %w", name, err) - } +func (a *AccessControlClientset) DiscoveryClient() discovery.CachedDiscoveryInterface { + return a.discoveryClient +} - url := []string{"api", "v1", "nodes", name, "proxy", "stats", "summary"} - return a.delegate.CoreV1().RESTClient(). - Get(). - AbsPath(url...), nil +func (a *AccessControlClientset) DynamicClient() dynamic.Interface { + return a.dynamicClient } -func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - return a.delegate.CoreV1().Pods(namespace), nil +func (a *AccessControlClientset) MetricsV1beta1Client() *metricsv1beta1.MetricsV1beta1Client { + return a.metricsV1beta1 } -func (a *AccessControlClientset) PodsExec(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - // Compute URL - // https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397 - execRequest := a.delegate.CoreV1().RESTClient(). - Post(). - Resource("pods"). - Namespace(namespace). - Name(name). - SubResource("exec") - execRequest.VersionedParams(podExecOptions, ParameterCodec) - spdyExec, err := remotecommand.NewSPDYExecutor(a.cfg, "POST", execRequest.URL()) - if err != nil { - return nil, err - } - webSocketExec, err := remotecommand.NewWebSocketExecutor(a.cfg, "GET", execRequest.URL().String()) - if err != nil { - return nil, err - } - return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool { - return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err) - }) +// Nodes returns NodeInterface +// Deprecated: use CoreV1().Nodes() directly +func (a *AccessControlClientset) Nodes() (corev1.NodeInterface, error) { + return a.CoreV1().Nodes(), nil } -func (a *AccessControlClientset) PodsMetricses(ctx context.Context, namespace, name string, listOptions metav1.ListOptions) (*metrics.PodMetricsList, error) { - gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "PodMetrics"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - versionedMetrics := &metricsv1beta1api.PodMetricsList{} - var err error - if name != "" { - m, err := a.metricsV1beta1.PodMetricses(namespace).Get(ctx, name, metav1.GetOptions{}) - if err != nil { - return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, name, err) - } - versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m} - } else { - versionedMetrics, err = a.metricsV1beta1.PodMetricses(namespace).List(ctx, listOptions) - if err != nil { - return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err) - } - } - convertedMetrics := &metrics.PodMetricsList{} - return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil) +// Pods returns PodInterface +// Deprecated: use CoreV1().Pods(namespace) directly +func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) { + return a.CoreV1().Pods(namespace), nil } +// Services returns ServiceInterface +// Deprecated: use CoreV1().Services(namespace) directly func (a *AccessControlClientset) Services(namespace string) (corev1.ServiceInterface, error) { - gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - return a.delegate.CoreV1().Services(namespace), nil + return a.CoreV1().Services(namespace), nil } +// SelfSubjectAccessReviews returns SelfSubjectAccessReviewInterface +// Deprecated: use AuthorizationV1().SelfSubjectAccessReviews() directly func (a *AccessControlClientset) SelfSubjectAccessReviews() (authorizationv1.SelfSubjectAccessReviewInterface, error) { - gvk := &schema.GroupVersionKind{Group: authorizationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "SelfSubjectAccessReview"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - return a.delegate.AuthorizationV1().SelfSubjectAccessReviews(), nil + return a.AuthorizationV1().SelfSubjectAccessReviews(), nil } // TokenReview returns TokenReviewInterface +// Deprecated: use AuthenticationV1().TokenReviews() directly func (a *AccessControlClientset) TokenReview() (authenticationv1.TokenReviewInterface, error) { - gvk := &schema.GroupVersionKind{Group: authenticationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "TokenReview"} - if !isAllowed(a.staticConfig, gvk) { - return nil, isNotAllowedError(gvk) - } - return a.delegate.AuthenticationV1().TokenReviews(), nil -} - -func NewAccessControlClientset(cfg *rest.Config, staticConfig *config.StaticConfig) (*AccessControlClientset, error) { - clientSet, err := kubernetes.NewForConfig(cfg) - if err != nil { - return nil, err - } - metricsClient, err := metricsv1beta1.NewForConfig(cfg) - if err != nil { - return nil, err - } - return &AccessControlClientset{ - cfg: cfg, - delegate: clientSet, - discoveryClient: clientSet.DiscoveryClient, - metricsV1beta1: metricsClient, - staticConfig: staticConfig, - }, nil + return a.AuthenticationV1().TokenReviews(), nil } diff --git a/pkg/kubernetes/accesscontrol_round_tripper.go b/pkg/kubernetes/accesscontrol_round_tripper.go index 909568c2..c818bb71 100644 --- a/pkg/kubernetes/accesscontrol_round_tripper.go +++ b/pkg/kubernetes/accesscontrol_round_tripper.go @@ -27,13 +27,39 @@ func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Respons if err != nil { return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to get kind for gvr %v: %w", gvr, err) } - if !isAllowed(rt.staticConfig, &gvk) { - return nil, isNotAllowedError(&gvk) + if !rt.isAllowed(gvk) { + return nil, fmt.Errorf("resource not allowed: %s", gvk.String()) } return rt.delegate.RoundTrip(req) } +// isAllowed checks the resource is in denied list or not. +// If it is in denied list, this function returns false. +func (rt *AccessControlRoundTripper) isAllowed( + gvk schema.GroupVersionKind, +) bool { + if rt.staticConfig == nil { + return true + } + + for _, val := range rt.staticConfig.DeniedResources { + // If kind is empty, that means Group/Version pair is denied entirely + if val.Kind == "" { + if gvk.Group == val.Group && gvk.Version == val.Version { + return false + } + } + if gvk.Group == val.Group && + gvk.Version == val.Version && + gvk.Kind == val.Kind { + return false + } + } + + return true +} + func parseURLToGVR(path string) (gvr schema.GroupVersionResource, ok bool) { parts := strings.Split(strings.Trim(path, "/"), "/") diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index 8ccdef2c..e0983888 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -49,5 +49,5 @@ func (k *Kubernetes) NewHelm() *helm.Helm { // NewKiali returns a Kiali client initialized with the same StaticConfig and bearer token // as the underlying derived Kubernetes manager. func (k *Kubernetes) NewKiali() *kiali.Kiali { - return kiali.NewKiali(k.manager.staticConfig, k.manager.cfg) + return kiali.NewKiali(k.manager.staticConfig, k.AccessControlClientset().cfg) } diff --git a/pkg/kubernetes/kubernetes_derived_test.go b/pkg/kubernetes/kubernetes_derived_test.go index 7d0dd90d..88a39da3 100644 --- a/pkg/kubernetes/kubernetes_derived_test.go +++ b/pkg/kubernetes/kubernetes_derived_test.go @@ -82,10 +82,10 @@ users: s.Equal(derived.manager.staticConfig, testStaticConfig, "staticConfig not properly wired to derived manager") s.Run("RestConfig is correctly copied and sensitive fields are omitted", func() { - derivedCfg := derived.manager.cfg + derivedCfg := derived.manager.accessControlClientSet.cfg s.Require().NotNil(derivedCfg, "derived config is nil") - originalCfg := testManager.cfg + originalCfg := testManager.accessControlClientSet.cfg s.Equalf(originalCfg.Host, derivedCfg.Host, "expected Host %s, got %s", originalCfg.Host, derivedCfg.Host) s.Equalf(originalCfg.APIPath, derivedCfg.APIPath, "expected APIPath %s, got %s", originalCfg.APIPath, derivedCfg.APIPath) s.Equalf(originalCfg.QPS, derivedCfg.QPS, "expected QPS %f, got %f", originalCfg.QPS, derivedCfg.QPS) @@ -121,12 +121,11 @@ users: }) s.Run("derived manager has initialized clients", func() { // Verify that the derived manager has proper clients initialized - s.NotNilf(derived.manager.accessControlClientSet, "expected accessControlClientSet to be initialized") - s.Equalf(testStaticConfig, derived.manager.accessControlClientSet.staticConfig, "staticConfig not properly wired to derived manager") - s.NotNilf(derived.manager.discoveryClient, "expected discoveryClient to be initialized") - s.NotNilf(derived.manager.restMapper, "expected accessControlRESTMapper to be initialized") - //s.Equalf(testStaticConfig, derived.manager.re.staticConfig, "staticConfig not properly wired to derived manager") - s.NotNilf(derived.manager.dynamicClient, "expected dynamicClient to be initialized") + s.NotNilf(derived.AccessControlClientset(), "expected accessControlClientSet to be initialized") + s.Equalf(testStaticConfig, derived.manager.staticConfig, "staticConfig not properly wired to derived manager") + s.NotNilf(derived.AccessControlClientset().DiscoveryClient(), "expected discoveryClient to be initialized") + s.NotNilf(derived.AccessControlClientset().RESTMapper(), "expected accessControlRESTMapper to be initialized") + s.NotNilf(derived.AccessControlClientset().DynamicClient(), "expected dynamicClient to be initialized") }) }) }) @@ -172,7 +171,7 @@ users: s.NotEqual(derived.manager, testManager, "expected new derived manager, got original manager") s.Equal(derived.manager.staticConfig, testStaticConfig, "staticConfig not properly wired to derived manager") - derivedCfg := derived.manager.cfg + derivedCfg := derived.manager.accessControlClientSet.cfg s.Require().NotNil(derivedCfg, "derived config is nil") s.Equalf("aiTana-julIA", derivedCfg.BearerToken, "expected BearerToken %s, got %s", "aiTana-julIA", derivedCfg.BearerToken) diff --git a/pkg/kubernetes/manager.go b/pkg/kubernetes/manager.go index d2e5d0fe..32bd278e 100644 --- a/pkg/kubernetes/manager.go +++ b/pkg/kubernetes/manager.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/http" "strings" "github.com/containers/kubernetes-mcp-server/pkg/config" @@ -14,22 +13,15 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/discovery" - "k8s.io/client-go/discovery/cached/memory" - "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" - "k8s.io/client-go/restmapper" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "k8s.io/klog/v2" ) type Manager struct { - cfg *rest.Config clientCmdConfig clientcmd.ClientConfig - discoveryClient discovery.CachedDiscoveryInterface - restMapper *restmapper.DeferredDiscoveryRESTMapper accessControlClientSet *AccessControlClientset - dynamicClient *dynamic.DynamicClient staticConfig *config.StaticConfig CloseWatchKubeConfig CloseWatchKubeConfig @@ -102,31 +94,14 @@ func NewInClusterManager(config *config.StaticConfig) (*Manager, error) { func newManager(config *config.StaticConfig, restConfig *rest.Config, clientCmdConfig clientcmd.ClientConfig) (*Manager, error) { k8s := &Manager{ staticConfig: config, - cfg: restConfig, clientCmdConfig: clientCmdConfig, } - if k8s.cfg.UserAgent == "" { - k8s.cfg.UserAgent = rest.DefaultKubernetesUserAgent() - } var err error // TODO: Won't work because not all client-go clients use the shared context (e.g. discovery client uses context.TODO()) //k8s.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper { // return &impersonateRoundTripper{original} //}) - k8s.accessControlClientSet, err = NewAccessControlClientset(k8s.cfg, k8s.staticConfig) - if err != nil { - return nil, err - } - k8s.discoveryClient = memory.NewMemCacheClient(k8s.accessControlClientSet.DiscoveryClient()) - k8s.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient) - k8s.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper { - return &AccessControlRoundTripper{ - delegate: original, - staticConfig: k8s.staticConfig, - restMapper: k8s.restMapper, - } - }) - k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg) + k8s.accessControlClientSet, err = NewAccessControlClientset(k8s.staticConfig, restConfig) if err != nil { return nil, err } @@ -190,16 +165,16 @@ func (m *Manager) NamespaceOrDefault(namespace string) string { } func (m *Manager) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { - return m.discoveryClient, nil + return m.accessControlClientSet.DiscoveryClient(), nil } func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) { - return m.restMapper, nil + return m.accessControlClientSet.RESTMapper(), nil } // ToRESTConfig returns the rest.Config object (genericclioptions.RESTClientGetter) func (m *Manager) ToRESTConfig() (*rest.Config, error) { - return m.cfg, nil + return m.accessControlClientSet.cfg, nil } // ToRawKubeConfigLoader returns the clientcmd.ClientConfig object (genericclioptions.RESTClientGetter) @@ -208,10 +183,7 @@ func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig { } func (m *Manager) VerifyToken(ctx context.Context, token, audience string) (*authenticationv1api.UserInfo, []string, error) { - tokenReviewClient, err := m.accessControlClientSet.TokenReview() - if err != nil { - return nil, nil, err - } + tokenReviewClient := m.accessControlClientSet.AuthenticationV1().TokenReviews() tokenReview := &authenticationv1api.TokenReview{ TypeMeta: metav1.TypeMeta{ APIVersion: "authentication.k8s.io/v1", @@ -248,22 +220,22 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { } klog.V(5).Infof("%s header found (Bearer), using provided bearer token", OAuthAuthorizationHeader) derivedCfg := &rest.Config{ - Host: m.cfg.Host, - APIPath: m.cfg.APIPath, - WrapTransport: m.cfg.WrapTransport, + Host: m.accessControlClientSet.cfg.Host, + APIPath: m.accessControlClientSet.cfg.APIPath, + WrapTransport: m.accessControlClientSet.cfg.WrapTransport, // Copy only server verification TLS settings (CA bundle and server name) TLSClientConfig: rest.TLSClientConfig{ - Insecure: m.cfg.Insecure, - ServerName: m.cfg.ServerName, - CAFile: m.cfg.CAFile, - CAData: m.cfg.CAData, + Insecure: m.accessControlClientSet.cfg.Insecure, + ServerName: m.accessControlClientSet.cfg.ServerName, + CAFile: m.accessControlClientSet.cfg.CAFile, + CAData: m.accessControlClientSet.cfg.CAData, }, BearerToken: strings.TrimPrefix(authorization, "Bearer "), // pass custom UserAgent to identify the client UserAgent: CustomUserAgent, - QPS: m.cfg.QPS, - Burst: m.cfg.Burst, - Timeout: m.cfg.Timeout, + QPS: m.accessControlClientSet.cfg.QPS, + Burst: m.accessControlClientSet.cfg.Burst, + Timeout: m.accessControlClientSet.cfg.Timeout, Impersonate: rest.ImpersonationConfig{}, } clientCmdApiConfig, err := m.clientCmdConfig.RawConfig() @@ -278,11 +250,10 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { derived := &Kubernetes{ manager: &Manager{ clientCmdConfig: clientcmd.NewDefaultClientConfig(clientCmdApiConfig, nil), - cfg: derivedCfg, staticConfig: m.staticConfig, }, } - derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.cfg, derived.manager.staticConfig) + derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.staticConfig, derivedCfg) if err != nil { if m.staticConfig.RequireOAuth { klog.Errorf("failed to get kubeconfig: %v", err) @@ -290,15 +261,5 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) { } return &Kubernetes{manager: m}, nil } - derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient()) - derived.manager.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient) - derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg) - if err != nil { - if m.staticConfig.RequireOAuth { - klog.Errorf("failed to initialize dynamic client: %v", err) - return nil, errors.New("failed to initialize dynamic client") - } - return &Kubernetes{manager: m}, nil - } return derived, nil } diff --git a/pkg/kubernetes/manager_test.go b/pkg/kubernetes/manager_test.go index 63241fa9..c6f9da6a 100644 --- a/pkg/kubernetes/manager_test.go +++ b/pkg/kubernetes/manager_test.go @@ -49,7 +49,7 @@ func (s *ManagerTestSuite) TestNewInClusterManager() { s.Equal("in-cluster", rawConfig.CurrentContext, "expected current context to be 'in-cluster'") }) s.Run("sets default user-agent", func() { - s.Contains(manager.cfg.UserAgent, "("+runtime.GOOS+"/"+runtime.GOARCH+")") + s.Contains(manager.accessControlClientSet.cfg.UserAgent, "("+runtime.GOOS+"/"+runtime.GOARCH+")") }) }) s.Run("with explicit kubeconfig", func() { @@ -98,10 +98,10 @@ func (s *ManagerTestSuite) TestNewKubeconfigManager() { s.Contains(manager.clientCmdConfig.ConfigAccess().GetLoadingPrecedence(), kubeconfig, "expected kubeconfig path to match") }) s.Run("sets default user-agent", func() { - s.Contains(manager.cfg.UserAgent, "("+runtime.GOOS+"/"+runtime.GOARCH+")") + s.Contains(manager.accessControlClientSet.cfg.UserAgent, "("+runtime.GOOS+"/"+runtime.GOARCH+")") }) s.Run("rest config host points to mock server", func() { - s.Equal(s.mockServer.Config().Host, manager.cfg.Host, "expected rest config host to match mock server") + s.Equal(s.mockServer.Config().Host, manager.accessControlClientSet.cfg.Host, "expected rest config host to match mock server") }) }) s.Run("with valid kubeconfig in env and explicit kubeconfig in config", func() { @@ -124,7 +124,7 @@ func (s *ManagerTestSuite) TestNewKubeconfigManager() { s.Contains(manager.clientCmdConfig.ConfigAccess().GetLoadingPrecedence(), kubeconfigExplicit, "expected kubeconfig path to match explicit") }) s.Run("rest config host points to mock server", func() { - s.Equal(s.mockServer.Config().Host, manager.cfg.Host, "expected rest config host to match mock server") + s.Equal(s.mockServer.Config().Host, manager.accessControlClientSet.cfg.Host, "expected rest config host to match mock server") }) }) s.Run("with valid kubeconfig in env and explicit kubeconfig context (valid)", func() { @@ -149,7 +149,7 @@ func (s *ManagerTestSuite) TestNewKubeconfigManager() { s.Contains(manager.clientCmdConfig.ConfigAccess().GetLoadingPrecedence(), kubeconfigFile, "expected kubeconfig path to match") }) s.Run("rest config host points to mock server", func() { - s.Equal(s.mockServer.Config().Host, manager.cfg.Host, "expected rest config host to match mock server") + s.Equal(s.mockServer.Config().Host, manager.accessControlClientSet.cfg.Host, "expected rest config host to match mock server") }) }) s.Run("with valid kubeconfig in env and explicit kubeconfig context (invalid)", func() { diff --git a/pkg/kubernetes/nodes.go b/pkg/kubernetes/nodes.go index a4321a9f..152f84cf 100644 --- a/pkg/kubernetes/nodes.go +++ b/pkg/kubernetes/nodes.go @@ -18,11 +18,13 @@ func (k *Kubernetes) NodesLog(ctx context.Context, name string, query string, ta // - /var/log/kube-proxy.log - kube-proxy logs // - /var/log/containers/ - container logs - req, err := k.AccessControlClientset().NodesLogs(ctx, name) - if err != nil { - return "", err + if _, err := k.AccessControlClientset().CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil { + return "", fmt.Errorf("failed to get node %s: %w", name, err) } + req := k.AccessControlClientset().CoreV1().RESTClient(). + Get(). + AbsPath("api", "v1", "nodes", name, "proxy", "logs") req.Param("query", query) // Query parameters for tail if tailLines > 0 { @@ -47,12 +49,14 @@ func (k *Kubernetes) NodesStatsSummary(ctx context.Context, name string) (string // https://kubernetes.io/docs/reference/instrumentation/understand-psi-metrics/ // This endpoint provides CPU, memory, filesystem, and network statistics - req, err := k.AccessControlClientset().NodesStatsSummary(ctx, name) - if err != nil { - return "", err + if _, err := k.AccessControlClientset().CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil { + return "", fmt.Errorf("failed to get node %s: %w", name, err) } - result := req.Do(ctx) + result := k.AccessControlClientset().CoreV1().RESTClient(). + Get(). + AbsPath("api", "v1", "nodes", name, "proxy", "stats", "summary"). + Do(ctx) if result.Error() != nil { return "", fmt.Errorf("failed to get node stats summary: %w", result.Error()) } @@ -75,5 +79,20 @@ func (k *Kubernetes) NodesTop(ctx context.Context, options NodesTopOptions) (*me if !k.supportsGroupVersion(metrics.GroupName + "/" + metricsv1beta1api.SchemeGroupVersion.Version) { return nil, errors.New("metrics API is not available") } - return k.manager.accessControlClientSet.NodesMetricses(ctx, options.Name, options.ListOptions) + versionedMetrics := &metricsv1beta1api.NodeMetricsList{} + var err error + if options.Name != "" { + m, err := k.AccessControlClientset().MetricsV1beta1Client().NodeMetricses().Get(ctx, options.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get metrics for node %s: %w", options.Name, err) + } + versionedMetrics.Items = []metricsv1beta1api.NodeMetrics{*m} + } else { + versionedMetrics, err = k.AccessControlClientset().MetricsV1beta1Client().NodeMetricses().List(ctx, options.ListOptions) + if err != nil { + return nil, fmt.Errorf("failed to list node metrics: %w", err) + } + } + convertedMetrics := &metrics.NodeMetricsList{} + return convertedMetrics, metricsv1beta1api.Convert_v1beta1_NodeMetricsList_To_metrics_NodeMetricsList(versionedMetrics, convertedMetrics, nil) } diff --git a/pkg/kubernetes/openshift.go b/pkg/kubernetes/openshift.go index 7cb3e273..cc6558cc 100644 --- a/pkg/kubernetes/openshift.go +++ b/pkg/kubernetes/openshift.go @@ -10,9 +10,13 @@ type Openshift interface { IsOpenShift(context.Context) bool } -func (m *Manager) IsOpenShift(_ context.Context) bool { +func (m *Manager) IsOpenShift(ctx context.Context) bool { // This method should be fast and not block (it's called at startup) - _, err := m.discoveryClient.ServerResourcesForGroupVersion(schema.GroupVersion{ + k, err := m.Derived(ctx) + if err != nil { + return false + } + _, err = k.AccessControlClientset().DiscoveryClient().ServerResourcesForGroupVersion(schema.GroupVersion{ Group: "project.openshift.io", Version: "v1", }.String()) diff --git a/pkg/kubernetes/pods.go b/pkg/kubernetes/pods.go index 4d333ea8..f36f1bee 100644 --- a/pkg/kubernetes/pods.go +++ b/pkg/kubernetes/pods.go @@ -12,6 +12,7 @@ import ( labelutil "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/httpstream" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/rand" "k8s.io/client-go/tools/remotecommand" @@ -22,7 +23,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/version" ) -// Default number of lines to retrieve from the end of the logs +// DefaultTailLines is the default number of lines to retrieve from the end of the logs const DefaultTailLines = int64(100) type PodsTopOptions struct { @@ -65,10 +66,7 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st // Delete managed service if isManaged { - services, err := k.manager.accessControlClientSet.Services(namespace) - if err != nil { - return "", err - } + services := k.AccessControlClientset().CoreV1().Services(namespace) if sl, _ := services.List(ctx, metav1.ListOptions{ LabelSelector: managedLabelSelector.String(), }); sl != nil { @@ -80,7 +78,7 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st // Delete managed Route if isManaged && k.supportsGroupVersion("route.openshift.io/v1") { - routeResources := k.manager.dynamicClient. + routeResources := k.AccessControlClientset().DynamicClient(). Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}). Namespace(namespace) if rl, _ := routeResources.List(ctx, metav1.ListOptions{ @@ -97,10 +95,7 @@ func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (st } func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string, previous bool, tail int64) (string, error) { - pods, err := k.manager.accessControlClientSet.Pods(k.NamespaceOrDefault(namespace)) - if err != nil { - return "", err - } + pods := k.AccessControlClientset().CoreV1().Pods(k.NamespaceOrDefault(namespace)) logOptions := &v1.PodLogOptions{ Container: container, @@ -218,15 +213,27 @@ func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metr } else { namespace = k.NamespaceOrDefault(namespace) } - return k.manager.accessControlClientSet.PodsMetricses(ctx, namespace, options.Name, options.ListOptions) + var err error + versionedMetrics := &metricsv1beta1api.PodMetricsList{} + if options.Name != "" { + m, err := k.AccessControlClientset().MetricsV1beta1Client().PodMetricses(namespace).Get(ctx, options.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, options.Name, err) + } + versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m} + } else { + versionedMetrics, err = k.AccessControlClientset().MetricsV1beta1Client().PodMetricses(namespace).List(ctx, options.ListOptions) + if err != nil { + return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err) + } + } + convertedMetrics := &metrics.PodMetricsList{} + return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil) } func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) { namespace = k.NamespaceOrDefault(namespace) - pods, err := k.manager.accessControlClientSet.Pods(namespace) - if err != nil { - return "", err - } + pods := k.AccessControlClientset().CoreV1().Pods(namespace) pod, err := pods.Get(ctx, name, metav1.GetOptions{}) if err != nil { return "", err @@ -244,7 +251,26 @@ func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container st Stdout: true, Stderr: true, } - executor, err := k.manager.accessControlClientSet.PodsExec(namespace, name, podExecOptions) + // Compute URL + // https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397 + execRequest := k.AccessControlClientset().CoreV1().RESTClient(). + Post(). + Resource("pods"). + Namespace(namespace). + Name(name). + SubResource("exec") + execRequest.VersionedParams(podExecOptions, ParameterCodec) + spdyExec, err := remotecommand.NewSPDYExecutor(k.AccessControlClientset().cfg, "POST", execRequest.URL()) + if err != nil { + return "", err + } + webSocketExec, err := remotecommand.NewWebSocketExecutor(k.AccessControlClientset().cfg, "GET", execRequest.URL().String()) + if err != nil { + return "", err + } + executor, err := remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool { + return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err) + }) if err != nil { return "", err } diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index 35fc1a93..c73cc0f4 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -43,7 +43,7 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion if options.AsTable { return k.resourcesListAsTable(ctx, gvk, gvr, namespace, options) } - return k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, options.ListOptions) + return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).List(ctx, options.ListOptions) } func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (*unstructured.Unstructured, error) { @@ -56,7 +56,7 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced { namespace = k.NamespaceOrDefault(namespace) } - return k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) } func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource string) ([]*unstructured.Unstructured, error) { @@ -83,7 +83,7 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced { namespace = k.NamespaceOrDefault(namespace) } - return k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) } // resourcesListAsTable retrieves a list of resources in a table format. @@ -102,7 +102,7 @@ func (k *Kubernetes) resourcesListAsTable(ctx context.Context, gvk *schema.Group } url = append(url, gvr.Resource) var table metav1.Table - err := k.manager.discoveryClient.RESTClient(). + err := k.AccessControlClientset().CoreV1().RESTClient(). Get(). SetHeader("Accept", strings.Join([]string{ fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName), @@ -146,7 +146,7 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced { namespace = k.NamespaceOrDefault(namespace) } - resources[i], rErr = k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{ + resources[i], rErr = k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{ FieldManager: version.BinaryName, }) if rErr != nil { @@ -154,14 +154,14 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u } // Clear the cache to ensure the next operation is performed on the latest exposed APIs (will change after the CRD creation) if gvk.Kind == "CustomResourceDefinition" { - k.manager.restMapper.Reset() + k.AccessControlClientset().RESTMapper().Reset() } } return resources, nil } func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) { - m, err := k.manager.restMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) + m, err := k.AccessControlClientset().RESTMapper().RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version) if err != nil { return nil, err } @@ -169,7 +169,7 @@ func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVer } func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) { - apiResourceList, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String()) + apiResourceList, err := k.AccessControlClientset().DiscoveryClient().ServerResourcesForGroupVersion(gvk.GroupVersion().String()) if err != nil { return false, err } @@ -182,17 +182,14 @@ func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) { } func (k *Kubernetes) supportsGroupVersion(groupVersion string) bool { - if _, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(groupVersion); err != nil { + if _, err := k.AccessControlClientset().DiscoveryClient().ServerResourcesForGroupVersion(groupVersion); err != nil { return false } return true } func (k *Kubernetes) canIUse(ctx context.Context, gvr *schema.GroupVersionResource, namespace, verb string) bool { - accessReviews, err := k.manager.accessControlClientSet.SelfSubjectAccessReviews() - if err != nil { - return false - } + accessReviews := k.AccessControlClientset().AuthorizationV1().SelfSubjectAccessReviews() response, err := accessReviews.Create(ctx, &authv1.SelfSubjectAccessReview{ Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authv1.ResourceAttributes{ Namespace: namespace, From 6292abe6a371955e983d01b952920dda4f702ada Mon Sep 17 00:00:00 2001 From: Alberto Gutierrez Date: Fri, 21 Nov 2025 17:22:14 +0100 Subject: [PATCH 08/21] fix(kiali): Tool names with prefix kiali_ (#471) * fix(kiali): Tool names with prefix kiali_ Signed-off-by: Alberto Gutierrez * Change links Signed-off-by: Alberto Gutierrez * Add comment to the url configuration Signed-off-by: Alberto Gutierrez --------- Signed-off-by: Alberto Gutierrez Signed-off-by: Muhammad Faizan --- README.md | 40 +++---- docs/KIALI.md | 37 +++++++ docs/KIALI_INTEGRATION.md | 170 ----------------------------- docs/README.md | 7 ++ pkg/toolsets/kiali/graph.go | 2 +- pkg/toolsets/kiali/health.go | 2 +- pkg/toolsets/kiali/istio_config.go | 10 +- pkg/toolsets/kiali/mesh.go | 2 +- pkg/toolsets/kiali/namespaces.go | 2 +- pkg/toolsets/kiali/services.go | 6 +- pkg/toolsets/kiali/traces.go | 6 +- pkg/toolsets/kiali/validations.go | 2 +- pkg/toolsets/kiali/workloads.go | 6 +- 13 files changed, 83 insertions(+), 209 deletions(-) create mode 100644 docs/KIALI.md delete mode 100644 docs/KIALI_INTEGRATION.md diff --git a/README.md b/README.md index 4758c0d9..ffd12a25 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ The following sets of tools are available (toolsets marked with ✓ in the Defau | config | View and manage the current local Kubernetes configuration (kubeconfig) | ✓ | | core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | ✓ | | helm | Tools for managing Helm charts and releases | ✓ | -| kiali | Most common tools for managing Kiali, check the [Kiali integration documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI_INTEGRATION.md) for more details. | | +| kiali | Most common tools for managing Kiali, check the [Kiali documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI.md) for more details. | | @@ -348,22 +348,22 @@ In case multi-cluster support is enabled (default) and you have access to multip kiali -- **graph** - Check the status of my mesh by querying Kiali graph +- **kiali_graph** - Check the status of my mesh by querying Kiali graph - `namespace` (`string`) - Optional single namespace to include in the graph (alternative to namespaces) - `namespaces` (`string`) - Optional comma-separated list of namespaces to include in the graph -- **mesh_status** - Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status +- **kiali_mesh_status** - Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status -- **istio_config** - Get all Istio configuration objects in the mesh including their full YAML resources and details +- **kiali_istio_config** - Get all Istio configuration objects in the mesh including their full YAML resources and details -- **istio_object_details** - Get detailed information about a specific Istio object including validation and help information +- **kiali_istio_object_details** - Get detailed information about a specific Istio object including validation and help information - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') - `name` (`string`) **(required)** - Name of the Istio object - `namespace` (`string`) **(required)** - Namespace containing the Istio object - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') -- **istio_object_patch** - Modify an existing Istio object using PATCH method. The JSON patch data will be applied to the existing object. +- **kiali_istio_object_patch** - Modify an existing Istio object using PATCH method. The JSON patch data will be applied to the existing object. - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') - `json_patch` (`string`) **(required)** - JSON patch data to apply to the object - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') @@ -371,34 +371,34 @@ In case multi-cluster support is enabled (default) and you have access to multip - `namespace` (`string`) **(required)** - Namespace containing the Istio object - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') -- **istio_object_create** - Create a new Istio object using POST method. The JSON data will be used to create the new object. +- **kiali_istio_object_create** - Create a new Istio object using POST method. The JSON data will be used to create the new object. - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') - `json_data` (`string`) **(required)** - JSON data for the new object - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') - `namespace` (`string`) **(required)** - Namespace where the Istio object will be created - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') -- **istio_object_delete** - Delete an existing Istio object using DELETE method. +- **kiali_istio_object_delete** - Delete an existing Istio object using DELETE method. - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') - `name` (`string`) **(required)** - Name of the Istio object - `namespace` (`string`) **(required)** - Namespace containing the Istio object - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') -- **validations_list** - List all the validations in the current cluster from all namespaces +- **kiali_validations_list** - List all the validations in the current cluster from all namespaces - `namespace` (`string`) - Optional single namespace to retrieve validations from (alternative to namespaces) - `namespaces` (`string`) - Optional comma-separated list of namespaces to retrieve validations from -- **namespaces** - Get all namespaces in the mesh that the user has access to +- **kiali_namespaces** - Get all namespaces in the mesh that the user has access to -- **services_list** - Get all services in the mesh across specified namespaces with health and Istio resource information +- **kiali_services_list** - Get all services in the mesh across specified namespaces with health and Istio resource information - `namespaces` (`string`) - Comma-separated list of namespaces to get services from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list services from all accessible namespaces -- **service_details** - Get detailed information for a specific service in a namespace, including validation, health status, and configuration +- **kiali_service_details** - Get detailed information for a specific service in a namespace, including validation, health status, and configuration - `namespace` (`string`) **(required)** - Namespace containing the service - `service` (`string`) **(required)** - Name of the service to get details for -- **service_metrics** - Get metrics for a specific service in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters +- **kiali_service_metrics** - Get metrics for a specific service in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds @@ -410,14 +410,14 @@ In case multi-cluster support is enabled (default) and you have access to multip - `service` (`string`) **(required)** - Name of the service to get metrics for - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds -- **workloads_list** - Get all workloads in the mesh across specified namespaces with health and Istio resource information +- **kiali_workloads_list** - Get all workloads in the mesh across specified namespaces with health and Istio resource information - `namespaces` (`string`) - Comma-separated list of namespaces to get workloads from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list workloads from all accessible namespaces -- **workload_details** - Get detailed information for a specific workload in a namespace, including validation, health status, and configuration +- **kiali_workload_details** - Get detailed information for a specific workload in a namespace, including validation, health status, and configuration - `namespace` (`string`) **(required)** - Namespace containing the workload - `workload` (`string`) **(required)** - Name of the workload to get details for -- **workload_metrics** - Get metrics for a specific workload in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters +- **kiali_workload_metrics** - Get metrics for a specific workload in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds @@ -429,7 +429,7 @@ In case multi-cluster support is enabled (default) and you have access to multip - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds - `workload` (`string`) **(required)** - Name of the workload to get metrics for -- **health** - Get health status for apps, workloads, and services across specified namespaces in the mesh. Returns health information including error rates and status for the requested resource type +- **kiali_health** - Get health status for apps, workloads, and services across specified namespaces in the mesh. Returns health information including error rates and status for the requested resource type - `namespaces` (`string`) - Comma-separated list of namespaces to get health from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, returns health for all accessible namespaces - `queryTime` (`string`) - Unix timestamp (in seconds) for the prometheus query. If not provided, uses current time. Optional - `rateInterval` (`string`) - Rate interval for fetching error rate (e.g., '10m', '5m', '1h'). Default: '10m' @@ -442,7 +442,7 @@ In case multi-cluster support is enabled (default) and you have access to multip - `tail` (`integer`) - Number of lines to retrieve from the end of logs (default: 100) - `workload` (`string`) **(required)** - Name of the workload to get logs for -- **app_traces** - Get distributed tracing data for a specific app in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. +- **kiali_app_traces** - Get distributed tracing data for a specific app in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. - `app` (`string`) **(required)** - Name of the app to get traces for - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) @@ -452,7 +452,7 @@ In case multi-cluster support is enabled (default) and you have access to multip - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) - `tags` (`string`) - JSON string of tags to filter traces (optional) -- **service_traces** - Get distributed tracing data for a specific service in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. +- **kiali_service_traces** - Get distributed tracing data for a specific service in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) - `limit` (`integer`) - Maximum number of traces to return (default: 100) @@ -462,7 +462,7 @@ In case multi-cluster support is enabled (default) and you have access to multip - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) - `tags` (`string`) - JSON string of tags to filter traces (optional) -- **workload_traces** - Get distributed tracing data for a specific workload in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. +- **kiali_workload_traces** - Get distributed tracing data for a specific workload in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) - `limit` (`integer`) - Maximum number of traces to return (default: 100) diff --git a/docs/KIALI.md b/docs/KIALI.md new file mode 100644 index 00000000..5b8f4b9f --- /dev/null +++ b/docs/KIALI.md @@ -0,0 +1,37 @@ +## Kiali integration + +This server can expose Kiali tools so assistants can query mesh information (e.g., mesh status/graph). + +### Enable the Kiali toolset + +Enable the Kiali tools via the server TOML configuration file. + +Config (TOML): + +```toml +toolsets = ["core", "kiali"] + +[toolset_configs.kiali] +url = "https://kiali.example" # Endpoint/route to reach Kiali console +# insecure = true # optional: allow insecure TLS (not recommended in production) +# certificate_authority = """-----BEGIN CERTIFICATE----- +# MIID... +# -----END CERTIFICATE-----""" +# When url is https and insecure is false, certificate_authority is required. +``` + +When the `kiali` toolset is enabled, a Kiali toolset configuration is required via `[toolset_configs.kiali]`. If missing or invalid, the server will refuse to start. + +### How authentication works + +- The server uses your existing Kubernetes credentials (from kubeconfig or in-cluster) to set a bearer token for Kiali calls. +- If you pass an HTTP Authorization header to the MCP HTTP endpoint, that is not required for Kiali; Kiali calls use the server's configured token. + +### Troubleshooting + +- Missing Kiali configuration when `kiali` toolset is enabled → set `[toolset_configs.kiali].url` in the config TOML. +- Invalid URL → ensure `[toolset_configs.kiali].url` is a valid `http(s)://host` URL. +- TLS certificate validation: + - If `[toolset_configs.kiali].url` uses HTTPS and `[toolset_configs.kiali].insecure` is false, you must set `[toolset_configs.kiali].certificate_authority` with the PEM-encoded certificate(s) used by the Kiali server. This field expects inline PEM content, not a file path. You may concatenate multiple PEM blocks to include an intermediate chain. + - For non-production environments you can set `[toolset_configs.kiali].insecure = true` to skip certificate verification. + diff --git a/docs/KIALI_INTEGRATION.md b/docs/KIALI_INTEGRATION.md deleted file mode 100644 index 00952744..00000000 --- a/docs/KIALI_INTEGRATION.md +++ /dev/null @@ -1,170 +0,0 @@ -## Kiali integration - -This server can expose Kiali tools so assistants can query mesh information (e.g., mesh status/graph). - -### Enable the Kiali toolset - -Enable the Kiali tools via the server TOML configuration file. - -Config (TOML): - -```toml -toolsets = ["core", "kiali"] - -[toolset_configs.kiali] -url = "https://kiali.example" -# insecure = true # optional: allow insecure TLS (not recommended in production) -# certificate_authority = """-----BEGIN CERTIFICATE----- -# MIID... -# -----END CERTIFICATE-----""" -# When url is https and insecure is false, certificate_authority is required. -``` - -When the `kiali` toolset is enabled, a Kiali toolset configuration is required via `[toolset_configs.kiali]`. If missing or invalid, the server will refuse to start. - -### How authentication works - -- The server uses your existing Kubernetes credentials (from kubeconfig or in-cluster) to set a bearer token for Kiali calls. -- If you pass an HTTP Authorization header to the MCP HTTP endpoint, that is not required for Kiali; Kiali calls use the server's configured token. - -### Available tools (initial) - -
- -kiali - -- **graph** - Check the status of my mesh by querying Kiali graph - - `namespace` (`string`) - Optional single namespace to include in the graph (alternative to namespaces) - - `namespaces` (`string`) - Optional comma-separated list of namespaces to include in the graph - -- **mesh_status** - Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status - -- **istio_config** - Get all Istio configuration objects in the mesh including their full YAML resources and details - -- **istio_object_details** - Get detailed information about a specific Istio object including validation and help information - - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') - - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') - - `name` (`string`) **(required)** - Name of the Istio object - - `namespace` (`string`) **(required)** - Namespace containing the Istio object - - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') - -- **istio_object_patch** - Modify an existing Istio object using PATCH method. The JSON patch data will be applied to the existing object. - - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') - - `json_patch` (`string`) **(required)** - JSON patch data to apply to the object - - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') - - `name` (`string`) **(required)** - Name of the Istio object - - `namespace` (`string`) **(required)** - Namespace containing the Istio object - - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') - -- **istio_object_create** - Create a new Istio object using POST method. The JSON data will be used to create the new object. - - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') - - `json_data` (`string`) **(required)** - JSON data for the new object - - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') - - `namespace` (`string`) **(required)** - Namespace where the Istio object will be created - - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') - -- **istio_object_delete** - Delete an existing Istio object using DELETE method. - - `group` (`string`) **(required)** - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io') - - `kind` (`string`) **(required)** - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway') - - `name` (`string`) **(required)** - Name of the Istio object - - `namespace` (`string`) **(required)** - Namespace containing the Istio object - - `version` (`string`) **(required)** - API version of the Istio object (e.g., 'v1', 'v1beta1') - -- **validations_list** - List all the validations in the current cluster from all namespaces - - `namespace` (`string`) - Optional single namespace to retrieve validations from (alternative to namespaces) - - `namespaces` (`string`) - Optional comma-separated list of namespaces to retrieve validations from - -- **namespaces** - Get all namespaces in the mesh that the user has access to - -- **services_list** - Get all services in the mesh across specified namespaces with health and Istio resource information - - `namespaces` (`string`) - Comma-separated list of namespaces to get services from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list services from all accessible namespaces - -- **service_details** - Get detailed information for a specific service in a namespace, including validation, health status, and configuration - - `namespace` (`string`) **(required)** - Namespace containing the service - - `service` (`string`) **(required)** - Name of the service to get details for - -- **service_metrics** - Get metrics for a specific service in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters - - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional - - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' - - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds - - `namespace` (`string`) **(required)** - Namespace containing the service - - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional - - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m' - - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' - - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional - - `service` (`string`) **(required)** - Name of the service to get metrics for - - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds - -- **workloads_list** - Get all workloads in the mesh across specified namespaces with health and Istio resource information - - `namespaces` (`string`) - Comma-separated list of namespaces to get workloads from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, will list workloads from all accessible namespaces - -- **workload_details** - Get detailed information for a specific workload in a namespace, including validation, health status, and configuration - - `namespace` (`string`) **(required)** - Namespace containing the workload - - `workload` (`string`) **(required)** - Name of the workload to get details for - -- **workload_metrics** - Get metrics for a specific workload in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters - - `byLabels` (`string`) - Comma-separated list of labels to group metrics by (e.g., 'source_workload,destination_service'). Optional - - `direction` (`string`) - Traffic direction: 'inbound' or 'outbound'. Optional, defaults to 'outbound' - - `duration` (`string`) - Duration of the query period in seconds (e.g., '1800' for 30 minutes). Optional, defaults to 1800 seconds - - `namespace` (`string`) **(required)** - Namespace containing the workload - - `quantiles` (`string`) - Comma-separated list of quantiles for histogram metrics (e.g., '0.5,0.95,0.99'). Optional - - `rateInterval` (`string`) - Rate interval for metrics (e.g., '1m', '5m'). Optional, defaults to '1m' - - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source' - - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional - - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds - - `workload` (`string`) **(required)** - Name of the workload to get metrics for - -- **health** - Get health status for apps, workloads, and services across specified namespaces in the mesh. Returns health information including error rates and status for the requested resource type - - `namespaces` (`string`) - Comma-separated list of namespaces to get health from (e.g. 'bookinfo' or 'bookinfo,default'). If not provided, returns health for all accessible namespaces - - `queryTime` (`string`) - Unix timestamp (in seconds) for the prometheus query. If not provided, uses current time. Optional - - `rateInterval` (`string`) - Rate interval for fetching error rate (e.g., '10m', '5m', '1h'). Default: '10m' - - `type` (`string`) - Type of health to retrieve: 'app', 'service', or 'workload'. Default: 'app' - -- **workload_logs** - Get logs for a specific workload's pods in a namespace. Only requires namespace and workload name - automatically discovers pods and containers. Optionally filter by container name, time range, and other parameters. Container is auto-detected if not specified. - - `container` (`string`) - Optional container name to filter logs. If not provided, automatically detects and uses the main application container (excludes istio-proxy and istio-init) - - `namespace` (`string`) **(required)** - Namespace containing the workload - - `since` (`string`) - Time duration to fetch logs from (e.g., '5m', '1h', '30s'). If not provided, returns recent logs - - `tail` (`integer`) - Number of lines to retrieve from the end of logs (default: 100) - - `workload` (`string`) **(required)** - Name of the workload to get logs for - -- **app_traces** - Get distributed tracing data for a specific app in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. - - `app` (`string`) **(required)** - Name of the app to get traces for - - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) - - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) - - `limit` (`integer`) - Maximum number of traces to return (default: 100) - - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) - - `namespace` (`string`) **(required)** - Namespace containing the app - - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) - - `tags` (`string`) - JSON string of tags to filter traces (optional) - -- **service_traces** - Get distributed tracing data for a specific service in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. - - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) - - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) - - `limit` (`integer`) - Maximum number of traces to return (default: 100) - - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) - - `namespace` (`string`) **(required)** - Namespace containing the service - - `service` (`string`) **(required)** - Name of the service to get traces for - - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) - - `tags` (`string`) - JSON string of tags to filter traces (optional) - -- **workload_traces** - Get distributed tracing data for a specific workload in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis. - - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional) - - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional) - - `limit` (`integer`) - Maximum number of traces to return (default: 100) - - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional) - - `namespace` (`string`) **(required)** - Namespace containing the workload - - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional) - - `tags` (`string`) - JSON string of tags to filter traces (optional) - - `workload` (`string`) **(required)** - Name of the workload to get traces for - -
- -### Troubleshooting - -- Missing Kiali configuration when `kiali` toolset is enabled → set `[toolset_configs.kiali].url` in the config TOML. -- Invalid URL → ensure `[toolset_configs.kiali].url` is a valid `http(s)://host` URL. -- TLS certificate validation: - - If `[toolset_configs.kiali].url` uses HTTPS and `[toolset_configs.kiali].insecure` is false, you must set `[toolset_configs.kiali].certificate_authority` with the PEM-encoded certificate(s) used by the Kiali server. This field expects inline PEM content, not a file path. You may concatenate multiple PEM blocks to include an intermediate chain. - - For non-production environments you can set `[toolset_configs.kiali].insecure = true` to skip certificate verification. - - diff --git a/docs/README.md b/docs/README.md index 0eaa634e..d33557d8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,14 @@ Choose the guide that matches your needs: 1. **Complete the base setup**: Start with [Getting Started with Kubernetes](GETTING_STARTED_KUBERNETES.md) to create a ServiceAccount and kubeconfig file 2. **Configure Claude Code**: Then follow the [Claude Code CLI guide](GETTING_STARTED_CLAUDE_CODE.md) +## Other toolsets + +- **[Kiali](KIALI.md)** - Tools for Kiali ServiceMesh with Istio + ## Additional Documentation - **[Keycloak OIDC Setup](KEYCLOAK_OIDC_SETUP.md)** - Developer guide for local Keycloak environment and testing with MCP Inspector - **[Main README](../README.md)** - Project overview and general information + + + diff --git a/pkg/toolsets/kiali/graph.go b/pkg/toolsets/kiali/graph.go index 6bf32d47..a0a50f92 100644 --- a/pkg/toolsets/kiali/graph.go +++ b/pkg/toolsets/kiali/graph.go @@ -14,7 +14,7 @@ func initGraph() []api.ServerTool { ret := make([]api.ServerTool, 0) ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "graph", + Name: "kiali_graph", Description: "Check the status of my mesh by querying Kiali graph", InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/toolsets/kiali/health.go b/pkg/toolsets/kiali/health.go index 01b86e1e..dd9f98fb 100644 --- a/pkg/toolsets/kiali/health.go +++ b/pkg/toolsets/kiali/health.go @@ -15,7 +15,7 @@ func initHealth() []api.ServerTool { // Cluster health tool ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "health", + Name: "kiali_health", Description: "Get health status for apps, workloads, and services across specified namespaces in the mesh. Returns health information including error rates and status for the requested resource type", InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/toolsets/kiali/istio_config.go b/pkg/toolsets/kiali/istio_config.go index df8a97c6..79fb75c5 100644 --- a/pkg/toolsets/kiali/istio_config.go +++ b/pkg/toolsets/kiali/istio_config.go @@ -13,7 +13,7 @@ func initIstioConfig() []api.ServerTool { ret := make([]api.ServerTool, 0) ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "istio_config", + Name: "kiali_istio_config", Description: "Get all Istio configuration objects in the mesh including their full YAML resources and details", InputSchema: &jsonschema.Schema{ Type: "object", @@ -45,7 +45,7 @@ func initIstioObjectDetails() []api.ServerTool { ret := make([]api.ServerTool, 0) ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "istio_object_details", + Name: "kiali_istio_object_details", Description: "Get detailed information about a specific Istio object including validation and help information", InputSchema: &jsonschema.Schema{ Type: "object", @@ -105,7 +105,7 @@ func initIstioObjectPatch() []api.ServerTool { ret := make([]api.ServerTool, 0) ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "istio_object_patch", + Name: "kiali_istio_object_patch", Description: "Modify an existing Istio object using PATCH method. The JSON patch data will be applied to the existing object.", InputSchema: &jsonschema.Schema{ Type: "object", @@ -170,7 +170,7 @@ func initIstioObjectCreate() []api.ServerTool { ret := make([]api.ServerTool, 0) ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "istio_object_create", + Name: "kiali_istio_object_create", Description: "Create a new Istio object using POST method. The JSON data will be used to create the new object.", InputSchema: &jsonschema.Schema{ Type: "object", @@ -230,7 +230,7 @@ func initIstioObjectDelete() []api.ServerTool { ret := make([]api.ServerTool, 0) ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "istio_object_delete", + Name: "kiali_istio_object_delete", Description: "Delete an existing Istio object using DELETE method.", InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/toolsets/kiali/mesh.go b/pkg/toolsets/kiali/mesh.go index d13fa48b..6e134f8a 100644 --- a/pkg/toolsets/kiali/mesh.go +++ b/pkg/toolsets/kiali/mesh.go @@ -13,7 +13,7 @@ func initMeshStatus() []api.ServerTool { ret := make([]api.ServerTool, 0) ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "mesh_status", + Name: "kiali_mesh_status", Description: "Get the status of mesh components including Istio, Kiali, Grafana, Prometheus and their interactions, versions, and health status", InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/toolsets/kiali/namespaces.go b/pkg/toolsets/kiali/namespaces.go index a006f2b1..a380cc82 100644 --- a/pkg/toolsets/kiali/namespaces.go +++ b/pkg/toolsets/kiali/namespaces.go @@ -13,7 +13,7 @@ func initNamespaces() []api.ServerTool { ret := make([]api.ServerTool, 0) ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "namespaces", + Name: "kiali_namespaces", Description: "Get all namespaces in the mesh that the user has access to", InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/toolsets/kiali/services.go b/pkg/toolsets/kiali/services.go index 1fd2018c..30ff6557 100644 --- a/pkg/toolsets/kiali/services.go +++ b/pkg/toolsets/kiali/services.go @@ -15,7 +15,7 @@ func initServices() []api.ServerTool { // Services list tool ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "services_list", + Name: "kiali_services_list", Description: "Get all services in the mesh across specified namespaces with health and Istio resource information", InputSchema: &jsonschema.Schema{ Type: "object", @@ -39,7 +39,7 @@ func initServices() []api.ServerTool { // Service details tool ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "service_details", + Name: "kiali_service_details", Description: "Get detailed information for a specific service in a namespace, including validation, health status, and configuration", InputSchema: &jsonschema.Schema{ Type: "object", @@ -68,7 +68,7 @@ func initServices() []api.ServerTool { // Service metrics tool ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "service_metrics", + Name: "kiali_service_metrics", Description: "Get metrics for a specific service in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters", InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/toolsets/kiali/traces.go b/pkg/toolsets/kiali/traces.go index fd5aacc9..e9169505 100644 --- a/pkg/toolsets/kiali/traces.go +++ b/pkg/toolsets/kiali/traces.go @@ -15,7 +15,7 @@ func initTraces() []api.ServerTool { // App traces tool ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "app_traces", + Name: "kiali_app_traces", Description: "Get distributed tracing data for a specific app in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis.", InputSchema: &jsonschema.Schema{ Type: "object", @@ -71,7 +71,7 @@ func initTraces() []api.ServerTool { // Service traces tool ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "service_traces", + Name: "kiali_service_traces", Description: "Get distributed tracing data for a specific service in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis.", InputSchema: &jsonschema.Schema{ Type: "object", @@ -127,7 +127,7 @@ func initTraces() []api.ServerTool { // Workload traces tool ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "workload_traces", + Name: "kiali_workload_traces", Description: "Get distributed tracing data for a specific workload in a namespace. Returns trace information including spans, duration, and error details for troubleshooting and performance analysis.", InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/toolsets/kiali/validations.go b/pkg/toolsets/kiali/validations.go index 898f7d03..6201da9a 100644 --- a/pkg/toolsets/kiali/validations.go +++ b/pkg/toolsets/kiali/validations.go @@ -14,7 +14,7 @@ func initValidations() []api.ServerTool { ret := make([]api.ServerTool, 0) ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "validations_list", + Name: "kiali_validations_list", Description: "List all the validations in the current cluster from all namespaces", InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/toolsets/kiali/workloads.go b/pkg/toolsets/kiali/workloads.go index f8d03a28..6d2b30cb 100644 --- a/pkg/toolsets/kiali/workloads.go +++ b/pkg/toolsets/kiali/workloads.go @@ -15,7 +15,7 @@ func initWorkloads() []api.ServerTool { // Workloads list tool ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "workloads_list", + Name: "kiali_workloads_list", Description: "Get all workloads in the mesh across specified namespaces with health and Istio resource information", InputSchema: &jsonschema.Schema{ Type: "object", @@ -39,7 +39,7 @@ func initWorkloads() []api.ServerTool { // Workload details tool ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "workload_details", + Name: "kiali_workload_details", Description: "Get detailed information for a specific workload in a namespace, including validation, health status, and configuration", InputSchema: &jsonschema.Schema{ Type: "object", @@ -68,7 +68,7 @@ func initWorkloads() []api.ServerTool { // Workload metrics tool ret = append(ret, api.ServerTool{ Tool: api.Tool{ - Name: "workload_metrics", + Name: "kiali_workload_metrics", Description: "Get metrics for a specific workload in a namespace. Supports filtering by time range, direction (inbound/outbound), reporter, and other query parameters", InputSchema: &jsonschema.Schema{ Type: "object", From 575490e83533b1e235c208f5dea9c1a6b08ac07e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 08:42:35 +0100 Subject: [PATCH 09/21] build(deps): bump github.com/mark3labs/mcp-go from 0.43.0 to 0.43.1 (#494) Bumps [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go) from 0.43.0 to 0.43.1. - [Release notes](https://github.com/mark3labs/mcp-go/releases) - [Commits](https://github.com/mark3labs/mcp-go/compare/v0.43.0...v0.43.1) --- updated-dependencies: - dependency-name: github.com/mark3labs/mcp-go dependency-version: 0.43.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Signed-off-by: Muhammad Faizan --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 53425351..883ade3b 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/go-jose/go-jose/v4 v4.1.3 github.com/google/jsonschema-go v0.3.0 - github.com/mark3labs/mcp-go v0.43.0 + github.com/mark3labs/mcp-go v0.43.1 github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/pkg/errors v0.9.1 github.com/spf13/afero v1.15.0 diff --git a/go.sum b/go.sum index 7cd638b4..9f40405b 100644 --- a/go.sum +++ b/go.sum @@ -187,8 +187,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mark3labs/mcp-go v0.43.0 h1:lgiKcWMddh4sngbU+hoWOZ9iAe/qp/m851RQpj3Y7jA= -github.com/mark3labs/mcp-go v0.43.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= +github.com/mark3labs/mcp-go v0.43.1 h1:WXNVd+bRM/7mOzCM9zulSwn/s9YEdAxbmeh9LoRHEXY= +github.com/mark3labs/mcp-go v0.43.1/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= From 5a699ac8e22726fae9d60fcb1f71f9a71468740f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 08:42:53 +0100 Subject: [PATCH 10/21] build(deps): bump github.com/coreos/go-oidc/v3 from 3.16.0 to 3.17.0 (#495) Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.16.0 to 3.17.0. - [Release notes](https://github.com/coreos/go-oidc/releases) - [Commits](https://github.com/coreos/go-oidc/compare/v3.16.0...v3.17.0) --- updated-dependencies: - dependency-name: github.com/coreos/go-oidc/v3 dependency-version: 3.17.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Signed-off-by: Muhammad Faizan --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 883ade3b..f9f1d232 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.24.10 require ( github.com/BurntSushi/toml v1.5.0 - github.com/coreos/go-oidc/v3 v3.16.0 + github.com/coreos/go-oidc/v3 v3.17.0 github.com/fsnotify/fsnotify v1.9.0 github.com/go-jose/go-jose/v4 v4.1.3 github.com/google/jsonschema-go v0.3.0 diff --git a/go.sum b/go.sum index 9f40405b..2a04ced6 100644 --- a/go.sum +++ b/go.sum @@ -48,8 +48,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= -github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= From 6d24b66b73e7c2df3144cf69e452146beb08e665 Mon Sep 17 00:00:00 2001 From: Andrew Block Date: Mon, 24 Nov 2025 02:56:35 -0600 Subject: [PATCH 11/21] feat(helm): add Helm chart for Kubernetes MCP Server deployment (#440) Added a Helm chart to simplify deployment of the Kubernetes MCP server with support for both Kubernetes and OpenShift clusters. Signed-off-by: Andrew Block Signed-off-by: Muhammad Faizan --- .gitignore | 1 + README.md | 4 + charts/kubernetes-mcp-server/.helmignore | 23 ++++ charts/kubernetes-mcp-server/Chart.yaml | 13 ++ charts/kubernetes-mcp-server/README.md | 76 +++++++++++ charts/kubernetes-mcp-server/README.md.gotmpl | 40 ++++++ .../templates/_helpers.tpl | 73 +++++++++++ .../templates/configmap.yaml | 10 ++ .../templates/deployment.yaml | 85 ++++++++++++ .../templates/ingress.yaml | 38 ++++++ .../templates/service.yaml | 16 +++ .../templates/serviceaccount.yaml | 13 ++ charts/kubernetes-mcp-server/values.yaml | 121 ++++++++++++++++++ 13 files changed, 513 insertions(+) create mode 100644 charts/kubernetes-mcp-server/.helmignore create mode 100644 charts/kubernetes-mcp-server/Chart.yaml create mode 100644 charts/kubernetes-mcp-server/README.md create mode 100644 charts/kubernetes-mcp-server/README.md.gotmpl create mode 100644 charts/kubernetes-mcp-server/templates/_helpers.tpl create mode 100644 charts/kubernetes-mcp-server/templates/configmap.yaml create mode 100644 charts/kubernetes-mcp-server/templates/deployment.yaml create mode 100644 charts/kubernetes-mcp-server/templates/ingress.yaml create mode 100644 charts/kubernetes-mcp-server/templates/service.yaml create mode 100644 charts/kubernetes-mcp-server/templates/serviceaccount.yaml create mode 100644 charts/kubernetes-mcp-server/values.yaml diff --git a/.gitignore b/.gitignore index 5db09f82..a6e1839d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ .npmrc kubernetes-mcp-server +!charts/kubernetes-mcp-server !cmd/kubernetes-mcp-server !pkg/kubernetes-mcp-server npm/kubernetes-mcp-server/README.md diff --git a/README.md b/README.md index ffd12a25..f768fd47 100644 --- a/README.md +++ b/README.md @@ -477,6 +477,10 @@ In case multi-cluster support is enabled (default) and you have access to multip +## Helm Chart + +A [Helm Chart](https://helm.sh) is available to simplify the deployment of the Kubernetes MCP server. Additional details can be found in he [chart README](./charts/kubernetes-mcp-server/README.md). + ## 🧑‍💻 Development ### Running with mcp-inspector diff --git a/charts/kubernetes-mcp-server/.helmignore b/charts/kubernetes-mcp-server/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/charts/kubernetes-mcp-server/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/charts/kubernetes-mcp-server/Chart.yaml b/charts/kubernetes-mcp-server/Chart.yaml new file mode 100644 index 00000000..a66650bb --- /dev/null +++ b/charts/kubernetes-mcp-server/Chart.yaml @@ -0,0 +1,13 @@ +apiVersion: v2 +name: kubernetes-mcp-server +description: Helm Chart for the Kubernetes MCP Server +home: https://github.com/containers/kubernetes-mcp-server +keywords: + - kubernetes + - mcp +maintainers: + - name: Andrew Block + email: ablock@redhat.com + - name: Marc Nuri + email: marc.nuri@redhat.com +version: 0.1.0 diff --git a/charts/kubernetes-mcp-server/README.md b/charts/kubernetes-mcp-server/README.md new file mode 100644 index 00000000..49b5759d --- /dev/null +++ b/charts/kubernetes-mcp-server/README.md @@ -0,0 +1,76 @@ +# kubernetes-mcp-server + +![Version: 0.1.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) + +Helm Chart for the Kubernetes MCP Server + +**Homepage:** + +## Maintainers + +| Name | Email | Url | +| ---- | ------ | --- | +| Andrew Block | | | +| Marc Nuri | | | + +## Installing the Chart + +The Chart can be installed quickly and easily to a Kubernetes cluster. Since an _Ingress_ is added as part of the default install of the Chart, the `ingress.host` Value must be specified. + +Install the Chart using the following command from the root of this directory: + +```shell +helm upgrade -i -n kubernetes-mcp-server --create-namespace kubernetes-mcp-server . --set openshift=true --set ingress.host= +``` + +### Optimized OpenShift Deployment + +Functionality has bee added to the Chart to simplify the deployment to OpenShift Cluster. + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| affinity | object | `{}` | | +| config.port | string | `"{{ .Values.service.port }}"` | | +| 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 | +| extraVolumeMounts | list | `[]` | Additional volumeMounts on the output Deployment definition. | +| extraVolumes | list | `[]` | Additional volumes on the output Deployment definition. | +| fullnameOverride | string | `""` | | +| image | object | `{"pullPolicy":"IfNotPresent","registry":"quay.io","repository":"containers/kubernetes_mcp_server","version":"latest"}` | This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ | +| image.pullPolicy | string | `"IfNotPresent"` | This sets the pull policy for images. | +| image.version | string | `"latest"` | This sets the tag or sha digest for the image. | +| imagePullSecrets | list | `[]` | This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ | +| ingress | object | `{"annotations":{},"className":"","enabled":true,"host":"","hosts":null,"path":"/","pathType":"ImplementationSpecific","termination":"edge","tls":null}` | This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ | +| livenessProbe | object | `{"httpGet":{"path":"/healthz","port":"http"}}` | Liveness and readiness probes for the container. | +| nameOverride | string | `""` | | +| nodeSelector | object | `{}` | | +| openshift | bool | `false` | Enable OpenShift specific features | +| podAnnotations | object | `{}` | For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ | +| podLabels | object | `{}` | For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ | +| podSecurityContext | object | `{}` | Define the Security Context for the Pod | +| readinessProbe.httpGet.path | string | `"/healthz"` | | +| readinessProbe.httpGet.port | string | `"http"` | | +| 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,"type":"ClusterIP"}` | This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/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.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 | +| serviceAccount.create | bool | `true` | Specifies whether a service account should be created | +| serviceAccount.name | string | `""` | If not set and create is true, a name is generated using the fullname template | +| tolerations | list | `[]` | | + +## Updating the README + +The contents of the README.md file is generated using [helm-docs](https://github.com/norwoodj/helm-docs). Whenever changes are introduced to the Chart and its _Values_, the documentation should be regenerated. + +Execute the following command to regenerate the documentation from within the Helm Chart directory. + +```shell +helm-docs -t README.md.gotpl +``` diff --git a/charts/kubernetes-mcp-server/README.md.gotmpl b/charts/kubernetes-mcp-server/README.md.gotmpl new file mode 100644 index 00000000..9e6ba772 --- /dev/null +++ b/charts/kubernetes-mcp-server/README.md.gotmpl @@ -0,0 +1,40 @@ +{{ template "chart.header" . }} +{{ template "chart.deprecationWarning" . }} + +{{ template "chart.badgesSection" . }} + +{{ template "chart.description" . }} + +{{ template "chart.homepageLine" . }} + +{{ template "chart.maintainersSection" . }} + +{{ template "chart.sourcesSection" . }} + +{{ template "chart.requirementsSection" . }} + +## Installing the Chart + +The Chart can be installed quickly and easily to a Kubernetes cluster. Since an _Ingress_ is added as part of the default install of the Chart, the `ingress.host` Value must be specified. + +Install the Chart using the following command from the root of this directory: + +```shell +helm upgrade -i -n kubernetes-mcp-server --create-namespace kubernetes-mcp-server . --set openshift=true --set ingress.host= +``` + +### Optimized OpenShift Deployment + +Functionality has bee added to the Chart to simplify the deployment to OpenShift Cluster. + +{{ template "chart.valuesSection" . }} + +## Updating the README + +The contents of the README.md file is generated using [helm-docs](https://github.com/norwoodj/helm-docs). Whenever changes are introduced to the Chart and its _Values_, the documentation should be regenerated. + +Execute the following command to regenerate the documentation from within the Helm Chart directory. + +```shell +helm-docs -t README.md.gotpl +``` diff --git a/charts/kubernetes-mcp-server/templates/_helpers.tpl b/charts/kubernetes-mcp-server/templates/_helpers.tpl new file mode 100644 index 00000000..991c9331 --- /dev/null +++ b/charts/kubernetes-mcp-server/templates/_helpers.tpl @@ -0,0 +1,73 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "kubernetes-mcp-server.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "kubernetes-mcp-server.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "kubernetes-mcp-server.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "kubernetes-mcp-server.labels" -}} +helm.sh/chart: {{ include "kubernetes-mcp-server.chart" . }} +{{ include "kubernetes-mcp-server.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "kubernetes-mcp-server.selectorLabels" -}} +app.kubernetes.io/name: {{ include "kubernetes-mcp-server.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "kubernetes-mcp-server.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "kubernetes-mcp-server.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the image path for the passed in image field +*/}} +{{- define "kubernetes-mcp-server.image" -}} +{{- if eq (substr 0 7 .version) "sha256:" -}} +{{- printf "%s/%s@%s" .registry .repository .version -}} +{{- else -}} +{{- printf "%s/%s:%s" .registry .repository .version -}} +{{- end -}} +{{- end -}} diff --git a/charts/kubernetes-mcp-server/templates/configmap.yaml b/charts/kubernetes-mcp-server/templates/configmap.yaml new file mode 100644 index 00000000..9d066b35 --- /dev/null +++ b/charts/kubernetes-mcp-server/templates/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "kubernetes-mcp-server.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kubernetes-mcp-server.labels" . | nindent 4 }} +data: + config.toml: | + {{- tpl (toToml .Values.config) . | replace ".0" "" | nindent 4 }} diff --git a/charts/kubernetes-mcp-server/templates/deployment.yaml b/charts/kubernetes-mcp-server/templates/deployment.yaml new file mode 100644 index 00000000..e6bc8a34 --- /dev/null +++ b/charts/kubernetes-mcp-server/templates/deployment.yaml @@ -0,0 +1,85 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "kubernetes-mcp-server.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kubernetes-mcp-server.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "kubernetes-mcp-server.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- tpl (toYaml .) . | nindent 8 }} + {{- end }} + labels: + {{- include "kubernetes-mcp-server.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- tpl (toYaml .) . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- tpl (toYaml .) . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "kubernetes-mcp-server.serviceAccountName" . }} + securityContext: + {{- tpl (toYaml (default .Values.defaultPodSecurityContext .Values.podSecurityContext)) . | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- tpl (toYaml (default .Values.defaultSecurityContext .Values.securityContext)) . | nindent 12 }} + image: "{{ template "kubernetes-mcp-server.image" .Values.image }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + args: + - "--config" + - "{{ .Values.configFilePath }}" + {{- with .Values.livenessProbe }} + livenessProbe: + {{- tpl (toYaml .) . | nindent 12 }} + {{- end }} + {{- with .Values.readinessProbe }} + readinessProbe: + {{- tpl (toYaml .) . | nindent 12 }} + {{- end }} + {{- with .Values.resources }} + resources: + {{- tpl (toYaml .) . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: {{ .Values.configFilePath | dir }} + {{- with .Values.extraVolumeMounts }} + {{- tpl (toYaml .) . | nindent 12 }} + {{- end }} + {{- with .Values.extraVolumeMounts }} + {{- tpl (toYaml .) . | nindent 12 }} + {{- end }} + volumes: + - name: config + configMap: + name: {{ include "kubernetes-mcp-server.fullname" . }} + {{- with .Values.extraVolumes }} + {{- tpl (toYaml .) . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- tpl (toYaml .) . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- tpl (toYaml .) . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- tpl (toYaml .) . | nindent 8 }} + {{- end }} diff --git a/charts/kubernetes-mcp-server/templates/ingress.yaml b/charts/kubernetes-mcp-server/templates/ingress.yaml new file mode 100644 index 00000000..e6179fcb --- /dev/null +++ b/charts/kubernetes-mcp-server/templates/ingress.yaml @@ -0,0 +1,38 @@ +{{- if .Values.ingress.enabled -}} +{{- $host := required "Ingress hostname must be specified" (tpl .Values.ingress.host .) }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "kubernetes-mcp-server.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kubernetes-mcp-server.labels" . | nindent 4 }} + annotations: + {{- if eq .Values.openshift true }} + route.openshift.io/termination: {{ .Values.ingress.termination }} + {{- end }} + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + - hosts: + - "{{ $host }}" + secretName: {{ .Values.ingress.tls.secretName }} + {{- end }} + rules: + - host: "{{ $host }}" + http: + paths: + - path: {{ .Values.ingress.path }} + pathType: {{ .Values.ingress.pathType }} + backend: + service: + name: {{ include "kubernetes-mcp-server.fullname" $ }} + port: + number: {{ $.Values.service.port }} +{{- end }} diff --git a/charts/kubernetes-mcp-server/templates/service.yaml b/charts/kubernetes-mcp-server/templates/service.yaml new file mode 100644 index 00000000..20bccf21 --- /dev/null +++ b/charts/kubernetes-mcp-server/templates/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "kubernetes-mcp-server.fullname" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kubernetes-mcp-server.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "kubernetes-mcp-server.selectorLabels" . | nindent 4 }} diff --git a/charts/kubernetes-mcp-server/templates/serviceaccount.yaml b/charts/kubernetes-mcp-server/templates/serviceaccount.yaml new file mode 100644 index 00000000..b75f47bf --- /dev/null +++ b/charts/kubernetes-mcp-server/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "kubernetes-mcp-server.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} + labels: + {{- include "kubernetes-mcp-server.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- tpl (toYaml .) . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/kubernetes-mcp-server/values.yaml b/charts/kubernetes-mcp-server/values.yaml new file mode 100644 index 00000000..4e4d3299 --- /dev/null +++ b/charts/kubernetes-mcp-server/values.yaml @@ -0,0 +1,121 @@ +# -- Enable OpenShift specific features +openshift: false + +# -- This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 1 + +# -- This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +image: + registry: quay.io + repository: containers/kubernetes_mcp_server + # -- This sets the tag or sha digest for the image. + version: latest + # -- This sets the pull policy for images. + pullPolicy: IfNotPresent + +# -- This is for the secrets for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +imagePullSecrets: [] +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +# -- This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # -- Specifies whether a service account should be created + create: true + # -- Annotations to add to the service account + annotations: {} + # -- The name of the service account to use. + # -- If not set and create is true, a name is generated using the fullname template + name: "" + +# -- This is for setting Kubernetes Annotations to a Pod. +# -- For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# -- This is for setting Kubernetes Labels to a Pod. +# -- For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +# -- Default Security Context for the Pod when one is not provided +defaultPodSecurityContext: + seccompProfile: + type: RuntimeDefault + +# -- Define the Security Context for the Pod +podSecurityContext: {} + +# -- Default Security Context for the Container when one is not provided +defaultSecurityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + +# -- Define the Security Context for the Container +securityContext: {} + +# -- This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ +service: + # -- This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + 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 + +# -- This block is for setting up the ingress for more information can be found here: https://kubernetes.io/docs/concepts/services-networking/ingress/ +ingress: + enabled: true + className: "" + annotations: {} + host: "" + path: / + pathType: ImplementationSpecific + termination: edge + hosts: + tls: + #secretName: "" + +# -- Resource requests and limits for the container. +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 100m + memory: 128Mi + +# -- Liveness and readiness probes for the container. +livenessProbe: + httpGet: + path: /healthz + port: http +readinessProbe: + httpGet: + path: /healthz + port: http + +# -- Additional volumes on the output Deployment definition. +extraVolumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# -- Additional volumeMounts on the output Deployment definition. +extraVolumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +# Path to the configuration file inside the container +configFilePath: /etc/kubernetes-mcp-server/config.toml + +# MCP Server configuration options. See https://github.com/containers/kubernetes-mcp-server/blob/main/pkg/config/config.go for details. +config: + port: "{{ .Values.service.port }}" From 9335ce019d5917b5a8ddbef2a416fd8935002170 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Mon, 24 Nov 2025 11:56:18 +0100 Subject: [PATCH 12/21] chore(helm): correct typos in deployment files and README (#497) Signed-off-by: Marc Nuri Signed-off-by: Muhammad Faizan --- README.md | 2 +- charts/kubernetes-mcp-server/README.md.gotmpl | 2 +- charts/kubernetes-mcp-server/templates/deployment.yaml | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f768fd47..f20e05ea 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ In case multi-cluster support is enabled (default) and you have access to multip ## Helm Chart -A [Helm Chart](https://helm.sh) is available to simplify the deployment of the Kubernetes MCP server. Additional details can be found in he [chart README](./charts/kubernetes-mcp-server/README.md). +A [Helm Chart](https://helm.sh) is available to simplify the deployment of the Kubernetes MCP server. Additional details can be found in the [chart README](./charts/kubernetes-mcp-server/README.md). ## 🧑‍💻 Development diff --git a/charts/kubernetes-mcp-server/README.md.gotmpl b/charts/kubernetes-mcp-server/README.md.gotmpl index 9e6ba772..b1b30d68 100644 --- a/charts/kubernetes-mcp-server/README.md.gotmpl +++ b/charts/kubernetes-mcp-server/README.md.gotmpl @@ -25,7 +25,7 @@ helm upgrade -i -n kubernetes-mcp-server --create-namespace kubernetes-mcp-serve ### Optimized OpenShift Deployment -Functionality has bee added to the Chart to simplify the deployment to OpenShift Cluster. +Functionality has been added to the Chart to simplify the deployment to OpenShift Cluster. {{ template "chart.valuesSection" . }} diff --git a/charts/kubernetes-mcp-server/templates/deployment.yaml b/charts/kubernetes-mcp-server/templates/deployment.yaml index e6bc8a34..372c7ba7 100644 --- a/charts/kubernetes-mcp-server/templates/deployment.yaml +++ b/charts/kubernetes-mcp-server/templates/deployment.yaml @@ -58,9 +58,6 @@ spec: volumeMounts: - name: config mountPath: {{ .Values.configFilePath | dir }} - {{- with .Values.extraVolumeMounts }} - {{- tpl (toYaml .) . | nindent 12 }} - {{- end }} {{- with .Values.extraVolumeMounts }} {{- tpl (toYaml .) . | nindent 12 }} {{- end }} From 7282a2bfa0f04628dd8bda0cd9a26841d2967b33 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Mon, 24 Nov 2025 13:13:37 +0100 Subject: [PATCH 13/21] feat(config)!: consolidate custom config parsing into config.Extended (#475) * feat(config)!: consolidate custom config parsing into config.Extended Signed-off-by: Marc Nuri * review(config)!: consolidate custom config parsing into config.Extended Signed-off-by: Marc Nuri --------- Signed-off-by: Marc Nuri Signed-off-by: Muhammad Faizan --- pkg/config/config.go | 79 +++--------------- pkg/config/context.go | 23 ++++++ pkg/config/extended.go | 61 ++++++++++++++ pkg/config/provider_config.go | 53 +----------- pkg/config/provider_config_test.go | 21 ++--- pkg/config/toolset_config.go | 33 +------- pkg/config/toolset_config_test.go | 128 +++++++++++++++++++++++++++++ pkg/kiali/config.go | 4 +- 8 files changed, 238 insertions(+), 164 deletions(-) create mode 100644 pkg/config/context.go create mode 100644 pkg/config/extended.go create mode 100644 pkg/config/toolset_config_test.go diff --git a/pkg/config/config.go b/pkg/config/config.go index 433a8df7..a002b7a1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -74,9 +74,9 @@ type StaticConfig struct { ToolsetConfigs map[string]toml.Primitive `toml:"toolset_configs,omitempty"` // Internal: parsed provider configs (not exposed to TOML package) - parsedClusterProviderConfigs map[string]ProviderConfig + parsedClusterProviderConfigs map[string]Extended // Internal: parsed toolset configs (not exposed to TOML package) - parsedToolsetConfigs map[string]ToolsetConfig + parsedToolsetConfigs map[string]Extended // Internal: the config.toml directory, to help resolve relative file paths configDirPath string @@ -130,87 +130,28 @@ func ReadToml(configData []byte, opts ...ReadConfigOpt) (*StaticConfig, error) { opt(config) } - if err := config.parseClusterProviderConfigs(md); err != nil { + ctx := withConfigDirPath(context.Background(), config.configDirPath) + + config.parsedClusterProviderConfigs, err = providerConfigRegistry.parse(ctx, md, config.ClusterProviderConfigs) + if err != nil { return nil, err } - if err := config.parseToolsetConfigs(md); err != nil { + config.parsedToolsetConfigs, err = toolsetConfigRegistry.parse(ctx, md, config.ToolsetConfigs) + if err != nil { return nil, err } return config, nil } -func (c *StaticConfig) GetProviderConfig(strategy string) (ProviderConfig, bool) { +func (c *StaticConfig) GetProviderConfig(strategy string) (Extended, bool) { config, ok := c.parsedClusterProviderConfigs[strategy] return config, ok } -func (c *StaticConfig) parseClusterProviderConfigs(md toml.MetaData) error { - if c.parsedClusterProviderConfigs == nil { - c.parsedClusterProviderConfigs = make(map[string]ProviderConfig, len(c.ClusterProviderConfigs)) - } - - ctx := withConfigDirPath(context.Background(), c.configDirPath) - - for strategy, primitive := range c.ClusterProviderConfigs { - parser, ok := getProviderConfigParser(strategy) - if !ok { - continue - } - - providerConfig, err := parser(ctx, primitive, md) - if err != nil { - return fmt.Errorf("failed to parse config for ClusterProvider '%s': %w", strategy, err) - } - - if err := providerConfig.Validate(); err != nil { - return fmt.Errorf("invalid config file for ClusterProvider '%s': %w", strategy, err) - } - - c.parsedClusterProviderConfigs[strategy] = providerConfig - } - - return nil -} - -func (c *StaticConfig) parseToolsetConfigs(md toml.MetaData) error { - if c.parsedToolsetConfigs == nil { - c.parsedToolsetConfigs = make(map[string]ToolsetConfig, len(c.ToolsetConfigs)) - } - - ctx := withConfigDirPath(context.Background(), c.configDirPath) - - for name, primitive := range c.ToolsetConfigs { - parser, ok := getToolsetConfigParser(name) - if !ok { - continue - } - - toolsetConfig, err := parser(ctx, primitive, md) - if err != nil { - return fmt.Errorf("failed to parse config for Toolset '%s': %w", name, err) - } - - if err := toolsetConfig.Validate(); err != nil { - return fmt.Errorf("invalid config file for Toolset '%s': %w", name, err) - } - - c.parsedToolsetConfigs[name] = toolsetConfig - } - - return nil -} - -func (c *StaticConfig) GetToolsetConfig(name string) (ToolsetConfig, bool) { +func (c *StaticConfig) GetToolsetConfig(name string) (Extended, bool) { cfg, ok := c.parsedToolsetConfigs[name] return cfg, ok } - -func (c *StaticConfig) SetToolsetConfig(name string, cfg ToolsetConfig) { - if c.parsedToolsetConfigs == nil { - c.parsedToolsetConfigs = make(map[string]ToolsetConfig) - } - c.parsedToolsetConfigs[name] = cfg -} diff --git a/pkg/config/context.go b/pkg/config/context.go new file mode 100644 index 00000000..e5dbd8d6 --- /dev/null +++ b/pkg/config/context.go @@ -0,0 +1,23 @@ +package config + +import "context" + +type configDirPathKey struct{} + +func withConfigDirPath(ctx context.Context, dirPath string) context.Context { + return context.WithValue(ctx, configDirPathKey{}, dirPath) +} + +func ConfigDirPathFromContext(ctx context.Context) string { + val := ctx.Value(configDirPathKey{}) + + if val == nil { + return "" + } + + if strVal, ok := val.(string); ok { + return strVal + } + + return "" +} diff --git a/pkg/config/extended.go b/pkg/config/extended.go new file mode 100644 index 00000000..a1f0598d --- /dev/null +++ b/pkg/config/extended.go @@ -0,0 +1,61 @@ +package config + +import ( + "context" + "fmt" + + "github.com/BurntSushi/toml" +) + +// Extended is the interface that all configuration extensions must implement. +// Each extended config manager registers a factory function to parse its config from TOML primitives +type Extended interface { + Validate() error +} + +type ExtendedConfigParser func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (Extended, error) + +type extendedConfigRegistry struct { + parsers map[string]ExtendedConfigParser +} + +func newExtendedConfigRegistry() *extendedConfigRegistry { + return &extendedConfigRegistry{ + parsers: make(map[string]ExtendedConfigParser), + } +} + +func (r *extendedConfigRegistry) register(name string, parser ExtendedConfigParser) { + if _, exists := r.parsers[name]; exists { + panic("extended config parser already registered for name: " + name) + } + + r.parsers[name] = parser +} + +func (r *extendedConfigRegistry) parse(ctx context.Context, metaData toml.MetaData, configs map[string]toml.Primitive) (map[string]Extended, error) { + if len(configs) == 0 { + return make(map[string]Extended), nil + } + parsedConfigs := make(map[string]Extended, len(configs)) + + for name, primitive := range configs { + parser, ok := r.parsers[name] + if !ok { + continue + } + + extendedConfig, err := parser(ctx, primitive, metaData) + if err != nil { + return nil, fmt.Errorf("failed to parse extended config for '%s': %w", name, err) + } + + if err = extendedConfig.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate extended config for '%s': %w", name, err) + } + + parsedConfigs[name] = extendedConfig + } + + return parsedConfigs, nil +} diff --git a/pkg/config/provider_config.go b/pkg/config/provider_config.go index 45dd2f8d..2e514d0a 100644 --- a/pkg/config/provider_config.go +++ b/pkg/config/provider_config.go @@ -1,54 +1,7 @@ package config -import ( - "context" - "fmt" +var providerConfigRegistry = newExtendedConfigRegistry() - "github.com/BurntSushi/toml" -) - -// ProviderConfig is the interface that all provider-specific configurations must implement. -// Each provider registers a factory function to parse its config from TOML primitives -type ProviderConfig interface { - Validate() error -} - -type ProviderConfigParser func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (ProviderConfig, error) - -type configDirPathKey struct{} - -func withConfigDirPath(ctx context.Context, dirPath string) context.Context { - return context.WithValue(ctx, configDirPathKey{}, dirPath) -} - -func ConfigDirPathFromContext(ctx context.Context) string { - val := ctx.Value(configDirPathKey{}) - - if val == nil { - return "" - } - - if strVal, ok := val.(string); ok { - return strVal - } - - return "" -} - -var ( - providerConfigParsers = make(map[string]ProviderConfigParser) -) - -func RegisterProviderConfig(strategy string, parser ProviderConfigParser) { - if _, exists := providerConfigParsers[strategy]; exists { - panic(fmt.Sprintf("provider config parser already registered for strategy '%s'", strategy)) - } - - providerConfigParsers[strategy] = parser -} - -func getProviderConfigParser(strategy string) (ProviderConfigParser, bool) { - provider, ok := providerConfigParsers[strategy] - - return provider, ok +func RegisterProviderConfig(name string, parser ExtendedConfigParser) { + providerConfigRegistry.register(name, parser) } diff --git a/pkg/config/provider_config_test.go b/pkg/config/provider_config_test.go index 84902da4..2afbd2d7 100644 --- a/pkg/config/provider_config_test.go +++ b/pkg/config/provider_config_test.go @@ -12,21 +12,16 @@ import ( type ProviderConfigSuite struct { BaseConfigSuite - originalProviderConfigParsers map[string]ProviderConfigParser + originalProviderConfigRegistry *extendedConfigRegistry } func (s *ProviderConfigSuite) SetupTest() { - s.originalProviderConfigParsers = make(map[string]ProviderConfigParser) - for k, v := range providerConfigParsers { - s.originalProviderConfigParsers[k] = v - } + s.originalProviderConfigRegistry = providerConfigRegistry + providerConfigRegistry = newExtendedConfigRegistry() } func (s *ProviderConfigSuite) TearDownTest() { - providerConfigParsers = make(map[string]ProviderConfigParser) - for k, v := range s.originalProviderConfigParsers { - providerConfigParsers[k] = v - } + providerConfigRegistry = s.originalProviderConfigRegistry } type ProviderConfigForTest struct { @@ -35,7 +30,7 @@ type ProviderConfigForTest struct { IntProp int `toml:"int_prop"` } -var _ ProviderConfig = (*ProviderConfigForTest)(nil) +var _ Extended = (*ProviderConfigForTest)(nil) func (p *ProviderConfigForTest) Validate() error { if p.StrProp == "force-error" { @@ -44,7 +39,7 @@ func (p *ProviderConfigForTest) Validate() error { return nil } -func providerConfigForTestParser(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (ProviderConfig, error) { +func providerConfigForTestParser(_ context.Context, primitive toml.Primitive, md toml.MetaData) (Extended, error) { var providerConfigForTest ProviderConfigForTest if err := md.PrimitiveDecode(primitive, &providerConfigForTest); err != nil { return nil, err @@ -133,7 +128,7 @@ func (s *ProviderConfigSuite) TestReadConfigUnregisteredProviderConfig() { } func (s *ProviderConfigSuite) TestReadConfigParserError() { - RegisterProviderConfig("test", func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (ProviderConfig, error) { + RegisterProviderConfig("test", func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (Extended, error) { return nil, errors.New("parser error forced by test") }) invalidConfigPath := s.writeConfig(` @@ -156,7 +151,7 @@ func (s *ProviderConfigSuite) TestReadConfigParserError() { func (s *ProviderConfigSuite) TestConfigDirPathInContext() { var capturedDirPath string - RegisterProviderConfig("test", func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (ProviderConfig, error) { + RegisterProviderConfig("test", func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (Extended, error) { capturedDirPath = ConfigDirPathFromContext(ctx) var providerConfigForTest ProviderConfigForTest if err := md.PrimitiveDecode(primitive, &providerConfigForTest); err != nil { diff --git a/pkg/config/toolset_config.go b/pkg/config/toolset_config.go index fb230e71..33af6a38 100644 --- a/pkg/config/toolset_config.go +++ b/pkg/config/toolset_config.go @@ -1,34 +1,7 @@ package config -import ( - "context" - "fmt" +var toolsetConfigRegistry = newExtendedConfigRegistry() - "github.com/BurntSushi/toml" -) - -// ToolsetConfig is the interface that all toolset-specific configurations must implement. -// Each toolset registers a factory function to parse its config from TOML primitives -type ToolsetConfig interface { - Validate() error -} - -type ToolsetConfigParser func(ctx context.Context, primitive toml.Primitive, md toml.MetaData) (ToolsetConfig, error) - -var ( - toolsetConfigParsers = make(map[string]ToolsetConfigParser) -) - -func RegisterToolsetConfig(name string, parser ToolsetConfigParser) { - if _, exists := toolsetConfigParsers[name]; exists { - panic(fmt.Sprintf("toolset config parser already registered for toolset '%s'", name)) - } - - toolsetConfigParsers[name] = parser -} - -func getToolsetConfigParser(name string) (ToolsetConfigParser, bool) { - parser, ok := toolsetConfigParsers[name] - - return parser, ok +func RegisterToolsetConfig(name string, parser ExtendedConfigParser) { + toolsetConfigRegistry.register(name, parser) } diff --git a/pkg/config/toolset_config_test.go b/pkg/config/toolset_config_test.go new file mode 100644 index 00000000..86f79c66 --- /dev/null +++ b/pkg/config/toolset_config_test.go @@ -0,0 +1,128 @@ +package config + +import ( + "context" + "errors" + "testing" + + "github.com/BurntSushi/toml" + "github.com/stretchr/testify/suite" +) + +type ToolsetConfigSuite struct { + BaseConfigSuite + originalToolsetConfigRegistry *extendedConfigRegistry +} + +func (s *ToolsetConfigSuite) SetupTest() { + s.originalToolsetConfigRegistry = toolsetConfigRegistry + toolsetConfigRegistry = newExtendedConfigRegistry() +} + +func (s *ToolsetConfigSuite) TearDownTest() { + toolsetConfigRegistry = s.originalToolsetConfigRegistry +} + +type ToolsetConfigForTest struct { + Enabled bool `toml:"enabled"` + Endpoint string `toml:"endpoint"` + Timeout int `toml:"timeout"` +} + +var _ Extended = (*ToolsetConfigForTest)(nil) + +func (t *ToolsetConfigForTest) Validate() error { + if t.Endpoint == "force-error" { + return errors.New("validation error forced by test") + } + return nil +} + +func toolsetConfigForTestParser(_ context.Context, primitive toml.Primitive, md toml.MetaData) (Extended, error) { + var toolsetConfigForTest ToolsetConfigForTest + if err := md.PrimitiveDecode(primitive, &toolsetConfigForTest); err != nil { + return nil, err + } + return &toolsetConfigForTest, nil +} + +func (s *ToolsetConfigSuite) TestRegisterToolsetConfig() { + s.Run("panics when registering duplicate toolset config parser", func() { + s.Panics(func() { + RegisterToolsetConfig("test-toolset", toolsetConfigForTestParser) + RegisterToolsetConfig("test-toolset", toolsetConfigForTestParser) + }, "Expected panic when registering duplicate toolset config parser") + }) +} + +func (s *ToolsetConfigSuite) TestReadConfigValid() { + RegisterToolsetConfig("test-toolset", toolsetConfigForTestParser) + validConfigPath := s.writeConfig(` + [toolset_configs.test-toolset] + enabled = true + endpoint = "https://example.com" + timeout = 30 + `) + + config, err := Read(validConfigPath) + s.Run("returns no error for valid file with registered toolset config", func() { + s.Require().NoError(err, "Expected no error for valid file, got %v", err) + }) + s.Run("returns config for valid file with registered toolset config", func() { + s.Require().NotNil(config, "Expected non-nil config for valid file") + }) + s.Run("parses toolset config correctly", func() { + toolsetConfig, ok := config.GetToolsetConfig("test-toolset") + s.Require().True(ok, "Expected to find toolset config for 'test-toolset'") + s.Require().NotNil(toolsetConfig, "Expected non-nil toolset config for 'test-toolset'") + testToolsetConfig, ok := toolsetConfig.(*ToolsetConfigForTest) + s.Require().True(ok, "Expected toolset config to be of type *ToolsetConfigForTest") + s.Equal(true, testToolsetConfig.Enabled, "Expected Enabled to be true") + s.Equal("https://example.com", testToolsetConfig.Endpoint, "Expected Endpoint to be 'https://example.com'") + s.Equal(30, testToolsetConfig.Timeout, "Expected Timeout to be 30") + }) +} + +func (s *ToolsetConfigSuite) TestReadConfigInvalidToolsetConfig() { + RegisterToolsetConfig("test-toolset", toolsetConfigForTestParser) + invalidConfigPath := s.writeConfig(` + [toolset_configs.test-toolset] + enabled = true + endpoint = "force-error" + timeout = 30 + `) + + config, err := Read(invalidConfigPath) + s.Run("returns error for invalid toolset config", func() { + s.Require().NotNil(err, "Expected error for invalid toolset config, got nil") + s.ErrorContains(err, "validation error forced by test", "Expected validation error from toolset config") + }) + s.Run("returns nil config for invalid toolset config", func() { + s.Nil(config, "Expected nil config for invalid toolset config") + }) +} + +func (s *ToolsetConfigSuite) TestReadConfigUnregisteredToolsetConfig() { + unregisteredConfigPath := s.writeConfig(` + [toolset_configs.unregistered-toolset] + enabled = true + endpoint = "https://example.com" + timeout = 30 + `) + + config, err := Read(unregisteredConfigPath) + s.Run("returns no error for unregistered toolset config", func() { + s.Require().NoError(err, "Expected no error for unregistered toolset config, got %v", err) + }) + s.Run("returns config for unregistered toolset config", func() { + s.Require().NotNil(config, "Expected non-nil config for unregistered toolset config") + }) + s.Run("does not parse unregistered toolset config", func() { + _, ok := config.GetToolsetConfig("unregistered-toolset") + s.Require().False(ok, "Expected no toolset config for unregistered toolset") + }) +} + +func TestToolsetConfig(t *testing.T) { + suite.Run(t, new(ToolsetConfigSuite)) +} diff --git a/pkg/kiali/config.go b/pkg/kiali/config.go index 82e8d7f3..1e64b4b0 100644 --- a/pkg/kiali/config.go +++ b/pkg/kiali/config.go @@ -17,7 +17,7 @@ type Config struct { CertificateAuthority string `toml:"certificate_authority,omitempty"` } -var _ config.ToolsetConfig = (*Config)(nil) +var _ config.Extended = (*Config)(nil) func (c *Config) Validate() error { if c == nil { @@ -36,7 +36,7 @@ func (c *Config) Validate() error { return nil } -func kialiToolsetParser(_ context.Context, primitive toml.Primitive, md toml.MetaData) (config.ToolsetConfig, error) { +func kialiToolsetParser(_ context.Context, primitive toml.Primitive, md toml.MetaData) (config.Extended, error) { var cfg Config if err := md.PrimitiveDecode(primitive, &cfg); err != nil { return nil, err From 4c5c0ef1c051ff3004ba40de1a38c523e0d4e077 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Mon, 24 Nov 2025 14:14:01 +0100 Subject: [PATCH 14/21] fix(kiali): misc follow-up changes for kiali toolsets (#498) - Additional tests - Regeneration of dynamic README.md content Signed-off-by: Marc Nuri Signed-off-by: Muhammad Faizan --- README.md | 10 ++++---- pkg/kiali/kiali.go | 14 ++++++++++-- pkg/kiali/kiali_test.go | 43 +++++++++++++++++++++++++++++++++++ pkg/toolsets/kiali/toolset.go | 2 +- 4 files changed, 61 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f20e05ea..97317a30 100644 --- a/README.md +++ b/README.md @@ -208,11 +208,11 @@ The following sets of tools are available (toolsets marked with ✓ in the Defau -| Toolset | Description | Default | -|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| -| config | View and manage the current local Kubernetes configuration (kubeconfig) | ✓ | -| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | ✓ | -| helm | Tools for managing Helm charts and releases | ✓ | +| Toolset | Description | Default | +|---------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------| +| config | View and manage the current local Kubernetes configuration (kubeconfig) | ✓ | +| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | ✓ | +| helm | Tools for managing Helm charts and releases | ✓ | | kiali | Most common tools for managing Kiali, check the [Kiali documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI.md) for more details. | | diff --git a/pkg/kiali/kiali.go b/pkg/kiali/kiali.go index 5d777e8a..2755ddca 100644 --- a/pkg/kiali/kiali.go +++ b/pkg/kiali/kiali.go @@ -60,12 +60,19 @@ func (k *Kiali) validateAndGetURL(endpoint string) (string, error) { if err != nil { return "", fmt.Errorf("invalid endpoint path: %w", err) } + // Reject absolute URLs - endpoint should be a relative path + if endpointURL.Scheme != "" || endpointURL.Host != "" { + return "", fmt.Errorf("endpoint must be a relative path, not an absolute URL") + } resultURL, err := url.JoinPath(baseURL.String(), endpointURL.Path) if err != nil { return "", fmt.Errorf("failed to join kiali base URL with endpoint path: %w", err) } - u, _ := url.Parse(resultURL) + u, err := url.Parse(resultURL) + if err != nil { + return "", fmt.Errorf("failed to parse joined URL: %w", err) + } u.RawQuery = endpointURL.RawQuery u.Fragment = endpointURL.Fragment @@ -145,7 +152,10 @@ func (k *Kiali) executeRequest(ctx context.Context, method, endpoint, contentTyp return "", err } defer func() { _ = resp.Body.Close() }() - respBody, _ := io.ReadAll(resp.Body) + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response body: %w", err) + } if resp.StatusCode < 200 || resp.StatusCode >= 300 { if len(respBody) > 0 { return "", fmt.Errorf("kiali API error: %s", strings.TrimSpace(string(respBody))) diff --git a/pkg/kiali/kiali_test.go b/pkg/kiali/kiali_test.go index 5028c32b..403bfd2d 100644 --- a/pkg/kiali/kiali_test.go +++ b/pkg/kiali/kiali_test.go @@ -128,6 +128,49 @@ func (s *KialiSuite) TestValidateAndGetURL() { s.Equal("true", u.Query().Get("health"), "Unexpected query parameter health") }) }) + + s.Run("Rejects absolute URLs in endpoint", func() { + s.Config = test.Must(config.ReadToml([]byte(` + [toolset_configs.kiali] + url = "https://kiali.example/" + insecure = true + `))) + k := NewKiali(s.Config, s.MockServer.Config()) + + s.Run("rejects http URLs", func() { + _, err := k.validateAndGetURL("http://other-server.com/api") + s.Require().Error(err, "Expected error for absolute URL") + s.ErrorContains(err, "endpoint must be a relative path", "Unexpected error message") + }) + + s.Run("rejects https URLs", func() { + _, err := k.validateAndGetURL("https://other-server.com/api") + s.Require().Error(err, "Expected error for absolute URL") + s.ErrorContains(err, "endpoint must be a relative path", "Unexpected error message") + }) + + s.Run("rejects URLs with host but no scheme", func() { + _, err := k.validateAndGetURL("//other-server.com/api") + s.Require().Error(err, "Expected error for URL with host") + s.ErrorContains(err, "endpoint must be a relative path", "Unexpected error message") + }) + }) + + s.Run("Preserves fragment in endpoint", func() { + s.Config = test.Must(config.ReadToml([]byte(` + [toolset_configs.kiali] + url = "https://kiali.example/" + insecure = true + `))) + k := NewKiali(s.Config, s.MockServer.Config()) + + full, err := k.validateAndGetURL("/api/path#section") + s.Require().NoError(err, "Expected no error validating URL with fragment") + u, err := url.Parse(full) + s.Require().NoError(err, "Expected to parse full URL") + s.Equal("/api/path", u.Path, "Unexpected path in parsed URL") + s.Equal("section", u.Fragment, "Unexpected fragment in parsed URL") + }) } // CurrentAuthorizationHeader behavior is now implicit via executeRequest using Manager.BearerToken diff --git a/pkg/toolsets/kiali/toolset.go b/pkg/toolsets/kiali/toolset.go index c89afcd4..7467992c 100644 --- a/pkg/toolsets/kiali/toolset.go +++ b/pkg/toolsets/kiali/toolset.go @@ -17,7 +17,7 @@ func (t *Toolset) GetName() string { } func (t *Toolset) GetDescription() string { - return "Most common tools for managing Kiali, check the [Kiali integration documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI_INTEGRATION.md) for more details." + return "Most common tools for managing Kiali, check the [Kiali documentation](https://github.com/containers/kubernetes-mcp-server/blob/main/docs/KIALI.md) for more details." } func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool { From 0b0bca2923f9ddffdd967a34b5364641abec9db4 Mon Sep 17 00:00:00 2001 From: Muhammad Faizan Date: Tue, 25 Nov 2025 15:58:56 +0100 Subject: [PATCH 15/21] updated Signed-off-by: Muhammad Faizan --- pkg/kubernetes/auth_headers.go | 132 +++++++++---------- pkg/kubernetes/kubernetes.go | 8 +- pkg/kubernetes/manager.go | 37 ++++++ pkg/kubernetes/provider_auth_headers.go | 160 ++---------------------- pkg/mcp/middleware.go | 7 +- 5 files changed, 130 insertions(+), 214 deletions(-) diff --git a/pkg/kubernetes/auth_headers.go b/pkg/kubernetes/auth_headers.go index 04d0f7b8..c33bc0fe 100644 --- a/pkg/kubernetes/auth_headers.go +++ b/pkg/kubernetes/auth_headers.go @@ -3,104 +3,110 @@ package kubernetes import ( "encoding/base64" "fmt" + "strings" ) // AuthType represents the type of Kubernetes authentication. type AuthType string +type ContextKey string const ( // AuthTypeToken represents token-based authentication. AuthTypeToken AuthType = "token" // AuthTypeClientCertificate represents client certificate authentication. AuthTypeClientCertificate AuthType = "client_certificate" - // AuthTypeUnknown represents unknown or unsupported authentication type. - AuthTypeUnknown AuthType = "unknown" - AuthHeadersContextKey string = "k8s_auth_headers" + // AuthHeadersContextKey is the context key for the Kubernetes authentication headers. + AuthHeadersContextKey ContextKey = "k8s_auth_headers" ) // K8sAuthHeaders represents Kubernetes API authentication headers. type K8sAuthHeaders struct { - // ClusterURL is the Kubernetes cluster URL. - ClusterURL string - // ClusterCertificateAuthorityData is the base64-encoded CA certificate. - ClusterCertificateAuthorityData string + // Server is the Kubernetes cluster URL. + Server string + // ClusterCertificateAuthorityData is the Certificate Authority data. + CertificateAuthorityData []byte // AuthorizationToken is the optional bearer token for authentication. AuthorizationToken string - // ClientCertificateData is the optional base64-encoded client certificate. - ClientCertificateData string - // ClientKeyData is the optional base64-encoded client key. - ClientKeyData string + // ClientCertificateData is the optional client certificate data. + ClientCertificateData []byte + // ClientKeyData is the optional client key data. + ClientKeyData []byte + // InsecureSkipTLSVerify is the optional flag to skip TLS verification. + InsecureSkipTLSVerify bool +} + +// GetDecodedData decodes and returns the data. +func GetDecodedData(data string) ([]byte, error) { + return base64.StdEncoding.DecodeString(data) } func NewK8sAuthHeadersFromHeaders(data map[string]any) (*K8sAuthHeaders, error) { - authHeaders := &K8sAuthHeaders{} var ok bool - authHeaders.ClusterURL, ok = data[string(CustomClusterURLHeader)].(string) - if !ok || authHeaders.ClusterURL == "" { - return nil, fmt.Errorf("%s header is required", CustomClusterURLHeader) + var err error + + // Initialize auth headers. + authHeaders := &K8sAuthHeaders{ + InsecureSkipTLSVerify: false, + } + + // Get cluster URL from headers. + authHeaders.Server, ok = data[string(CustomServerHeader)].(string) + if !ok || authHeaders.Server == "" { + return nil, fmt.Errorf("%s header is required", CustomServerHeader) } - authHeaders.ClusterCertificateAuthorityData, ok = data[string(CustomCertificateAuthorityDataHeader)].(string) - if !ok || authHeaders.ClusterCertificateAuthorityData == "" { + // Get certificate authority data from headers. + certificateAuthorityDataBase64, ok := data[string(CustomCertificateAuthorityDataHeader)].(string) + if !ok || certificateAuthorityDataBase64 == "" { return nil, fmt.Errorf("%s header is required", CustomCertificateAuthorityDataHeader) } + // Decode certificate authority data. + authHeaders.CertificateAuthorityData, err = GetDecodedData(certificateAuthorityDataBase64) + if err != nil { + return nil, fmt.Errorf("invalid certificate authority data: %w", err) + } + + // Get insecure skip TLS verify flag from headers. + if data[string(CustomInsecureSkipTLSVerifyHeader)] != nil && strings.ToLower(data[string(CustomInsecureSkipTLSVerifyHeader)].(string)) == "true" { + authHeaders.InsecureSkipTLSVerify = true + } - // Token or client certificate and key data (optional). + // Get authorization token from headers. authHeaders.AuthorizationToken, _ = data[string(CustomAuthorizationHeader)].(string) - authHeaders.ClientCertificateData, _ = data[string(CustomClientCertificateDataHeader)].(string) - authHeaders.ClientKeyData, _ = data[string(CustomClientKeyDataHeader)].(string) - // Check if either token auth or client certificate auth is provided - hasTokenAuth := authHeaders.AuthorizationToken != "" - hasClientCertAuth := authHeaders.ClientCertificateData != "" && authHeaders.ClientKeyData != "" + // Get client certificate data from headers. + clientCertificateDataBase64, _ := data[string(CustomClientCertificateDataHeader)].(string) + if clientCertificateDataBase64 != "" { + authHeaders.ClientCertificateData, err = GetDecodedData(clientCertificateDataBase64) + if err != nil { + return nil, fmt.Errorf("invalid client certificate data: %w", err) + } + } + // Get client key data from headers. + clientKeyDataBase64, _ := data[string(CustomClientKeyDataHeader)].(string) + if clientKeyDataBase64 != "" { + authHeaders.ClientKeyData, err = GetDecodedData(clientKeyDataBase64) + if err != nil { + return nil, fmt.Errorf("invalid client key data: %w", err) + } + } - if !hasTokenAuth && !hasClientCertAuth { - return nil, fmt.Errorf("either %s header or (%s and %s) headers are required", CustomAuthorizationHeader, CustomClientCertificateDataHeader, CustomClientKeyDataHeader) + // Check if a valid authentication type is provided. + _, err = authHeaders.GetAuthType() + if err != nil { + return nil, fmt.Errorf("either %s header for token authentication or (%s and %s) headers for client certificate authentication required", CustomAuthorizationHeader, CustomClientCertificateDataHeader, CustomClientKeyDataHeader) } return authHeaders, nil } // GetAuthType returns the authentication type based on the provided headers. -func (h *K8sAuthHeaders) GetAuthType() AuthType { +func (h *K8sAuthHeaders) GetAuthType() (AuthType, error) { if h.AuthorizationToken != "" { - return AuthTypeToken + return AuthTypeToken, nil } - if h.ClientCertificateData != "" && h.ClientKeyData != "" { - return AuthTypeClientCertificate + if h.ClientCertificateData != nil && h.ClientKeyData != nil { + return AuthTypeClientCertificate, nil } - return AuthTypeUnknown + return "", fmt.Errorf("invalid authentication type") } - -// GetDecodedCertificateAuthorityData decodes and returns the CA certificate data. -func (h *K8sAuthHeaders) GetDecodedCertificateAuthorityData() ([]byte, error) { - data, err := base64.StdEncoding.DecodeString(h.ClusterCertificateAuthorityData) - if err != nil { - return nil, fmt.Errorf("failed to decode certificate authority data: %w", err) - } - return data, nil -} - -// // GetDecodedClientCertificateData decodes and returns the client certificate data. -// func (h *K8sAuthHeaders) GetDecodedClientCertificateData() ([]byte, error) { -// if h.ClientCertificateData == nil || *h.ClientCertificateData == "" { -// return nil, errors.New("client certificate data is not available") -// } -// data, err := base64.StdEncoding.DecodeString(*h.ClientCertificateData) -// if err != nil { -// return nil, fmt.Errorf("failed to decode client certificate data: %w", err) -// } -// return data, nil -// } - -// // GetDecodedClientKeyData decodes and returns the client key data. -// func (h *K8sAuthHeaders) GetDecodedClientKeyData() ([]byte, error) { -// if h.ClientKeyData == nil || *h.ClientKeyData == "" { -// return nil, errors.New("client key data is not available") -// } -// data, err := base64.StdEncoding.DecodeString(*h.ClientKeyData) -// if err != nil { -// return nil, fmt.Errorf("failed to decode client key data: %w", err) -// } -// return data, nil -// } diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index e0983888..22442cdb 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -12,14 +12,18 @@ import ( type HeaderKey string const ( - CustomClusterURLHeader = HeaderKey("kubernetes-cluster-url") - CustomAuthorizationHeader = HeaderKey("kubernetes-authorization") + // CustomServerHeader is the Kubernetes cluster URL. + CustomServerHeader = HeaderKey("kubernetes-server") // CustomCertificateAuthorityData is the base64-encoded CA certificate. CustomCertificateAuthorityDataHeader = HeaderKey("kubernetes-certificate-authority-data") + // CustomAuthorizationHeader is the optional bearer token for authentication. + CustomAuthorizationHeader = HeaderKey("kubernetes-authorization") // CustomClientCertificateData is the base64-encoded client certificate. CustomClientCertificateDataHeader = HeaderKey("kubernetes-client-certificate-data") // CustomClientKeyData is the base64-encoded client key. CustomClientKeyDataHeader = HeaderKey("kubernetes-client-key-data") + // CustomInsecureSkipTLSVerify is the optional flag to skip TLS verification. + CustomInsecureSkipTLSVerifyHeader = HeaderKey("kubernetes-insecure-skip-tls-verify") OAuthAuthorizationHeader = HeaderKey("Authorization") diff --git a/pkg/kubernetes/manager.go b/pkg/kubernetes/manager.go index 32bd278e..f143941c 100644 --- a/pkg/kubernetes/manager.go +++ b/pkg/kubernetes/manager.go @@ -91,6 +91,43 @@ func NewInClusterManager(config *config.StaticConfig) (*Manager, error) { return newManager(config, restConfig, clientcmd.NewDefaultClientConfig(*clientCmdConfig, nil)) } +func NewAuthHeadersClusterManager(authHeaders *K8sAuthHeaders, config *config.StaticConfig) (*Manager, error) { + + var certData []byte = nil + if len(authHeaders.ClientCertificateData) > 0 { + certData = authHeaders.ClientCertificateData + } + + var keyData []byte = nil + if len(authHeaders.ClientKeyData) > 0 { + keyData = authHeaders.ClientKeyData + } + + restConfig := &rest.Config{ + Host: authHeaders.Server, + BearerToken: authHeaders.AuthorizationToken, + TLSClientConfig: rest.TLSClientConfig{ + Insecure: authHeaders.InsecureSkipTLSVerify, + CAData: authHeaders.CertificateAuthorityData, + CertData: certData, + KeyData: keyData, + }, + } + // Create a dummy kubeconfig clientcmdapi.Config for in-cluster config to be used in places where clientcmd.ClientConfig is required + clientCmdConfig := clientcmdapi.NewConfig() + clientCmdConfig.Clusters["cluster"] = &clientcmdapi.Cluster{ + Server: authHeaders.Server, + InsecureSkipTLSVerify: authHeaders.InsecureSkipTLSVerify, + } + clientCmdConfig.AuthInfos["user"] = &clientcmdapi.AuthInfo{ + Token: authHeaders.AuthorizationToken, + ClientCertificateData: certData, + ClientKeyData: keyData, + } + + return newManager(config, restConfig, clientcmd.NewDefaultClientConfig(*clientCmdConfig, nil)) +} + func newManager(config *config.StaticConfig, restConfig *rest.Config, clientCmdConfig clientcmd.ClientConfig) (*Manager, error) { k8s := &Manager{ staticConfig: config, diff --git a/pkg/kubernetes/provider_auth_headers.go b/pkg/kubernetes/provider_auth_headers.go index 2e42ba97..cd9ef152 100644 --- a/pkg/kubernetes/provider_auth_headers.go +++ b/pkg/kubernetes/provider_auth_headers.go @@ -2,6 +2,7 @@ package kubernetes import ( "context" + "errors" "fmt" "github.com/containers/kubernetes-mcp-server/pkg/config" @@ -24,67 +25,8 @@ func init() { } // newAuthHeadersClusterProvider creates a provider that requires header-based authentication. -// Users must provide tokens via request headers (server URL, CA cert). +// Users must provide tokens via request headers (server URL, Token or client certificate and key). func newAuthHeadersClusterProvider(cfg *config.StaticConfig) (Provider, error) { - // // Create a base manager using kubeconfig for cluster connection details - // m, err := NewKubeconfigManager(cfg, "") - // if err != nil { - // return nil, fmt.Errorf("failed to create auth-headers provider: %w", err) - // } - - // // Create a minimal kubeconfig with only cluster connection info (no auth) - // rawConfig, err := m.clientCmdConfig.RawConfig() - // if err != nil { - // return nil, fmt.Errorf("failed to read kubeconfig: %w", err) - // } - - // // Get the current context to extract cluster info - // currentContext := rawConfig.Contexts[rawConfig.CurrentContext] - // if currentContext == nil { - // return nil, fmt.Errorf("current context not found in kubeconfig") - // } - - // cluster := rawConfig.Clusters[currentContext.Cluster] - // if cluster == nil { - // return nil, fmt.Errorf("cluster %s not found in kubeconfig", currentContext.Cluster) - // } - - // // Create a REST config with only cluster connection details (no auth) - // restConfig := &rest.Config{ - // Host: cluster.Server, - // APIPath: m.cfg.APIPath, - // TLSClientConfig: rest.TLSClientConfig{ - // Insecure: cluster.InsecureSkipTLSVerify, - // ServerName: cluster.TLSServerName, - // CAData: cluster.CertificateAuthorityData, - // CAFile: cluster.CertificateAuthority, - // }, - // UserAgent: rest.DefaultKubernetesUserAgent(), - // QPS: m.cfg.QPS, - // Burst: m.cfg.Burst, - // Timeout: m.cfg.Timeout, - // } - - // // Create a minimal clientcmd config without any authentication - // minimalConfig := clientcmdapi.NewConfig() - // minimalConfig.Clusters["cluster"] = &clientcmdapi.Cluster{ - // Server: cluster.Server, - // InsecureSkipTLSVerify: cluster.InsecureSkipTLSVerify, - // CertificateAuthority: cluster.CertificateAuthority, - // CertificateAuthorityData: cluster.CertificateAuthorityData, - // TLSServerName: cluster.TLSServerName, - // } - // minimalConfig.Contexts["auth-headers-context"] = &clientcmdapi.Context{ - // Cluster: "cluster", - // } - // minimalConfig.CurrentContext = "auth-headers-context" - - // // Create a new manager with the stripped-down config - // baseManager, err := newManager(cfg, restConfig, clientcmd.NewDefaultClientConfig(*minimalConfig, nil)) - // if err != nil { - // return nil, fmt.Errorf("failed to create base manager for auth-headers provider: %w", err) - // } - klog.V(1).Infof("Auth-headers provider initialized - all requests must include valid headers") return &AuthHeadersClusterProvider{staticConfig: cfg}, nil @@ -108,93 +50,17 @@ func (p *AuthHeadersClusterProvider) GetTargetParameterName() string { } func (p *AuthHeadersClusterProvider) GetDerivedKubernetes(ctx context.Context, target string) (*Kubernetes, error) { - // authHeaders, ok := ctx.Value(AuthHeadersContextKey).(*K8sAuthHeaders) - // if !ok { - // return nil, errors.New("authHeaders required") - // } - - // decodedCA, err := authHeaders.GetDecodedCertificateAuthorityData() - // if err != nil { - // return nil, fmt.Errorf("failed to decode certificate authority data: %w", err) - // } - - // restConfig := &rest.Config{ - // Host: authHeaders.ClusterURL, - // BearerToken: authHeaders.AuthorizationToken, - // TLSClientConfig: rest.TLSClientConfig{ - // Insecure: false, - // CAData: decodedCA, - // }, - // } - - // _ := clientcmd.NewDefaultClientConfig(*restConfig, nil) - - // // Create a REST config with only cluster connection details (no auth) - // restConfig := &rest.Config{ - // Host: cluster.Server, - // APIPath: m.cfg.APIPath, - // TLSClientConfig: rest.TLSClientConfig{ - // Insecure: cluster.InsecureSkipTLSVerify, - // ServerName: cluster.TLSServerName, - // CAData: cluster.CertificateAuthorityData, - // CAFile: cluster.CertificateAuthority, - // }, - // UserAgent: rest.DefaultKubernetesUserAgent(), - // QPS: m.cfg.QPS, - // Burst: m.cfg.Burst, - // Timeout: m.cfg.Timeout, - // } - - // // Create a minimal clientcmd config without any authentication - // minimalConfig := clientcmdapi.NewConfig() - // minimalConfig.Clusters["cluster"] = &clientcmdapi.Cluster{ - // Server: cluster.Server, - // InsecureSkipTLSVerify: cluster.InsecureSkipTLSVerify, - // CertificateAuthority: cluster.CertificateAuthority, - // CertificateAuthorityData: cluster.CertificateAuthorityData, - // TLSServerName: cluster.TLSServerName, - // } - // minimalConfig.Contexts["auth-headers-context"] = &clientcmdapi.Context{ - // Cluster: "cluster", - // } - // minimalConfig.CurrentContext = "auth-headers-context" - - // derivedCfg := &rest.Config{ - // Host: authHeaders.ClusterURL, - // APIPath: m.cfg.APIPath, - // // Copy only server verification TLS settings (CA bundle and server name) - // TLSClientConfig: rest.TLSClientConfig{ - // Insecure: m.cfg.Insecure, - // ServerName: m.cfg.ServerName, - // CAFile: m.cfg.CAFile, - // CAData: m.cfg.CAData, - // }, - // BearerToken: strings.TrimPrefix(authorization, "Bearer "), - // // pass custom UserAgent to identify the client - // UserAgent: CustomUserAgent, - // QPS: m.cfg.QPS, - // Burst: m.cfg.Burst, - // Timeout: m.cfg.Timeout, - // Impersonate: rest.ImpersonationConfig{}, - // } - - // type Manager struct { - // cfg *rest.Config - // clientCmdConfig clientcmd.ClientConfig - // discoveryClient discovery.CachedDiscoveryInterface - // accessControlClientSet *AccessControlClientset - // accessControlRESTMapper *AccessControlRESTMapper - // dynamicClient *dynamic.DynamicClient - - // staticConfig *config.StaticConfig - // CloseWatchKubeConfig CloseWatchKubeConfig - // } - - // k := &Kubernetes{ - // manager: p.baseManager, - // } - - return nil, nil + authHeaders, ok := ctx.Value(AuthHeadersContextKey).(*K8sAuthHeaders) + if !ok { + return nil, errors.New("authHeaders required") + } + + manager, err := NewAuthHeadersClusterManager(authHeaders, p.staticConfig) + if err != nil { + return nil, fmt.Errorf("failed to create auth headers cluster manager: %w", err) + } + + return &Kubernetes{manager: manager}, nil } func (p *AuthHeadersClusterProvider) GetDefaultTarget() string { diff --git a/pkg/mcp/middleware.go b/pkg/mcp/middleware.go index b1181696..c86ce7b1 100644 --- a/pkg/mcp/middleware.go +++ b/pkg/mcp/middleware.go @@ -36,6 +36,7 @@ func customAuthHeadersPropagationMiddleware(next mcp.MethodHandler) mcp.MethodHa var authHeaders *internalk8s.K8sAuthHeaders = nil var err error = nil + // Try to parse auth headers from tool params meta. if req.GetParams() != nil { if toolParams, ok := req.GetParams().(*mcp.CallToolParamsRaw); ok { toolParamsMeta := toolParams.GetMeta() @@ -46,21 +47,23 @@ func customAuthHeadersPropagationMiddleware(next mcp.MethodHandler) mcp.MethodHa } } + // If auth headers are not found in tool params meta, try to parse from request extra. if authHeaders == nil && req.GetExtra() != nil && req.GetExtra().Header != nil { - // Convert http.Header to map[string]any + // Convert http.Header to map[string]any with lowercased keys. headerMap := make(map[string]any) for key, values := range req.GetExtra().Header { if len(values) > 0 { headerMap[strings.ToLower(key)] = values[0] } } + // Filter auth headers to only include the ones that are allowed. authHeaders, err = internalk8s.NewK8sAuthHeadersFromHeaders(headerMap) if err != nil { return nil, err } } - // add auth headers to context + // Add auth headers to context if authHeaders != nil { ctx = context.WithValue(ctx, internalk8s.AuthHeadersContextKey, authHeaders) } From 3c01de56abec0d96d0a4d42a2978dfebef6c398d Mon Sep 17 00:00:00 2001 From: Muhammad Faizan Date: Tue, 25 Nov 2025 15:59:22 +0100 Subject: [PATCH 16/21] updated Signed-off-by: Muhammad Faizan --- pkg/kubernetes/manager.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/kubernetes/manager.go b/pkg/kubernetes/manager.go index f143941c..b92bc55b 100644 --- a/pkg/kubernetes/manager.go +++ b/pkg/kubernetes/manager.go @@ -113,7 +113,7 @@ func NewAuthHeadersClusterManager(authHeaders *K8sAuthHeaders, config *config.St KeyData: keyData, }, } - // Create a dummy kubeconfig clientcmdapi.Config for in-cluster config to be used in places where clientcmd.ClientConfig is required + // Create a dummy kubeconfig clientcmdapi.Config to be used in places where clientcmd.ClientConfig is required. clientCmdConfig := clientcmdapi.NewConfig() clientCmdConfig.Clusters["cluster"] = &clientcmdapi.Cluster{ Server: authHeaders.Server, From c69f1d601b23da65af0f83a2c1b041905dd3d6c4 Mon Sep 17 00:00:00 2001 From: Muhammad Faizan Date: Tue, 25 Nov 2025 16:00:22 +0100 Subject: [PATCH 17/21] updated Signed-off-by: Muhammad Faizan --- pkg/mcp/middleware.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/mcp/middleware.go b/pkg/mcp/middleware.go index c86ce7b1..7a85a3fc 100644 --- a/pkg/mcp/middleware.go +++ b/pkg/mcp/middleware.go @@ -35,7 +35,7 @@ func customAuthHeadersPropagationMiddleware(next mcp.MethodHandler) mcp.MethodHa return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { var authHeaders *internalk8s.K8sAuthHeaders = nil - var err error = nil + var err error // Try to parse auth headers from tool params meta. if req.GetParams() != nil { if toolParams, ok := req.GetParams().(*mcp.CallToolParamsRaw); ok { From 868ed2271ed28e0c79e6aa31e11b6d288c919a3a Mon Sep 17 00:00:00 2001 From: Muhammad Faizan Date: Wed, 26 Nov 2025 10:17:11 +0100 Subject: [PATCH 18/21] updated Signed-off-by: Muhammad Faizan --- pkg/kubernetes/auth_headers.go | 20 +- pkg/kubernetes/provider_auth_headers.go | 10 +- pkg/kubernetes/provider_auth_headers_test.go | 250 ++++++++++++++----- 3 files changed, 209 insertions(+), 71 deletions(-) diff --git a/pkg/kubernetes/auth_headers.go b/pkg/kubernetes/auth_headers.go index c33bc0fe..860a6220 100644 --- a/pkg/kubernetes/auth_headers.go +++ b/pkg/kubernetes/auth_headers.go @@ -11,10 +11,6 @@ type AuthType string type ContextKey string const ( - // AuthTypeToken represents token-based authentication. - AuthTypeToken AuthType = "token" - // AuthTypeClientCertificate represents client certificate authentication. - AuthTypeClientCertificate AuthType = "client_certificate" // AuthHeadersContextKey is the context key for the Kubernetes authentication headers. AuthHeadersContextKey ContextKey = "k8s_auth_headers" ) @@ -40,11 +36,12 @@ func GetDecodedData(data string) ([]byte, error) { return base64.StdEncoding.DecodeString(data) } +// NewK8sAuthHeadersFromHeaders creates a new K8sAuthHeaders from the provided headers. func NewK8sAuthHeadersFromHeaders(data map[string]any) (*K8sAuthHeaders, error) { var ok bool var err error - // Initialize auth headers. + // Initialize auth headers with default values. authHeaders := &K8sAuthHeaders{ InsecureSkipTLSVerify: false, } @@ -92,21 +89,20 @@ func NewK8sAuthHeadersFromHeaders(data map[string]any) (*K8sAuthHeaders, error) } // Check if a valid authentication type is provided. - _, err = authHeaders.GetAuthType() - if err != nil { + if !authHeaders.IsValid() { return nil, fmt.Errorf("either %s header for token authentication or (%s and %s) headers for client certificate authentication required", CustomAuthorizationHeader, CustomClientCertificateDataHeader, CustomClientKeyDataHeader) } return authHeaders, nil } -// GetAuthType returns the authentication type based on the provided headers. -func (h *K8sAuthHeaders) GetAuthType() (AuthType, error) { +// IsValid checks if the authentication headers are valid. +func (h *K8sAuthHeaders) IsValid() bool { if h.AuthorizationToken != "" { - return AuthTypeToken, nil + return true } if h.ClientCertificateData != nil && h.ClientKeyData != nil { - return AuthTypeClientCertificate, nil + return true } - return "", fmt.Errorf("invalid authentication type") + return false } diff --git a/pkg/kubernetes/provider_auth_headers.go b/pkg/kubernetes/provider_auth_headers.go index cd9ef152..1bc183db 100644 --- a/pkg/kubernetes/provider_auth_headers.go +++ b/pkg/kubernetes/provider_auth_headers.go @@ -27,25 +27,27 @@ func init() { // newAuthHeadersClusterProvider creates a provider that requires header-based authentication. // Users must provide tokens via request headers (server URL, Token or client certificate and key). func newAuthHeadersClusterProvider(cfg *config.StaticConfig) (Provider, error) { - klog.V(1).Infof("Auth-headers provider initialized - all requests must include valid headers") + klog.V(1).Infof("Auth-headers provider initialized - all requests must include valid k8s auth headers") return &AuthHeadersClusterProvider{staticConfig: cfg}, nil } func (p *AuthHeadersClusterProvider) IsOpenShift(ctx context.Context) bool { + klog.V(1).Infof("IsOpenShift not supported for auth-headers provider. Returning false.") return false } func (p *AuthHeadersClusterProvider) VerifyToken(ctx context.Context, target, token, audience string) (*authenticationv1api.UserInfo, []string, error) { - return nil, nil, fmt.Errorf("auth-headers VerifyToken not implemented") + return nil, nil, fmt.Errorf("VerifyToken not supported for auth-headers provider") } func (p *AuthHeadersClusterProvider) GetTargets(_ context.Context) ([]string, error) { - // Single cluster mode + klog.V(1).Infof("GetTargets not supported for auth-headers provider. Returning empty list.") return []string{""}, nil } func (p *AuthHeadersClusterProvider) GetTargetParameterName() string { + klog.V(1).Infof("GetTargetParameterName not supported for auth-headers provider. Returning empty name.") return "" } @@ -64,10 +66,12 @@ func (p *AuthHeadersClusterProvider) GetDerivedKubernetes(ctx context.Context, t } func (p *AuthHeadersClusterProvider) GetDefaultTarget() string { + klog.V(1).Infof("GetDefaultTarget not supported for auth-headers provider. Returning empty name.") return "" } func (p *AuthHeadersClusterProvider) WatchTargets(watch func() error) { + klog.V(1).Infof("WatchTargets not supported for auth-headers provider. Ignoring watch function.") } func (p *AuthHeadersClusterProvider) Close() { diff --git a/pkg/kubernetes/provider_auth_headers_test.go b/pkg/kubernetes/provider_auth_headers_test.go index b8a48836..6f637b45 100644 --- a/pkg/kubernetes/provider_auth_headers_test.go +++ b/pkg/kubernetes/provider_auth_headers_test.go @@ -2,34 +2,20 @@ package kubernetes import ( "context" + "encoding/base64" "testing" "github.com/containers/kubernetes-mcp-server/internal/test" "github.com/containers/kubernetes-mcp-server/pkg/config" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "k8s.io/client-go/rest" ) func TestAuthHeadersProviderFactory(t *testing.T) { - t.Run("auth-headers provider requires kubeconfig for cluster connection info", func(t *testing.T) { + t.Run("auth-headers provider initializes without kubeconfig", func(t *testing.T) { cfg := &config.StaticConfig{ - KubeConfig: "", ClusterProviderStrategy: config.ClusterProviderAuthHeaders, } - _, err := newAuthHeadersClusterProvider(cfg) - require.Error(t, err) - }) - - t.Run("auth-headers provider initializes with valid kubeconfig", func(t *testing.T) { - mockServer := test.NewMockServer() - defer mockServer.Close() - - cfg := &config.StaticConfig{ - KubeConfig: mockServer.KubeconfigFile(t), - ClusterProviderStrategy: config.ClusterProviderAuthHeaders, - RequireOAuth: true, - } provider, err := newAuthHeadersClusterProvider(cfg) require.NoError(t, err) @@ -37,48 +23,20 @@ func TestAuthHeadersProviderFactory(t *testing.T) { assert.IsType(t, &AuthHeadersClusterProvider{}, provider) }) - t.Run("auth-headers provider automatically enables RequireOAuth", func(t *testing.T) { - mockServer := test.NewMockServer() - defer mockServer.Close() - + t.Run("auth-headers provider initializes with minimal config", func(t *testing.T) { cfg := &config.StaticConfig{ - KubeConfig: mockServer.KubeconfigFile(t), ClusterProviderStrategy: config.ClusterProviderAuthHeaders, - RequireOAuth: false, // Will be forced to true } provider, err := newAuthHeadersClusterProvider(cfg) require.NoError(t, err) require.NotNil(t, provider) - assert.True(t, cfg.RequireOAuth, "RequireOAuth should be forced to true") - }) - - t.Run("auth-headers provider rejects in-cluster config", func(t *testing.T) { - cfg := &config.StaticConfig{ - ClusterProviderStrategy: config.ClusterProviderAuthHeaders, - } - - // Temporarily mock IsInCluster to return true - originalInClusterConfig := InClusterConfig - InClusterConfig = func() (*rest.Config, error) { - return &rest.Config{Host: "https://kubernetes.default.svc"}, nil - } - defer func() { InClusterConfig = originalInClusterConfig }() - - _, err := newAuthHeadersClusterProvider(cfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "cannot be used in in-cluster deployments") }) } func TestAuthHeadersProviderInterface(t *testing.T) { - mockServer := test.NewMockServer() - defer mockServer.Close() - cfg := &config.StaticConfig{ - KubeConfig: mockServer.KubeconfigFile(t), ClusterProviderStrategy: config.ClusterProviderAuthHeaders, - RequireOAuth: true, } provider, err := newAuthHeadersClusterProvider(cfg) @@ -98,30 +56,210 @@ func TestAuthHeadersProviderInterface(t *testing.T) { assert.Equal(t, "", provider.GetDefaultTarget()) }) - t.Run("GetDerivedKubernetes requires token in context", func(t *testing.T) { + t.Run("IsOpenShift returns false", func(t *testing.T) { + assert.False(t, provider.IsOpenShift(context.Background())) + }) + + t.Run("VerifyToken not supported", func(t *testing.T) { + _, _, err := provider.VerifyToken(context.Background(), "", "token", "audience") + require.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + }) + + t.Run("WatchTargets does nothing", func(t *testing.T) { + called := false + provider.WatchTargets(func() error { + called = true + return nil + }) + // WatchTargets should not call the function + assert.False(t, called) + }) + + t.Run("Close does nothing", func(t *testing.T) { + // Should not panic + provider.Close() + }) +} + +func TestAuthHeadersProviderGetDerivedKubernetes(t *testing.T) { + mockServer := test.NewMockServer() + defer mockServer.Close() + + cfg := &config.StaticConfig{ + ClusterProviderStrategy: config.ClusterProviderAuthHeaders, + } + + provider, err := newAuthHeadersClusterProvider(cfg) + require.NoError(t, err) + + // Generate test CA certificate data in valid PEM format + caCert := []byte(`-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJAKHHCgVZU8BiMA0GCSqGSIb3DQEBBQUAMA0xCzAJBgNVBAYTAlVT +MB4XDTA5MDUxOTE1MTc1N1oXDTEwMDUxOTE1MTc1N1owDTELMAkGA1UEBhMCVVMw +gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBANLJhPHhITqQbPklG3ibCVxwGMRf +p/v4XqhfdQHdcVfHap6NQ5Wok/9X5gK7d1ONlGjn/Ut9Pz4xwqGy3nLxVz1CsE2k +TqQxdqEQBVNvFrAB4OlD9K9wQ3R+0S1wPPQ9yg9i6vF2JlOvD1HFJzIGcz1kLZU2 +wj5FqYY5SHmXF2YbAgMBAAEwDQYJKoZIhvcNAQEFBQADgYEAc9NQIv8J/cqV0zBX +c6d5Wm1NJdTxYwG/+xHDaLDK8R3W5Y1e7YwNg7nN8K2GqMh3YYxmDJCLDhGdKDEV +V5qHcKhFCFPxTmKgzVjy8vhR7VqZU4dJhC8sDbE/IkKH7hBo7CLHH/T2Ly9LcDY0 +9C2zNtDN3KEzGW3V7/J7IvVBDy0= +-----END CERTIFICATE-----`) + caCertBase64 := base64.StdEncoding.EncodeToString(caCert) + + t.Run("GetDerivedKubernetes requires auth headers in context", func(t *testing.T) { ctx := context.Background() _, err := provider.GetDerivedKubernetes(ctx, "") require.Error(t, err) - assert.Contains(t, err.Error(), "required") + assert.Contains(t, err.Error(), "authHeaders required") }) - t.Run("GetDerivedKubernetes works with valid bearer token", func(t *testing.T) { - ctx := context.WithValue(context.Background(), OAuthAuthorizationHeader, "Bearer test-token") + t.Run("GetDerivedKubernetes works with token authentication", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + Server: mockServer.Config().Host, + CertificateAuthorityData: nil, + AuthorizationToken: "test-token", + InsecureSkipTLSVerify: true, + } + + ctx := context.WithValue(context.Background(), AuthHeadersContextKey, authHeaders) k, err := provider.GetDerivedKubernetes(ctx, "") require.NoError(t, err) require.NotNil(t, k) + assert.NotNil(t, k.manager) }) - t.Run("GetDerivedKubernetes rejects non-empty target", func(t *testing.T) { - ctx := context.WithValue(context.Background(), OAuthAuthorizationHeader, "Bearer test-token") - _, err := provider.GetDerivedKubernetes(ctx, "some-cluster") + t.Run("GetDerivedKubernetes accepts client certificate authentication", func(t *testing.T) { + // Note: We use dummy cert/key data since we can't easily create valid certificates for testing. + // The actual validation happens when connecting to the cluster, not during manager creation. + clientCert := []byte("dummy-cert") + clientKey := []byte("dummy-key") + + authHeaders := &K8sAuthHeaders{ + Server: mockServer.Config().Host, + CertificateAuthorityData: nil, + ClientCertificateData: clientCert, + ClientKeyData: clientKey, + InsecureSkipTLSVerify: true, + AuthorizationToken: "", // No token when using client cert + } + + // This should fail because the certificates are invalid, but we're testing that the provider + // accepts the auth headers and attempts to create the manager + ctx := context.WithValue(context.Background(), AuthHeadersContextKey, authHeaders) + _, err := provider.GetDerivedKubernetes(ctx, "") + // Expect an error about invalid certificates, which means the provider accepted the headers require.Error(t, err) - assert.Contains(t, err.Error(), "does not support multiple targets") + assert.Contains(t, err.Error(), "failed to create auth headers cluster manager") + }) + + t.Run("GetDerivedKubernetes works with insecure skip TLS verify", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + Server: mockServer.Config().Host, + CertificateAuthorityData: nil, // Don't provide CA data when skipping TLS verification + AuthorizationToken: "test-token", + InsecureSkipTLSVerify: true, + } + + ctx := context.WithValue(context.Background(), AuthHeadersContextKey, authHeaders) + k, err := provider.GetDerivedKubernetes(ctx, "") + require.NoError(t, err) + require.NotNil(t, k) + }) + + t.Run("NewK8sAuthHeadersFromHeaders parses token auth correctly", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): "Bearer test-token", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.Equal(t, mockServer.Config().Host, authHeaders.Server) + assert.Equal(t, caCert, authHeaders.CertificateAuthorityData) + assert.Equal(t, "Bearer test-token", authHeaders.AuthorizationToken) + assert.False(t, authHeaders.InsecureSkipTLSVerify) + assert.True(t, authHeaders.IsValid()) }) - t.Run("VerifyToken rejects non-empty target", func(t *testing.T) { - _, _, err := provider.VerifyToken(context.Background(), "some-cluster", "token", "audience") + t.Run("NewK8sAuthHeadersFromHeaders parses cert auth correctly", func(t *testing.T) { + clientCert := []byte("test-client-cert") + clientKey := []byte("test-client-key") + clientCertBase64 := base64.StdEncoding.EncodeToString(clientCert) + clientKeyBase64 := base64.StdEncoding.EncodeToString(clientKey) + + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientCertificateDataHeader): clientCertBase64, + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.Equal(t, mockServer.Config().Host, authHeaders.Server) + assert.Equal(t, caCert, authHeaders.CertificateAuthorityData) + assert.Equal(t, clientCert, authHeaders.ClientCertificateData) + assert.Equal(t, clientKey, authHeaders.ClientKeyData) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("NewK8sAuthHeadersFromHeaders requires server header", func(t *testing.T) { + headers := map[string]any{ + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): "Bearer test-token", + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-server") + }) + + t.Run("NewK8sAuthHeadersFromHeaders requires CA data header", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomAuthorizationHeader): "Bearer test-token", + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-certificate-authority-data") + }) + + t.Run("NewK8sAuthHeadersFromHeaders requires valid auth method", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication") + }) + + t.Run("NewK8sAuthHeadersFromHeaders handles insecure skip TLS verify", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): "Bearer test-token", + string(CustomInsecureSkipTLSVerifyHeader): "true", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.True(t, authHeaders.InsecureSkipTLSVerify) + }) + + t.Run("NewK8sAuthHeadersFromHeaders handles invalid base64 CA data", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): mockServer.Config().Host, + string(CustomCertificateAuthorityDataHeader): "invalid-base64!!!", + string(CustomAuthorizationHeader): "Bearer test-token", + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) require.Error(t, err) - assert.Contains(t, err.Error(), "does not support multiple targets") + assert.Contains(t, err.Error(), "certificate authority data") }) } From b969b4d1f532aa8596a2ca3f07618be87df70d45 Mon Sep 17 00:00:00 2001 From: Muhammad Faizan Date: Wed, 26 Nov 2025 10:36:13 +0100 Subject: [PATCH 19/21] updated documentation Signed-off-by: Muhammad Faizan --- docs/AUTH_HEADERS_PROVIDER.md | 312 ++++++++++++++++------------------ 1 file changed, 149 insertions(+), 163 deletions(-) diff --git a/docs/AUTH_HEADERS_PROVIDER.md b/docs/AUTH_HEADERS_PROVIDER.md index f480679f..7d611a3a 100644 --- a/docs/AUTH_HEADERS_PROVIDER.md +++ b/docs/AUTH_HEADERS_PROVIDER.md @@ -1,21 +1,22 @@ # Auth-Headers Provider -The `auth-headers` cluster provider strategy enables multi-tenant Kubernetes MCP server deployments where each user authenticates with their own Kubernetes token via HTTP request headers. +The `auth-headers` cluster provider strategy enables multi-tenant Kubernetes MCP server deployments where each request provides complete cluster connection details and authentication via HTTP headers or MCP tool parameters. ## Overview This provider: -- **Requires authentication via request headers** (`Authorization` or `kubernetes-authorization`) -- **Extracts cluster connection details** from kubeconfig (server URL, CA certificates) -- **Strips all authentication credentials** from the kubeconfig -- **Creates dynamic Kubernetes clients** per request using the provided bearer tokens +- **Requires cluster connection details per request** via custom headers (server URL, CA certificate) +- **Requires authentication per request** via bearer token OR client certificates +- **Does not use kubeconfig** - all configuration comes from request headers +- **Creates dynamic Kubernetes clients** per request using the provided credentials ## Use Cases -- **Multi-tenant SaaS deployments** - Single MCP server instance serving multiple users -- **Zero-trust architectures** - No stored credentials, authentication per request -- **OIDC/OAuth integration** - Users authenticate via identity provider, tokens forwarded to Kubernetes +- **Multi-tenant SaaS deployments** - Single MCP server instance serving multiple users/clusters +- **Zero-trust architectures** - No stored credentials, complete authentication per request +- **Dynamic cluster access** - Connect to different clusters without server configuration - **Auditing & compliance** - Each request uses the user's actual identity for Kubernetes RBAC +- **Temporary access** - Short-lived credentials without persistent configuration ## Configuration @@ -24,35 +25,40 @@ This provider: ```bash kubernetes-mcp-server \ --port 8080 \ - --kubeconfig /path/to/kubeconfig \ --cluster-provider-strategy auth-headers ``` The server will: -1. Read cluster connection details from the kubeconfig -2. Automatically enable `--require-oauth` -3. Reject any requests without valid bearer tokens +1. Accept requests with cluster connection details in headers +2. Create a Kubernetes client dynamically for each request +3. Reject any requests without required authentication headers ### TOML Configuration ```toml cluster_provider_strategy = "auth-headers" -kubeconfig = "/path/to/kubeconfig" -require_oauth = true -validate_token = true # Optional: validate tokens against Kubernetes API +# No kubeconfig needed - all details come from request headers ``` -### With Token Validation +### Required Headers -```bash -kubernetes-mcp-server \ - --port 8080 \ - --kubeconfig /path/to/kubeconfig \ - --cluster-provider-strategy auth-headers \ - --validate-token -``` +Each request must include the following custom headers: + +**Required for all requests:** +- `kubernetes-server` - Kubernetes API server URL (e.g., `https://kubernetes.example.com:6443`) +- `kubernetes-certificate-authority-data` - Base64-encoded CA certificate + +**Authentication (choose one):** + +Option 1: Bearer Token +- `kubernetes-authorization` - Bearer token (e.g., `Bearer eyJhbGci...`) + +Option 2: Client Certificate +- `kubernetes-client-certificate-data` - Base64-encoded client certificate +- `kubernetes-client-key-data` - Base64-encoded client key -This validates each token using Kubernetes TokenReview API before allowing operations. +**Optional:** +- `kubernetes-insecure-skip-tls-verify` - Set to `true` to skip TLS verification (not recommended for production) ## How It Works @@ -60,38 +66,79 @@ This validates each token using Kubernetes TokenReview API before allowing opera When the server starts: ``` -Kubeconfig → Extract cluster info (server URL, CA cert) → Create base manager - ↓ - Strip all auth credentials - ↓ - Ready to accept requests +Server starts with auth-headers provider + ↓ +No kubeconfig or credentials loaded + ↓ +Ready to accept requests with headers ``` ### 2. Request Processing For each MCP request: ``` -HTTP Request → Extract Authorization header → Create derived Kubernetes client - ↓ ↓ - "Bearer " Uses token for authentication - ↓ - Execute Kubernetes operation +HTTP Request with custom headers + ↓ +Extract kubernetes-server, kubernetes-certificate-authority-data + ↓ +Extract authentication (token OR client cert/key) + ↓ +Create K8sAuthHeaders struct + ↓ +Build rest.Config dynamically + ↓ +Create new Kubernetes client + ↓ +Execute Kubernetes operation + ↓ +Discard client after request ``` -### 3. Security Model +### 3. Header Extraction + +Headers can be provided in two ways: + +**A. HTTP Request Headers** (standard way): +``` +POST /mcp HTTP/1.1 +kubernetes-server: https://k8s.example.com:6443 +kubernetes-certificate-authority-data: LS0tLS1CRUdJ... +kubernetes-authorization: Bearer eyJhbGci... +``` + +**B. MCP Tool Parameters Meta** (advanced): +```json +{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "pods_list", + "arguments": {"namespace": "default"}, + "_meta": { + "kubernetes-server": "https://k8s.example.com:6443", + "kubernetes-certificate-authority-data": "LS0tLS1CRUdJ...", + "kubernetes-authorization": "Bearer eyJhbGci..." + } + } +} +``` + +### 4. Security Model ``` ┌──────────────────┐ -│ MCP Client │ (User's application) +│ MCP Client │ │ (Claude, etc) │ └────────┬─────────┘ - │ Bearer + │ All cluster info + auth in headers ↓ ┌──────────────────┐ │ MCP Server │ │ (auth-headers) │ +│ NO CREDENTIALS │ +│ STORED │ └────────┬─────────┘ - │ Uses user's token + │ Creates temporary client ↓ ┌──────────────────┐ │ Kubernetes API │ @@ -99,7 +146,7 @@ HTTP Request → Extract Authorization header → Create derived Kubernetes clie └──────────────────┘ ↓ RBAC enforced with - user's actual identity + credentials from headers ``` ## Client Usage @@ -107,14 +154,24 @@ HTTP Request → Extract Authorization header → Create derived Kubernetes clie ### Using the Go MCP Client ```go -import "github.com/mark3labs/mcp-go/client/transport" +import ( + "encoding/base64" + "github.com/mark3labs/mcp-go/client/transport" +) + +// Get cluster connection details +serverURL := "https://k8s.example.com:6443" +caCert := getCAcertificate() // PEM-encoded CA certificate +token := getUserKubernetesToken() -// Get user's Kubernetes token (from OIDC, service account, etc.) -userToken := getUserKubernetesToken() +// Encode CA certificate to base64 +caCertBase64 := base64.StdEncoding.EncodeToString(caCert) client := NewMCPClient( transport.WithHTTPHeaders(map[string]string{ - "Authorization": "Bearer " + userToken + "kubernetes-server": serverURL, + "kubernetes-certificate-authority-data": caCertBase64, + "kubernetes-authorization": "Bearer " + token, }) ) ``` @@ -127,7 +184,27 @@ client := NewMCPClient( "kubernetes": { "url": "https://mcp-server.example.com/sse", "headers": { - "Authorization": "Bearer YOUR_KUBERNETES_TOKEN" + "kubernetes-server": "https://k8s.example.com:6443", + "kubernetes-certificate-authority-data": "LS0tLS1CRUdJTi...", + "kubernetes-authorization": "Bearer YOUR_KUBERNETES_TOKEN" + } + } + } +} +``` + +### Using Client Certificates + +```json +{ + "mcpServers": { + "kubernetes": { + "url": "https://mcp-server.example.com/sse", + "headers": { + "kubernetes-server": "https://k8s.example.com:6443", + "kubernetes-certificate-authority-data": "LS0tLS1CRUdJTi...", + "kubernetes-client-certificate-data": "LS0tLS1CRUdJTi...", + "kubernetes-client-key-data": "LS0tLS1CRUdJTi..." } } } @@ -137,9 +214,12 @@ client := NewMCPClient( ### Using cURL ```bash +# With bearer token curl -X POST https://mcp-server.example.com/mcp \ -H "Content-Type: application/json" \ - -H "Authorization: Bearer eyJhbGci..." \ + -H "kubernetes-server: https://k8s.example.com:6443" \ + -H "kubernetes-certificate-authority-data: LS0tLS1CRUdJTi..." \ + -H "kubernetes-authorization: Bearer eyJhbGci..." \ -d '{ "jsonrpc": "2.0", "method": "tools/call", @@ -151,132 +231,38 @@ curl -X POST https://mcp-server.example.com/mcp \ }' ``` -## Comparison with Other Providers - -| Feature | auth-headers | kubeconfig | in-cluster | disabled | -|---------|--------------|------------|------------|----------| -| **Multi-tenant** | ✅ Yes | ❌ No | ❌ No | ❌ No | -| **Multi-cluster** | ❌ No | ✅ Yes | ❌ No | ❌ No | -| **Per-request auth** | ✅ Yes | ❌ No | ❌ No | ❌ No | -| **Requires headers** | ✅ Required | ❌ Optional | ❌ Optional | ❌ Optional | -| **Stored credentials** | ❌ None | ✅ Kubeconfig | ✅ SA token | ✅ Kubeconfig | -| **Use case** | SaaS/Multi-user | Local dev | In-cluster | Single cluster | - -## Security Considerations - -### ✅ Advantages - -- **No stored credentials** - Server doesn't store any Kubernetes authentication -- **Per-request authentication** - Each request uses fresh, user-specific token -- **RBAC enforcement** - Kubernetes enforces permissions using actual user identity -- **Token expiration** - Short-lived tokens automatically expire -- **Audit trails** - Kubernetes audit logs show actual user, not service account - -### ⚠️ Important Notes - -1. **Tokens in transit** - Use HTTPS to protect tokens in HTTP headers -2. **Token validation** - Enable `--validate-token` for additional security -3. **Rate limiting** - Consider implementing rate limiting per token/user -4. **Token rotation** - Clients must handle token refresh/expiration -5. **Network security** - Ensure MCP server can reach Kubernetes API - -## Example Deployment - -### Docker Compose - -```yaml -version: '3.8' -services: - kubernetes-mcp-server: - image: quay.io/containers/kubernetes_mcp_server:latest - ports: - - "8080:8080" - command: - - --port=8080 - - --kubeconfig=/kubeconfig/config - - --cluster-provider-strategy=auth-headers - - --validate-token - volumes: - - ./kubeconfig:/kubeconfig:ro - environment: - - LOG_LEVEL=1 -``` - -### Kubernetes Deployment - -```yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: kubernetes-mcp-server -spec: - replicas: 3 - selector: - matchLabels: - app: kubernetes-mcp-server - template: - metadata: - labels: - app: kubernetes-mcp-server - spec: - containers: - - name: server - image: quay.io/containers/kubernetes_mcp_server:latest - args: - - --port=8080 - - --kubeconfig=/kubeconfig/config - - --cluster-provider-strategy=auth-headers - - --validate-token - ports: - - containerPort: 8080 - volumeMounts: - - name: kubeconfig - mountPath: /kubeconfig - readOnly: true - volumes: - - name: kubeconfig - configMap: - name: cluster-kubeconfig ---- -apiVersion: v1 -kind: Service -metadata: - name: kubernetes-mcp-server -spec: - selector: - app: kubernetes-mcp-server - ports: - - port: 80 - targetPort: 8080 -``` - -## Troubleshooting - -### Error: "bearer token required in Authorization header" - -**Cause**: Request missing authentication header +### Getting Required Values -**Solution**: Include `Authorization: Bearer ` header in all requests +#### 1. Kubernetes Server URL -### Error: "auth-headers ClusterProviderStrategy cannot be used in in-cluster deployments" - -**Cause**: Trying to use auth-headers provider from within a Kubernetes cluster +```bash +kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}' +``` -**Solution**: Use `in-cluster` or `disabled` strategy for in-cluster deployments, or explicitly set a kubeconfig path +#### 2. CA Certificate (base64) -### Error: "token-based authentication required" +```bash +kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' +``` -**Cause**: `RequireOAuth` is enabled but no token provided +#### 3. Bearer Token -**Solution**: Ensure client sends bearer token in Authorization header +```bash +# From current context +kubectl config view --minify --raw -o jsonpath='{.users[0].user.token}' -### Warning: "auth-headers ClusterProviderStrategy requires OAuth authentication, enabling RequireOAuth" +# Or get a service account token +kubectl create token -n +``` -**Info**: This is expected - auth-headers provider automatically enables OAuth requirement +#### 4. Client Certificate (base64) -## Related Documentation +```bash +kubectl config view --minify --raw -o jsonpath='{.users[0].user.client-certificate-data}' +``` -- [OIDC/OAuth Setup Guide](./KEYCLOAK_OIDC_SETUP.md) -- [Getting Started](./GETTING_STARTED_KUBERNETES.md) -- [Claude Integration](./GETTING_STARTED_CLAUDE_CODE.md) +#### 5. Client Key (base64) +```bash +kubectl config view --minify --raw -o jsonpath='{.users[0].user.client-key-data}' +``` From 922054e26963cbe6d8124f2c55ee714168be7946 Mon Sep 17 00:00:00 2001 From: Muhammad Faizan Date: Wed, 26 Nov 2025 12:11:47 +0100 Subject: [PATCH 20/21] added tests Signed-off-by: Muhammad Faizan --- pkg/config/config.go | 2 +- pkg/kubernetes/auth_headers.go | 2 +- pkg/kubernetes/auth_headers_test.go | 413 ++++++++++++++++++++++++++++ pkg/kubernetes/manager_test.go | 160 +++++++++++ pkg/mcp/mcp_middleware_test.go | 62 +++++ 5 files changed, 637 insertions(+), 2 deletions(-) create mode 100644 pkg/kubernetes/auth_headers_test.go diff --git a/pkg/config/config.go b/pkg/config/config.go index a002b7a1..ce13f14a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -13,8 +13,8 @@ import ( const ( ClusterProviderKubeConfig = "kubeconfig" ClusterProviderInCluster = "in-cluster" - ClusterProviderDisabled = "disabled" ClusterProviderAuthHeaders = "auth-headers" + ClusterProviderDisabled = "disabled" ) // StaticConfig is the configuration for the server. diff --git a/pkg/kubernetes/auth_headers.go b/pkg/kubernetes/auth_headers.go index 860a6220..5b0c22e3 100644 --- a/pkg/kubernetes/auth_headers.go +++ b/pkg/kubernetes/auth_headers.go @@ -101,7 +101,7 @@ func (h *K8sAuthHeaders) IsValid() bool { if h.AuthorizationToken != "" { return true } - if h.ClientCertificateData != nil && h.ClientKeyData != nil { + if len(h.ClientCertificateData) > 0 && len(h.ClientKeyData) > 0 { return true } return false diff --git a/pkg/kubernetes/auth_headers_test.go b/pkg/kubernetes/auth_headers_test.go new file mode 100644 index 00000000..fb89e96e --- /dev/null +++ b/pkg/kubernetes/auth_headers_test.go @@ -0,0 +1,413 @@ +package kubernetes + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDecodedData(t *testing.T) { + t.Run("decodes valid base64 string", func(t *testing.T) { + input := "SGVsbG8gV29ybGQ=" // "Hello World" in base64 + expected := []byte("Hello World") + + result, err := GetDecodedData(input) + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("decodes empty string", func(t *testing.T) { + input := "" + expected := []byte{} + + result, err := GetDecodedData(input) + require.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("returns error for invalid base64", func(t *testing.T) { + input := "not-valid-base64!!!" + + _, err := GetDecodedData(input) + require.Error(t, err) + }) + + t.Run("decodes base64 with padding", func(t *testing.T) { + input := "dGVzdA==" // "test" in base64 + expected := []byte("test") + + result, err := GetDecodedData(input) + require.NoError(t, err) + assert.Equal(t, expected, result) + }) +} + +func TestNewK8sAuthHeadersFromHeaders(t *testing.T) { + serverURL := "https://kubernetes.example.com:6443" + caCert := []byte("test-ca-cert") + caCertBase64 := base64.StdEncoding.EncodeToString(caCert) + token := "Bearer test-token" + clientCert := []byte("test-client-cert") + clientCertBase64 := base64.StdEncoding.EncodeToString(clientCert) + clientKey := []byte("test-client-key") + clientKeyBase64 := base64.StdEncoding.EncodeToString(clientKey) + + t.Run("creates auth headers with token authentication", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + require.NotNil(t, authHeaders) + + assert.Equal(t, serverURL, authHeaders.Server) + assert.Equal(t, caCert, authHeaders.CertificateAuthorityData) + assert.Equal(t, token, authHeaders.AuthorizationToken) + assert.Nil(t, authHeaders.ClientCertificateData) + assert.Nil(t, authHeaders.ClientKeyData) + assert.False(t, authHeaders.InsecureSkipTLSVerify) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("creates auth headers with client certificate authentication", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientCertificateDataHeader): clientCertBase64, + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + require.NotNil(t, authHeaders) + + assert.Equal(t, serverURL, authHeaders.Server) + assert.Equal(t, caCert, authHeaders.CertificateAuthorityData) + assert.Equal(t, "", authHeaders.AuthorizationToken) + assert.Equal(t, clientCert, authHeaders.ClientCertificateData) + assert.Equal(t, clientKey, authHeaders.ClientKeyData) + assert.False(t, authHeaders.InsecureSkipTLSVerify) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("creates auth headers with both token and client certificate", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + string(CustomClientCertificateDataHeader): clientCertBase64, + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + require.NotNil(t, authHeaders) + + // Should have both auth methods + assert.Equal(t, token, authHeaders.AuthorizationToken) + assert.Equal(t, clientCert, authHeaders.ClientCertificateData) + assert.Equal(t, clientKey, authHeaders.ClientKeyData) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("sets InsecureSkipTLSVerify to true when header is 'true'", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + string(CustomInsecureSkipTLSVerifyHeader): "true", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.True(t, authHeaders.InsecureSkipTLSVerify) + }) + + t.Run("sets InsecureSkipTLSVerify to true when header is 'TRUE' (case insensitive)", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + string(CustomInsecureSkipTLSVerifyHeader): "TRUE", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.True(t, authHeaders.InsecureSkipTLSVerify) + }) + + t.Run("sets InsecureSkipTLSVerify to false when header is 'false'", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + string(CustomInsecureSkipTLSVerifyHeader): "false", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.False(t, authHeaders.InsecureSkipTLSVerify) + }) + + t.Run("sets InsecureSkipTLSVerify to false when header is missing", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.False(t, authHeaders.InsecureSkipTLSVerify) + }) + + t.Run("returns error when server header is missing", func(t *testing.T) { + headers := map[string]any{ + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-server") + assert.Contains(t, err.Error(), "required") + }) + + t.Run("returns error when server header is empty string", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): "", + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-server") + }) + + t.Run("returns error when server header is not a string", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): 123, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-server") + }) + + t.Run("returns error when CA data header is missing", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-certificate-authority-data") + assert.Contains(t, err.Error(), "required") + }) + + t.Run("returns error when CA data header is empty string", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): "", + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "kubernetes-certificate-authority-data") + }) + + t.Run("returns error when CA data is invalid base64", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): "invalid-base64!!!", + string(CustomAuthorizationHeader): token, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid certificate authority data") + }) + + t.Run("returns error when no authentication method is provided", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication") + assert.Contains(t, err.Error(), "kubernetes-authorization") + assert.Contains(t, err.Error(), "kubernetes-client-certificate-data") + assert.Contains(t, err.Error(), "kubernetes-client-key-data") + }) + + t.Run("returns error when only client certificate is provided without key", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientCertificateDataHeader): clientCertBase64, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication") + }) + + t.Run("returns error when only client key is provided without certificate", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "authentication") + }) + + t.Run("returns error when client certificate is invalid base64", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientCertificateDataHeader): "invalid-base64!!!", + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client certificate data") + }) + + t.Run("returns error when client key is invalid base64", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomClientCertificateDataHeader): clientCertBase64, + string(CustomClientKeyDataHeader): "invalid-base64!!!", + } + + _, err := NewK8sAuthHeadersFromHeaders(headers) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid client key data") + }) + + t.Run("handles empty token string gracefully", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): "", + string(CustomClientCertificateDataHeader): clientCertBase64, + string(CustomClientKeyDataHeader): clientKeyBase64, + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + // Empty token is OK if we have client cert + assert.Equal(t, "", authHeaders.AuthorizationToken) + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("handles empty client cert/key strings gracefully when token is provided", func(t *testing.T) { + headers := map[string]any{ + string(CustomServerHeader): serverURL, + string(CustomCertificateAuthorityDataHeader): caCertBase64, + string(CustomAuthorizationHeader): token, + string(CustomClientCertificateDataHeader): "", + string(CustomClientKeyDataHeader): "", + } + + authHeaders, err := NewK8sAuthHeadersFromHeaders(headers) + require.NoError(t, err) + assert.Nil(t, authHeaders.ClientCertificateData) + assert.Nil(t, authHeaders.ClientKeyData) + assert.True(t, authHeaders.IsValid()) + }) +} + +func TestK8sAuthHeaders_IsValid(t *testing.T) { + t.Run("returns true when token is provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + AuthorizationToken: "Bearer test-token", + } + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("returns true when client certificate and key are provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientCertificateData: []byte("cert-data"), + ClientKeyData: []byte("key-data"), + } + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("returns true when both token and client cert are provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + AuthorizationToken: "Bearer test-token", + ClientCertificateData: []byte("cert-data"), + ClientKeyData: []byte("key-data"), + } + assert.True(t, authHeaders.IsValid()) + }) + + t.Run("returns false when no authentication is provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{} + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when only client certificate is provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientCertificateData: []byte("cert-data"), + } + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when only client key is provided", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientKeyData: []byte("key-data"), + } + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when token is empty string", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + AuthorizationToken: "", + } + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when client cert and key are empty slices", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientCertificateData: []byte{}, + ClientKeyData: []byte{}, + } + // Empty slices have length 0, so they're considered invalid + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when client cert is nil and key has data", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientCertificateData: nil, + ClientKeyData: []byte("key-data"), + } + assert.False(t, authHeaders.IsValid()) + }) + + t.Run("returns false when client cert has data and key is nil", func(t *testing.T) { + authHeaders := &K8sAuthHeaders{ + ClientCertificateData: []byte("cert-data"), + ClientKeyData: nil, + } + assert.False(t, authHeaders.IsValid()) + }) +} diff --git a/pkg/kubernetes/manager_test.go b/pkg/kubernetes/manager_test.go index c6f9da6a..d351e79a 100644 --- a/pkg/kubernetes/manager_test.go +++ b/pkg/kubernetes/manager_test.go @@ -197,6 +197,166 @@ func (s *ManagerTestSuite) TestNewKubeconfigManager() { }) } +func (s *ManagerTestSuite) TestNewAuthHeadersClusterManager() { + serverURL := s.mockServer.Config().Host + token := "test-token" + + s.Run("creates manager with token authentication", func() { + authHeaders := &K8sAuthHeaders{ + Server: serverURL, + CertificateAuthorityData: nil, // Use insecure for testing + AuthorizationToken: token, + InsecureSkipTLSVerify: true, + } + + cfg := &config.StaticConfig{} + manager, err := NewAuthHeadersClusterManager(authHeaders, cfg) + + s.Require().NoError(err) + s.Require().NotNil(manager) + + s.Run("rest config is properly configured", func() { + restConfig, err := manager.ToRESTConfig() + s.Require().NoError(err) + s.Equal(serverURL, restConfig.Host) + s.Equal(token, restConfig.BearerToken) + s.Nil(restConfig.CAData) + s.Nil(restConfig.CertData) + s.Nil(restConfig.KeyData) + s.True(restConfig.Insecure) + }) + + s.Run("client cmd config is properly configured", func() { + rawConfig, err := manager.ToRawKubeConfigLoader().RawConfig() + s.Require().NoError(err) + s.NotNil(rawConfig.Clusters["cluster"]) + s.Equal(serverURL, rawConfig.Clusters["cluster"].Server) + s.True(rawConfig.Clusters["cluster"].InsecureSkipTLSVerify) + s.NotNil(rawConfig.AuthInfos["user"]) + s.Equal(token, rawConfig.AuthInfos["user"].Token) + s.Nil(rawConfig.AuthInfos["user"].ClientCertificateData) + s.Nil(rawConfig.AuthInfos["user"].ClientKeyData) + }) + + s.Run("manager can create discovery client", func() { + discoveryClient, err := manager.ToDiscoveryClient() + s.Require().NoError(err) + s.NotNil(discoveryClient) + }) + + s.Run("manager can create REST mapper", func() { + restMapper, err := manager.ToRESTMapper() + s.Require().NoError(err) + s.NotNil(restMapper) + }) + }) + + // Note: Client certificate tests are omitted because they require valid PEM-encoded certificates + // to pass Kubernetes client initialization. The logic for setting cert data is covered by + // the tests for empty/nil certificate handling below. + + s.Run("creates manager with InsecureSkipTLSVerify enabled and no CA", func() { + authHeaders := &K8sAuthHeaders{ + Server: serverURL, + CertificateAuthorityData: nil, // No CA data when using insecure + AuthorizationToken: token, + InsecureSkipTLSVerify: true, + } + + cfg := &config.StaticConfig{} + manager, err := NewAuthHeadersClusterManager(authHeaders, cfg) + + s.Require().NoError(err) + s.Require().NotNil(manager) + + s.Run("rest config has insecure flag enabled", func() { + restConfig, err := manager.ToRESTConfig() + s.Require().NoError(err) + s.True(restConfig.Insecure) + s.Nil(restConfig.CAData) + }) + + s.Run("client cmd config has insecure flag enabled", func() { + rawConfig, err := manager.ToRawKubeConfigLoader().RawConfig() + s.Require().NoError(err) + s.True(rawConfig.Clusters["cluster"].InsecureSkipTLSVerify) + }) + }) + + s.Run("creates manager with empty client certificate slices", func() { + authHeaders := &K8sAuthHeaders{ + Server: serverURL, + CertificateAuthorityData: nil, // Use insecure for testing + AuthorizationToken: token, + ClientCertificateData: []byte{}, + ClientKeyData: []byte{}, + InsecureSkipTLSVerify: true, + } + + cfg := &config.StaticConfig{} + manager, err := NewAuthHeadersClusterManager(authHeaders, cfg) + + s.Require().NoError(err) + s.Require().NotNil(manager) + + s.Run("rest config has nil cert data for empty slices", func() { + restConfig, err := manager.ToRESTConfig() + s.Require().NoError(err) + s.Nil(restConfig.CertData) + s.Nil(restConfig.KeyData) + }) + }) + + s.Run("creates manager with nil client certificate data", func() { + authHeaders := &K8sAuthHeaders{ + Server: serverURL, + CertificateAuthorityData: nil, // Use insecure for testing + AuthorizationToken: token, + ClientCertificateData: nil, + ClientKeyData: nil, + InsecureSkipTLSVerify: true, + } + + cfg := &config.StaticConfig{} + manager, err := NewAuthHeadersClusterManager(authHeaders, cfg) + + s.Require().NoError(err) + s.Require().NotNil(manager) + + s.Run("rest config has nil cert data", func() { + restConfig, err := manager.ToRESTConfig() + s.Require().NoError(err) + s.Nil(restConfig.CertData) + s.Nil(restConfig.KeyData) + }) + }) + + s.Run("creates manager with custom static config", func() { + authHeaders := &K8sAuthHeaders{ + Server: serverURL, + CertificateAuthorityData: nil, // Use insecure for testing + AuthorizationToken: token, + InsecureSkipTLSVerify: true, + } + + cfg := &config.StaticConfig{ + DeniedResources: []config.GroupVersionKind{ + {Group: "", Version: "v1", Kind: "Secret"}, + }, + } + manager, err := NewAuthHeadersClusterManager(authHeaders, cfg) + + s.Require().NoError(err) + s.Require().NotNil(manager) + + s.Run("manager is created successfully with denied resources", func() { + // We can't directly access staticConfig, but we can verify the manager was created + // The access control will be tested when actually using the manager + s.NotNil(manager) + }) + }) +} + func TestManager(t *testing.T) { suite.Run(t, new(ManagerTestSuite)) } diff --git a/pkg/mcp/mcp_middleware_test.go b/pkg/mcp/mcp_middleware_test.go index ce88e7b4..2b150ae5 100644 --- a/pkg/mcp/mcp_middleware_test.go +++ b/pkg/mcp/mcp_middleware_test.go @@ -85,3 +85,65 @@ func (s *McpLoggingSuite) TestLogsToolCallHeaders() { func TestMcpLogging(t *testing.T) { suite.Run(t, new(McpLoggingSuite)) } + +type CustomAuthHeadersMiddlewareSuite struct { + BaseMcpSuite +} + +func (s *CustomAuthHeadersMiddlewareSuite) TestParsesAuthHeadersFromHTTPHeaders() { + caCertBase64 := "dGVzdC1jYS1jZXJ0" // base64 of "test-ca-cert" + serverURL := "https://k8s.example.com:6443" + token := "Bearer test-token" + + s.InitMcpClient(transport.WithHTTPHeaders(map[string]string{ + "kubernetes-server": serverURL, + "kubernetes-certificate-authority-data": caCertBase64, + "kubernetes-authorization": token, + })) + + _, err := s.CallTool("configuration_view", map[string]interface{}{"minified": false}) + s.Require().NoError(err, "call to tool configuration_view failed") + + // The middleware should have successfully parsed and added auth headers to context + // This is validated indirectly by the tool call succeeding +} + +func (s *CustomAuthHeadersMiddlewareSuite) TestHeadersAreLowercased() { + caCertBase64 := "dGVzdC1jYS1jZXJ0" // base64 of "test-ca-cert" + serverURL := "https://k8s.example.com:6443" + token := "Bearer test-token" + + // Use uppercase header names + s.InitMcpClient(transport.WithHTTPHeaders(map[string]string{ + "Kubernetes-Server": serverURL, // uppercase K + "KUBERNETES-CERTIFICATE-AUTHORITY-DATA": caCertBase64, // all uppercase + "Kubernetes-Authorization": token, // mixed case + })) + + _, err := s.CallTool("configuration_view", map[string]interface{}{"minified": false}) + s.Require().NoError(err, "call should succeed even with uppercase headers") +} + +func (s *CustomAuthHeadersMiddlewareSuite) TestIgnoresInvalidAuthHeadersWhenNotUsingAuthHeadersProvider() { + // When not using auth-headers provider, invalid custom headers are ignored + // and the default kubeconfig provider is used instead + s.InitMcpClient(transport.WithHTTPHeaders(map[string]string{ + "kubernetes-server": "https://k8s.example.com:6443", + // Missing CA cert and authorization - will be ignored + })) + + _, err := s.CallTool("configuration_view", map[string]interface{}{"minified": false}) + s.Require().NoError(err, "call should succeed using default kubeconfig provider") +} + +func (s *CustomAuthHeadersMiddlewareSuite) TestPassesThroughWithNoHeaders() { + // No custom headers provided - should work with default kubeconfig + s.InitMcpClient() + + _, err := s.CallTool("configuration_view", map[string]interface{}{"minified": false}) + s.Require().NoError(err, "call should succeed without custom headers") +} + +func TestCustomAuthHeadersMiddleware(t *testing.T) { + suite.Run(t, new(CustomAuthHeadersMiddlewareSuite)) +} From 12b1a046161cc5d79e3d5419fd91e6771103f74b Mon Sep 17 00:00:00 2001 From: Muhammad Faizan Date: Wed, 26 Nov 2025 13:38:36 +0100 Subject: [PATCH 21/21] updated documentation Signed-off-by: Muhammad Faizan --- docs/AUTH_HEADERS_PROVIDER.md | 91 ----------------------------------- 1 file changed, 91 deletions(-) diff --git a/docs/AUTH_HEADERS_PROVIDER.md b/docs/AUTH_HEADERS_PROVIDER.md index 7d611a3a..c8dd3b9b 100644 --- a/docs/AUTH_HEADERS_PROVIDER.md +++ b/docs/AUTH_HEADERS_PROVIDER.md @@ -175,94 +175,3 @@ client := NewMCPClient( }) ) ``` - -### Using Claude Desktop - -```json -{ - "mcpServers": { - "kubernetes": { - "url": "https://mcp-server.example.com/sse", - "headers": { - "kubernetes-server": "https://k8s.example.com:6443", - "kubernetes-certificate-authority-data": "LS0tLS1CRUdJTi...", - "kubernetes-authorization": "Bearer YOUR_KUBERNETES_TOKEN" - } - } - } -} -``` - -### Using Client Certificates - -```json -{ - "mcpServers": { - "kubernetes": { - "url": "https://mcp-server.example.com/sse", - "headers": { - "kubernetes-server": "https://k8s.example.com:6443", - "kubernetes-certificate-authority-data": "LS0tLS1CRUdJTi...", - "kubernetes-client-certificate-data": "LS0tLS1CRUdJTi...", - "kubernetes-client-key-data": "LS0tLS1CRUdJTi..." - } - } - } -} -``` - -### Using cURL - -```bash -# With bearer token -curl -X POST https://mcp-server.example.com/mcp \ - -H "Content-Type: application/json" \ - -H "kubernetes-server: https://k8s.example.com:6443" \ - -H "kubernetes-certificate-authority-data: LS0tLS1CRUdJTi..." \ - -H "kubernetes-authorization: Bearer eyJhbGci..." \ - -d '{ - "jsonrpc": "2.0", - "method": "tools/call", - "params": { - "name": "pods_list", - "arguments": {"namespace": "default"} - }, - "id": 1 - }' -``` - -### Getting Required Values - -#### 1. Kubernetes Server URL - -```bash -kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}' -``` - -#### 2. CA Certificate (base64) - -```bash -kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' -``` - -#### 3. Bearer Token - -```bash -# From current context -kubectl config view --minify --raw -o jsonpath='{.users[0].user.token}' - -# Or get a service account token -kubectl create token -n -``` - -#### 4. Client Certificate (base64) - -```bash -kubectl config view --minify --raw -o jsonpath='{.users[0].user.client-certificate-data}' -``` - -#### 5. Client Key (base64) - -```bash -kubectl config view --minify --raw -o jsonpath='{.users[0].user.client-key-data}' -```