## What this does The publisher CLI now derives the OIDC audience to request from the `--registry` URL instead of using a fixed constant, and the server validates the token's `aud` claim against a new per-deployment env var (`MCP_REGISTRY_GITHUB_OIDC_AUDIENCE`). Production sets this env var to `https://registry.modelcontextprotocol.io` (and per-environment URL elsewhere), so the deployment only accepts tokens that were requested for it. ## Compatibility This is a breaking change for older `mcp-publisher` binaries that still request the legacy hardcoded audience. After this rolls to a deployment, users on old `mcp-publisher` will see a clear `invalid audience: expected …, got […]` error and need to upgrade. Users following the documented `releases/latest` install pattern get the new binary automatically. ## Test plan - [ ] CI green - [ ] Run a synthetic publish from a real GitHub Actions workflow against staging after deploy - [ ] Confirm an older `mcp-publisher` against the same staging deploy returns the expected `invalid audience` error 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
381 lines
14 KiB
Go
381 lines
14 KiB
Go
package k8s
|
||
|
||
import (
|
||
"os/exec"
|
||
"strings"
|
||
|
||
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apiextensions"
|
||
v1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apps/v1"
|
||
corev1 "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"
|
||
networkingv1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/networking/v1"
|
||
policyv1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/policy/v1"
|
||
"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"
|
||
)
|
||
|
||
// getGitCommitHash returns the current git commit hash
|
||
func getGitCommitHash() string {
|
||
cmd := exec.Command("git", "rev-parse", "HEAD")
|
||
output, err := cmd.Output()
|
||
if err != nil {
|
||
// Fallback to a default value if git command fails
|
||
return "unknown"
|
||
}
|
||
return strings.TrimSpace(string(output))
|
||
}
|
||
|
||
// DeployMCPRegistry deploys the MCP Registry to the Kubernetes cluster
|
||
func DeployMCPRegistry(ctx *pulumi.Context, cluster *providers.ProviderInfo, environment string, ingressNginx *helm.Chart, pgCluster *apiextensions.CustomResource) (*corev1.Service, error) {
|
||
conf := config.New(ctx, "mcp-registry")
|
||
githubClientId := conf.Require("githubClientId")
|
||
|
||
// Determine Docker image tag based on environment
|
||
imageTag := "main" // Default for staging
|
||
if environment == "prod" {
|
||
// Use explicitly configured image tag for production
|
||
// This prevents automatic promotion and requires manual control of production releases
|
||
imageTag = conf.Require("imageTag") // Explicit configuration required - no fallback
|
||
}
|
||
|
||
// Create Secret with sensitive configuration
|
||
secret, err := corev1.NewSecret(ctx, "mcp-registry-secrets", &corev1.SecretArgs{
|
||
Metadata: &metav1.ObjectMetaArgs{
|
||
Name: pulumi.String("mcp-registry-secrets"),
|
||
Namespace: pulumi.String("default"),
|
||
Labels: pulumi.StringMap{
|
||
"app": pulumi.String("mcp-registry"),
|
||
"environment": pulumi.String(environment),
|
||
},
|
||
},
|
||
StringData: pulumi.StringMap{
|
||
"GITHUB_CLIENT_SECRET": conf.RequireSecret("githubClientSecret"),
|
||
"JWT_PRIVATE_KEY": conf.RequireSecret("jwtPrivateKey"),
|
||
},
|
||
Type: pulumi.String("Opaque"),
|
||
}, pulumi.Provider(cluster.Provider))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Create Deployment
|
||
_, err = v1.NewDeployment(ctx, "mcp-registry", &v1.DeploymentArgs{
|
||
Metadata: &metav1.ObjectMetaArgs{
|
||
Name: pulumi.String("mcp-registry"),
|
||
Namespace: pulumi.String("default"),
|
||
Labels: pulumi.StringMap{
|
||
"app": pulumi.String("mcp-registry"),
|
||
"environment": pulumi.String(environment),
|
||
},
|
||
},
|
||
Spec: &v1.DeploymentSpecArgs{
|
||
Replicas: pulumi.Int(2),
|
||
Strategy: &v1.DeploymentStrategyArgs{
|
||
Type: pulumi.String("RollingUpdate"),
|
||
RollingUpdate: &v1.RollingUpdateDeploymentArgs{
|
||
MaxUnavailable: pulumi.IntPtr(0), // Never reduce capacity during updates
|
||
MaxSurge: pulumi.IntPtr(1), // Create new pods first, then terminate old
|
||
},
|
||
},
|
||
Selector: &metav1.LabelSelectorArgs{
|
||
MatchLabels: pulumi.StringMap{
|
||
"app": pulumi.String("mcp-registry"),
|
||
},
|
||
},
|
||
Template: &corev1.PodTemplateSpecArgs{
|
||
Metadata: &metav1.ObjectMetaArgs{
|
||
Labels: pulumi.StringMap{
|
||
"app": pulumi.String("mcp-registry"),
|
||
},
|
||
Annotations: pulumi.StringMap{
|
||
// Use git commit hash to trigger pod restarts when deploying new infra versions
|
||
"registry.modelcontextprotocol.io/deployCommit": pulumi.String(getGitCommitHash()),
|
||
},
|
||
},
|
||
Spec: &corev1.PodSpecArgs{
|
||
// Soft-spread pods across nodes so the PodDisruptionBudget below can't
|
||
// deadlock a node drain when both pods happen to co-locate.
|
||
// `ScheduleAnyway` keeps single-node clusters (e.g. constrained dev
|
||
// envs) schedulable while still preferring spread under normal load.
|
||
TopologySpreadConstraints: corev1.TopologySpreadConstraintArray{
|
||
&corev1.TopologySpreadConstraintArgs{
|
||
MaxSkew: pulumi.Int(1),
|
||
TopologyKey: pulumi.String("kubernetes.io/hostname"),
|
||
WhenUnsatisfiable: pulumi.String("ScheduleAnyway"),
|
||
LabelSelector: &metav1.LabelSelectorArgs{
|
||
MatchLabels: pulumi.StringMap{
|
||
"app": pulumi.String("mcp-registry"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
Containers: corev1.ContainerArray{
|
||
&corev1.ContainerArgs{
|
||
Name: pulumi.String("mcp-registry"),
|
||
Image: pulumi.Sprintf("ghcr.io/modelcontextprotocol/registry:%s", imageTag),
|
||
ImagePullPolicy: pulumi.String("Always"),
|
||
Ports: corev1.ContainerPortArray{
|
||
&corev1.ContainerPortArgs{
|
||
ContainerPort: pulumi.Int(8080),
|
||
Name: pulumi.String("http"),
|
||
},
|
||
},
|
||
Env: corev1.EnvVarArray{
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_DATABASE_URL"),
|
||
ValueFrom: &corev1.EnvVarSourceArgs{
|
||
SecretKeyRef: &corev1.SecretKeySelectorArgs{
|
||
Name: pgCluster.Metadata.Name().ApplyT(func(name *string) string {
|
||
if name == nil {
|
||
return "registry-pg-app"
|
||
}
|
||
return *name + "-app"
|
||
}).(pulumi.StringOutput),
|
||
Key: pulumi.String("uri"),
|
||
},
|
||
},
|
||
},
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_GITHUB_CLIENT_ID"),
|
||
Value: pulumi.String(githubClientId),
|
||
},
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_GITHUB_CLIENT_SECRET"),
|
||
ValueFrom: &corev1.EnvVarSourceArgs{
|
||
SecretKeyRef: &corev1.SecretKeySelectorArgs{
|
||
Name: secret.Metadata.Name(),
|
||
Key: pulumi.String("GITHUB_CLIENT_SECRET"),
|
||
},
|
||
},
|
||
},
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_JWT_PRIVATE_KEY"),
|
||
ValueFrom: &corev1.EnvVarSourceArgs{
|
||
SecretKeyRef: &corev1.SecretKeySelectorArgs{
|
||
Name: secret.Metadata.Name(),
|
||
Key: pulumi.String("JWT_PRIVATE_KEY"),
|
||
},
|
||
},
|
||
},
|
||
// Google Cloud Identity OIDC for admin access
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_OIDC_ENABLED"),
|
||
Value: pulumi.String("true"),
|
||
},
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_OIDC_ISSUER"),
|
||
Value: pulumi.String("https://accounts.google.com"),
|
||
},
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_OIDC_CLIENT_ID"),
|
||
Value: pulumi.String("32555940559.apps.googleusercontent.com"),
|
||
},
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_OIDC_EXTRA_CLAIMS"),
|
||
Value: pulumi.String(`[{"hd":"modelcontextprotocol.io"}]`),
|
||
},
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_OIDC_EDIT_PERMISSIONS"),
|
||
Value: pulumi.String("*"),
|
||
},
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_OIDC_PUBLISH_PERMISSIONS"),
|
||
Value: pulumi.String("*"),
|
||
},
|
||
&corev1.EnvVarArgs{
|
||
Name: pulumi.String("MCP_REGISTRY_GITHUB_OIDC_AUDIENCE"),
|
||
Value: pulumi.String(func() string {
|
||
if environment == "prod" {
|
||
return "https://registry.modelcontextprotocol.io"
|
||
}
|
||
return "https://" + environment + ".registry.modelcontextprotocol.io"
|
||
}()),
|
||
},
|
||
},
|
||
// StartupProbe protects the DB-retry budget in cmd/registry/main.go:
|
||
// 30 × 5s = 150s allowed before liveness/readiness take over, which
|
||
// covers the worst-case retry budget (8 attempts × 10s + ~40s sleep).
|
||
StartupProbe: &corev1.ProbeArgs{
|
||
HttpGet: &corev1.HTTPGetActionArgs{
|
||
Path: pulumi.String("/v0/health"),
|
||
Port: pulumi.Int(8080),
|
||
},
|
||
PeriodSeconds: pulumi.Int(5),
|
||
FailureThreshold: pulumi.Int(30),
|
||
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),
|
||
},
|
||
Resources: &corev1.ResourceRequirementsArgs{
|
||
// Memory was 256Mi/512Mi when pgxpool MaxConns was 30. After
|
||
// #1221 doubled MaxConns to 60 per pod, peak per-pod memory
|
||
// shifted from ~330MB to ~450MB under scraper load and one
|
||
// pod was OOMKilled at 17:01 UTC on 2026-04-29. pgxpool keeps
|
||
// per-connection state (prepared statement cache, scratch
|
||
// buffers, row iteration state) so memory scales roughly
|
||
// linearly with the connection count. Doubled both request
|
||
// and limit to match.
|
||
Requests: pulumi.StringMap{
|
||
"memory": pulumi.String("512Mi"),
|
||
"cpu": pulumi.String("100m"),
|
||
},
|
||
Limits: pulumi.StringMap{
|
||
"memory": pulumi.String("1Gi"),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}, pulumi.Provider(cluster.Provider))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Keep at least one registry pod available during voluntary disruptions
|
||
// (node drains, evictions, etc.) so a synchronized eviction can't take both
|
||
// pods down at once.
|
||
_, err = policyv1.NewPodDisruptionBudget(ctx, "mcp-registry", &policyv1.PodDisruptionBudgetArgs{
|
||
Metadata: &metav1.ObjectMetaArgs{
|
||
Name: pulumi.String("mcp-registry"),
|
||
Namespace: pulumi.String("default"),
|
||
Labels: pulumi.StringMap{
|
||
"app": pulumi.String("mcp-registry"),
|
||
"environment": pulumi.String(environment),
|
||
},
|
||
},
|
||
Spec: &policyv1.PodDisruptionBudgetSpecArgs{
|
||
MinAvailable: pulumi.Int(1),
|
||
Selector: &metav1.LabelSelectorArgs{
|
||
MatchLabels: pulumi.StringMap{
|
||
"app": pulumi.String("mcp-registry"),
|
||
},
|
||
},
|
||
},
|
||
}, pulumi.Provider(cluster.Provider))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Create Service
|
||
service, err := corev1.NewService(ctx, "mcp-registry", &corev1.ServiceArgs{
|
||
Metadata: &metav1.ObjectMetaArgs{
|
||
Name: pulumi.String("mcp-registry"),
|
||
Namespace: pulumi.String("default"),
|
||
Labels: pulumi.StringMap{
|
||
"app": pulumi.String("mcp-registry"),
|
||
"environment": pulumi.String(environment),
|
||
},
|
||
},
|
||
Spec: &corev1.ServiceSpecArgs{
|
||
Selector: pulumi.StringMap{
|
||
"app": pulumi.String("mcp-registry"),
|
||
},
|
||
Ports: corev1.ServicePortArray{
|
||
&corev1.ServicePortArgs{
|
||
Port: pulumi.Int(80),
|
||
TargetPort: pulumi.Int(8080),
|
||
Name: pulumi.String("http"),
|
||
},
|
||
},
|
||
Type: pulumi.String("ClusterIP"),
|
||
},
|
||
}, pulumi.Provider(cluster.Provider))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Create Ingress
|
||
hosts := []string{
|
||
environment + ".registry.modelcontextprotocol.io",
|
||
}
|
||
|
||
// Add root domain for prod environment
|
||
if environment == "prod" {
|
||
hosts = append(hosts, "registry.modelcontextprotocol.io")
|
||
}
|
||
|
||
ingress, err := networkingv1.NewIngress(ctx, "mcp-registry", &networkingv1.IngressArgs{
|
||
Metadata: &metav1.ObjectMetaArgs{
|
||
Name: pulumi.String("mcp-registry"),
|
||
Namespace: pulumi.String("default"),
|
||
Labels: pulumi.StringMap{
|
||
"app": pulumi.String("mcp-registry"),
|
||
"environment": pulumi.String(environment),
|
||
},
|
||
Annotations: pulumi.StringMap{
|
||
"cert-manager.io/cluster-issuer": pulumi.String("letsencrypt-prod"),
|
||
"kubernetes.io/ingress.class": pulumi.String("nginx"),
|
||
// Rate limiting to protect against abuse
|
||
// Allows 180 requests/minute (3 req/sec avg), with bursts up to 540 requests
|
||
// Status code 429 is set globally via NGINX ConfigMap (per-Ingress annotation doesn't work)
|
||
"nginx.ingress.kubernetes.io/limit-rpm": pulumi.String("180"),
|
||
"nginx.ingress.kubernetes.io/limit-burst-multiplier": pulumi.String("3"),
|
||
},
|
||
},
|
||
Spec: &networkingv1.IngressSpecArgs{
|
||
Tls: networkingv1.IngressTLSArray{
|
||
&networkingv1.IngressTLSArgs{
|
||
Hosts: pulumi.ToStringArray(hosts),
|
||
SecretName: pulumi.Sprintf("mcp-registry-%s-tls", environment),
|
||
},
|
||
},
|
||
Rules: pulumi.ToStringArray(hosts).ToStringArrayOutput().ApplyT(func(hosts []string) networkingv1.IngressRuleArray {
|
||
rules := make(networkingv1.IngressRuleArray, 0, len(hosts))
|
||
for _, host := range hosts {
|
||
rules = append(rules, &networkingv1.IngressRuleArgs{
|
||
Host: pulumi.String(host),
|
||
Http: &networkingv1.HTTPIngressRuleValueArgs{
|
||
Paths: networkingv1.HTTPIngressPathArray{
|
||
&networkingv1.HTTPIngressPathArgs{
|
||
Path: pulumi.String("/"),
|
||
PathType: pulumi.String("Prefix"),
|
||
Backend: &networkingv1.IngressBackendArgs{
|
||
Service: &networkingv1.IngressServiceBackendArgs{
|
||
Name: service.Metadata.Name().Elem(),
|
||
Port: &networkingv1.ServiceBackendPortArgs{
|
||
Number: pulumi.Int(80),
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
}
|
||
return rules
|
||
}).(networkingv1.IngressRuleArrayOutput),
|
||
},
|
||
}, pulumi.Provider(cluster.Provider), pulumi.DependsOnInputs(ingressNginx.Ready))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
ctx.Export("ingressHosts", ingress.Spec.Rules().ApplyT(func(rules []networkingv1.IngressRule) []string {
|
||
hosts := make([]string, 0, len(rules))
|
||
for _, rule := range rules {
|
||
hosts = append(hosts, *rule.Host)
|
||
}
|
||
return hosts
|
||
}))
|
||
|
||
return service, nil
|
||
}
|