deploy: Deploy the registry to GCP (#255)

Adds the Pulumi code to:
- Deploy the registry (and associated services e.g. mongodb) to Google
Cloud Platform (GCP), on top of Google Kubernetes Engine (GKE)
- Sets up proper environments and secrets management
- Uses the real container image, now that it's published in #225. At the
moment attached to latest, we might want to pin the version later (or
perhaps always use `latest` in staging, and pin prod)
- Uses real domains (`staging.registry.modelcontextprotocol.io`) rather
than examples (``)

## Motivation and Context

Setting up infrastructure to deploy it. I set something up in Azure in
#227, although not super robust (e.g. no service accounts etc.). Think
we will use GCP as:
- the maintainers have experience with GCP, but none with Azure
- costs are quite low, and Anthropic is happy to cover them in the short
term
- means we only have to maintain one login system (just Google Cloud
Identity), not two (Google Workspace + Azure)

## How Has This Been Tested?

Deployed this to a staging and production cluster. Try it yourself at:

```bash
curl -H "Host: staging.registry.modelcontextprotocol.io" -k https://35.222.36.75/v0/ping
```

(will be sorting out domains very soon)

## Breaking Changes

NA - just adds support for GCP deployment

## Types of changes
<!-- What types of changes does your code introduce? Put an `x` in all
the boxes that apply: -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [ ] New and existing tests pass locally
- [x] I have added appropriate error handling
- [x] I have added or updated documentation as needed

## Additional context
<!-- Add any other context, implementation notes, or design decisions
-->

Expected follow-ups:
- GitHub Action setup to deploy things to the cluster from GitHub, to
avoid gatekeeping to just the people with the secrets.
This commit is contained in:
adam jones 2025-08-12 17:44:50 +01:00 • committed by GitHub
commit fe52766676
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 395 additions and 144 deletions

View file

@ -1,8 +1,10 @@
package k8s
import (
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/helm/v3"
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes"
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apiextensions"
v1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/helm/v3"
metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
@ -41,5 +43,36 @@ func SetupCertManager(ctx *pulumi.Context, cluster *providers.ProviderInfo) erro
return err
}
_, err = apiextensions.NewCustomResource(ctx, "letsencrypt-prod", &apiextensions.CustomResourceArgs{
ApiVersion: pulumi.String("cert-manager.io/v1"),
Kind: pulumi.String("ClusterIssuer"),
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("letsencrypt-prod"),
},
OtherFields: kubernetes.UntypedArgs{
"spec": pulumi.Map{
"acme": pulumi.Map{
"server": pulumi.String("https://acme-v02.api.letsencrypt.org/directory"),
"email": pulumi.String("admin@modelcontextprotocol.io"),
"privateKeySecretRef": pulumi.Map{
"name": pulumi.String("letsencrypt-prod-key"),
},
"solvers": pulumi.Array{
pulumi.Map{
"http01": pulumi.Map{
"ingress": pulumi.Map{
"ingressClassName": pulumi.String("nginx"),
},
},
},
},
},
},
},
}, pulumi.Provider(cluster.Provider))
if err != nil {
return err
}
return nil
}
}

View file

