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
5 changes: 5 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,10 @@ jobs:
with:
go-version-file: go.mod

- name: Install Helm
uses: azure/setup-helm@v4
with:
version: 'latest'
Comment on lines +19 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

latest stable version of Helm 2025 2026

💡 Result:

As of February 9, 2026, the latest stable Helm releases are:

  • Helm v4 (current major): v4.1.0 — released January 21, 2026. [1]
  • Helm v3 (still maintained): v3.20.0 — released January 21, 2026. [2]

(Helm v4 was officially announced/released on November 12, 2025.) [3]

Sources:
[1] (github.com)
[2] (github.com)
[3] (cncf.io)

Citations:


🏁 Script executed:

# Check the actual workflow file for context
cat -n .github/workflows/test.yaml | sed -n '1,30p'

Repository: mcpchecker/kubernetes-extension

Length of output: 653


🌐 Web query:

azure/setup-helm action latest version v4 v5

💡 Result:

  • Latest azure/setup-helm release/tag: v4.3.1 (github.com)
  • Latest major version: v4 (you can use azure/setup-helm@v4 to track the latest v4.x). (github.com)
  • Is there a v5? Not currently—there’s no v5 release/tag in the upstream repo releases. (github.com)

Citations:


Pin Helm to a specific version for reproducible CI builds.

Using version: 'latest' introduces non-determinism—different workflow runs may install different Helm versions, potentially causing unexpected test failures when a new Helm release ships breaking changes. Pin to a specific version (e.g., v4.1.0 for Helm v4, or v3.20.0 for Helm v3) to ensure reproducibility and easier local debugging.

♻️ Suggested fix
      - name: Install Helm
        uses: azure/setup-helm@v4
        with:
-          version: 'latest'
+          version: 'v4.1.0'
🤖 Prompt for AI Agents
In @.github/workflows/test.yaml around lines 19 - 22, The workflow currently
sets the Helm installer to use `version: 'latest'` (in the azure/setup-helm@v4
step), which makes CI non-deterministic; update the Helm setup step to pin a
specific, tested release (e.g., replace 'latest' with a concrete tag such as
'v4.1.0' for Helm v4 or 'v3.20.0' for Helm v3) so every run installs the exact
same Helm version for reproducible CI builds and local debugging.


- name: Run tests
run: make test
91 changes: 91 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ This extension enables declarative Kubernetes interactions within mcpchecker tas
| `kubernetes.create` | Create a Kubernetes resource |
| `kubernetes.delete` | Delete a Kubernetes resource |
| `kubernetes.getCurrentContext` | Get the current context from kubeconfig |
| `kubernetes.helmInstall` | Install a Helm chart as a release |
| `kubernetes.helmList` | List Helm releases in a namespace or all namespaces |
| `kubernetes.helmUninstall` | Uninstall a Helm release |
| `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`) |
Expand Down Expand Up @@ -87,6 +90,52 @@ spec:
inline: Create an nginx pod named web-server in the test-namespace namespace
```

### Helm Example

Test Helm operations using declarative setup and cleanup:

```yaml
kind: Task
apiVersion: gevals/v1alpha2
metadata:
name: "list-helm-releases"
difficulty: easy
spec:
requires:
- extension: kubernetes

setup:
# Create namespace
- kubernetes.create:
apiVersion: v1
kind: Namespace
metadata:
name: helm-test

# Install a test release
- kubernetes.helmInstall:
chart: oci://registry-1.docker.io/bitnamicharts/nginx
name: test-nginx
namespace: helm-test

cleanup:
# Uninstall the release
- kubernetes.helmUninstall:
name: test-nginx
namespace: helm-test

# Delete namespace
- kubernetes.delete:
apiVersion: v1
kind: Namespace
metadata:
name: helm-test
ignoreNotFound: true

prompt:
inline: List all Helm releases in the cluster
```

## Operation Reference

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

### kubernetes.helmInstall

Installs a Helm chart as a release. Supports chart repositories and OCI registries.

```yaml
- kubernetes.helmInstall:
chart: oci://registry-1.docker.io/bitnamicharts/nginx
name: my-nginx # optional, generates name if not provided
namespace: default # optional
values: # optional Helm values
replicaCount: 2
service:
type: LoadBalancer
```

### kubernetes.helmList

Lists Helm releases in a namespace or across all namespaces.

```yaml
# List in specific namespace
- kubernetes.helmList:
namespace: default

