diff --git a/README.md b/README.md
index 1eb1f284f..092c81e4d 100644
--- a/README.md
+++ b/README.md
@@ -311,10 +311,10 @@ The following sets of tools are available (toolsets marked with ✓ in the Defau
| Toolset | Description | Default |
|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
+| 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. | |
| config | View and manage the current local Kubernetes configuration (kubeconfig) | ✓ |
| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | ✓ |
| kcp | Manage kcp workspaces and multi-tenancy features | |
-| 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. | |
| kubevirt | KubeVirt virtual machine management tools | |
| helm | Tools for managing Helm charts and releases | ✓ |
@@ -328,6 +328,72 @@ In case multi-cluster support is enabled (default) and you have access to multip
+kiali
+
+- **kiali_mesh_graph** - Returns the topology of a specific namespaces, health, status of the mesh and namespaces. Includes a mesh health summary overview with aggregated counts of healthy, degraded, and failing apps, workloads, and services. Use this for high-level overviews
+ - `graphType` (`string`) - Optional type of graph to return: 'versionedApp', 'app', 'service', 'workload', 'mesh'
+ - `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
+ - `rateInterval` (`string`) - Optional rate interval for fetching (e.g., '10m', '5m', '1h').
+
+- **kiali_manage_istio_config_read** - Lists or gets Istio configuration objects (Gateways, VirtualServices, etc.)
+ - `action` (`string`) **(required)** - Action to perform: list or get
+ - `group` (`string`) - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')
+ - `kind` (`string`) - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')
+ - `name` (`string`) - Name of the Istio object
+ - `namespace` (`string`) - Namespace containing the Istio object
+ - `version` (`string`) - API version of the Istio object (e.g., 'v1', 'v1beta1')
+
+- **kiali_manage_istio_config** - Creates, patches, or deletes Istio configuration objects (Gateways, VirtualServices, etc.)
+ - `action` (`string`) **(required)** - Action to perform: create, patch, or delete
+ - `group` (`string`) - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')
+ - `json_data` (`string`) - JSON data to apply or create the object
+ - `kind` (`string`) - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')
+ - `name` (`string`) - Name of the Istio object
+ - `namespace` (`string`) - Namespace containing the Istio object
+ - `version` (`string`) - API version of the Istio object (e.g., 'v1', 'v1beta1')
+
+- **kiali_get_resource_details** - Gets lists or detailed info for Kubernetes resources (services, workloads) within the mesh
+ - `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
+ - `resource_name` (`string`) - Name of the resource to get details for (optional string - if provided, gets details; if empty, lists all).
+ - `resource_type` (`string`) - Type of resource to get details for (service, workload)
+
+- **kiali_get_metrics** - Gets lists or detailed info for Kubernetes resources (services, workloads) within the mesh
+ - `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`) - Time range to get metrics for (optional string - if provided, gets metrics (e.g., '1m', '5m', '1h'); if empty, get default 30m).
+ - `namespace` (`string`) **(required)** - Namespace to get resources from
+ - `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 '10m'
+ - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source'
+ - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional
+ - `resource_name` (`string`) **(required)** - Name of the resource to get details for (optional string - if provided, gets details; if empty, lists all).
+ - `resource_type` (`string`) **(required)** - Type of resource to get details for (service, workload)
+ - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds
+
+- **kiali_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
+
+- **kiali_get_traces** - Gets traces for a specific resource (app, service, workload) in a namespace, or gets detailed information for a specific trace by its ID. If traceId is provided, it returns detailed trace information and other parameters are not required.
+ - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional, only used when traceId is not provided)
+ - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional, defaults to 10 minutes after startMicros if not provided, only used when traceId is not provided)
+ - `limit` (`integer`) - Maximum number of traces to return (default: 100, only used when traceId is not provided)
+ - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional, only used when traceId is not provided)
+ - `namespace` (`string`) - Namespace to get resources from. Required if traceId is not provided.
+ - `resource_name` (`string`) - Name of the resource to get traces for. Required if traceId is not provided.
+ - `resource_type` (`string`) - Type of resource to get traces for (app, service, workload). Required if traceId is not provided.
+ - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional, defaults to 10 minutes before current time if not provided, only used when traceId is not provided)
+ - `tags` (`string`) - JSON string of tags to filter traces (optional, only used when traceId is not provided)
+ - `traceId` (`string`) - Unique identifier of the trace to retrieve detailed information for. If provided, this will return detailed trace information and other parameters (resource_type, namespace, resource_name) are not required.
+
+
+
+
+
config
- **configuration_contexts_list** - List all available context names and associated server urls from the kubeconfig file
@@ -453,72 +519,6 @@ In case multi-cluster support is enabled (default) and you have access to multip
-kiali
-
-- **kiali_mesh_graph** - Returns the topology of a specific namespaces, health, status of the mesh and namespaces. Includes a mesh health summary overview with aggregated counts of healthy, degraded, and failing apps, workloads, and services. Use this for high-level overviews
- - `graphType` (`string`) - Optional type of graph to return: 'versionedApp', 'app', 'service', 'workload', 'mesh'
- - `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
- - `rateInterval` (`string`) - Optional rate interval for fetching (e.g., '10m', '5m', '1h').
-
-- **kiali_manage_istio_config_read** - Lists or gets Istio configuration objects (Gateways, VirtualServices, etc.)
- - `action` (`string`) **(required)** - Action to perform: list or get
- - `group` (`string`) - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')
- - `kind` (`string`) - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')
- - `name` (`string`) - Name of the Istio object
- - `namespace` (`string`) - Namespace containing the Istio object
- - `version` (`string`) - API version of the Istio object (e.g., 'v1', 'v1beta1')
-
-- **kiali_manage_istio_config** - Creates, patches, or deletes Istio configuration objects (Gateways, VirtualServices, etc.)
- - `action` (`string`) **(required)** - Action to perform: create, patch, or delete
- - `group` (`string`) - API group of the Istio object (e.g., 'networking.istio.io', 'gateway.networking.k8s.io')
- - `json_data` (`string`) - JSON data to apply or create the object
- - `kind` (`string`) - Kind of the Istio object (e.g., 'DestinationRule', 'VirtualService', 'HTTPRoute', 'Gateway')
- - `name` (`string`) - Name of the Istio object
- - `namespace` (`string`) - Namespace containing the Istio object
- - `version` (`string`) - API version of the Istio object (e.g., 'v1', 'v1beta1')
-
-- **kiali_get_resource_details** - Gets lists or detailed info for Kubernetes resources (services, workloads) within the mesh
- - `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
- - `resource_name` (`string`) - Name of the resource to get details for (optional string - if provided, gets details; if empty, lists all).
- - `resource_type` (`string`) - Type of resource to get details for (service, workload)
-
-- **kiali_get_metrics** - Gets lists or detailed info for Kubernetes resources (services, workloads) within the mesh
- - `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`) - Time range to get metrics for (optional string - if provided, gets metrics (e.g., '1m', '5m', '1h'); if empty, get default 30m).
- - `namespace` (`string`) **(required)** - Namespace to get resources from
- - `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 '10m'
- - `reporter` (`string`) - Metrics reporter: 'source', 'destination', or 'both'. Optional, defaults to 'source'
- - `requestProtocol` (`string`) - Filter by request protocol (e.g., 'http', 'grpc', 'tcp'). Optional
- - `resource_name` (`string`) **(required)** - Name of the resource to get details for (optional string - if provided, gets details; if empty, lists all).
- - `resource_type` (`string`) **(required)** - Type of resource to get details for (service, workload)
- - `step` (`string`) - Step between data points in seconds (e.g., '15'). Optional, defaults to 15 seconds
-
-- **kiali_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
-
-- **kiali_get_traces** - Gets traces for a specific resource (app, service, workload) in a namespace, or gets detailed information for a specific trace by its ID. If traceId is provided, it returns detailed trace information and other parameters are not required.
- - `clusterName` (`string`) - Cluster name for multi-cluster environments (optional, only used when traceId is not provided)
- - `endMicros` (`string`) - End time for traces in microseconds since epoch (optional, defaults to 10 minutes after startMicros if not provided, only used when traceId is not provided)
- - `limit` (`integer`) - Maximum number of traces to return (default: 100, only used when traceId is not provided)
- - `minDuration` (`integer`) - Minimum trace duration in microseconds (optional, only used when traceId is not provided)
- - `namespace` (`string`) - Namespace to get resources from. Required if traceId is not provided.
- - `resource_name` (`string`) - Name of the resource to get traces for. Required if traceId is not provided.
- - `resource_type` (`string`) - Type of resource to get traces for (app, service, workload). Required if traceId is not provided.
- - `startMicros` (`string`) - Start time for traces in microseconds since epoch (optional, defaults to 10 minutes before current time if not provided, only used when traceId is not provided)
- - `tags` (`string`) - JSON string of tags to filter traces (optional, only used when traceId is not provided)
- - `traceId` (`string`) - Unique identifier of the trace to retrieve detailed information for. If provided, this will return detailed trace information and other parameters (resource_type, namespace, resource_name) are not required.
-
-
-
-
-
kubevirt
- **vm_create** - Create a VirtualMachine in the cluster with the specified configuration, automatically resolving instance types, preferences, and container disk images. VM will be created in Halted state by default; use autostart parameter to start it immediately.
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
new file mode 100644
index 000000000..003c289cc
--- /dev/null
+++ b/docs/VALIDATION.md
@@ -0,0 +1,120 @@
+# Pre-Execution Validation
+
+The kubernetes-mcp-server includes a validation layer that catches errors before they reach the Kubernetes API. This prevents AI hallucinations (like typos in resource names) and permission issues from causing confusing failures.
+
+## Why Validation?
+
+When an AI assistant makes a Kubernetes API call with errors, the raw Kubernetes error messages can be cryptic:
+
+```
+the server doesn't have a resource type "Deploymnt"
+```
+
+With validation enabled, you get clearer feedback:
+
+```
+Resource apps/v1/Deploymnt does not exist in the cluster
+```
+
+The validation layer catches these types of issues:
+
+1. **Resource Existence** - Catches typos like "Deploymnt" instead of "Deployment" (checked in access control)
+2. **Schema Validation** - Catches invalid fields like "spec.replcias" instead of "spec.replicas"
+3. **RBAC Validation** - Pre-checks permissions before attempting operations
+
+## Configuration
+
+Validation is **disabled by default**. Schema and RBAC validators run together when enabled. Resource existence is always checked as part of access control.
+
+```toml
+# Enable all validation (default: false)
+validation_enabled = true
+```
+
+### Configuration Reference
+
+| TOML Field | Default | Description |
+|------------|---------|-------------|
+| `validation_enabled` | `false` | Enable/disable all validators |
+
+**Note:** The schema validator caches the OpenAPI schema for 15 minutes internally.
+
+## How It Works
+
+### Validation Flow
+
+Validation happens at the HTTP RoundTripper level, intercepting all Kubernetes API calls:
+
+```
+MCP Tool Call → Kubernetes Client → HTTP RoundTripper → Kubernetes API
+ ↓
+ Access Control
+ - Check deny list
+ - Check resource exists
+ ↓
+ Schema Validator (if enabled)
+ "Are the fields valid?"
+ ↓
+ RBAC Validator (if enabled)
+ "Does the user have permission?"
+ ↓
+ Forward to K8s API
+```
+
+This HTTP-layer approach ensures **all** Kubernetes API calls are validated, including those from plugins (KubeVirt, Kiali, Helm, etc.) - not just the core tools.
+
+If any validator fails, the request is rejected with a clear error message before reaching the Kubernetes API.
+
+### 1. Resource Existence (Access Control)
+
+The access control layer validates that the requested resource type exists in the cluster. This check runs regardless of whether validation is enabled.
+
+**What it catches:**
+- Typos in Kind names: "Deploymnt" → should be "Deployment"
+- Wrong API versions: "apps/v2" → should be "apps/v1"
+- Non-existent custom resources
+
+**Example error:**
+```
+RESOURCE_NOT_FOUND: Resource deployments.apps does not exist in the cluster
+```
+
+### 2. Schema Validation
+
+Validates resource manifests against the cluster's OpenAPI schema for create/update operations.
+
+**What it catches:**
+- Invalid field names: "spec.replcias" → should be "spec.replicas"
+- Wrong field types: string where integer expected
+- Missing required fields
+
+**Example error:**
+```
+INVALID_FIELD: unknown field "spec.replcias"
+```
+
+**Note:** Schema validation uses kubectl's validation library and caches the OpenAPI schema for 15 minutes.
+
+### 3. RBAC Validation
+
+Pre-checks permissions using Kubernetes `SelfSubjectAccessReview` before attempting operations.
+
+**What it catches:**
+- Missing permissions: can't create Deployments in namespace X
+- Cluster-scoped vs namespace-scoped mismatches
+- Read-only access attempting writes
+
+**Example error:**
+```
+PERMISSION_DENIED: Cannot create deployments.apps in namespace "production"
+```
+
+**Note:** RBAC validation uses the same credentials as the actual operation - either the server's service account or the user's token (when OAuth is enabled).
+
+## Error Codes
+
+| Code | Description |
+|------|-------------|
+| `RESOURCE_NOT_FOUND` | The requested resource type doesn't exist in the cluster |
+| `INVALID_FIELD` | A field in the manifest doesn't exist or has the wrong type |
+| `PERMISSION_DENIED` | RBAC denies the requested operation |
diff --git a/docs/configuration.md b/docs/configuration.md
index 1c24e5608..7e9feb18f 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -184,10 +184,10 @@ Toolsets group related tools together. Enable only the toolsets you need to redu
| Toolset | Description | Default |
|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|
+| 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. | |
| config | View and manage the current local Kubernetes configuration (kubeconfig) | ✓ |
| core | Most common tools for Kubernetes management (Pods, Generic Resources, Events, etc.) | ✓ |
| kcp | Manage kcp workspaces and multi-tenancy features | |
-| 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. | |
| kubevirt | KubeVirt virtual machine management tools | |
| helm | Tools for managing Helm charts and releases | ✓ |
diff --git a/pkg/api/config.go b/pkg/api/config.go
index 85c095cd8..929a6f3e6 100644
--- a/pkg/api/config.go
+++ b/pkg/api/config.go
@@ -53,10 +53,16 @@ type StsConfigProvider interface {
GetStsScopes() []string
}
+// ValidationEnabledProvider provides access to validation enabled setting.
+type ValidationEnabledProvider interface {
+ IsValidationEnabled() bool
+}
+
type BaseConfig interface {
AuthProvider
ClusterProvider
DeniedResourcesProvider
ExtendedConfigProvider
StsConfigProvider
+ ValidationEnabledProvider
}
diff --git a/pkg/api/validation.go b/pkg/api/validation.go
new file mode 100644
index 000000000..36f1dcedd
--- /dev/null
+++ b/pkg/api/validation.go
@@ -0,0 +1,81 @@
+package api
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// HTTPValidationRequest contains info extracted from an HTTP request for validation.
+type HTTPValidationRequest struct {
+ GVR *schema.GroupVersionResource
+ GVK *schema.GroupVersionKind
+ HTTPMethod string // GET, POST, PUT, DELETE, PATCH
+ Verb string // get, list, create, update, delete, patch
+ Namespace string
+ ResourceName string
+ Body []byte // For create/update validation
+ Path string
+}
+
+// HTTPValidator validates HTTP requests before they reach the K8s API server.
+type HTTPValidator interface {
+ Validate(ctx context.Context, req *HTTPValidationRequest) error
+ Name() string
+}
+
+// ValidationErrorCode categorizes validation failures.
+type ValidationErrorCode string
+
+const (
+ ErrorCodeResourceNotFound ValidationErrorCode = "RESOURCE_NOT_FOUND"
+ ErrorCodeInvalidField ValidationErrorCode = "INVALID_FIELD"
+ ErrorCodePermissionDenied ValidationErrorCode = "PERMISSION_DENIED"
+)
+
+// ValidationError provides AI-friendly error information for validation failures.
+type ValidationError struct {
+ Code ValidationErrorCode
+ Message string
+ Field string // optional, for field-level errors
+}
+
+// Error implements the error interface.
+func (e *ValidationError) Error() string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("Validation Error [%s]: %s", e.Code, e.Message))
+
+ if e.Field != "" {
+ sb.WriteString(fmt.Sprintf("\n Field: %s", e.Field))
+ }
+
+ return sb.String()
+}
+
+// NewPermissionDeniedError creates an error for RBAC permission failures.
+func NewPermissionDeniedError(verb, resource, namespace string) *ValidationError {
+ var msg string
+ if namespace != "" {
+ msg = fmt.Sprintf("Cannot %s %s in namespace %q", verb, resource, namespace)
+ } else {
+ msg = fmt.Sprintf("Cannot %s %s (cluster-scoped)", verb, resource)
+ }
+
+ return &ValidationError{
+ Code: ErrorCodePermissionDenied,
+ Message: msg,
+ }
+}
+
+// FormatResourceName creates a human-readable resource identifier from GVR.
+func FormatResourceName(gvr *schema.GroupVersionResource) string {
+ if gvr == nil {
+ return "unknown"
+ }
+ if gvr.Group == "" {
+ return gvr.Resource
+ }
+ return gvr.Resource + "." + gvr.Group
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 668cb6dd2..a4f8e53a1 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -90,6 +90,11 @@ type StaticConfig struct {
// These can also be configured via OTEL_* environment variables.
Telemetry TelemetryConfig `toml:"telemetry,omitempty"`
+ // ValidationEnabled enables pre-execution validation of tool calls.
+ // When enabled, validates resources, schemas, and RBAC before execution.
+ // Defaults to false.
+ ValidationEnabled bool `toml:"validation_enabled,omitempty"`
+
// Internal: parsed provider configs (not exposed to TOML package)
parsedClusterProviderConfigs map[string]api.ExtendedConfig
// Internal: parsed toolset configs (not exposed to TOML package)
@@ -341,3 +346,7 @@ func (c *StaticConfig) GetStsAudience() string {
func (c *StaticConfig) GetStsScopes() []string {
return c.StsScopes
}
+
+func (c *StaticConfig) IsValidationEnabled() bool {
+ return c.ValidationEnabled
+}
diff --git a/pkg/kubernetes/accesscontrol_round_tripper.go b/pkg/kubernetes/accesscontrol_round_tripper.go
index 24bc513ee..a97db9adf 100644
--- a/pkg/kubernetes/accesscontrol_round_tripper.go
+++ b/pkg/kubernetes/accesscontrol_round_tripper.go
@@ -1,19 +1,57 @@
package kubernetes
import (
+ "bytes"
"fmt"
+ "io"
"net/http"
"strings"
"github.com/containers/kubernetes-mcp-server/pkg/api"
"k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/client-go/discovery"
+ authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
+ "k8s.io/klog/v2"
)
+// AccessControlRoundTripper intercepts HTTP requests to enforce access control
+// and optionally run validators before they reach the Kubernetes API.
type AccessControlRoundTripper struct {
delegate http.RoundTripper
deniedResourcesProvider api.DeniedResourcesProvider
restMapperProvider func() meta.RESTMapper
+ validationEnabled bool
+ validators []api.HTTPValidator
+}
+
+// AccessControlRoundTripperConfig configures the AccessControlRoundTripper.
+type AccessControlRoundTripperConfig struct {
+ Delegate http.RoundTripper
+ DeniedResourcesProvider api.DeniedResourcesProvider
+ RestMapperProvider func() meta.RESTMapper
+ DiscoveryProvider func() discovery.DiscoveryInterface
+ AuthClientProvider func() authv1client.AuthorizationV1Interface
+ ValidationEnabled bool
+}
+
+// NewAccessControlRoundTripper creates a new AccessControlRoundTripper.
+func NewAccessControlRoundTripper(cfg AccessControlRoundTripperConfig) *AccessControlRoundTripper {
+ rt := &AccessControlRoundTripper{
+ delegate: cfg.Delegate,
+ deniedResourcesProvider: cfg.DeniedResourcesProvider,
+ restMapperProvider: cfg.RestMapperProvider,
+ validationEnabled: cfg.ValidationEnabled,
+ }
+
+ if cfg.ValidationEnabled {
+ rt.validators = CreateValidators(ValidatorProviders{
+ Discovery: cfg.DiscoveryProvider,
+ AuthClient: cfg.AuthClientProvider,
+ })
+ }
+
+ return rt
}
func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -33,12 +71,56 @@ func (rt *AccessControlRoundTripper) RoundTrip(req *http.Request) (*http.Respons
gvk, err := restMapper.KindFor(gvr)
if err != nil {
+ if meta.IsNoMatchError(err) {
+ return nil, &api.ValidationError{
+ Code: api.ErrorCodeResourceNotFound,
+ Message: fmt.Sprintf("Resource %s does not exist in the cluster", api.FormatResourceName(&gvr)),
+ }
+ }
return nil, fmt.Errorf("failed to make request: AccessControlRoundTripper failed to get kind for gvr %v: %w", gvr, err)
}
if !rt.isAllowed(gvk) {
return nil, fmt.Errorf("resource not allowed: %s", gvk.String())
}
+ // Skip validators if disabled or if this is SelfSubjectAccessReview (used by RBAC validator)
+ skipValidation := !rt.validationEnabled || (gvr.Group == "authorization.k8s.io" && gvr.Resource == "selfsubjectaccessreviews")
+ if skipValidation {
+ return rt.delegate.RoundTrip(req)
+ }
+
+ namespace, resourceName := parseURLToNamespaceAndName(req.URL.Path)
+ verb := httpMethodToVerb(req.Method, req.URL.Path)
+
+ validationReq := &api.HTTPValidationRequest{
+ GVR: &gvr,
+ GVK: &gvk,
+ HTTPMethod: req.Method,
+ Verb: verb,
+ Namespace: namespace,
+ ResourceName: resourceName,
+ Path: req.URL.Path,
+ }
+
+ if req.Body != nil && (req.Method == "POST" || req.Method == "PUT" || req.Method == "PATCH") {
+ body, readErr := io.ReadAll(req.Body)
+ _ = req.Body.Close()
+ if readErr != nil {
+ return nil, fmt.Errorf("failed to read request body: %w", readErr)
+ }
+ req.Body = io.NopCloser(bytes.NewReader(body))
+ validationReq.Body = body
+ }
+
+ for _, v := range rt.validators {
+ if validationErr := v.Validate(req.Context(), validationReq); validationErr != nil {
+ if ve, ok := validationErr.(*api.ValidationError); ok {
+ klog.V(4).Infof("Validation failed [%s]: %v", v.Name(), ve)
+ }
+ return nil, validationErr
+ }
+ }
+
return rt.delegate.RoundTrip(req)
}
@@ -102,3 +184,79 @@ func parseURLToGVR(path string) (gvr schema.GroupVersionResource, ok bool) {
}
return gvr, true
}
+
+func parseURLToNamespaceAndName(path string) (namespace, name string) {
+ parts := strings.Split(strings.Trim(path, "/"), "/")
+
+ for i, part := range parts {
+ if part == "namespaces" && i+1 < len(parts) {
+ namespace = parts[i+1]
+ break
+ }
+ }
+
+ resourceIdx := findResourceTypeIndex(parts)
+ if resourceIdx >= 0 && resourceIdx+1 < len(parts) {
+ name = parts[resourceIdx+1]
+ }
+
+ return namespace, name
+}
+
+func findResourceTypeIndex(parts []string) int {
+ if len(parts) == 0 {
+ return -1
+ }
+
+ switch parts[0] {
+ case "api":
+ if len(parts) < 3 {
+ return -1
+ }
+ if parts[2] == "namespaces" && len(parts) > 4 {
+ return 4
+ }
+ return 2
+ case "apis":
+ if len(parts) < 4 {
+ return -1
+ }
+ if parts[3] == "namespaces" && len(parts) > 5 {
+ return 5
+ }
+ return 3
+ }
+ return -1
+}
+
+func httpMethodToVerb(method, path string) string {
+ switch method {
+ case "GET":
+ if isCollectionPath(path) {
+ return "list"
+ }
+ return "get"
+ case "POST":
+ return "create"
+ case "PUT":
+ return "update"
+ case "PATCH":
+ return "patch"
+ case "DELETE":
+ if isCollectionPath(path) {
+ return "deletecollection"
+ }
+ return "delete"
+ default:
+ return strings.ToLower(method)
+ }
+}
+
+func isCollectionPath(path string) bool {
+ parts := strings.Split(strings.Trim(path, "/"), "/")
+ resourceIdx := findResourceTypeIndex(parts)
+ if resourceIdx < 0 {
+ return false
+ }
+ return resourceIdx == len(parts)-1
+}
diff --git a/pkg/kubernetes/accesscontrol_round_tripper_test.go b/pkg/kubernetes/accesscontrol_round_tripper_test.go
index c8a5de34a..facfa5bbd 100644
--- a/pkg/kubernetes/accesscontrol_round_tripper_test.go
+++ b/pkg/kubernetes/accesscontrol_round_tripper_test.go
@@ -287,7 +287,8 @@ func (s *AccessControlRoundTripperTestSuite) TestRoundTripForDeniedAPIResources(
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")
+ s.Contains(err.Error(), "RESOURCE_NOT_FOUND")
+ s.Contains(err.Error(), "does not exist in the cluster")
})
}
diff --git a/pkg/kubernetes/auth.go b/pkg/kubernetes/auth.go
new file mode 100644
index 000000000..311b9285e
--- /dev/null
+++ b/pkg/kubernetes/auth.go
@@ -0,0 +1,54 @@
+package kubernetes
+
+import (
+ "context"
+
+ authv1 "k8s.io/api/authorization/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
+ "k8s.io/klog/v2"
+)
+
+// CanI checks if the current identity can perform verb on resource.
+// Uses SelfSubjectAccessReview to pre-check RBAC permissions.
+func CanI(
+ ctx context.Context,
+ authClient authv1client.AuthorizationV1Interface,
+ gvr *schema.GroupVersionResource,
+ namespace, resourceName, verb string,
+) (bool, error) {
+ if authClient == nil {
+ return true, nil
+ }
+
+ accessReview := &authv1.SelfSubjectAccessReview{
+ Spec: authv1.SelfSubjectAccessReviewSpec{
+ ResourceAttributes: &authv1.ResourceAttributes{
+ Namespace: namespace,
+ Verb: verb,
+ Group: gvr.Group,
+ Version: gvr.Version,
+ Resource: gvr.Resource,
+ Name: resourceName,
+ },
+ },
+ }
+
+ response, err := authClient.SelfSubjectAccessReviews().Create(ctx, accessReview, metav1.CreateOptions{})
+ if err != nil {
+ return false, err
+ }
+
+ if klog.V(5).Enabled() {
+ if response.Status.Allowed {
+ klog.V(5).Infof("RBAC check: allowed %s on %s/%s in %s",
+ verb, gvr.Group, gvr.Resource, namespace)
+ } else {
+ klog.V(5).Infof("RBAC check: denied %s on %s/%s in %s: %s",
+ verb, gvr.Group, gvr.Resource, namespace, response.Status.Reason)
+ }
+ }
+
+ return response.Status.Allowed, nil
+}
diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go
index 3cd47c3b4..f44bd46de 100644
--- a/pkg/kubernetes/kubernetes.go
+++ b/pkg/kubernetes/kubernetes.go
@@ -12,6 +12,7 @@ import (
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
+ authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
"k8s.io/client-go/rest"
"k8s.io/client-go/restmapper"
@@ -50,21 +51,25 @@ type Kubernetes struct {
var _ api.KubernetesClient = (*Kubernetes)(nil)
-func NewKubernetes(config api.BaseConfig, clientCmdConfig clientcmd.ClientConfig, restConfig *rest.Config) (*Kubernetes, error) {
+func NewKubernetes(baseConfig api.BaseConfig, clientCmdConfig clientcmd.ClientConfig, restConfig *rest.Config) (*Kubernetes, error) {
k := &Kubernetes{
- config: config,
+ config: baseConfig,
clientCmdConfig: clientCmdConfig,
restConfig: rest.CopyConfig(restConfig),
}
if k.restConfig.UserAgent == "" {
k.restConfig.UserAgent = rest.DefaultKubernetesUserAgent()
}
+
k.restConfig.Wrap(func(original http.RoundTripper) http.RoundTripper {
- return &AccessControlRoundTripper{
- delegate: original,
- deniedResourcesProvider: config,
- restMapperProvider: func() meta.RESTMapper { return k.restMapper },
- }
+ return NewAccessControlRoundTripper(AccessControlRoundTripperConfig{
+ Delegate: original,
+ DeniedResourcesProvider: baseConfig,
+ RestMapperProvider: func() meta.RESTMapper { return k.restMapper },
+ DiscoveryProvider: func() discovery.DiscoveryInterface { return k.discoveryClient },
+ AuthClientProvider: func() authv1client.AuthorizationV1Interface { return k.AuthorizationV1() },
+ ValidationEnabled: baseConfig.IsValidationEnabled(),
+ })
})
k.restConfig.Wrap(func(original http.RoundTripper) http.RoundTripper {
return &UserAgentRoundTripper{delegate: original}
diff --git a/pkg/kubernetes/rbac_validator.go b/pkg/kubernetes/rbac_validator.go
new file mode 100644
index 000000000..450330f5c
--- /dev/null
+++ b/pkg/kubernetes/rbac_validator.go
@@ -0,0 +1,52 @@
+package kubernetes
+
+import (
+ "context"
+
+ "github.com/containers/kubernetes-mcp-server/pkg/api"
+ authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
+ "k8s.io/klog/v2"
+)
+
+// RBACValidator pre-checks RBAC permissions before execution.
+type RBACValidator struct {
+ authClientProvider func() authv1client.AuthorizationV1Interface
+}
+
+// NewRBACValidator creates a new RBAC validator.
+func NewRBACValidator(authClientProvider func() authv1client.AuthorizationV1Interface) *RBACValidator {
+ return &RBACValidator{
+ authClientProvider: authClientProvider,
+ }
+}
+
+func (v *RBACValidator) Name() string {
+ return "rbac"
+}
+
+func (v *RBACValidator) Validate(ctx context.Context, req *api.HTTPValidationRequest) error {
+ if req.GVR == nil || req.Verb == "" {
+ return nil
+ }
+
+ authClient := v.authClientProvider()
+ if authClient == nil {
+ return nil
+ }
+
+ allowed, err := CanI(ctx, authClient, req.GVR, req.Namespace, req.ResourceName, req.Verb)
+ if err != nil {
+ klog.V(4).Infof("RBAC pre-validation failed with error: %v", err)
+ return nil
+ }
+
+ if !allowed {
+ return api.NewPermissionDeniedError(
+ req.Verb,
+ api.FormatResourceName(req.GVR),
+ req.Namespace,
+ )
+ }
+
+ return nil
+}
diff --git a/pkg/kubernetes/rbac_validator_test.go b/pkg/kubernetes/rbac_validator_test.go
new file mode 100644
index 000000000..28328bbc6
--- /dev/null
+++ b/pkg/kubernetes/rbac_validator_test.go
@@ -0,0 +1,155 @@
+package kubernetes
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/containers/kubernetes-mcp-server/pkg/api"
+ "github.com/stretchr/testify/suite"
+ authv1 "k8s.io/api/authorization/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
+ "k8s.io/client-go/rest"
+)
+
+type mockSelfSubjectAccessReviewInterface struct {
+ allowed bool
+ err error
+}
+
+func (m *mockSelfSubjectAccessReviewInterface) Create(ctx context.Context, review *authv1.SelfSubjectAccessReview, opts metav1.CreateOptions) (*authv1.SelfSubjectAccessReview, error) {
+ if m.err != nil {
+ return nil, m.err
+ }
+ review.Status.Allowed = m.allowed
+ return review, nil
+}
+
+type mockAuthorizationV1Interface struct {
+ authv1client.AuthorizationV1Interface
+ selfSubjectAccessReview *mockSelfSubjectAccessReviewInterface
+}
+
+func (m *mockAuthorizationV1Interface) RESTClient() rest.Interface {
+ return nil
+}
+
+func (m *mockAuthorizationV1Interface) SelfSubjectAccessReviews() authv1client.SelfSubjectAccessReviewInterface {
+ return m.selfSubjectAccessReview
+}
+
+type RBACValidatorTestSuite struct {
+ suite.Suite
+}
+
+func (s *RBACValidatorTestSuite) TestName() {
+ v := NewRBACValidator(nil)
+ s.Equal("rbac", v.Name())
+}
+
+func (s *RBACValidatorTestSuite) TestValidate() {
+ testCases := []struct {
+ name string
+ req *api.HTTPValidationRequest
+ authClient authv1client.AuthorizationV1Interface
+ expectError bool
+ errorCode api.ValidationErrorCode
+ }{
+ {
+ name: "nil GVR passes validation",
+ req: &api.HTTPValidationRequest{GVR: nil, Verb: "get"},
+ authClient: nil,
+ expectError: false,
+ },
+ {
+ name: "empty verb passes validation",
+ req: &api.HTTPValidationRequest{
+ GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"},
+ Verb: "",
+ },
+ authClient: nil,
+ expectError: false,
+ },
+ {
+ name: "nil auth client passes validation",
+ req: &api.HTTPValidationRequest{
+ GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"},
+ Verb: "get",
+ },
+ authClient: nil,
+ expectError: false,
+ },
+ {
+ name: "allowed action passes validation",
+ req: &api.HTTPValidationRequest{
+ GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"},
+ Verb: "get",
+ Namespace: "default",
+ },
+ authClient: &mockAuthorizationV1Interface{
+ selfSubjectAccessReview: &mockSelfSubjectAccessReviewInterface{allowed: true},
+ },
+ expectError: false,
+ },
+ {
+ name: "denied action fails validation",
+ req: &api.HTTPValidationRequest{
+ GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "secrets"},
+ Verb: "delete",
+ Namespace: "kube-system",
+ },
+ authClient: &mockAuthorizationV1Interface{
+ selfSubjectAccessReview: &mockSelfSubjectAccessReviewInterface{allowed: false},
+ },
+ expectError: true,
+ errorCode: api.ErrorCodePermissionDenied,
+ },
+ {
+ name: "auth client error passes validation",
+ req: &api.HTTPValidationRequest{
+ GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "pods"},
+ Verb: "get",
+ Namespace: "default",
+ },
+ authClient: &mockAuthorizationV1Interface{
+ selfSubjectAccessReview: &mockSelfSubjectAccessReviewInterface{err: errors.New("connection refused")},
+ },
+ expectError: false,
+ },
+ {
+ name: "cluster-scoped resource denied",
+ req: &api.HTTPValidationRequest{
+ GVR: &schema.GroupVersionResource{Group: "", Version: "v1", Resource: "nodes"},
+ Verb: "delete",
+ Namespace: "",
+ },
+ authClient: &mockAuthorizationV1Interface{
+ selfSubjectAccessReview: &mockSelfSubjectAccessReviewInterface{allowed: false},
+ },
+ expectError: true,
+ errorCode: api.ErrorCodePermissionDenied,
+ },
+ }
+
+ for _, tc := range testCases {
+ s.Run(tc.name, func() {
+ v := NewRBACValidator(func() authv1client.AuthorizationV1Interface { return tc.authClient })
+ err := v.Validate(context.Background(), tc.req)
+
+ if tc.expectError {
+ s.Error(err)
+ if ve, ok := err.(*api.ValidationError); ok {
+ s.Equal(tc.errorCode, ve.Code)
+ }
+ } else {
+ s.NoError(err)
+ }
+ })
+ }
+}
+
+func TestRBACValidator(t *testing.T) {
+ suite.Run(t, new(RBACValidatorTestSuite))
+}
diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go
index 513a51be2..3769361bd 100644
--- a/pkg/kubernetes/resources.go
+++ b/pkg/kubernetes/resources.go
@@ -11,7 +11,6 @@ import (
"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/version"
- authv1 "k8s.io/api/authorization/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
@@ -230,19 +229,6 @@ func (c *Core) supportsGroupVersion(groupVersion string) bool {
}
func (c *Core) canIUse(ctx context.Context, gvr *schema.GroupVersionResource, namespace, verb string) bool {
- accessReviews := c.AuthorizationV1().SelfSubjectAccessReviews()
- response, err := accessReviews.Create(ctx, &authv1.SelfSubjectAccessReview{
- Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authv1.ResourceAttributes{
- Namespace: namespace,
- Verb: verb,
- Group: gvr.Group,
- Version: gvr.Version,
- Resource: gvr.Resource,
- }},
- }, metav1.CreateOptions{})
- if err != nil {
- // TODO: maybe return the error too
- return false
- }
- return response.Status.Allowed
+ allowed, _ := CanI(ctx, c.AuthorizationV1(), gvr, namespace, "", verb)
+ return allowed
}
diff --git a/pkg/kubernetes/schema_validator.go b/pkg/kubernetes/schema_validator.go
new file mode 100644
index 000000000..fabd48676
--- /dev/null
+++ b/pkg/kubernetes/schema_validator.go
@@ -0,0 +1,130 @@
+package kubernetes
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/containers/kubernetes-mcp-server/pkg/api"
+ "k8s.io/client-go/discovery"
+ "k8s.io/klog/v2"
+ kubectlopenapi "k8s.io/kubectl/pkg/util/openapi"
+ kubectlvalidation "k8s.io/kubectl/pkg/validation"
+)
+
+const schemaCacheTTL = 15 * time.Minute
+
+// SchemaValidator validates resource manifests against the OpenAPI schema.
+type SchemaValidator struct {
+ discoveryClientProvider func() discovery.DiscoveryInterface
+ kubectlValidator kubectlvalidation.Schema
+ validatorMu sync.Mutex
+ validatorCachedAt time.Time
+}
+
+// NewSchemaValidator creates a new schema validator.
+func NewSchemaValidator(discoveryClientProvider func() discovery.DiscoveryInterface) *SchemaValidator {
+ return &SchemaValidator{
+ discoveryClientProvider: discoveryClientProvider,
+ }
+}
+
+func (v *SchemaValidator) Name() string {
+ return "schema"
+}
+
+func (v *SchemaValidator) Validate(ctx context.Context, req *api.HTTPValidationRequest) error {
+ if req.GVK == nil || len(req.Body) == 0 {
+ return nil
+ }
+
+ // Only validate for create/update operations (exclude patch as partial bodies cause false positives)
+ if req.Verb != "create" && req.Verb != "update" {
+ return nil
+ }
+
+ validator, err := v.getValidator()
+ if err != nil {
+ klog.V(4).Infof("Failed to get schema validator: %v", err)
+ return nil
+ }
+
+ if validator == nil {
+ return nil
+ }
+
+ err = validator.ValidateBytes(req.Body)
+ if err != nil {
+ // Check if this is a parsing error (e.g., binary data that can't be parsed as YAML)
+ // In that case, skip validation rather than blocking the request
+ errMsg := err.Error()
+ if strings.Contains(errMsg, "yaml:") || strings.Contains(errMsg, "json:") {
+ klog.V(4).Infof("Schema validation skipped due to parsing error: %v", err)
+ return nil
+ }
+ return convertKubectlValidationError(err)
+ }
+
+ return nil
+}
+
+// openAPIResourcesAdapter adapts CachedOpenAPIParser to OpenAPIResourcesGetter interface.
+type openAPIResourcesAdapter struct {
+ parser *kubectlopenapi.CachedOpenAPIParser
+}
+
+func (a *openAPIResourcesAdapter) OpenAPISchema() (kubectlopenapi.Resources, error) {
+ return a.parser.Parse()
+}
+
+func (v *SchemaValidator) getValidator() (kubectlvalidation.Schema, error) {
+ v.validatorMu.Lock()
+ defer v.validatorMu.Unlock()
+
+ if v.kubectlValidator != nil && time.Since(v.validatorCachedAt) <= schemaCacheTTL {
+ return v.kubectlValidator, nil
+ }
+
+ discoveryClient := v.discoveryClientProvider()
+ if discoveryClient == nil {
+ return nil, nil
+ }
+
+ openAPIClient, ok := discoveryClient.(discovery.OpenAPISchemaInterface)
+ if !ok {
+ klog.V(4).Infof("Discovery client does not support OpenAPI schema")
+ return nil, nil
+ }
+
+ parser := kubectlopenapi.NewOpenAPIParser(openAPIClient)
+ adapter := &openAPIResourcesAdapter{parser: parser}
+
+ v.kubectlValidator = kubectlvalidation.NewSchemaValidation(adapter)
+ v.validatorCachedAt = time.Now()
+
+ return v.kubectlValidator, nil
+}
+
+func convertKubectlValidationError(err error) *api.ValidationError {
+ if err == nil {
+ return nil
+ }
+
+ errMsg := err.Error()
+
+ var field string
+ if strings.Contains(errMsg, "unknown field") {
+ if start := strings.Index(errMsg, "\""); start != -1 {
+ if end := strings.Index(errMsg[start+1:], "\""); end != -1 {
+ field = errMsg[start+1 : start+1+end]
+ }
+ }
+ }
+
+ return &api.ValidationError{
+ Code: api.ErrorCodeInvalidField,
+ Message: errMsg,
+ Field: field,
+ }
+}
diff --git a/pkg/kubernetes/validator_registry.go b/pkg/kubernetes/validator_registry.go
new file mode 100644
index 000000000..a6457c69e
--- /dev/null
+++ b/pkg/kubernetes/validator_registry.go
@@ -0,0 +1,41 @@
+package kubernetes
+
+import (
+ "github.com/containers/kubernetes-mcp-server/pkg/api"
+ "k8s.io/client-go/discovery"
+ authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
+)
+
+// ValidatorProviders holds the providers needed to create validators.
+type ValidatorProviders struct {
+ Discovery func() discovery.DiscoveryInterface
+ AuthClient func() authv1client.AuthorizationV1Interface
+}
+
+// ValidatorFactory creates a validator given the providers.
+type ValidatorFactory func(ValidatorProviders) api.HTTPValidator
+
+var validatorFactories []ValidatorFactory
+
+// RegisterValidator adds a validator factory to the registry.
+func RegisterValidator(factory ValidatorFactory) {
+ validatorFactories = append(validatorFactories, factory)
+}
+
+// CreateValidators creates all registered validators with the given providers.
+func CreateValidators(providers ValidatorProviders) []api.HTTPValidator {
+ validators := make([]api.HTTPValidator, 0, len(validatorFactories))
+ for _, factory := range validatorFactories {
+ validators = append(validators, factory(providers))
+ }
+ return validators
+}
+
+func init() {
+ RegisterValidator(func(p ValidatorProviders) api.HTTPValidator {
+ return NewSchemaValidator(p.Discovery)
+ })
+ RegisterValidator(func(p ValidatorProviders) api.HTTPValidator {
+ return NewRBACValidator(p.AuthClient)
+ })
+}
diff --git a/pkg/kubernetes/validator_registry_test.go b/pkg/kubernetes/validator_registry_test.go
new file mode 100644
index 000000000..8fab8b4f1
--- /dev/null
+++ b/pkg/kubernetes/validator_registry_test.go
@@ -0,0 +1,49 @@
+package kubernetes
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+ "k8s.io/client-go/discovery"
+ authv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
+)
+
+type ValidatorRegistryTestSuite struct {
+ suite.Suite
+}
+
+func (s *ValidatorRegistryTestSuite) TestCreateValidatorsReturnsRegisteredValidators() {
+ providers := ValidatorProviders{
+ Discovery: func() discovery.DiscoveryInterface { return nil },
+ AuthClient: func() authv1client.AuthorizationV1Interface { return nil },
+ }
+
+ validators := CreateValidators(providers)
+
+ s.GreaterOrEqual(len(validators), 2, "Expected at least 2 validators (schema, rbac)")
+
+ names := make(map[string]bool)
+ for _, v := range validators {
+ names[v.Name()] = true
+ }
+
+ s.True(names["schema"], "Expected schema validator to be registered")
+ s.True(names["rbac"], "Expected rbac validator to be registered")
+}
+
+func (s *ValidatorRegistryTestSuite) TestCreateValidatorsWithNilProviders() {
+ providers := ValidatorProviders{
+ Discovery: nil,
+ AuthClient: nil,
+ }
+
+ // Should not panic
+ s.NotPanics(func() {
+ validators := CreateValidators(providers)
+ s.NotEmpty(validators, "Expected validators to be created even with nil providers")
+ })
+}
+
+func TestValidatorRegistry(t *testing.T) {
+ suite.Run(t, new(ValidatorRegistryTestSuite))
+}
diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go
index 1e5ac1b6d..30fa77c8e 100644
--- a/pkg/mcp/mcp.go
+++ b/pkg/mcp/mcp.go
@@ -110,6 +110,7 @@ func NewServer(configuration Configuration, targetProvider internalk8s.Provider)
s.server.AddReceivingMiddleware(userAgentPropagationMiddleware(version.BinaryName, version.Version))
s.server.AddReceivingMiddleware(toolCallLoggingMiddleware)
s.server.AddReceivingMiddleware(s.metricsMiddleware())
+
err = s.reloadToolsets()
if err != nil {
return nil, err