Skip to content

Commit 87a18e1

Browse files
feat(core): add resources_scale tool (#512)
* feat(core): add resources_scale tool Signed-off-by: Calum Murray <cmurray@redhat.com> * fix: use default namespace if not set Signed-off-by: Calum Murray <cmurray@redhat.com> * Apply suggestions from code review Co-authored-by: Nader Ziada <nziada@redhat.com> Signed-off-by: Calum Murray <cmurray@redhat.com> * refactor: move scaling logic to pkg/kubernetes Signed-off-by: Calum Murray <cmurray@redhat.com> --------- Signed-off-by: Calum Murray <cmurray@redhat.com> Co-authored-by: Nader Ziada <nziada@redhat.com>
1 parent f4e6a94 commit 87a18e1

File tree

9 files changed

+481
-0
lines changed

9 files changed

+481
-0
lines changed

README.md

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

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

328335
<details>

pkg/kubernetes/resources.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88

99
"k8s.io/apimachinery/pkg/runtime"
10+
"k8s.io/client-go/dynamic"
1011

1112
"github.com/containers/kubernetes-mcp-server/pkg/version"
1213
authv1 "k8s.io/api/authorization/v1"
@@ -86,6 +87,50 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
8687
return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
8788
}
8889

90+
func (k *Kubernetes) ResourcesScale(
91+
ctx context.Context,
92+
gvk *schema.GroupVersionKind,
93+
namespace, name string,
94+
desiredScale int64,
95+
shouldScale bool,
96+
) (*unstructured.Unstructured, error) {
97+
gvr, err := k.resourceFor(gvk)
98+
if err != nil {
99+
return nil, err
100+
}
101+
102+
var resourceClient dynamic.ResourceInterface
103+
104+
if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
105+
resourceClient = k.
106+
AccessControlClientset().
107+
DynamicClient().
108+
Resource(*gvr).
109+
Namespace(k.NamespaceOrDefault(namespace))
110+
} else {
111+
resourceClient = k.
112+
AccessControlClientset().DynamicClient().Resource(*gvr)
113+
}
114+
115+
scale, err := resourceClient.Get(ctx, name, metav1.GetOptions{}, "scale")
116+
if err != nil {
117+
return nil, err
118+
}
119+
120+
if shouldScale {
121+
if err := unstructured.SetNestedField(scale.Object, desiredScale, "spec", "replicas"); err != nil {
122+
return scale, fmt.Errorf("failed to set .spec.replicas on scale object %v: %w", scale, err)
123+
}
124+
125+
scale, err = resourceClient.Update(ctx, scale, metav1.UpdateOptions{}, "scale")
126+
if err != nil {
127+
return scale, fmt.Errorf("failed to update scale: %w", err)
128+
}
129+
}
130+
131+
return scale, nil
132+
}
133+
89134
// resourcesListAsTable retrieves a list of resources in a table format.
90135
// It's almost identical to the dynamic.DynamicClient implementation, but it uses a specific Accept header to request the table format.
91136
// dynamic.DynamicClient does not provide a way to set the HTTP header (TODO: create an issue to request this feature)

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 to 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 to 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)