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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,13 @@ In case multi-cluster support is enabled (default) and you have access to multip
- `name` (`string`) **(required)** - Name of the resource
- `namespace` (`string`) - Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace

- **resources_scale** - Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource
- `apiVersion` (`string`) **(required)** - apiVersion of the resource (examples of valid apiVersion are apps/v1)
- `kind` (`string`) **(required)** - kind of the resource (examples of valid kind are: StatefulSet, Deployment)
- `name` (`string`) **(required)** - Name of the resource
- `namespace` (`string`) - Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace
- `scale` (`integer`) - Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it

</details>

<details>
Expand Down Expand Up @@ -397,7 +404,7 @@ In case multi-cluster support is enabled (default) and you have access to multip
- `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)
- `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.

</details>
Expand Down
10 changes: 5 additions & 5 deletions pkg/kubernetes/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
}

// Check if operation is allowed for all namespaces (applicable for namespaced resources)
isNamespaced, _ := k.isNamespaced(gvk)
isNamespaced, _ := k.IsNamespaced(gvk)
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
namespace = k.configuredNamespace()
}
Expand All @@ -53,7 +53,7 @@ func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionK
}

// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
if namespaced, nsErr := k.IsNamespaced(gvk); nsErr == nil && namespaced {
namespace = k.NamespaceOrDefault(namespace)
}
return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
Expand All @@ -80,7 +80,7 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
}

// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
if namespaced, nsErr := k.IsNamespaced(gvk); nsErr == nil && namespaced {
namespace = k.NamespaceOrDefault(namespace)
}
return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
Expand Down Expand Up @@ -143,7 +143,7 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u

namespace := obj.GetNamespace()
// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced {
if namespaced, nsErr := k.IsNamespaced(&gvk); nsErr == nil && namespaced {
namespace = k.NamespaceOrDefault(namespace)
}
resources[i], rErr = k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{
Expand All @@ -168,7 +168,7 @@ func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVer
return &m.Resource, nil
}

func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
func (k *Kubernetes) IsNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
apiResourceList, err := k.AccessControlClientset().DiscoveryClient().ServerResourcesForGroupVersion(gvk.GroupVersion().String())
if err != nil {
return false, err
Expand Down
118 changes: 118 additions & 0 deletions pkg/mcp/resources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/BurntSushi/toml"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/suite"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
v1 "k8s.io/api/rbac/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
Expand All @@ -16,6 +17,7 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/utils/ptr"
"sigs.k8s.io/yaml"
)

Expand Down Expand Up @@ -605,6 +607,122 @@ func (s *ResourcesSuite) TestResourcesDeleteDenied() {
})
}

func (s *ResourcesSuite) TestResourcesScale() {
s.InitMcpClient()
kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
deploymentName := "deployment-to-scale"
_, _ = kc.AppsV1().Deployments("default").Create(s.T().Context(), &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: deploymentName},
Spec: appsv1.DeploymentSpec{
Replicas: ptr.To(int32(2)),
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": deploymentName},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": deploymentName}},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}},
},
},
},
}, metav1.CreateOptions{})