@ -34,7 +34,7 @@ func SetupIngressController(ctx *pulumi.Context, cluster *providers.ProviderInfo
ingressType = "NodePort"
}
nginxIngress, err := helm.NewChart(ctx, "nginx-ingress", helm.ChartArgs{
nginxIngress, err := helm.NewChart(ctx, "ingress-nginx", helm.ChartArgs{
Chart: pulumi.String("ingress-nginx"),
Version: pulumi.String("4.13.0"),
FetchArgs: helm.FetchArgs{
@ -45,8 +45,8 @@ func SetupIngressController(ctx *pulumi.Context, cluster *providers.ProviderInfo
"controller": pulumi.Map{
"service": pulumi.Map{
"type": pulumi.String(ingressType),
// Add Azure Load Balancer health probe annotation as otherwise it defaults to / which fails
"annotations": pulumi.Map{
// Add Azure Load Balancer health probe annotation as otherwise it defaults to / which fails
"service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path": pulumi.String("/healthz"),
},
},
@ -75,8 +75,8 @@ func SetupIngressController(ctx *pulumi.Context, cluster *providers.ProviderInfo
// Look up the service after the chart is ready
svc, err := v1.GetService(
ctx,
"nginx-ingress-controller-lookup",
pulumi.ID("ingress-nginx/nginx-ingress-ingress-nginx-controller"),
"ingress-nginx-controller-lookup",
pulumi.ID("ingress-nginx/ingress-nginx-controller"),
&v1.ServiceState{},
pulumi.Provider(cluster.Provider),
pulumi.DependsOn([]pulumi.Resource{nginxIngress}),

View file

@ -62,43 +62,25 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
Spec: &corev1.PodSpecArgs{
Containers: corev1.ContainerArray{
&corev1.ContainerArgs{
Name: pulumi.String("mcp-registry"),
// TODO: Replace with actual MCP registry image once on GHCR
Image: pulumi.String("nginx:alpine"),
Name: pulumi.String("mcp-registry"),
Image: pulumi.String("ghcr.io/modelcontextprotocol/registry:latest"),
Ports: corev1.ContainerPortArray{
&corev1.ContainerPortArgs{
// TODO: Change to port 8080 once using registry image
ContainerPort: pulumi.Int(80),
ContainerPort: pulumi.Int(8080),
Name: pulumi.String("http"),
},
},
Env: corev1.EnvVarArray{
&corev1.EnvVarArgs{
Name: pulumi.String("DATABASE_URL"),
Name: pulumi.String("MCP_REGISTRY_DATABASE_URL"),
Value: pulumi.String("mongodb://mongodb.default.svc.cluster.local:27017"),
},
&corev1.EnvVarArgs{
Name: pulumi.String("PORT"),
Value: pulumi.String("8080"),
},
&corev1.EnvVarArgs{
Name: pulumi.String("NODE_ENV"),
Value: pulumi.String("production"),
},
&corev1.EnvVarArgs{
Name: pulumi.String("LOG_LEVEL"),
Value: pulumi.String("info"),
},
&corev1.EnvVarArgs{
Name: pulumi.String("CORS_ORIGINS"),
Value: pulumi.String("*"),
},
&corev1.EnvVarArgs{
Name: pulumi.String("GITHUB_CLIENT_ID"),
Name: pulumi.String("MCP_REGISTRY_GITHUB_CLIENT_ID"),
Value: pulumi.String(githubClientId),
},
&corev1.EnvVarArgs{
Name: pulumi.String("GITHUB_CLIENT_SECRET"),
Name: pulumi.String("MCP_REGISTRY_GITHUB_CLIENT_SECRET"),
ValueFrom: &corev1.EnvVarSourceArgs{
SecretKeyRef: &corev1.SecretKeySelectorArgs{
Name: secret.Metadata.Name(),
@ -107,23 +89,22 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
},
},
},
// TODO: uncomment when using registry image
// LivenessProbe: &corev1.ProbeArgs{
// HttpGet: &corev1.HTTPGetActionArgs{
// Path: pulumi.String("/v0/health"),
// Port: pulumi.Int(8080),
// },
// InitialDelaySeconds: pulumi.Int(30),
// TimeoutSeconds: pulumi.Int(5),
// },
// ReadinessProbe: &corev1.ProbeArgs{
// HttpGet: &corev1.HTTPGetActionArgs{
// Path: pulumi.String("/v0/health"),
// Port: pulumi.Int(8080),
// },
// InitialDelaySeconds: pulumi.Int(5),
// TimeoutSeconds: pulumi.Int(3),
// },
LivenessProbe: &corev1.ProbeArgs{
HttpGet: &corev1.HTTPGetActionArgs{
Path: pulumi.String("/v0/health"),
Port: pulumi.Int(8080),
},
InitialDelaySeconds: pulumi.Int(30),
TimeoutSeconds: pulumi.Int(5),
},
ReadinessProbe: &corev1.ProbeArgs{
HttpGet: &corev1.HTTPGetActionArgs{
Path: pulumi.String("/v0/health"),
Port: pulumi.Int(8080),
},
InitialDelaySeconds: pulumi.Int(5),
TimeoutSeconds: pulumi.Int(3),
},
},
},
},
@ -150,9 +131,8 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
},
Ports: corev1.ServicePortArray{
&corev1.ServicePortArgs{
Port: pulumi.Int(80),
// TODO: Change to port 8080 once using registry image
TargetPort: pulumi.Int(80),
Port: pulumi.Int(80),
TargetPort: pulumi.Int(8080),
Name: pulumi.String("http"),
},
},
@ -164,6 +144,7 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
}
// Create Ingress
domain := "%s.registry.modelcontextprotocol.io"
ingress, err := networkingv1.NewIngress(ctx, "mcp-registry", &networkingv1.IngressArgs{
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("mcp-registry"),
@ -173,23 +154,21 @@ func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, env
"environment": pulumi.String(environment),
},
Annotations: pulumi.StringMap{
"kubernetes.io/ingress.class": pulumi.String("nginx"),
"cert-manager.io/cluster-issuer": pulumi.String("letsencrypt-prod"),
"nginx.ingress.kubernetes.io/rewrite-target": pulumi.String("/"),
"kubernetes.io/ingress.class": pulumi.String("nginx"),
},
},
Spec: &networkingv1.IngressSpecArgs{
Tls: networkingv1.IngressTLSArray{
&networkingv1.IngressTLSArgs{
Hosts: pulumi.StringArray{
pulumi.Sprintf("mcp-registry-%s.example.com", environment),
pulumi.Sprintf(domain, environment),
},
SecretName: pulumi.Sprintf("mcp-registry-%s-tls", environment),
},
},
Rules: networkingv1.IngressRuleArray{
&networkingv1.IngressRuleArgs{
Host: pulumi.Sprintf("mcp-registry-%s.example.com", environment),
Host: pulumi.Sprintf(domain, environment),
Http: &networkingv1.HTTPIngressRuleValueArgs{
Paths: networkingv1.HTTPIngressPathArray{
&networkingv1.HTTPIngressPathArgs{

View file

@ -0,0 +1,182 @@
package gcp
import (
"encoding/base64"
"fmt"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp"
"github.com/pulumi/pulumi-gcp/sdk/v8/go/gcp/container"
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers"
)
// Provider implements the ClusterProvider interface for Google Kubernetes Engine
type Provider struct{}
// CreateCluster creates a Google Kubernetes Engine cluster
func (p *Provider) CreateCluster(ctx *pulumi.Context, environment string) (*providers.ProviderInfo, error) {
// Get configuration
gcpConf := config.New(ctx, "gcp")
// Get project ID from config or use default
projectID := gcpConf.Get("project")
if projectID == "" {
return nil, fmt.Errorf("GCP project ID not configured. Set gcp:project")
}
// Get region from config or use default
region := gcpConf.Get("region")
if region == "" {
region = "us-central1"
}
// Get credentials from config (base64 encoded service account JSON)
credentials := gcpConf.Get("credentials")
if credentials != "" {
// Decode the base64 credentials
decodedCreds, err := base64.StdEncoding.DecodeString(credentials)
if err != nil {
return nil, fmt.Errorf("failed to decode GCP credentials: %w", err)
}
credentials = string(decodedCreds)
}
// Create a GCP provider with explicit credentials if provided
var gcpProvider *gcp.Provider
var err error
if credentials != "" {
gcpProvider, err = gcp.NewProvider(ctx, "gcp-explicit", &gcp.ProviderArgs{
Project: pulumi.String(projectID),
Region: pulumi.String(region),
Credentials: pulumi.String(credentials),
})
if err != nil {
return nil, fmt.Errorf("failed to create GCP provider: %w", err)
}
}
// Create GKE cluster
clusterName := fmt.Sprintf("mcp-registry-%s", environment)
// Use a specific zone instead of region for zonal cluster
zone := fmt.Sprintf("%s-b", region)
// Configure the GKE cluster
clusterArgs := &container.ClusterArgs{
Name: pulumi.String(clusterName),
Location: pulumi.String(zone),
Project: pulumi.String(projectID),
Description: pulumi.String(fmt.Sprintf("MCP Registry %s GKE Cluster", environment)),
// Initial node count (will be managed by node pool)
InitialNodeCount: pulumi.Int(1),
// Remove default node pool after cluster creation
RemoveDefaultNodePool: pulumi.Bool(true),
AddonsConfig: &container.ClusterAddonsConfigArgs{
// Disable as we use ingress-nginx
HttpLoadBalancing: &container.ClusterAddonsConfigHttpLoadBalancingArgs{
Disabled: pulumi.Bool(true),
},
},
}
// Add provider if we have explicit credentials
clusterOpts := []pulumi.ResourceOption{}
if gcpProvider != nil {
clusterOpts = append(clusterOpts, pulumi.Provider(gcpProvider))
}
cluster, err := container.NewCluster(ctx, clusterName, clusterArgs, clusterOpts...)
if err != nil {
return nil, fmt.Errorf("failed to create GKE cluster: %w", err)
}
// Create a managed node pool for the cluster
nodePoolName := fmt.Sprintf("%s-nodepool", clusterName)
nodePoolArgs := &container.NodePoolArgs{
Cluster: cluster.Name,
Location: pulumi.String(zone),
Project: pulumi.String(projectID),
// Node pool configuration
NodeCount: pulumi.Int(2),
NodeConfig: &container.NodePoolNodeConfigArgs{
MachineType: pulumi.String("e2-small"),
DiskSizeGb: pulumi.Int(20),
DiskType: pulumi.String("pd-standard"),
},
// Node management configuration
Management: &container.NodePoolManagementArgs{
AutoRepair: pulumi.Bool(true),
AutoUpgrade: pulumi.Bool(true),
},
}
// Add provider if we have explicit credentials
nodePoolOpts := []pulumi.ResourceOption{}
if gcpProvider != nil {
nodePoolOpts = append(nodePoolOpts, pulumi.Provider(gcpProvider))
}
nodePool, err := container.NewNodePool(ctx, nodePoolName, nodePoolArgs, nodePoolOpts...)
if err != nil {
return nil, fmt.Errorf("failed to create node pool: %w", err)
}
// Create Kubernetes provider using the cluster directly
k8sProvider, err := kubernetes.NewProvider(ctx, "k8s-provider", &kubernetes.ProviderArgs{
Kubeconfig: pulumi.All(cluster.Endpoint, cluster.MasterAuth).ApplyT(func(args []any) (string, error) {
endpoint := args[0].(string)
masterAuth := args[1].(container.ClusterMasterAuth)
context := fmt.Sprintf("%s_%s_%s", projectID, zone, clusterName)
// Extract CA certificate
caCert := ""
if masterAuth.ClusterCaCertificate != nil {
caCert = *masterAuth.ClusterCaCertificate
}
// Create kubeconfig using gke-gcloud-auth-plugin
kubeconfigYAML := fmt.Sprintf(`apiVersion: v1
clusters:
- cluster:
certificate-authority-data: %s
server: https://%s
name: %s
contexts:
- context:
cluster: %s
user: %s
name: %s
current-context: %s
kind: Config
preferences: {}
users:
- name: %s
user:
exec:
apiVersion: client.authentication.k8s.io/v1beta1
command: gke-gcloud-auth-plugin
installHint: Install gke-gcloud-auth-plugin for use with kubectl by following
https://cloud.google.com/blog/products/containers-kubernetes/kubectl-auth-changes-in-gke
provideClusterInfo: true
`, caCert, endpoint, context, context, context, context, context, context)
return kubeconfigYAML, nil
}).(pulumi.StringOutput),
}, pulumi.DependsOn([]pulumi.Resource{cluster, nodePool}))
if err != nil {
return nil, fmt.Errorf("failed to create Kubernetes provider: %w", err)
}
return &providers.ProviderInfo{
Name: nodePool.Cluster,
Provider: k8sProvider,
}, nil
}