# List across all namespaces
- kubernetes.helmList:
allNamespaces: true
```

**Outputs:**
- `releases`: Information about found Helm releases (name, namespace, status, chart)
Comment on lines +216 to +217

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Documentation inconsistency: releases output doesn't match implementation.

The documentation states that helmList outputs a releases field, but the implementation in helm.go returns a formatted string message via sdk.Success(), not structured data with a releases key.

Either update the documentation to reflect the actual string output, or modify the implementation to return structured release data.

🤖 Prompt for AI Agents
In `@README.md` around lines 216 - 217, The README claim that helmList outputs a
`releases` field is inconsistent with the implementation: update the helmList
implementation in helm.go (the function/method named helmList that currently
calls sdk.Success with a formatted string) to return structured data (e.g., an
object/struct with a `releases` array of {name, namespace, status, chart}
entries) via the SDK success path instead of a formatted string, or
alternatively update README.md to describe the actual string returned by
sdk.Success; pick one approach and make the code/docs consistent by changing
either helmList/sdk.Success usage or the README description accordingly.


### kubernetes.helmUninstall

Uninstalls a Helm release. Gracefully handles releases that don't exist.

```yaml
- kubernetes.helmUninstall:
name: my-nginx
namespace: default # optional
```

### kubernetes.listContexts

Lists all contexts from the kubeconfig file, including which one is currently active.
Expand Down
6 changes: 4 additions & 2 deletions pkg/extension/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ import (
// Extension wraps the SDK extension with Kubernetes client
type Extension struct {
*sdk.Extension
client ResourceClient
client ResourceClient
kubeconfigPath string

mu sync.Mutex
mu sync.Mutex
generatedNamespaces []string
}

Expand Down Expand Up @@ -89,6 +90,7 @@ func (e *Extension) handleInitialize(config map[string]any) error {
authzClient: authzClient,
kubeconfigPath: kubeconfigPath,
}
e.kubeconfigPath = kubeconfigPath
return nil
}

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

import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"

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

// handleHelmInstall installs a Helm chart as a release
func (e *Extension) handleHelmInstall(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
args, ok := req.Args.(map[string]any)
if !ok {
return sdk.Failure(fmt.Errorf("args must be an object")), nil
}

chart, ok := args["chart"].(string)
if !ok || chart == "" {
return sdk.Failure(fmt.Errorf("chart parameter is required")), nil
}

// Optional parameters
name, _ := args["name"].(string)
namespace, _ := args["namespace"].(string)
values, _ := args["values"].(map[string]interface{})

cmdArgs := []string{"install"}

if name != "" {
cmdArgs = append(cmdArgs, name)
} else {
cmdArgs = append(cmdArgs, "--generate-name")
}

cmdArgs = append(cmdArgs, chart)

if namespace != "" {
cmdArgs = append(cmdArgs, "--namespace", namespace)
}

Comment on lines +32 to +43

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

here let's also set the --kubeconfig flag - the extension can receive a different kubeconfig path than the default on startup.

We don't currently expose the path to that file through the client, but it should be simple to add something around here:

// ResourceClient abstracts Kubernetes resource operations for testability.
// Implementations can use the real dynamic client or a mock for testing.
type ResourceClient interface {
// Create creates a Kubernetes resource and returns the created object.
Create(ctx context.Context, gvr schema.GroupVersionResource, obj *unstructured.Unstructured, namespace string) (*unstructured.Unstructured, error)
// Get retrieves a Kubernetes resource by name.
Get(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string) (*unstructured.Unstructured, error)
// Delete removes a Kubernetes resource.
Delete(ctx context.Context, gvr schema.GroupVersionResource, name, namespace string, opts metav1.DeleteOptions) error
// 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
kubeconfigPath string
}

if e.kubeconfigPath != "" {
cmdArgs = append(cmdArgs, "--kubeconfig", e.kubeconfigPath)
}

// Add values as --set flags
for k, v := range values {
cmdArgs = append(cmdArgs, "--set", fmt.Sprintf("%s=%v", k, v))
}
Comment on lines +48 to +51

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Command injection risk and limited values support.

Two concerns with the --set flag handling:

  1. Shell metacharacters: If k or v contain special characters (e.g., commas, brackets, quotes), they can break Helm's --set parsing or cause unexpected behavior.

  2. Nested values limitation: The current approach doesn't handle nested values. For example, {"service": {"type": "LoadBalancer"}} would need --set service.type=LoadBalancer, not --set service=map[type:LoadBalancer].

Consider using a temporary values file with --values for complex structures, or recursively flatten nested maps with dot notation.

Sketch: Flatten nested values
func flattenValues(prefix string, values map[string]interface{}, result map[string]string) {
	for k, v := range values {
		key := k
		if prefix != "" {
			key = prefix + "." + k
		}
		switch val := v.(type) {
		case map[string]interface{}:
			flattenValues(key, val, result)
		default:
			result[key] = fmt.Sprintf("%v", val)
		}
	}
}
🤖 Prompt for AI Agents
In `@pkg/extension/helm.go` around lines 44 - 47, The loop building cmdArgs with
"--set" is vulnerable to shell/Helm parsing problems and doesn't support nested
maps; instead either serialize complex structures to a temporary YAML file and
pass it via "--values" or implement a recursive flattener (e.g.,
flattenValues(prefix string, values map[string]interface{}, result
map[string]string)) to produce dot-notated keys (service.type=LoadBalancer) and
then append safe, properly formatted values to cmdArgs; additionally ensure
values are sanitized/quoted (use fmt.Sprintf or strconv.Quote-like formatting)
when constructing the final "--set" arguments to avoid injection/escaping
issues.


e.LogInfo(ctx, "Installing Helm chart", map[string]any{
"chart": chart,
"name": name,
"namespace": namespace,
})

cmd := exec.CommandContext(ctx, "helm", cmdArgs...)
output, err := cmd.CombinedOutput()
if err != nil {
e.LogError(ctx, "Helm install failed", map[string]any{
"chart": chart,
"error": err.Error(),
})
return sdk.Failure(fmt.Errorf("helm install failed: %s\nOutput: %s", err, string(output))), nil
}

e.LogInfo(ctx, "Helm chart installed successfully", map[string]any{
"chart": chart,
"name": name,
})

return sdk.Success(fmt.Sprintf("Helm chart installed successfully\n%s", string(output))), nil
}

// handleHelmList lists Helm releases
func (e *Extension) handleHelmList(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
args, ok := req.Args.(map[string]any)
if !ok {
return sdk.Failure(fmt.Errorf("args must be an object")), nil
}

cmdArgs := []string{"list", "--output", "json"}

namespace, _ := args["namespace"].(string)
allNamespaces, _ := args["allNamespaces"].(bool)

if allNamespaces {
cmdArgs = append(cmdArgs, "--all-namespaces")
} else if namespace != "" {
cmdArgs = append(cmdArgs, "--namespace", namespace)
}

if e.kubeconfigPath != "" {
cmdArgs = append(cmdArgs, "--kubeconfig", e.kubeconfigPath)
}

e.LogInfo(ctx, "Listing Helm releases", map[string]any{
"namespace": namespace,
"allNamespaces": allNamespaces,
})

cmd := exec.CommandContext(ctx, "helm", cmdArgs...)
output, err := cmd.CombinedOutput()
if err != nil {
e.LogError(ctx, "Helm list failed", map[string]any{
"error": err.Error(),
})
return sdk.Failure(fmt.Errorf("helm list failed: %s\nOutput: %s", err, string(output))), nil
}

// Parse JSON output
var releases []map[string]interface{}
if len(output) > 0 {
if err := json.Unmarshal(output, &releases); err != nil {
return sdk.Failure(fmt.Errorf("failed to parse helm list output: %s", err)), nil
}
}

if len(releases) == 0 {
return sdk.Success("No Helm releases found"), nil
}

// Format releases as a readable string
var result strings.Builder
result.WriteString(fmt.Sprintf("Found %d Helm release(s):\n", len(releases)))
for _, release := range releases {
name, _ := release["name"].(string)
ns, _ := release["namespace"].(string)
status, _ := release["status"].(string)
chart, _ := release["chart"].(string)
result.WriteString(fmt.Sprintf(" - %s (namespace: %s, status: %s, chart: %s)\n", name, ns, status, chart))
}

return sdk.Success(result.String()), nil
}

// handleHelmUninstall uninstalls a Helm release
func (e *Extension) handleHelmUninstall(ctx context.Context, req *sdk.OperationRequest) (*sdk.OperationResult, error) {
args, ok := req.Args.(map[string]any)
if !ok {
return sdk.Failure(fmt.Errorf("args must be an object")), nil
}

name, ok := args["name"].(string)
if !ok || name == "" {
return sdk.Failure(fmt.Errorf("name parameter is required")), nil
}

cmdArgs := []string{"uninstall", name}

namespace, _ := args["namespace"].(string)
if namespace != "" {
cmdArgs = append(cmdArgs, "--namespace", namespace)
}

if e.kubeconfigPath != "" {
cmdArgs = append(cmdArgs, "--kubeconfig", e.kubeconfigPath)
}

e.LogInfo(ctx, "Uninstalling Helm release", map[string]any{
"name": name,
"namespace": namespace,
})

cmd := exec.CommandContext(ctx, "helm", cmdArgs...)
output, err := cmd.CombinedOutput()
if err != nil {
// Check if it's a "not found" error
if strings.Contains(string(output), "not found") {
e.LogInfo(ctx, "Helm release not found (ignored)", map[string]any{
"name": name,
})
return sdk.Success(fmt.Sprintf("Helm release '%s' not found (already uninstalled)", name)), nil
}
e.LogError(ctx, "Helm uninstall failed", map[string]any{
"name": name,
"error": err.Error(),
})
return sdk.Failure(fmt.Errorf("helm uninstall failed: %s\nOutput: %s", err, string(output))), nil
}

e.LogInfo(ctx, "Helm release uninstalled successfully", map[string]any{
"name": name,
})

return sdk.Success(fmt.Sprintf("Helm release '%s' uninstalled successfully\n%s", name, string(output))), nil
}
Loading