s.Run("resources_scale with missing apiVersion returns error", func() {
toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{})
s.Truef(toolResult.IsError, "call tool should fail")
s.Equalf("failed to get/update resource scale, missing argument apiVersion", toolResult.Content[0].(mcp.TextContent).Text,
"invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
})
s.Run("resources_scale with missing kind returns error", func() {
toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{"apiVersion": "apps/v1"})
s.Truef(toolResult.IsError, "call tool should fail")
s.Equalf("failed to get/update resource scale, missing argument kind", toolResult.Content[0].(mcp.TextContent).Text,
"invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
})
s.Run("resources_scale with missing name returns error", func() {
toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{"apiVersion": "apps/v1", "kind": "Deployment"})
s.Truef(toolResult.IsError, "call tool should fail")
s.Equalf("failed to get/update resource scale, missing argument name", toolResult.Content[0].(mcp.TextContent).Text,
"invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
})
s.Run("resources_scale get returns current scale", func() {
result, err := s.CallTool("resources_scale", map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"namespace": "default",
"name": deploymentName,
})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(result.IsError, "call tool failed: %v", result.Content)
})
s.Run("returns scale yaml", func() {
content := result.Content[0].(mcp.TextContent).Text
s.Truef(strings.HasPrefix(content, "# Current resource scale (YAML) is below"),
"Expected success message, got %v", content)
var decodedScale unstructured.Unstructured
err = yaml.Unmarshal([]byte(strings.TrimPrefix(content, "# Current resource scale (YAML) is below\n")), &decodedScale)
s.Nilf(err, "invalid tool result content %v", err)
replicas, found, _ := unstructured.NestedInt64(decodedScale.Object, "spec", "replicas")
s.Truef(found, "replicas not found in scale object")
s.Equalf(int64(2), replicas, "expected 2 replicas, got %d", replicas)
})
})
s.Run("resources_scale update changes the scale", func() {
result, err := s.CallTool("resources_scale", map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"namespace": "default",
"name": deploymentName,
"scale": 5,
})
s.Run("no error", func() {
s.Nilf(err, "call tool failed %v", err)
s.Falsef(result.IsError, "call tool failed: %v", result.Content)
})
s.Run("returns updated scale yaml", func() {
content := result.Content[0].(mcp.TextContent).Text
var decodedScale unstructured.Unstructured
err = yaml.Unmarshal([]byte(strings.TrimPrefix(content, "# Current resource scale (YAML) is below\n")), &decodedScale)
s.Nilf(err, "invalid tool result content %v", err)
replicas, found, _ := unstructured.NestedInt64(decodedScale.Object, "spec", "replicas")
s.Truef(found, "replicas not found in scale object")
s.Equalf(int64(5), replicas, "expected 5 replicas after update, got %d", replicas)
})
s.Run("deployment was actually scaled", func() {
deployment, _ := kc.AppsV1().Deployments("default").Get(s.T().Context(), deploymentName, metav1.GetOptions{})
s.Equalf(int32(5), *deployment.Spec.Replicas, "expected 5 replicas in deployment, got %d", *deployment.Spec.Replicas)
})
})
s.Run("resources_scale with nonexistent resource returns error", func() {
toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{
"apiVersion": "apps/v1",
"kind": "Deployment",
"namespace": "default",
"name": "nonexistent-deployment",
})
s.Truef(toolResult.IsError, "call tool should fail")
s.Containsf(toolResult.Content[0].(mcp.TextContent).Text, "not found",
"expected not found error, got %v", toolResult.Content[0].(mcp.TextContent).Text)
})
s.Run("resources_scale with resource that does not support scale subresource returns error", func() {
configMapName := "configmap-without-scale"
_, _ = kc.CoreV1().ConfigMaps("default").Create(s.T().Context(), &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{Name: configMapName},
Data: map[string]string{"key": "value"},
}, metav1.CreateOptions{})
toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{
"apiVersion": "v1",
"kind": "ConfigMap",
"namespace": "default",
"name": configMapName,
})
s.Truef(toolResult.IsError, "call tool should fail")
s.Containsf(toolResult.Content[0].(mcp.TextContent).Text, "the server could not find the requested resource",
"expected scale subresource not found error, got %v", toolResult.Content[0].(mcp.TextContent).Text)
})
}

func TestResources(t *testing.T) {
suite.Run(t, new(ResourcesSuite))
}
40 changes: 40 additions & 0 deletions pkg/mcp/testdata/toolsets-core-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -483,5 +483,45 @@
]
},
"name": "resources_list"
},
{
"annotations": {
"title": "Resources: Scale",
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
"type": "string"
},
"kind": {
"description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
"type": "string"
},
"name": {
"description": "Name of the resource",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace",
"type": "string"
},
"scale": {
"description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
"type": "integer"
}
},
"required": [
"apiVersion",
"kind",
"name"
]
},
"name": "resources_scale"
}
]
48 changes: 48 additions & 0 deletions pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json
Original file line number Diff line number Diff line change
Expand Up @@ -760,5 +760,53 @@
]
},
"name": "resources_list"
},
{
"annotations": {
"title": "Resources: Scale",
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
"type": "string"
},
"context": {
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
"enum": [
"extra-cluster",
"fake-context"
],
"type": "string"
},
"kind": {
"description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
"type": "string"
},
"name": {
"description": "Name of the resource",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace",
"type": "string"
},
"scale": {
"description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
"type": "integer"
}
},
"required": [
"apiVersion",
"kind",
"name"
]
},
"name": "resources_scale"
}
]
44 changes: 44 additions & 0 deletions pkg/mcp/testdata/toolsets-full-tools-multicluster.json
Original file line number Diff line number Diff line change
Expand Up @@ -680,5 +680,49 @@
]
},
"name": "resources_list"
},
{
"annotations": {
"title": "Resources: Scale",
"destructiveHint": true,
"idempotentHint": true,
"openWorldHint": true
},
"description": "Get or update the scale of a Kubernetes resource in the current cluster by providing its apiVersion, kind, name, and optionally the namespace. If the scale is set in the tool call, the scale will be updated to that value. Always returns the current scale of the resource",
"inputSchema": {
"type": "object",
"properties": {
"apiVersion": {
"description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
"type": "string"
},
"context": {
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
"type": "string"
},
"kind": {
"description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
"type": "string"
},
"name": {
"description": "Name of the resource",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to get/update the namespaced resource scale from (ignored in case of cluster scoped resources). If not provided, will get/update resource scale from configured namespace",
"type": "string"
},
"scale": {
"description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
"type": "integer"
}
},
"required": [
"apiVersion",
"kind",
"name"
]
},
"name": "resources_scale"
}
]
Loading