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
5 changes: 3 additions & 2 deletions api/v1alpha1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,9 +347,10 @@ type MCPServerDeployment struct {
NodeSelector map[string]string `json:"nodeSelector,omitempty"`

// Replicas defines the number of desired pod replicas.
// Defaults to 1 if not specified.
// If not specified, the Deployment is created with 1 replica and its
// replica count is left untouched afterwards, so that it can be managed
// by a HorizontalPodAutoscaler.
// +optional
// +kubebuilder:default=1
Replicas *int32 `json:"replicas,omitempty"`

// ImagePullSecrets defines the list of secrets to use for pulling container images.
Expand Down
5 changes: 3 additions & 2 deletions config/crd/bases/kagent.dev_mcpservers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1590,10 +1590,11 @@ spec:
listen.
type: integer
replicas:
default: 1
description: |-
Replicas defines the number of desired pod replicas.
Defaults to 1 if not specified.
If not specified, the Deployment is created with 1 replica and its
replica count is left untouched afterwards, so that it can be managed
by a HorizontalPodAutoscaler.
format: int32
type: integer
resources:
Expand Down
5 changes: 3 additions & 2 deletions helm/kmcp-crds/templates/mcpserver-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1590,10 +1590,11 @@ spec:
listen.
type: integer
replicas:
default: 1
description: |-
Replicas defines the number of desired pod replicas.
Defaults to 1 if not specified.
If not specified, the Deployment is created with 1 replica and its
replica count is left untouched afterwards, so that it can be managed
by a HorizontalPodAutoscaler.
format: int32
type: integer
resources:
Expand Down
16 changes: 16 additions & 0 deletions pkg/controller/mcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,9 +365,25 @@ func upsertOutput(ctx context.Context, kube client.Client, output client.Object)
} else {
// If found, update it
output.SetResourceVersion(existing.GetResourceVersion())
preserveReplicas(existing, output)
if err := kube.Update(ctx, output); err != nil {
return err
}
}
return nil
}

// preserveReplicas keeps the live replica count of a Deployment whose desired
// spec leaves replicas unset, so that a HorizontalPodAutoscaler scaling it is
// not reverted on every reconcile.
func preserveReplicas(existing, desired client.Object) {
existingDeployment, ok := existing.(*appsv1.Deployment)
if !ok {
return
}
desiredDeployment, ok := desired.(*appsv1.Deployment)
if !ok || desiredDeployment.Spec.Replicas != nil {
return
}
desiredDeployment.Spec.Replicas = existingDeployment.Spec.Replicas
}
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,8 @@ func (t *transportAdapterTranslator) translateTransportAdapterDeployment(
}
}

// Determine replicas (default to 1)
// Leave replicas unset when not specified: a new Deployment defaults to 1,
// and upsertOutput keeps the live count on updates.
var replicas *int32
if server.Spec.Deployment.Replicas != nil {
replicas = server.Spec.Deployment.Replicas
Expand Down
67 changes: 67 additions & 0 deletions pkg/controller/upsert_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
Copyright 2025.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package controller

import (
"context"
"testing"

appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
)

func TestUpsertOutputReplicas(t *testing.T) {
tests := []struct {
name string
desired *int32
want int32
}{
{name: "unset keeps the live count", desired: nil, want: 3},
{name: "explicit value overrides the live count", desired: int32Ptr(2), want: 2},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
meta := metav1.ObjectMeta{Name: "mcp", Namespace: "default"}
kube := fake.NewClientBuilder().WithObjects(&appsv1.Deployment{
ObjectMeta: meta,
Spec: appsv1.DeploymentSpec{Replicas: int32Ptr(3)},
}).Build()

desired := &appsv1.Deployment{
ObjectMeta: meta,
Spec: appsv1.DeploymentSpec{Replicas: tt.desired},
}
if err := upsertOutput(ctx, kube, desired); err != nil {
t.Fatalf("upsertOutput: %v", err)
}

got := &appsv1.Deployment{}
if err := kube.Get(ctx, client.ObjectKeyFromObject(desired), got); err != nil {
t.Fatalf("get deployment: %v", err)
}
if got.Spec.Replicas == nil || *got.Spec.Replicas != tt.want {
t.Fatalf("replicas = %v, want %d", got.Spec.Replicas, tt.want)
}
})
}
}

func int32Ptr(i int32) *int32 { return &i }