Skip to content

Commit 0541c25

Browse files
Cali0707manusa
authored andcommitted
feat(core): add resources_scale tool
Signed-off-by: Calum Murray <[email protected]>
1 parent 3a03479 commit 0541c25

File tree

9 files changed

+477
-6
lines changed

9 files changed

+477
-6
lines changed

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,13 @@ In case multi-cluster support is enabled (default) and you have access to multip
322322
- `name` (`string`) **(required)** - Name of the resource
323323
- `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
324324

325+
- **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
326+
- `apiVersion` (`string`) **(required)** - apiVersion of the resource (examples of valid apiVersion are apps/v1)
327+
- `kind` (`string`) **(required)** - kind of the resource (examples of valid kind are: StatefulSet, Deployment)
328+
- `name` (`string`) **(required)** - Name of the resource
329+
- `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
330+
- `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
331+
325332
</details>
326333

327334
<details>
@@ -397,7 +404,7 @@ In case multi-cluster support is enabled (default) and you have access to multip
397404
- `resource_name` (`string`) - Name of the resource to get traces for. Required if traceId is not provided.
398405
- `resource_type` (`string`) - Type of resource to get traces for (app, service, workload). Required if traceId is not provided.
399406
- `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)
400-
- `tags` (`string`) - JSON string of tags to filter traces (optional, only used when traceId is not provided)
407+
- `tags` (`string`) - JSON string of tags to filter traces (optional, only used when traceId is not provided)
401408
- `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.
402409

403410
</details>

pkg/kubernetes/resources.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersion
3636
}
3737

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

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

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

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

171-
func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
171+
func (k *Kubernetes) IsNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
172172
apiResourceList, err := k.AccessControlClientset().DiscoveryClient().ServerResourcesForGroupVersion(gvk.GroupVersion().String())
173173
if err != nil {
174174
return false, err

pkg/mcp/resources_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/BurntSushi/toml"
99
"github.com/mark3labs/mcp-go/mcp"
1010
"github.com/stretchr/testify/suite"
11+
appsv1 "k8s.io/api/apps/v1"
1112
corev1 "k8s.io/api/core/v1"
1213
v1 "k8s.io/api/rbac/v1"
1314
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1"
@@ -16,6 +17,7 @@ import (
1617
"k8s.io/apimachinery/pkg/runtime/schema"
1718
"k8s.io/client-go/dynamic"
1819
"k8s.io/client-go/kubernetes"
20+
"k8s.io/utils/ptr"
1921
"sigs.k8s.io/yaml"
2022
)
2123

@@ -605,6 +607,122 @@ func (s *ResourcesSuite) TestResourcesDeleteDenied() {
605607
})
606608
}
607609

610+
func (s *ResourcesSuite) TestResourcesScale() {
611+
s.InitMcpClient()
612+
kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
613+
deploymentName := "deployment-to-scale"
614+
_, _ = kc.AppsV1().Deployments("default").Create(s.T().Context(), &appsv1.Deployment{
615+
ObjectMeta: metav1.ObjectMeta{Name: deploymentName},
616+
Spec: appsv1.DeploymentSpec{
617+
Replicas: ptr.To(int32(2)),
618+
Selector: &metav1.LabelSelector{
619+
MatchLabels: map[string]string{"app": deploymentName},
620+
},
621+
Template: corev1.PodTemplateSpec{
622+
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": deploymentName}},
623+
Spec: corev1.PodSpec{
624+
Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}},
625+
},
626+
},
627+
},
628+
}, metav1.CreateOptions{})
629+
630+
s.Run("resources_scale with missing apiVersion returns error", func() {
631+
toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{})
632+
s.Truef(toolResult.IsError, "call tool should fail")
633+
s.Equalf("failed get/update resource scale, missing argument apiVersion", toolResult.Content[0].(mcp.TextContent).Text,
634+
"invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
635+
})
636+
s.Run("resources_scale with missing kind returns error", func() {
637+
toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{"apiVersion": "apps/v1"})
638+
s.Truef(toolResult.IsError, "call tool should fail")
639+
s.Equalf("failed get/update resource scale, missing argument kind", toolResult.Content[0].(mcp.TextContent).Text,
640+
"invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
641+
})
642+
s.Run("resources_scale with missing name returns error", func() {
643+
toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{"apiVersion": "apps/v1", "kind": "Deployment"})
644+
s.Truef(toolResult.IsError, "call tool should fail")
645+
s.Equalf("failed to get/update resource scale, missing argument name", toolResult.Content[0].(mcp.TextContent).Text,
646+
"invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
647+
})
648+
s.Run("resources_scale get returns current scale", func() {
649+
result, err := s.CallTool("resources_scale", map[string]interface{}{
650+
"apiVersion": "apps/v1",
651+
"kind": "Deployment",
652+
"namespace": "default",
653+
"name": deploymentName,
654+
})
655+
s.Run("no error", func() {
656+
s.Nilf(err, "call tool failed %v", err)
657+
s.Falsef(result.IsError, "call tool failed: %v", result.Content)
658+
})
659+
s.Run("returns scale yaml", func() {
660+
content := result.Content[0].(mcp.TextContent).Text
661+
s.Truef(strings.HasPrefix(content, "# Current resource scale (YAML) is below"),
662+
"Expected success message, got %v", content)
663+
var decodedScale unstructured.Unstructured
664+
err = yaml.Unmarshal([]byte(strings.TrimPrefix(content, "# Current resource scale (YAML) is below\n")), &decodedScale)
665+
s.Nilf(err, "invalid tool result content %v", err)
666+
replicas, found, _ := unstructured.NestedInt64(decodedScale.Object, "spec", "replicas")
667+
s.Truef(found, "replicas not found in scale object")
668+
s.Equalf(int64(2), replicas, "expected 2 replicas, got %d", replicas)
669+
})
670+
})
671+
s.Run("resources_scale update changes the scale", func() {
672+
result, err := s.CallTool("resources_scale", map[string]interface{}{
673+
"apiVersion": "apps/v1",
674+
"kind": "Deployment",
675+
"namespace": "default",
676+
"name": deploymentName,
677+
"scale": 5,
678+
})
679+
s.Run("no error", func() {
680+
s.Nilf(err, "call tool failed %v", err)
681+
s.Falsef(result.IsError, "call tool failed: %v", result.Content)
682+
})
683+
s.Run("returns updated scale yaml", func() {
684+
content := result.Content[0].(mcp.TextContent).Text
685+
var decodedScale unstructured.Unstructured
686+
err = yaml.Unmarshal([]byte(strings.TrimPrefix(content, "# Current resource scale (YAML) is below\n")), &decodedScale)
687+
s.Nilf(err, "invalid tool result content %v", err)
688+
replicas, found, _ := unstructured.NestedInt64(decodedScale.Object, "spec", "replicas")
689+
s.Truef(found, "replicas not found in scale object")
690+
s.Equalf(int64(5), replicas, "expected 5 replicas after update, got %d", replicas)
691+
})
692+
s.Run("deployment was actually scaled", func() {
693+
deployment, _ := kc.AppsV1().Deployments("default").Get(s.T().Context(), deploymentName, metav1.GetOptions{})
694+
s.Equalf(int32(5), *deployment.Spec.Replicas, "expected 5 replicas in deployment, got %d", *deployment.Spec.Replicas)
695+
})
696+
})
697+
s.Run("resources_scale with nonexistent resource returns error", func() {
698+
toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{
699+
"apiVersion": "apps/v1",
700+
"kind": "Deployment",
701+
"namespace": "default",
702+
"name": "nonexistent-deployment",
703+
})
704+
s.Truef(toolResult.IsError, "call tool should fail")
705+
s.Containsf(toolResult.Content[0].(mcp.TextContent).Text, "not found",
706+
"expected not found error, got %v", toolResult.Content[0].(mcp.TextContent).Text)
707+
})
708+
s.Run("resources_scale with resource that does not support scale subresource returns error", func() {
709+
configMapName := "configmap-without-scale"
710+
_, _ = kc.CoreV1().ConfigMaps("default").Create(s.T().Context(), &corev1.ConfigMap{
711+
ObjectMeta: metav1.ObjectMeta{Name: configMapName},
712+
Data: map[string]string{"key": "value"},
713+
}, metav1.CreateOptions{})
714+
toolResult, _ := s.CallTool("resources_scale", map[string]interface{}{
715+
"apiVersion": "v1",
716+
"kind": "ConfigMap",
717+
"namespace": "default",
718+
"name": configMapName,
719+
})
720+
s.Truef(toolResult.IsError, "call tool should fail")
721+
s.Containsf(toolResult.Content[0].(mcp.TextContent).Text, "the server could not find the requested resource",
722+
"expected scale subresource not found error, got %v", toolResult.Content[0].(mcp.TextContent).Text)
723+
})
724+
}
725+
608726
func TestResources(t *testing.T) {
609727
suite.Run(t, new(ResourcesSuite))
610728
}

pkg/mcp/testdata/toolsets-core-tools.json

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,5 +483,45 @@
483483
]
484484
},
485485
"name": "resources_list"
486+
},
487+
{
488+
"annotations": {
489+
"title": "Resources: Scale",
490+
"destructiveHint": true,
491+
"idempotentHint": true,
492+
"openWorldHint": true
493+
},
494+
"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",
495+
"inputSchema": {
496+
"type": "object",
497+
"properties": {
498+
"apiVersion": {
499+
"description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
500+
"type": "string"
501+
},
502+
"kind": {
503+
"description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
504+
"type": "string"
505+
},
506+
"name": {
507+
"description": "Name of the resource",
508+
"type": "string"
509+
},
510+
"namespace": {
511+
"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",
512+
"type": "string"
513+
},
514+
"scale": {
515+
"description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
516+
"type": "integer"
517+
}
518+
},
519+
"required": [
520+
"apiVersion",
521+
"kind",
522+
"name"
523+
]
524+
},
525+
"name": "resources_scale"
486526
}
487527
]

pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,5 +760,53 @@
760760
]
761761
},
762762
"name": "resources_list"
763+
},
764+
{
765+
"annotations": {
766+
"title": "Resources: Scale",
767+
"destructiveHint": true,
768+
"idempotentHint": true,
769+
"openWorldHint": true
770+
},
771+
"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",
772+
"inputSchema": {
773+
"type": "object",
774+
"properties": {
775+
"apiVersion": {
776+
"description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
777+
"type": "string"
778+
},
779+
"context": {
780+
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
781+
"enum": [
782+
"extra-cluster",
783+
"fake-context"
784+
],
785+
"type": "string"
786+
},
787+
"kind": {
788+
"description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
789+
"type": "string"
790+
},
791+
"name": {
792+
"description": "Name of the resource",
793+
"type": "string"
794+
},
795+
"namespace": {
796+
"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",
797+
"type": "string"
798+
},
799+
"scale": {
800+
"description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
801+
"type": "integer"
802+
}
803+
},
804+
"required": [
805+
"apiVersion",
806+
"kind",
807+
"name"
808+
]
809+
},
810+
"name": "resources_scale"
763811
}
764812
]

pkg/mcp/testdata/toolsets-full-tools-multicluster.json

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,5 +680,49 @@
680680
]
681681
},
682682
"name": "resources_list"
683+
},
684+
{
685+
"annotations": {
686+
"title": "Resources: Scale",
687+
"destructiveHint": true,
688+
"idempotentHint": true,
689+
"openWorldHint": true
690+
},
691+
"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",
692+
"inputSchema": {
693+
"type": "object",
694+
"properties": {
695+
"apiVersion": {
696+
"description": "apiVersion of the resource (examples of valid apiVersion are apps/v1)",
697+
"type": "string"
698+
},
699+
"context": {
700+
"description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set",
701+
"type": "string"
702+
},
703+
"kind": {
704+
"description": "kind of the resource (examples of valid kind are: StatefulSet, Deployment)",
705+
"type": "string"
706+
},
707+
"name": {
708+
"description": "Name of the resource",
709+
"type": "string"
710+
},
711+
"namespace": {
712+
"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",
713+
"type": "string"
714+
},
715+
"scale": {
716+
"description": "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it",
717+
"type": "integer"
718+
}
719+
},
720+
"required": [
721+
"apiVersion",
722+
"kind",
723+
"name"
724+
]
725+
},
726+
"name": "resources_scale"
683727
}
684728
]

0 commit comments

Comments
 (0)