Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ This extension enables declarative Kubernetes interactions within mcpchecker tas
| `kubernetes.authCanI` | Check if a user or service account can perform an action on a resource |
| `kubernetes.create` | Create a Kubernetes resource |
| `kubernetes.delete` | Delete a Kubernetes resource |
| `kubernetes.getCurrentContext` | Get the current context from kubeconfig |
| `kubernetes.listContexts` | List all contexts from kubeconfig |
| `kubernetes.viewConfig` | View kubeconfig as YAML (optionally minified) |
| `kubernetes.wait` | Wait for a condition on a resource (e.g., `Ready`, `Available`) |

## Configuration
Expand Down Expand Up @@ -132,6 +135,43 @@ Waits for a condition on a resource. Supports configurable timeout and expected
timeout: 5m # optional, defaults to 60s
```

### kubernetes.listContexts

Lists all contexts from the kubeconfig file, including which one is currently active.

```yaml
- kubernetes.listContexts:
# No parameters required
```

**Outputs:**
- `current`: Name of the current context
- `count`: Number of contexts found

### kubernetes.getCurrentContext

Returns the current context name from the kubeconfig.

```yaml
- kubernetes.getCurrentContext:
# No parameters required
```

**Outputs:**
- `context`: Name of the current context

### kubernetes.viewConfig

Views the kubeconfig as YAML, optionally minified to show only the current context.

```yaml
- kubernetes.viewConfig:
minify: false # optional, defaults to false
```

**Outputs:**
- `config`: The kubeconfig content as YAML

## Contributing

Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, project structure, and guidelines for adding new operations.
Expand Down
117 changes: 115 additions & 2 deletions pkg/extension/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ package extension

import (
"context"
"fmt"
"sort"

authorizationv1 "k8s.io/api/authorization/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
authorizationv1client "k8s.io/client-go/kubernetes/typed/authorization/v1"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)

// ResourceClient abstracts Kubernetes resource operations for testability.
Expand All @@ -25,12 +29,25 @@ type ResourceClient interface {

// CheckAccess checks if a user can perform an action on a resource.
CheckAccess(ctx context.Context, user, verb, resource, apiGroup, namespace, resourceName string) (bool, string, error)

// ListContexts returns all contexts from the kubeconfig sorted by name.
// Each context includes its name, cluster, user, namespace, and whether it's the current context.
ListContexts(ctx context.Context) ([]ContextInfo, error)

// GetCurrentContext returns the current context name from the kubeconfig.
// Returns an error if the kubeconfig cannot be loaded.
GetCurrentContext(ctx context.Context) (string, error)

// ViewConfig returns the kubeconfig as YAML.
// When minify is true, only the current context and its dependencies are included.
ViewConfig(ctx context.Context, minify bool) (string, error)
}

// dynamicClientAdapter adapts the Kubernetes dynamic client to the ResourceClient interface.
type dynamicClientAdapter struct {
client dynamic.Interface
authzClient authorizationv1client.AuthorizationV1Interface
client dynamic.Interface
authzClient authorizationv1client.AuthorizationV1Interface
kubeconfigPath string
}

func (a *dynamicClientAdapter) Create(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error) {
Expand Down Expand Up @@ -75,3 +92,99 @@ func (a *dynamicClientAdapter) CheckAccess(ctx context.Context, user, verb, reso

return result.Status.Allowed, result.Status.Reason, nil
}

func (a *dynamicClientAdapter) ListContexts(ctx context.Context) ([]ContextInfo, error) {
config, err := clientcmd.LoadFromFile(a.kubeconfigPath)
if err != nil {
return nil, fmt.Errorf("failed to load kubeconfig: %w", err)
}

var contexts []ContextInfo
for name, context := range config.Contexts {
contexts = append(contexts, ContextInfo{
Name: name,
Cluster: context.Cluster,
User: context.AuthInfo,
Namespace: context.Namespace,
IsCurrent: name == config.CurrentContext,
})
}

// Sort contexts by name for deterministic output
sort.Slice(contexts, func(i, j int) bool {
return contexts[i].Name < contexts[j].Name
})

return contexts, nil
}

func (a *dynamicClientAdapter) GetCurrentContext(ctx context.Context) (string, error) {
config, err := clientcmd.LoadFromFile(a.kubeconfigPath)
if err != nil {
return "", fmt.Errorf("failed to load kubeconfig: %w", err)
}

return config.CurrentContext, nil
}

func (a *dynamicClientAdapter) ViewConfig(ctx context.Context, minify bool) (string, error) {
// Load the full config
rawConfig, err := clientcmd.LoadFromFile(a.kubeconfigPath)
if err != nil {
return "", fmt.Errorf("failed to load kubeconfig: %w", err)
}

// Apply minification if requested
if minify {
// Get current context
currentContext := rawConfig.CurrentContext
if currentContext == "" {
return "", fmt.Errorf("no current context set in kubeconfig")
}

// Create minified config with only current context and its dependencies
currentCtx, exists := rawConfig.Contexts[currentContext]
if !exists {
return "", fmt.Errorf("current context %q not found in kubeconfig", currentContext)
}

minifiedConfig := clientcmdapi.NewConfig()
minifiedConfig.CurrentContext = currentContext
minifiedConfig.Contexts = map[string]*clientcmdapi.Context{
currentContext: currentCtx,
}

// Add the cluster referenced by current context
if currentCtx.Cluster == "" {
return "", fmt.Errorf("current context %q has no cluster", currentContext)
}
if cluster, exists := rawConfig.Clusters[currentCtx.Cluster]; exists {
minifiedConfig.Clusters = map[string]*clientcmdapi.Cluster{
currentCtx.Cluster: cluster,
}
} else {
return "", fmt.Errorf("cluster %q not found in kubeconfig", currentCtx.Cluster)
}

// Add the user referenced by current context (optional)
if currentCtx.AuthInfo != "" {
authInfo, exists := rawConfig.AuthInfos[currentCtx.AuthInfo]
if !exists {
return "", fmt.Errorf("user %q not found in kubeconfig", currentCtx.AuthInfo)
}
minifiedConfig.AuthInfos = map[string]*clientcmdapi.AuthInfo{
currentCtx.AuthInfo: authInfo,
}
}
Comment thread
matzew marked this conversation as resolved.

rawConfig = minifiedConfig
}

// Convert to YAML
yamlBytes, err := clientcmd.Write(*rawConfig)
if err != nil {
return "", fmt.Errorf("failed to marshal config to YAML: %w", err)
}

return string(yamlBytes), nil
}
6 changes: 5 additions & 1 deletion pkg/extension/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ func (e *Extension) handleInitialize(config map[string]any) error {
return fmt.Errorf("failed to create authorization client: %w", err)
}

e.client = &dynamicClientAdapter{client: client, authzClient: authzClient}
e.client = &dynamicClientAdapter{
client: client,
authzClient: authzClient,
kubeconfigPath: kubeconfigPath,
}
return nil
}

Expand Down
134 changes: 134 additions & 0 deletions pkg/extension/kubeconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package extension

import (
"context"
"fmt"

"github.com/mcpchecker/mcpchecker/pkg/extension/sdk"
)

// ContextInfo represents information about a Kubernetes context
type ContextInfo struct {
Name string `json:"name"`
Cluster string `json:"cluster"`
User string `json:"user"`
Namespace string `json:"namespace,omitempty"`
IsCurrent bool `json:"isCurrent"`
}

// handleListContexts lists all contexts from the kubeconfig file.
// Returns the current context name and total count.
func (e *Extension) handleListContexts(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
if e.client == nil {
return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil
}

e.LogInfo(ctx, "Listing kubeconfig contexts", nil)

contexts, err := e.client.ListContexts(ctx)
if err != nil {
e.LogError(ctx, "Failed to list contexts", map[string]any{
"error": err.Error(),
})
return sdk.Failure(fmt.Errorf("failed to list contexts: %w", err)), nil
}

if len(contexts) == 0 {
return sdk.Failure(fmt.Errorf("no contexts found in kubeconfig")), nil
}

// Find current context
var currentContext string
for _, c := range contexts {
if c.IsCurrent {
currentContext = c.Name
break
}
}

e.LogInfo(ctx, "Contexts listed successfully", map[string]any{
"count": len(contexts),
"current": currentContext,
})

return sdk.SuccessWithOutputs(
fmt.Sprintf("Found %d context(s), current: %s", len(contexts), currentContext),
map[string]string{
"current": currentContext,
"count": fmt.Sprintf("%d", len(contexts)),
},
), nil
}

// handleGetCurrentContext returns the current context name from the kubeconfig.
// Fails if no current context is set.
func (e *Extension) handleGetCurrentContext(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
if e.client == nil {
return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil
}

e.LogInfo(ctx, "Getting current kubeconfig context", nil)

currentContext, err := e.client.GetCurrentContext(ctx)
if err != nil {
e.LogError(ctx, "Failed to get current context", map[string]any{
"error": err.Error(),
})
return sdk.Failure(fmt.Errorf("failed to get current context: %w", err)), nil
}

if currentContext == "" {
return sdk.Failure(fmt.Errorf("no current context set in kubeconfig")), nil
}

e.LogInfo(ctx, "Current context retrieved", map[string]any{
"context": currentContext,
})

return sdk.SuccessWithOutputs(
fmt.Sprintf("Current context: %s", currentContext),
map[string]string{
"context": currentContext,
},
), nil
}

// handleViewConfig returns the kubeconfig as YAML.
// When minify is true, returns only the current context and its dependencies.
func (e *Extension) handleViewConfig(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
if e.client == nil {
return sdk.Failure(fmt.Errorf("kubernetes client not initialized")), nil
}

// Parse args
args, ok := req.Args.(map[string]any)
if !ok {
args = make(map[string]any)
}

minify := false
if m, ok := args["minify"].(bool); ok {
minify = m
}

e.LogInfo(ctx, "Viewing kubeconfig", map[string]any{
"minify": minify,
})

configYAML, err := e.client.ViewConfig(ctx, minify)
if err != nil {
e.LogError(ctx, "Failed to view config", map[string]any{
"error": err.Error(),
})
return sdk.Failure(fmt.Errorf("failed to view config: %w", err)), nil
}

e.LogInfo(ctx, "Kubeconfig retrieved successfully", nil)

return sdk.SuccessWithOutputs(
"Kubeconfig retrieved",
map[string]string{
"config": configYAML,
},
), nil
}
Loading