diff --git a/README.md b/README.md index f4c4a6be..099d3a48 100644 --- a/README.md +++ b/README.md @@ -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 +
@@ -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.
diff --git a/pkg/kubernetes/resources.go b/pkg/kubernetes/resources.go index 4f25052e..55091f9c 100644 --- a/pkg/kubernetes/resources.go +++ b/pkg/kubernetes/resources.go @@ -7,6 +7,7 @@ import ( "strings" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/dynamic" "github.com/containers/kubernetes-mcp-server/pkg/version" authv1 "k8s.io/api/authorization/v1" @@ -86,6 +87,50 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi return k.AccessControlClientset().DynamicClient().Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}) } +func (k *Kubernetes) ResourcesScale( + ctx context.Context, + gvk *schema.GroupVersionKind, + namespace, name string, + desiredScale int64, + shouldScale bool, +) (*unstructured.Unstructured, error) { + gvr, err := k.resourceFor(gvk) + if err != nil { + return nil, err + } + + var resourceClient dynamic.ResourceInterface + + if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced { + resourceClient = k. + AccessControlClientset(). + DynamicClient(). + Resource(*gvr). + Namespace(k.NamespaceOrDefault(namespace)) + } else { + resourceClient = k. + AccessControlClientset().DynamicClient().Resource(*gvr) + } + + scale, err := resourceClient.Get(ctx, name, metav1.GetOptions{}, "scale") + if err != nil { + return nil, err + } + + if shouldScale { + if err := unstructured.SetNestedField(scale.Object, desiredScale, "spec", "replicas"); err != nil { + return scale, fmt.Errorf("failed to set .spec.replicas on scale object %v: %w", scale, err) + } + + scale, err = resourceClient.Update(ctx, scale, metav1.UpdateOptions{}, "scale") + if err != nil { + return scale, fmt.Errorf("failed to update scale: %w", err) + } + } + + return scale, nil +} + // resourcesListAsTable retrieves a list of resources in a table format. // It's almost identical to the dynamic.DynamicClient implementation, but it uses a specific Accept header to request the table format. // dynamic.DynamicClient does not provide a way to set the HTTP header (TODO: create an issue to request this feature) diff --git a/pkg/mcp/resources_test.go b/pkg/mcp/resources_test.go index 005a74b4..27846574 100644 --- a/pkg/mcp/resources_test.go +++ b/pkg/mcp/resources_test.go @@ -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" @@ -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" ) @@ -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)) } diff --git a/pkg/mcp/testdata/toolsets-core-tools.json b/pkg/mcp/testdata/toolsets-core-tools.json index b4c5667f..c351a239 100644 --- a/pkg/mcp/testdata/toolsets-core-tools.json +++ b/pkg/mcp/testdata/toolsets-core-tools.json @@ -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" } ] diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json index 7831c054..6b394f01 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster-enum.json @@ -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" } ] diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json index b95f179c..289c6f3d 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json @@ -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" } ] diff --git a/pkg/mcp/testdata/toolsets-full-tools-openshift.json b/pkg/mcp/testdata/toolsets-full-tools-openshift.json index e4488b0a..58661521 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-openshift.json +++ b/pkg/mcp/testdata/toolsets-full-tools-openshift.json @@ -597,5 +597,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" } ] diff --git a/pkg/mcp/testdata/toolsets-full-tools.json b/pkg/mcp/testdata/toolsets-full-tools.json index ca270027..9384218b 100644 --- a/pkg/mcp/testdata/toolsets-full-tools.json +++ b/pkg/mcp/testdata/toolsets-full-tools.json @@ -584,5 +584,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" } ] diff --git a/pkg/toolsets/core/resources.go b/pkg/toolsets/core/resources.go index 52a613b3..916b0bfc 100644 --- a/pkg/toolsets/core/resources.go +++ b/pkg/toolsets/core/resources.go @@ -138,6 +138,42 @@ func initResources(o internalk8s.Openshift) []api.ServerTool { OpenWorldHint: ptr.To(true), }, }, Handler: resourcesDelete}, + {Tool: api.Tool{ + Name: "resources_scale", + 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: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "apiVersion": { + Type: "string", + Description: "apiVersion of the resource (examples of valid apiVersion are apps/v1)", + }, + "kind": { + Type: "string", + Description: "kind of the resource (examples of valid kind are: StatefulSet, Deployment)", + }, + "namespace": { + Type: "string", + 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", + }, + "name": { + Type: "string", + Description: "Name of the resource", + }, + "scale": { + Type: "integer", + Description: "Optional scale to update the resources scale to. If not provided, will return the current scale of the resource, and not update it", + }, + }, + Required: []string{"apiVersion", "kind", "name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Resources: Scale", + DestructiveHint: ptr.To(true), + IdempotentHint: ptr.To(true), + OpenWorldHint: ptr.To(true), + }, + }, Handler: resourcesScale}, } } @@ -259,6 +295,69 @@ func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) return api.NewToolCallResult("Resource deleted successfully", err), nil } +func resourcesScale(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := params.GetArguments()["namespace"] + if namespace == nil { + namespace = "" + } + + gvk, err := parseGroupVersionKind(params.GetArguments()) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get/update resource scale, %w", err)), nil + } + + name := params.GetArguments()["name"] + if name == nil { + return api.NewToolCallResult("", errors.New("failed to get/update resource scale, missing argument name")), nil + } + + ns, ok := namespace.(string) + if !ok { + return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil + } + + ns = params.NamespaceOrDefault(ns) + + n, ok := name.(string) + if !ok { + return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil + } + + var desiredScale int64 + scaleVal, shouldScale := params.GetArguments()["scale"] + if shouldScale { + desiredScale, err = parseScaleValue(scaleVal) + if err != nil { + return api.NewToolCallResult("", err), nil + } + } + + scale, err := params.ResourcesScale(params.Context, gvk, ns, n, desiredScale, shouldScale) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get/update resource scale: %w", err)), nil + } + + marshalled, err := output.MarshalYaml(scale) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to marshall scale to yaml format: %v", scale)), nil + } + + return api.NewToolCallResult("# Current resource scale (YAML) is below\n"+marshalled, err), nil +} + +func parseScaleValue(desiredScale interface{}) (int64, error) { + switch s := desiredScale.(type) { + case float64: + return int64(s), nil + case int: + return int64(s), nil + case int64: + return s, nil + default: + return 0, fmt.Errorf("failed to parse scale parameter: expected integer, got %T", desiredScale) + } +} + func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) { apiVersion := arguments["apiVersion"] if apiVersion == nil {