Skip to content
Merged
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
6 changes: 6 additions & 0 deletions api/v1alpha1/mcpserver_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ type MCPServerSpec struct {
// +optional
ConfigMapVolumeName string `json:"configMapVolumeName,omitempty"`

// Replicas is the number of MCP server pod replicas to run.
// Defaults to 1 if not specified.
// +optional
// +kubebuilder:validation:Minimum=1
Replicas *int32 `json:"replicas,omitempty"`

// ServiceAccountName is the name of the ServiceAccount to use for the MCP server pods.
// The ServiceAccount should have appropriate RBAC permissions for the MCP server's operations.
// If not specified, the default ServiceAccount for the namespace will be used.
Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions config/crd/bases/mcp.x-k8s.io_mcpservers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,13 @@ spec:
maximum: 65535
minimum: 1
type: integer
replicas:
description: |-
Replicas is the number of MCP server pod replicas to run.
Defaults to 1 if not specified.
format: int32
minimum: 1
type: integer
secretKey:
description: |-
SecretKey specifies the key of the Secret to mount.
Expand Down
7 changes: 6 additions & 1 deletion internal/controller/mcpserver_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,11 @@ func (r *MCPServerReconciler) reconcileDeployment(
!equality.Semantic.DeepEqual(oldPodSpec.SecurityContext, newPodSpec.SecurityContext) ||
!equality.Semantic.DeepEqual(oldPodSpec.Volumes, newPodSpec.Volumes) ||
!equality.Semantic.DeepEqual(oldPodSpec.Containers[0].VolumeMounts, newPodSpec.Containers[0].VolumeMounts) ||
oldPodSpec.ServiceAccountName != newPodSpec.ServiceAccountName
oldPodSpec.ServiceAccountName != newPodSpec.ServiceAccountName ||
!equality.Semantic.DeepEqual(existingDeployment.Spec.Replicas, deployment.Spec.Replicas)
if needsUpdate {
logger.Info("Updating Deployment", "name", existingDeployment.Name)
existingDeployment.Spec.Replicas = deployment.Spec.Replicas
existingDeployment.Spec.Template.Spec = deployment.Spec.Template.Spec
if err := r.Update(ctx, existingDeployment); err != nil {
logger.Error(err, "Failed to update Deployment")
Expand All @@ -262,6 +264,9 @@ func (r *MCPServerReconciler) reconcileDeployment(
// createDeployment creates a Deployment for the MCPServer
func (r *MCPServerReconciler) createDeployment(ctx context.Context, mcpServer *mcpv1alpha1.MCPServer) (*appsv1.Deployment, error) {
replicas := int32(1)
if mcpServer.Spec.Replicas != nil {
replicas = *mcpServer.Spec.Replicas
}
labels := map[string]string{
"app": "mcp-server",
"mcp-server": mcpServer.Name,
Expand Down
189 changes: 189 additions & 0 deletions internal/controller/mcpserver_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

Expand Down Expand Up @@ -539,6 +540,194 @@ var _ = Describe("MCPServer Controller", func() {
})
})

Context("When reconciling a resource with replicas", func() {
const resourceName = "test-resource-replicas"

ctx := context.Background()

typeNamespacedName := types.NamespacedName{
Name: resourceName,
Namespace: "default",
}

AfterEach(func() {
resource := &mcpv1alpha1.MCPServer{}
err := k8sClient.Get(ctx, typeNamespacedName, resource)
if err == nil {
Expect(k8sClient.Delete(ctx, resource)).To(Succeed())
}
})

It("should set replicas on deployment when specified", func() {
resource := &mcpv1alpha1.MCPServer{
ObjectMeta: metav1.ObjectMeta{
Name: resourceName,
Namespace: "default",
},
Spec: mcpv1alpha1.MCPServerSpec{
Image: "test-image:latest",
Port: 8080,
Replicas: ptr.To(int32(3)),
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())

controllerReconciler := &MCPServerReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
}
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

deployment := &appsv1.Deployment{}
err = k8sClient.Get(ctx, client.ObjectKey{
Name: resourceName,
Namespace: "default",
}, deployment)
Expect(err).NotTo(HaveOccurred())
Expect(*deployment.Spec.Replicas).To(Equal(int32(3)))
})

It("should default to 1 replica when not specified", func() {
resource := &mcpv1alpha1.MCPServer{
ObjectMeta: metav1.ObjectMeta{
Name: resourceName,
Namespace: "default",
},
Spec: mcpv1alpha1.MCPServerSpec{
Image: "test-image:latest",
Port: 8080,
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())

controllerReconciler := &MCPServerReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
}
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

deployment := &appsv1.Deployment{}
err = k8sClient.Get(ctx, client.ObjectKey{
Name: resourceName,
Namespace: "default",
}, deployment)
Expect(err).NotTo(HaveOccurred())
Expect(*deployment.Spec.Replicas).To(Equal(int32(1)))
})

It("should update deployment when replicas changes", func() {
resource := &mcpv1alpha1.MCPServer{
ObjectMeta: metav1.ObjectMeta{
Name: resourceName,
Namespace: "default",
},
Spec: mcpv1alpha1.MCPServerSpec{
Image: "test-image:latest",
Port: 8080,
Replicas: ptr.To(int32(2)),
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())

controllerReconciler := &MCPServerReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
}

By("Reconciling to create the initial deployment with 2 replicas")
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

deployment := &appsv1.Deployment{}
err = k8sClient.Get(ctx, client.ObjectKey{
Name: resourceName,
Namespace: "default",
}, deployment)
Expect(err).NotTo(HaveOccurred())
Expect(*deployment.Spec.Replicas).To(Equal(int32(2)))

By("Updating replicas to 5")
mcpServer := &mcpv1alpha1.MCPServer{}
Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed())
mcpServer.Spec.Replicas = ptr.To(int32(5))
Expect(k8sClient.Update(ctx, mcpServer)).To(Succeed())

By("Reconciling again to pick up the change")
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

err = k8sClient.Get(ctx, client.ObjectKey{
Name: resourceName,
Namespace: "default",
}, deployment)
Expect(err).NotTo(HaveOccurred())
Expect(*deployment.Spec.Replicas).To(Equal(int32(5)))
})

It("should update deployment when replicas is removed", func() {
resource := &mcpv1alpha1.MCPServer{
ObjectMeta: metav1.ObjectMeta{
Name: resourceName,
Namespace: "default",
},
Spec: mcpv1alpha1.MCPServerSpec{
Image: "test-image:latest",
Port: 8080,
Replicas: ptr.To(int32(3)),
},
}
Expect(k8sClient.Create(ctx, resource)).To(Succeed())

controllerReconciler := &MCPServerReconciler{
Client: k8sClient,
Scheme: k8sClient.Scheme(),
}

By("Reconciling to create the initial deployment with 3 replicas")
_, err := controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

deployment := &appsv1.Deployment{}
err = k8sClient.Get(ctx, client.ObjectKey{
Name: resourceName,
Namespace: "default",
}, deployment)
Expect(err).NotTo(HaveOccurred())
Expect(*deployment.Spec.Replicas).To(Equal(int32(3)))

By("Removing replicas from the MCPServer")
mcpServer := &mcpv1alpha1.MCPServer{}
Expect(k8sClient.Get(ctx, typeNamespacedName, mcpServer)).To(Succeed())
mcpServer.Spec.Replicas = nil
Expect(k8sClient.Update(ctx, mcpServer)).To(Succeed())

By("Reconciling again to pick up the removal")
_, err = controllerReconciler.Reconcile(ctx, reconcile.Request{
NamespacedName: typeNamespacedName,
})
Expect(err).NotTo(HaveOccurred())

err = k8sClient.Get(ctx, client.ObjectKey{
Name: resourceName,
Namespace: "default",
}, deployment)
Expect(err).NotTo(HaveOccurred())
Expect(*deployment.Spec.Replicas).To(Equal(int32(1)))
})
})

Context("When reconciling a resource with envFrom", func() {
const resourceName = "test-resource-envfrom"

Expand Down