```
├── .env.example (omitted)
├── .githooks/
├── pre-commit (200 tokens)
├── .github/
├── copilot-instructions.md (100 tokens)
├── dependabot.yml (100 tokens)
├── workflows/
├── cancel-pulumi-lock.yml (500 tokens)
├── ci.yml (300 tokens)
├── claude.yml (300 tokens)
├── deploy-production.yml (300 tokens)
├── deploy-staging.yml (600 tokens)
├── release.yml (500 tokens)
├── sync-db.yml (3.5k tokens)
├── sync-schema.yml (500 tokens)
├── .gitignore
├── .golangci.yml (300 tokens)
├── .goreleaser.yaml (600 tokens)
├── .ko.dockerignore (100 tokens)
├── .ko.yaml (200 tokens)
├── .vscode/
├── launch.json (200 tokens)
├── CHANGES.md
├── CLAUDE.md (100 tokens)
├── CONTRIBUTING.md (300 tokens)
├── LICENSE (omitted)
├── Makefile (900 tokens)
├── README.md (1500 tokens)
├── SECURITY.md (200 tokens)
├── cmd/
├── publisher/
├── README.md (300 tokens)
├── auth/
├── azurekeyvault/
├── common.go (500 tokens)
├── common.go (1400 tokens)
├── dns.go (100 tokens)
├── github-at.go (2000 tokens)
├── github-oidc.go (1100 tokens)
├── github-oidc_internal_test.go (300 tokens)
├── github_at_test.go (1300 tokens)
├── googlekms/
├── common.go (800 tokens)
├── http.go (100 tokens)
├── interface.go (100 tokens)
├── none.go (300 tokens)
├── commands/
├── init.go (2.4k tokens)
├── init_test.go (600 tokens)
├── login.go (1800 tokens)
├── login_test.go (600 tokens)
├── logout.go (300 tokens)
├── logout_test.go (900 tokens)
├── publish.go (900 tokens)
├── publish_test.go (2.3k tokens)
├── status.go (2.2k tokens)
├── status_test.go (1800 tokens)
├── testutil_test.go (600 tokens)
├── validate.go (1800 tokens)
├── validate_test.go (1000 tokens)
├── main.go (1500 tokens)
├── registry/
├── main.go (900 tokens)
├── complete.md (20.7k tokens)
├── data/
├── seed.json (1100 tokens)
├── deploy/
├── .gitignore
├── Makefile (400 tokens)
├── Pulumi.gcpProd.yaml (1000 tokens)
├── Pulumi.gcpStaging.yaml (1000 tokens)
├── Pulumi.local.yaml (100 tokens)
├── Pulumi.yaml
├── README.md (2.3k tokens)
├── go.mod (1200 tokens)
├── go.sum (6.7k tokens)
├── main.go (300 tokens)
├── pkg/
├── k8s/
├── backup.go (900 tokens)
├── cert_manager.go (500 tokens)
├── deploy.go (300 tokens)
├── ingress.go (800 tokens)
├── monitoring.go (4.3k tokens)
├── postgres.go (800 tokens)
├── registry.go (2.8k tokens)
├── providers/
├── gcp/
├── provider.go (3.1k tokens)
├── local/
├── provider.go (1800 tokens)
├── types.go (200 tokens)
├── docker-compose.yml (500 tokens)
├── docs/
├── README.md (200 tokens)
├── administration/
├── admin-operations.md (1200 tokens)
├── maintainer-onboarding.md (200 tokens)
├── community-projects.md (700 tokens)
├── contributing/
├── add-package-registry.md (1000 tokens)
├── releasing.md (600 tokens)
├── design/
├── design-principles.md (400 tokens)
├── dev-summit-2025-05-registry-goals-presentation.pdf
├── dev-summit-2025-10-registry-status-presentation.pdf
├── ecosystem-diagram.excalidraw.svg (566.8k tokens)
├── ecosystem-vision.md (600 tokens)
├── proposed-enhanced-validation.md (9k tokens)
├── roadmap.md (300 tokens)
├── tech-architecture.md (1100 tokens)
├── modelcontextprotocol-io/
├── about.mdx (1300 tokens)
├── authentication.mdx (2.4k tokens)
├── ecosystem-diagram.excalidraw.svg (566.8k tokens)
├── faq.mdx (600 tokens)
├── github-actions.mdx (1400 tokens)
├── moderation-policy.mdx (600 tokens)
├── package-types.mdx (2.1k tokens)
├── quickstart.mdx (2000 tokens)
├── registry-aggregators.mdx (1000 tokens)
├── remote-servers.mdx (1000 tokens)
├── terms-of-service.mdx (1500 tokens)
├── versioning.mdx (1200 tokens)
├── reference/
├── README.md (100 tokens)
├── api/
├── CHANGELOG.md (1000 tokens)
├── extensions.md (500 tokens)
├── generic-registry-api.md (600 tokens)
├── official-registry-api.md (1000 tokens)
├── openapi.yaml (8.7k tokens)
├── registry-authorization.md (300 tokens)
├── cli/
├── commands.md (2.2k tokens)
├── server-json/
├── CHANGELOG.md (1800 tokens)
├── CONTRIBUTING.md (300 tokens)
├── draft/
├── server.schema.json (4.5k tokens)
├── generic-server-json.md (4.9k tokens)
├── official-registry-requirements.md (1200 tokens)
├── go.mod (800 tokens)
├── go.sum (3.8k tokens)
├── internal/
├── api/
├── cors_test.go (1100 tokens)
├── handlers/
├── v0/
├── auth/
├── common.go (2.6k tokens)
├── common_test.go (200 tokens)
├── dns.go (1100 tokens)
├── dns_test.go (7.3k tokens)
├── github_at.go (1200 tokens)
├── github_at_test.go (3.7k tokens)
├── github_oidc.go (1800 tokens)
├── github_oidc_test.go (2k tokens)
├── http.go (1900 tokens)
├── http_internal_test.go (500 tokens)
├── http_test.go (8.8k tokens)
├── main.go (200 tokens)
├── none.go (600 tokens)
├── none_test.go (400 tokens)
├── oidc.go (1700 tokens)
├── oidc_internal_test.go (300 tokens)
├── oidc_test.go (600 tokens)
├── edit.go (900 tokens)
├── edit_test.go (3.8k tokens)
├── health.go (400 tokens)
├── health_test.go (400 tokens)
├── list_errors.go (200 tokens)
├── list_errors_test.go (300 tokens)
├── ping.go (200 tokens)
├── ping_test.go (200 tokens)
├── publish.go (800 tokens)
├── publish_integration_test.go (1800 tokens)
├── publish_registry_validation_test.go (1600 tokens)
├── publish_test.go (3.5k tokens)
├── response.go (100 tokens)
├── servers.go (1900 tokens)
├── servers_test.go (4.6k tokens)
├── status.go (2.5k tokens)
├── status_test.go (6k tokens)
├── telemetry_test.go (600 tokens)
├── ui.go
├── ui_index.html (4.8k tokens)
├── validate.go (300 tokens)
├── validate_test.go (1300 tokens)
├── version.go (200 tokens)
├── version_test.go (400 tokens)
├── openapi_compliance_test.go (700 tokens)
├── router/
├── router.go (1700 tokens)
├── router_test.go (800 tokens)
├── v0.go (400 tokens)
├── server.go (1100 tokens)
├── server_test.go (1900 tokens)
├── auth/
├── blocks.go (100 tokens)
├── jwt.go (1000 tokens)
├── jwt_test.go (2.4k tokens)
├── types.go (100 tokens)
├── config/
├── config.go (300 tokens)
├── database/
├── database.go (1100 tokens)
├── migrate.go (1000 tokens)
├── migrations/
├── 001_initial_schema.sql (400 tokens)
├── 002_add_server_extensions.sql (500 tokens)
├── 003_simplify_to_key_value.sql (200 tokens)
├── 004_update_meta_field_format.sql (100 tokens)
├── 005_add_server_id_rename_version_id.sql (600 tokens)
├── 006_migrate_server_json_camelcase.sql (600 tokens)
├── 007_add_publish_constraints.sql (200 tokens)
├── 008_clean_invalid_data.sql (1600 tokens)
├── 009_separate_official_metadata.sql (1800 tokens)
├── 010_migrate_canonical_package_refs.sql (1200 tokens)
├── 011_fix_empty_schema_fields.sql (500 tokens)
├── 012_fix_nuget_registry_base_url.sql (400 tokens)
├── 013_add_status_fields.sql (200 tokens)
├── 014_heal_is_latest.sql (400 tokens)
├── postgres.go (6k tokens)
├── postgres_test.go (12.5k tokens)
├── testutil.go (800 tokens)
├── importer/
├── importer.go (1200 tokens)
├── importer_test.go (1500 tokens)
├── service/
├── registry_service.go (3.6k tokens)
├── registry_service_test.go (7k tokens)
├── service.go (400 tokens)
├── versioning.go (600 tokens)
├── versioning_test.go (1100 tokens)
├── telemetry/
├── metrics.go (800 tokens)
├── metrics_test.go (400 tokens)
├── validators/
├── constants.go (300 tokens)
├── package.go (200 tokens)
├── registries/
├── cargo.go (3.1k tokens)
├── cargo_internal_test.go (400 tokens)
├── cargo_test.go (3.9k tokens)
├── export_test.go (100 tokens)
├── mcpb.go (1200 tokens)
├── mcpb_test.go (1300 tokens)
├── mcpname.go (800 tokens)
├── mcpname_internal_test.go (900 tokens)
├── npm.go (600 tokens)
├── npm_test.go (800 tokens)
├── nuget.go (2k tokens)
├── nuget_test.go (700 tokens)
├── oci.go (1300 tokens)
├── oci_test.go (1900 tokens)
├── pypi.go (700 tokens)
├── pypi_test.go (500 tokens)
├── testutils_test.go (100 tokens)
├── schema.go (3.2k tokens)
├── schema_regex_test.go (700 tokens)
├── schema_test.go (800 tokens)
├── schemas/
├── 2025-07-09.json (3.5k tokens)
├── 2025-09-16.json (3.5k tokens)
├── 2025-09-29.json (3.4k tokens)
├── 2025-10-11.json (4.1k tokens)
├── 2025-10-17.json (4.1k tokens)
├── 2025-12-11.json (4.4k tokens)
├── README.md (200 tokens)
├── utils.go (1100 tokens)
├── validation_detailed_test.go (3k tokens)
├── validation_types.go (1200 tokens)
├── validation_types_test.go (1000 tokens)
├── validators.go (4.8k tokens)
├── validators_test.go (13.7k tokens)
├── pkg/
├── api/
├── v0/
├── types.go (800 tokens)
├── model/
├── constants.go (300 tokens)
├── types.go (2000 tokens)
├── scripts/
├── mirror_data/
├── .gitignore
├── README.md (1300 tokens)
├── fetch_production_data.go (400 tokens)
├── load_production_data.go (900 tokens)
├── test_endpoints.sh (1300 tokens)
├── test_publish.sh (1400 tokens)
├── tests/
├── integration/
├── README.md (300 tokens)
├── docker-compose.integration-test.yml (100 tokens)
├── main.go (2.1k tokens)
├── run.sh (400 tokens)
├── tools/
├── admin/
├── auth.sh (200 tokens)
├── takedown.sh (200 tokens)
├── extract-server-schema/
├── main.go (1200 tokens)
├── validate-examples.sh
├── validate-examples/
├── main.go (1400 tokens)
├── validate-schemas.sh
├── validate-schemas/
├── main.go (500 tokens)
```
## /.githooks/pre-commit
```githooks/pre-commit path="/.githooks/pre-commit"
#!/bin/bash
# Pre-commit hook for MCP Registry
# Runs linting and formatting checks before allowing commits
set -e
echo "Running pre-commit checks..."
# Check if golangci-lint is installed
if ! command -v golangci-lint &> /dev/null; then
echo "❌ golangci-lint is not installed!"
echo "See README.md Prerequisites section for installation instructions."
exit 1
fi
# Run golangci-lint
echo "Running golangci-lint..."
if ! golangci-lint run --timeout=5m; then
echo "❌ Linting failed! Please fix the issues above."
exit 1
fi
# Check formatting
echo "Checking Go formatting..."
UNFORMATTED=$(gofmt -s -l .)
if [ -n "$UNFORMATTED" ]; then
echo "❌ The following files need formatting:"
echo "$UNFORMATTED"
echo ""
echo "Run 'gofmt -s -w .' to fix formatting issues."
exit 1
fi
echo "✅ All pre-commit checks passed!"
```
## /.github/copilot-instructions.md
# Copilot Instructions for MCP Registry
## Important: Publishing MCP servers
The `data/seed.json` file is seed data for local development only. Do NOT create pull requests that add or modify server entries in `data/seed.json` to publish a server.
To publish an MCP server to the registry, use the `mcp-publisher` CLI tool. See `docs/modelcontextprotocol-io/quickstart.mdx` for instructions.
## Development
- Use `make` targets where possible (run `make help` to see available targets)
- Run `make check` to run lint, unit tests, and integration tests
- Run `make dev-compose` to start the local development environment
## /.github/dependabot.yml
```yml path="/.github/dependabot.yml"
version: 2
updates:
- package-ecosystem: gomod
directory: /
groups:
opentelemetry:
patterns:
- "go.opentelemetry.io/*"
schedule:
interval: weekly
- package-ecosystem: gomod
directory: /deploy
groups:
opentelemetry:
patterns:
- "go.opentelemetry.io/*"
schedule:
interval: weekly
- package-ecosystem: github-actions
directory: /
groups:
actions:
patterns:
- "*"
schedule:
interval: weekly
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "weekly"
```
## /.github/workflows/cancel-pulumi-lock.yml
```yml path="/.github/workflows/cancel-pulumi-lock.yml"
name: Cancel Pulumi Lock
on:
workflow_dispatch:
inputs:
environment:
description: 'Environment to cancel lock for'
required: true
type: choice
options:
- staging
- production
permissions:
contents: read
env:
PULUMI_VERSION: "3.188.0"
jobs:
cancel-lock:
name: Cancel Pulumi Lock
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Setup Pulumi
uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d
with:
pulumi-version: ${{ env.PULUMI_VERSION }}
- name: Authenticate to Google Cloud (Staging)
if: inputs.environment == 'staging'
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093
with:
credentials_json: ${{ secrets.GCP_STAGING_SERVICE_ACCOUNT_KEY }}
- name: Authenticate to Google Cloud (Production)
if: inputs.environment == 'production'
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093
with:
credentials_json: ${{ secrets.GCP_PROD_SERVICE_ACCOUNT_KEY }}
- name: Setup Google Cloud SDK (Staging)
if: inputs.environment == 'staging'
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db
with:
project_id: mcp-registry-staging
- name: Setup Google Cloud SDK (Production)
if: inputs.environment == 'production'
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db
with:
project_id: mcp-registry-prod
- name: Cancel Pulumi Lock (Staging)
if: inputs.environment == 'staging'
working-directory: ./deploy
env:
PULUMI_STAGING_PASSPHRASE: ${{ secrets.PULUMI_STAGING_PASSPHRASE }}
run: |
echo "$PULUMI_STAGING_PASSPHRASE" > passphrase.staging.txt
pulumi login gs://mcp-registry-staging-pulumi-state
PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.staging.txt pulumi cancel --stack gcpStaging --yes
- name: Cancel Pulumi Lock (Production)
if: inputs.environment == 'production'
working-directory: ./deploy
env:
PULUMI_PROD_PASSPHRASE: ${{ secrets.PULUMI_PROD_PASSPHRASE }}
run: |
echo "$PULUMI_PROD_PASSPHRASE" > passphrase.prod.txt
pulumi login gs://mcp-registry-prod-pulumi-state
PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt pulumi cancel --stack gcpProd --yes
```
## /.github/workflows/ci.yml
```yml path="/.github/workflows/ci.yml"
name: CI Pipeline
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
# Build, Lint, and Validate
build-lint-validate:
name: Build, Lint, and Validate
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
with:
go-version: 'stable'
cache: true
- name: Run lint
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee
with:
version: v2.11.4
- name: Validate schemas and examples
run: make validate
- name: Build application
run: make build
- name: Run govulncheck
uses: golang/govulncheck-action@v1
with:
go-version-input: 'stable'
go-package: ./...
repo-checkout: false
# All Tests
tests:
name: Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
with:
go-version: 'stable'
cache: true
- name: Set up ko
uses: ko-build/setup-ko@v0.9
- name: Run all tests
run: make test-all
- name: Upload coverage artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: coverage-report
path: |
coverage.out
coverage.html
```
## /.github/workflows/claude.yml
```yml path="/.github/workflows/claude.yml"
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Allow Claude to read CI results on PRs
additional_permissions: |
actions: read
# Trigger when assigned to an issue
assignee_trigger: "claude"
claude_args: |
--allowedTools Bash
--system-prompt "If posting a comment to GitHub, give a concise summary of the comment at the top and put all the details in a <details> block."
```
## /.github/workflows/deploy-production.yml
```yml path="/.github/workflows/deploy-production.yml"
name: Deploy to Production
on:
push:
branches:
- main
paths:
- 'deploy/Pulumi.gcpProd.yaml'
permissions:
contents: read
env:
PULUMI_VERSION: "3.188.0"
jobs:
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
environment: production
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
with:
go-version: 'stable'
cache: true
- name: Setup Pulumi
uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d
with:
pulumi-version: ${{ env.PULUMI_VERSION }}
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093
with:
credentials_json: ${{ secrets.GCP_PROD_SERVICE_ACCOUNT_KEY }}
- name: Setup Google Cloud SDK
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db
with:
project_id: mcp-registry-prod
install_components: gke-gcloud-auth-plugin
- name: Deploy to Production
working-directory: ./deploy
env:
PULUMI_PROD_PASSPHRASE: ${{ secrets.PULUMI_PROD_PASSPHRASE }}
run: |
echo "$PULUMI_PROD_PASSPHRASE" > passphrase.prod.txt
make prod-up
```
## /.github/workflows/deploy-staging.yml
```yml path="/.github/workflows/deploy-staging.yml"
name: Deploy to Staging
on:
push:
branches:
- main
paths-ignore:
- 'deploy/Pulumi.gcpProd.yaml'
permissions:
contents: read
env:
PULUMI_VERSION: "3.188.0"
jobs:
ko-push:
name: Build and Push Image with ko
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
with:
go-version: 'stable'
cache: true
- name: Set up ko
uses: ko-build/setup-ko@v0.9
- name: Log in to Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get build timestamp and tags
id: build-info
run: |
echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
echo "date=$(date -u +%Y%m%d)" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Build and push with ko
env:
KO_DOCKER_REPO: ghcr.io/${{ github.repository }}
VERSION: main-${{ steps.build-info.outputs.date }}-${{ steps.build-info.outputs.short_sha }}
GIT_COMMIT: ${{ github.sha }}
BUILD_TIME: ${{ steps.build-info.outputs.timestamp }}
run: |
# Build and push multi-platform image
ko build ./cmd/registry \
--bare \
--platform=linux/amd64,linux/arm64 \
--tags=main-${{ steps.build-info.outputs.date }}-${{ steps.build-info.outputs.short_sha }},main
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
environment: staging
needs: ko-push
concurrency:
group: deploy-staging
cancel-in-progress: false
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Setup Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
with:
go-version: 'stable'
cache: true
- name: Setup Pulumi
uses: pulumi/actions@8e5e406f4007fca908480587cb9893c07090f58d
with:
pulumi-version: ${{ env.PULUMI_VERSION }}
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093
with:
credentials_json: ${{ secrets.GCP_STAGING_SERVICE_ACCOUNT_KEY }}
- name: Setup Google Cloud SDK
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db
with:
project_id: mcp-registry-staging
install_components: gke-gcloud-auth-plugin
- name: Deploy to Staging
working-directory: ./deploy
env:
PULUMI_STAGING_PASSPHRASE: ${{ secrets.PULUMI_STAGING_PASSPHRASE }}
run: |
echo "$PULUMI_STAGING_PASSPHRASE" > passphrase.staging.txt
make staging-up
```
## /.github/workflows/release.yml
```yml path="/.github/workflows/release.yml"
name: Release
on:
release:
types: [published]
permissions:
contents: write
packages: write
id-token: write # needed for signing
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
with:
go-version: 'stable'
cache: true
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Install Syft
uses: anchore/sbom-action/download-syft@v0.24.0
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89
with:
distribution: goreleaser
version: v2.12.0
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ko-push:
name: Build and Push Image with ko
runs-on: ubuntu-latest
needs: goreleaser
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
- name: Set up Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c
with:
go-version: 'stable'
cache: true
- name: Set up ko
uses: ko-build/setup-ko@v0.9
- name: Log in to Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get build timestamp and version
id: build-info
run: |
echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> $GITHUB_OUTPUT
# Extract version from tag and strip 'v' prefix (e.g., refs/tags/v1.2.3 -> 1.2.3)
VERSION="${GITHUB_REF#refs/tags/}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Build and push with ko
env:
KO_DOCKER_REPO: ghcr.io/${{ github.repository }}
VERSION: ${{ steps.build-info.outputs.version }}
GIT_COMMIT: ${{ github.sha }}
BUILD_TIME: ${{ steps.build-info.outputs.timestamp }}
run: |
# Build and push multi-platform image with version tag and latest
ko build ./cmd/registry \
--bare \
--platform=linux/amd64,linux/arm64 \
--tags=${{ steps.build-info.outputs.version }},latest
```
## /.github/workflows/sync-db.yml
```yml path="/.github/workflows/sync-db.yml"
name: Sync Production DB to Staging (from backups)
on:
# schedule:
# # Run daily at 2 AM UTC (during low-traffic hours)
# - cron: '0 2 * * *'
workflow_dispatch: # Allow manual triggering
permissions:
contents: read
jobs:
sync-database:
name: Sync Prod DB to Staging from k8up Backups
runs-on: ubuntu-latest
environment: staging
concurrency:
group: sync-staging-database
cancel-in-progress: false
steps:
- name: Authenticate to Google Cloud (Production)
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093
with:
credentials_json: ${{ secrets.GCP_PROD_SERVICE_ACCOUNT_KEY }}
- name: Setup Google Cloud SDK
uses: google-github-actions/setup-gcloud@aa5489c8933f4cc7a4f7d45035b3b1440c9c10db
with:
project_id: mcp-registry-prod
install_components: gke-gcloud-auth-plugin
- name: Get backup credentials from prod cluster
id: backup-creds
run: |
gcloud container clusters get-credentials mcp-registry-prod \
--zone=us-central1-b \
--project=mcp-registry-prod
# Store in outputs (GitHub Actions encrypts these automatically)
kubectl get secret k8up-backup-credentials -n default -o json | jq -r '
"access_key=" + (.data.AWS_ACCESS_KEY_ID | @base64d),
"secret_key=" + (.data.AWS_SECRET_ACCESS_KEY | @base64d)
' >> $GITHUB_OUTPUT
- name: Remove all production access (SAFETY MEASURE)
run: |
# Remove production cluster from kubeconfig
kubectl config delete-context gke_mcp-registry-prod_us-central1-b_mcp-registry-prod 2>/dev/null || true
# Revoke gcloud credentials
gcloud auth revoke --all 2>/dev/null || true
# Clear gcloud configuration
gcloud config unset project 2>/dev/null || true
gcloud config unset account 2>/dev/null || true
# Verify no contexts remain
CONTEXT_COUNT=$(kubectl config get-contexts -o name 2>/dev/null | wc -l)
if [ "$CONTEXT_COUNT" -gt 0 ]; then
echo "❌ ERROR: $CONTEXT_COUNT context(s) still exist after cleanup!"
kubectl config get-contexts
exit 1
fi
- name: Switch to staging cluster
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093
with:
credentials_json: ${{ secrets.GCP_STAGING_SERVICE_ACCOUNT_KEY }}
- name: Configure staging cluster access
run: |
gcloud config set project mcp-registry-staging
gcloud container clusters get-credentials mcp-registry-staging \
--zone=us-central1-b \
--project=mcp-registry-staging
- name: Create secret for prod backup bucket access
run: |
# Create/update secret in staging with access to prod backups
kubectl create secret generic prod-to-staging-sync-credentials \
--from-literal=AWS_ACCESS_KEY_ID="${{ steps.backup-creds.outputs.access_key }}" \
--from-literal=AWS_SECRET_ACCESS_KEY="${{ steps.backup-creds.outputs.secret_key }}" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Create restore PVC
run: |
kubectl apply -f - <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: restore-data-pvc
namespace: default
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 50Gi
EOF
- name: Trigger k8up restore from prod backups
id: restore
run: |
RESTORE_NAME="restore-from-prod-$(date +%Y%m%d-%H%M%S)"
echo "restore_name=$RESTORE_NAME" >> $GITHUB_OUTPUT
# Create a k8up Restore resource to restore from prod backups
kubectl apply -f - <<EOF
apiVersion: k8up.io/v1
kind: Restore
metadata:
name: $RESTORE_NAME
namespace: default
spec:
snapshot: latest
restoreMethod:
folder:
claimName: restore-data-pvc
backend:
repoPasswordSecretRef:
name: k8up-repo-password
key: password
s3:
bucket: mcp-registry-prod-backups
endpoint: https://storage.googleapis.com
accessKeyIDSecretRef:
name: prod-to-staging-sync-credentials
key: AWS_ACCESS_KEY_ID
secretAccessKeySecretRef:
name: prod-to-staging-sync-credentials
key: AWS_SECRET_ACCESS_KEY
EOF
- name: Wait for k8up restore to complete
run: |
RESTORE_NAME="${{ steps.restore.outputs.restore_name }}"
echo "Waiting for restore job to start..."
sleep 15
# Find the job created by k8up for this restore
# k8up creates jobs with name pattern "restore-<restore-name>"
# Since our restore is named "restore-from-prod-*", the job will be "restore-restore-from-prod-*"
for i in {1..60}; do
JOB_NAME=$(kubectl get jobs -n default --no-headers 2>/dev/null | grep "^restore-$RESTORE_NAME" | awk '{print $1}' | head -1)
if [ -n "$JOB_NAME" ]; then
echo "Found restore job: $JOB_NAME"
break
fi
echo "Waiting for job to be created... ($i/60)"
sleep 2
done
if [ -z "$JOB_NAME" ]; then
echo "ERROR: Restore job not found after 120 seconds"
echo "Checking restore resource status:"
kubectl get restore $RESTORE_NAME -n default
kubectl describe restore $RESTORE_NAME -n default
echo "Checking for any restore jobs:"
kubectl get jobs -n default | grep restore || echo "No restore jobs found"
echo "Checking k8up operator logs:"
kubectl logs deployment/k8up -n default --tail=50 | grep -i restore || echo "No restore logs found"
exit 1
fi
# Wait for the restore job to complete (max 15 minutes)
echo "Waiting for restore job to complete..."
kubectl wait --for=condition=complete \
job/$JOB_NAME \
--timeout=900s -n default || {
echo "Restore job failed or timed out"
echo "Job status:"
kubectl get job/$JOB_NAME -n default
echo "Job details:"
kubectl describe job/$JOB_NAME -n default
echo "Job logs (if available):"
kubectl logs job/$JOB_NAME -n default --tail=100 || echo "No logs available"
# Check if it's a credential issue
echo "Checking if credentials exist:"
kubectl get secret prod-to-staging-sync-credentials -n default || echo "Credentials secret missing!"
exit 1
}
- name: Find staging PostgreSQL PVC
id: pgdata-pvc
run: |
# Find the PVC used by the PostgreSQL cluster
PVC_NAME=$(kubectl get pvc -n default -l cnpg.io/cluster=registry-pg -o jsonpath='{.items[0].metadata.name}')
if [ -z "$PVC_NAME" ]; then
echo "ERROR: Could not find PostgreSQL PVC"
kubectl get pvc -n default -l cnpg.io/cluster=registry-pg
exit 1
fi
echo "pvc_name=$PVC_NAME" >> $GITHUB_OUTPUT
- name: Scale down staging PostgreSQL
run: |
echo "Scaling down PostgreSQL cluster..."
kubectl patch cluster registry-pg -n default \
--type merge \
--patch '{"spec":{"instances":0}}'
# Wait for pods to terminate
echo "Waiting for pods to terminate..."
kubectl wait --for=delete pod -l cnpg.io/cluster=registry-pg -n default --timeout=300s || true
- name: Verify we are in staging cluster (SAFETY CHECK)
run: |
# Get current cluster context
CURRENT_CONTEXT=$(kubectl config current-context)
CURRENT_PROJECT=$(gcloud config get-value project)
echo "Current kubectl context: $CURRENT_CONTEXT"
echo "Current GCP project: $CURRENT_PROJECT"
# Verify we're in staging
if ! echo "$CURRENT_CONTEXT" | grep -qi "staging"; then
echo "❌ SAFETY CHECK FAILED: Not in staging cluster"
echo "Context: $CURRENT_CONTEXT"
echo "Expected: staging cluster"
exit 1
fi
if [ "$CURRENT_PROJECT" != "mcp-registry-staging" ]; then
echo "❌ SAFETY CHECK FAILED: Not in staging project"
echo "Project: $CURRENT_PROJECT"
echo "Expected: mcp-registry-staging"
exit 1
fi
- name: Replace staging database with restored backup
id: copy-job
run: |
JOB_NAME="copy-pgdata-$(date +%Y%m%d-%H%M%S)"
echo "job_name=$JOB_NAME" >> $GITHUB_OUTPUT
# Create a job to copy the restored backup data to the staging PVC
kubectl apply -f - <<EOF
apiVersion: batch/v1
kind: Job
metadata:
name: $JOB_NAME
namespace: default
spec:
ttlSecondsAfterFinished: 600
template:
spec:
restartPolicy: Never
containers:
- name: copy-data
image: busybox:latest
command:
- /bin/sh
- -c
- |
set -e
echo "Finding PostgreSQL data in backup..."
echo "Restore structure:"
find /restore -maxdepth 3 -type d 2>/dev/null | head -20
# Try different possible paths for pgdata
PGDATA_SOURCE=""
for path in \$(find /restore -type d -name "pgdata" 2>/dev/null); do
if [ -f "\$path/PG_VERSION" ]; then
PGDATA_SOURCE="\$path"
break
fi
done
if [ -z "\$PGDATA_SOURCE" ]; then
echo "ERROR: Could not find valid pgdata directory with PG_VERSION"
echo "Searched paths:"
find /restore -type d -name "pgdata" 2>/dev/null
exit 1
fi
echo "Found pgdata at: \$PGDATA_SOURCE"
echo "Contents:"
ls -lah \$PGDATA_SOURCE/ | head -10
echo "Backing up existing staging data..."
mkdir -p /pgdata-backup
if [ "\$(ls -A /pgdata)" ]; then
cp -a /pgdata/. /pgdata-backup/ || echo "Warning: Could not backup existing data"
fi
echo "Clearing existing data..."
rm -rf /pgdata/*
echo "Copying backup data to staging PVC..."
cp -a \$PGDATA_SOURCE/. /pgdata/
echo "Setting correct permissions..."
chmod 700 /pgdata
ls -lah /pgdata/ | head -20
echo "PostgreSQL version: \$(cat /pgdata/PG_VERSION)"
volumeMounts:
- name: restore-data
mountPath: /restore
- name: staging-pgdata
mountPath: /pgdata
volumes:
- name: restore-data
persistentVolumeClaim:
claimName: restore-data-pvc
- name: staging-pgdata
persistentVolumeClaim:
claimName: ${{ steps.pgdata-pvc.outputs.pvc_name }}
EOF
- name: Wait for data copy to complete
run: |
JOB_NAME="${{ steps.copy-job.outputs.job_name }}"
# Wait for copy to complete
kubectl wait --for=condition=complete job/$JOB_NAME --timeout=600s -n default || {
echo "Data copy job failed"
kubectl describe job/$JOB_NAME -n default
kubectl logs job/$JOB_NAME -n default --tail=100
exit 1
}
- name: Scale up staging PostgreSQL
run: |
echo "Scaling up PostgreSQL cluster..."
kubectl patch cluster registry-pg -n default \
--type merge \
--patch '{"spec":{"instances":1}}'
# Wait for PostgreSQL pod to be created
echo "Waiting for PostgreSQL pod to be created..."
for i in {1..60}; do
POD_COUNT=$(kubectl get pods -l cnpg.io/cluster=registry-pg -n default --no-headers 2>/dev/null | wc -l)
if [ "$POD_COUNT" -gt 0 ]; then
echo "Pod created"
break
fi
echo "Waiting... ($i/60)"
sleep 2
done
# Wait for PostgreSQL to be ready
echo "Waiting for PostgreSQL to be ready..."
kubectl wait --for=condition=ready pod -l cnpg.io/cluster=registry-pg -n default --timeout=300s
- name: Verify staging DB is functional
run: |
# Create a verification pod
kubectl run pg-verify-$(date +%s) \
--image=postgres:15 \
--rm -i --restart=Never \
--env="PGPASSWORD=$(kubectl get secret registry-pg-superuser -n default -o jsonpath='{.data.password}' | base64 -d)" \
-- bash -c '
echo "Waiting for database to accept connections..."
for i in {1..30}; do
if pg_isready -h registry-pg-rw -U postgres 2>/dev/null; then
break
fi
echo "Waiting... ($i/30)"
sleep 2
done
echo "Querying database..."
TABLE_COUNT=$(psql -h registry-pg-rw -U postgres -d app -tAc "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '\''public'\'';" 2>&1)
if [ $? -ne 0 ]; then
echo "ERROR: Could not query database"
echo "$TABLE_COUNT"
exit 1
fi
if [ "$TABLE_COUNT" -lt 1 ]; then
echo "ERROR: Staging DB has no tables!"
exit 1
fi
echo "Staging DB has $TABLE_COUNT tables"
echo "Top 10 tables by row count:"
psql -h registry-pg-rw -U postgres -d app \
-c "SELECT schemaname, tablename, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;" || true
'
- name: Cleanup
if: always()
run: |
# Wait for any restore jobs to finish before cleanup (max 5 minutes for large database restores)
echo "Checking for running restore jobs..."
RESTORE_NAME="${{ steps.restore.outputs.restore_name }}"
for i in {1..60}; do
# Check specifically for our restore job in Running or Pending state
RUNNING_JOBS=$(kubectl get jobs -n default --no-headers 2>/dev/null | grep "^restore-${RESTORE_NAME}" | grep -E "Running|Pending" | wc -l)
if [ "$RUNNING_JOBS" -eq 0 ]; then
echo "No running restore jobs found"
break
fi
echo "Waiting for $RUNNING_JOBS restore job(s) to finish... ($i/60)"
sleep 5
done
# Clean up jobs first
if [ -n "${{ steps.copy-job.outputs.job_name }}" ]; then
kubectl delete job ${{ steps.copy-job.outputs.job_name }} -n default || true
fi
# Clean up the restore job if it exists (with proper error handling)
if [ -n "$RESTORE_NAME" ]; then
if kubectl get job "restore-$RESTORE_NAME" -n default >/dev/null 2>&1; then
echo "Deleting restore job: restore-$RESTORE_NAME"
kubectl delete job "restore-$RESTORE_NAME" -n default || echo "Failed to delete job, may have already been cleaned up"
else
echo "Restore job restore-$RESTORE_NAME not found, skipping deletion"
fi
fi
# Remove restore PVC (will wait for jobs to finish)
kubectl delete pvc restore-data-pvc -n default || true
# Remove prod backup credentials (for security)
kubectl delete secret prod-to-staging-sync-credentials -n default || true
# Clean up old restore resources (keep last 3)
kubectl get restore -n default --sort-by=.metadata.creationTimestamp -o name | head -n -3 | xargs -r kubectl delete || true
# Clean up old restore jobs (keep last 3)
kubectl get jobs -n default --sort-by=.metadata.creationTimestamp -o name | grep '^job.batch/restore-' | head -n -3 | xargs -r kubectl delete -n default || true
# Clean up old copy jobs (keep last 3)
kubectl get jobs -n default --sort-by=.metadata.creationTimestamp -o name | grep 'copy-pgdata-' | head -n -3 | xargs -r kubectl delete -n default || true
```
## /.github/workflows/sync-schema.yml
```yml path="/.github/workflows/sync-schema.yml"
name: Sync Schema
on:
workflow_dispatch: # Manual trigger
# TODO: Add daily schedule later
# schedule:
# - cron: '0 2 * * *' # Run daily at 2 AM UTC
permissions:
contents: write
jobs:
sync-schema:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Checkout static repo
uses: actions/checkout@v6
with:
repository: modelcontextprotocol/static
path: static-repo
- name: Sync schemas from static repo
run: |
echo "🔍 Syncing schemas from modelcontextprotocol/static..."
mkdir -p internal/validators/schemas
# Copy all versioned schema files
for dir in static-repo/schemas/*/; do
if [ -f "$dir/server.schema.json" ]; then
version=$(basename "$dir")
# Skip draft directory if it exists
if [ "$version" != "draft" ]; then
output_file="internal/validators/schemas/${version}.json"
if [ ! -f "$output_file" ] || ! cmp -s "$dir/server.schema.json" "$output_file"; then
echo "⬇ Adding/updating ${version}/server.schema.json -> ${version}.json"
cp "$dir/server.schema.json" "$output_file"
else
echo "✓ ${version} is already up to date"
fi
fi
fi
done
echo "✅ Schema sync complete"
- name: Check for changes
id: changes
run: |
# Check for both modified and untracked files
if [ -n "$(git status --porcelain internal/validators/schemas/)" ]; then
echo "changed=true" >> $GITHUB_OUTPUT
git status --porcelain internal/validators/schemas/
else
echo "changed=false" >> $GITHUB_OUTPUT
echo "No changes to schemas"
fi
- name: Commit and push changes
if: steps.changes.outputs.changed == 'true'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add internal/validators/schemas/
git commit -m "Sync schemas from modelcontextprotocol/static [skip ci]"
git push
```
## /.gitignore
```gitignore path="/.gitignore"
build/
bin/
tmp/
.db
.env
.mcpregistry*
.DS_Store
validate-examples
validate-schemas
.idea/
coverage.out
coverage.html
deploy/infra/infra
registry
```
## /.golangci.yml
```yml path="/.golangci.yml"
version: "2"
run:
modules-download-mode: readonly
linters:
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- containedctx
- contextcheck
- cyclop
- dupl
- durationcheck
- errname
- errorlint
- exhaustive
- forbidigo
- gocognit
- goconst
- gocritic
- gocyclo
- godox
- gomoddirectives
- gomodguard
- goprintffuncname
- gosec
- grouper
- importas
- makezero
- misspell
- nakedret
- nestif
- nilerr
- nilnil
- noctx
- nolintlint
- nosprintfhostport
- predeclared
- promlinter
- reassign
- revive
- rowserrcheck
- sqlclosecheck
- testpackage
- thelper
- tparallel
- unconvert
- unparam
- usestdlibvars
- wastedassign
- whitespace
settings:
cyclop:
max-complexity: 20
dupl:
threshold: 300
gocognit:
min-complexity: 51
nestif:
min-complexity: 10
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
# httptest.NewRequest is idiomatic in tests; noctx is not useful there.
- path: _test\.go
linters:
- noctx
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
```
## /.goreleaser.yaml
```yaml path="/.goreleaser.yaml"
# GoReleaser configuration for MCP Registry
version: 2
# Build configuration
builds:
# Registry server binary
- id: registry
binary: registry
main: ./cmd/registry
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X main.Version={{.Version}}
- -X main.GitCommit={{.FullCommit}}
- -X main.BuildTime={{.Date}}
# Publisher CLI tool binary
- id: publisher
binary: mcp-publisher
main: ./cmd/publisher
env:
- CGO_ENABLED=0
goos:
- linux
- darwin
- windows
goarch:
- amd64
- arm64
ldflags:
- -s -w
- -X main.Version={{.Version}}
- -X main.GitCommit={{.FullCommit}}
- -X main.BuildTime={{.Date}}
# This section defines whether we want to release the source code too.
source:
enabled: true
# This section defines how to generate the changelog
changelog:
sort: asc
use: github
filters:
exclude:
- '^docs:'
- '^test:'
- '^ci:'
- '^chore:'
- '^style:'
- 'merge conflict'
- 'Merge pull request'
- 'Merge remote-tracking branch'
- 'Merge branch'
groups:
- title: 'New Features'
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+{{contextString}}#39;
order: 0
- title: 'Bug Fixes'
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+{{contextString}}#39;
order: 1
- title: 'Performance Improvements'
regexp: '^.*?perf(\([[:word:]]+\))??!?:.+{{contextString}}#39;
order: 2
- title: 'Refactors'
regexp: '^.*?refactor(\([[:word:]]+\))??!?:.+{{contextString}}#39;
order: 3
- title: 'Documentation'
regexp: '^.*?docs?(\([[:word:]]+\))??!?:.+{{contextString}}#39;
order: 4
- title: 'Other Changes'
order: 999
# This section defines for which artifact types to generate SBOMs.
sboms:
- artifacts: archive
# This section defines the release policy.
release:
github:
owner: modelcontextprotocol
name: registry
# This section defines how and which artifacts we want to sign for the release.
signs:
- id: archives
cmd: cosign
args:
- "sign-blob"
- "--bundle=${signature}" # cosign v3+: bundles signature and certificate together
- "${artifact}"
- "--yes" # needed on cosign 2.0.0+
artifacts: archive
output: true
signature: "${artifact}.sigstore.json"
# Also sign checksums file for additional verification
- id: checksums
cmd: cosign
args:
- "sign-blob"
- "--bundle=${signature}" # cosign v3+: bundles signature and certificate together
- "${artifact}"
- "--yes"
artifacts: checksum
output: true
signature: "${artifact}.sigstore.json"
# This section defines the release format.
archives:
# Registry server archive
- id: registry
name_template: "registry_{{ .Os }}_{{ .Arch }}"
ids:
- registry
# Publisher CLI archive
- id: publisher
name_template: "mcp-publisher_{{ .Os }}_{{ .Arch }}"
ids:
- publisher
```
## /.ko.dockerignore
```dockerignore path="/.ko.dockerignore"
# Build artifacts
bin/
*.exe
*.dll
*.so
*.dylib
# Test and coverage files
coverage.out
coverage.html
*.test
# Database files
*.db
.db/
# Deployment files
deploy/
# Documentation
docs/
# Tests
tests/
# Git
.git/
.gitignore
# GitHub Actions
.github/
# Docker files (no longer used)
Dockerfile
.dockerignore
docker-compose.yml
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# Temporary files
tmp/
temp/
*.tmp
# OS files
.DS_Store
Thumbs.db
```
## /.ko.yaml
```yaml path="/.ko.yaml"
# ko configuration for MCP Registry
# Documentation: https://ko.build/configuration/
# Default base image for all builds
# Using Chainguard's static image for minimal, secure containers (~2MB)
defaultBaseImage: cgr.dev/chainguard/static:latest
# Default platforms for multi-architecture builds
defaultPlatforms:
- linux/amd64
- linux/arm64
# Build configuration
builds:
- id: registry
# Main package to build
main: ./cmd/registry
# Inject version information at build time via ldflags
ldflags:
- -s -w
- -X main.Version={{ .Env.VERSION }}
- -X main.GitCommit={{ .Env.GIT_COMMIT }}
- -X main.BuildTime={{ .Env.BUILD_TIME }}
env:
- CGO_ENABLED=0
# SBOM (Software Bill of Materials) configuration
sbom: spdx
# Base import path handling
# Set to false to preserve full import paths in image names
baseImportPaths: false
```
## /.vscode/launch.json
```json path="/.vscode/launch.json"
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch server",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/registry",
"envFile": "${workspaceFolder}/.env",
},
{
"name": "Launch publisher",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/publisher/main.go",
"args": [
"-registry-url=http://localhost:8080",
"-mcp-file=${workspaceFolder}/cmd/publisher/server.json",
],
}
]
}
```
## /CHANGES.md
## /CLAUDE.md
# CLAUDE.md
_Guidance for Claude Code (claude.ai/code) when working in this repository. If it's also useful to humans (probably most things!), put the instructions in README.md instead._
Import @README.md
## Important: Publishing MCP servers
The `data/seed.json` file is seed data for local development only. Do NOT create pull requests or commits that add server entries to `data/seed.json` as a way to publish a server to the registry.
To publish an MCP server, use the `mcp-publisher` CLI tool. See `docs/modelcontextprotocol-io/quickstart.mdx` for instructions.
## /CONTRIBUTING.md
# Contributing to the MCP Registry
Thank you for your interest in contributing!
## Want to publish an MCP server?
**Do NOT open a pull request to add your server to `data/seed.json`.**
The `data/seed.json` file is seed data used only for local development. Modifying it will not publish your server to the registry.
To publish an MCP server, use the official `mcp-publisher` CLI tool. See the [publishing quickstart guide](docs/modelcontextprotocol-io/quickstart.mdx) for step-by-step instructions.
## Contributing to the registry codebase
We welcome contributions to the registry itself! Here's how to get started:
### Communication channels
We use multiple channels for collaboration - see [modelcontextprotocol.io/community/communication](https://modelcontextprotocol.io/community/communication).
Often (but not always) ideas flow through this pipeline:
- **[Discord](https://modelcontextprotocol.io/community/communication)** - Real-time community discussions
- **[Discussions](https://github.com/modelcontextprotocol/registry/discussions)** - Propose and discuss product/technical requirements
- **[Issues](https://github.com/modelcontextprotocol/registry/issues)** - Track well-scoped technical work
- **[Pull Requests](https://github.com/modelcontextprotocol/registry/pulls)** - Contribute work towards issues
### Development setup
See the [README](README.md#quick-start) for prerequisites and instructions on running the server locally.
### Running checks
```bash
# Run lint, unit tests and integration tests
make check
```
Run `make help` for more available commands.
## /Makefile
``` path="/Makefile"
.PHONY: help build test test-unit test-integration test-endpoints test-publish test-all lint lint-fix validate validate-schemas validate-examples check ko-build ko-rebuild dev-compose dev-down clean publisher generate-schema check-schema
# Use bash for all commands to support pipefail
SHELL := /bin/bash
# Default target
help: ## Show this help message
@echo "Available targets:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $1, $2}'
# Build targets
build: ## Build the registry application with version info
@mkdir -p bin
go build -ldflags="-X main.Version=dev-$(shell git rev-parse --short HEAD) -X main.GitCommit=$(shell git rev-parse HEAD) -X main.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/registry ./cmd/registry
publisher: ## Build the publisher tool with version info
@mkdir -p bin
go build -ldflags="-X main.Version=dev-$(shell git rev-parse --short HEAD) -X main.GitCommit=$(shell git rev-parse HEAD) -X main.BuildTime=$(shell date -u +%Y-%m-%dT%H:%M:%SZ)" -o bin/mcp-publisher ./cmd/publisher
# Schema generation targets
generate-schema: ## Generate server.schema.json from openapi.yaml
@mkdir -p bin
go build -o bin/extract-server-schema ./tools/extract-server-schema
@./bin/extract-server-schema
check-schema: ## Check if server.schema.json is in sync with openapi.yaml
@mkdir -p bin
go build -o bin/extract-server-schema ./tools/extract-server-schema
@./bin/extract-server-schema -check
# Test targets
test-unit: ## Run unit tests with coverage (requires PostgreSQL)
@echo "Starting PostgreSQL for unit tests..."
@docker compose up -d postgres 2>&1 | grep -v "Pulling\|Pulled\|Creating\|Created\|Starting\|Started" || true
@echo "Waiting for PostgreSQL to be ready..."
@sleep 3
@echo ""
@echo "Running unit tests..."
@set -o pipefail; if command -v gotestsum >/dev/null 2>&1; then \
gotestsum --format pkgname-and-test-fails -- -race -coverprofile=coverage.out -covermode=atomic ./internal/... ./cmd/... 2>&1 | grep -v "ld: warning:"; \
else \
go test -race -coverprofile=coverage.out -covermode=atomic ./internal/... ./cmd/... 2>&1 | grep -v "ld: warning:" | grep -v "^ld:"; \
fi
@echo ""
@go tool cover -html=coverage.out -o coverage.html
@echo "✅ Coverage report: coverage.html"
@go tool cover -func=coverage.out | tail -1
@echo ""
@docker compose down postgres >/dev/null 2>&1
@echo "✅ Tests complete"
test: ## Run unit tests (use 'make test-all' to run all tests)
@echo "⚠️ Running unit tests only. Use 'make test-all' to run both unit and integration tests."
@$(MAKE) test-unit
test-integration: ## Run integration tests
./tests/integration/run.sh
test-endpoints: ## Test API endpoints (requires running server)
./scripts/test_endpoints.sh
test-publish: ## Test publish endpoint (requires BEARER_TOKEN env var)
./scripts/test_publish.sh
test-all: test-unit test-integration ## Run all tests (unit and integration)
# Validation targets
validate-schemas: ## Validate JSON schemas
./tools/validate-schemas.sh
@$(MAKE) check-schema
validate-examples: ## Validate examples against schemas
./tools/validate-examples.sh
validate: validate-schemas validate-examples ## Run all validation checks
# Lint targets
lint: ## Run linter (includes formatting)
golangci-lint run --timeout=5m
lint-fix: ## Run linter with auto-fix (includes formatting)
golangci-lint run --fix --timeout=5m
# Combined targets
check: dev-down lint validate test-all ## Run all checks (lint, validate, unit tests) and ensure dev environment is down
@echo "All checks passed!"
# Development targets
ko-build: ## Build registry image using ko (loads into local docker daemon)
@echo "Building registry with ko..."
VERSION=dev-$(git rev-parse --short HEAD) \
GIT_COMMIT=$(git rev-parse HEAD) \
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
KO_DOCKER_REPO=ko.local \
ko build --preserve-import-paths --tags=dev --sbom=none ./cmd/registry
@echo "Image built: ko.local/github.com/modelcontextprotocol/registry/cmd/registry:dev"
ko-rebuild: ## Rebuild with ko and restart registry container
@$(MAKE) ko-build
@echo "Restarting registry container..."
@docker compose restart registry
dev-compose: ko-build ## Start development environment with Docker Compose (builds with ko first)
@echo "Starting Docker Compose..."
docker compose up
dev-down: ## Stop development environment
docker compose down
# Cleanup
clean: ## Clean build artifacts and coverage files
rm -rf bin
rm -f coverage.out coverage.html
.DEFAULT_GOAL := help
```
## /README.md
# MCP Registry
The MCP registry provides MCP clients with a list of MCP servers, like an app store for MCP servers.
[**📤 Publish my MCP server**](docs/modelcontextprotocol-io/quickstart.mdx) | [**⚡️ Live API docs**](https://registry.modelcontextprotocol.io/docs) | [**👀 Ecosystem vision**](docs/design/ecosystem-vision.md) | 📖 **[Full documentation](./docs)**
## Development Status
**2025-10-24 update**: The Registry API has entered an **API freeze (v0.1)** 🎉. For the next month or more, the API will remain stable with no breaking changes, allowing integrators to confidently implement support. This freeze applies to v0.1 while development continues on v0. We'll use this period to validate the API in real-world integrations and gather feedback to shape v1 for general availability. Thank you to everyone for your contributions and patience—your involvement has been key to getting us here!
**2025-09-08 update**: The registry has launched in preview 🎉 ([announcement blog post](https://blog.modelcontextprotocol.io/posts/2025-09-08-mcp-registry-preview/)). While the system is now more stable, this is still a preview release and breaking changes or data resets may occur. A general availability (GA) release will follow later. We'd love your feedback in [GitHub discussions](https://github.com/modelcontextprotocol/registry/discussions/new?category=ideas) or in the [#registry-dev Discord](https://discord.com/channels/1358869848138059966/1369487942862504016) ([joining details here](https://modelcontextprotocol.io/community/communication)).
Registry Working Group:
- **Tadas Antanavicius** (PulseMCP) [@tadasant](https://github.com/tadasant)
- **Radoslav (Rado) Dimitrov** (Stacklok) [@rdimitrov](https://github.com/rdimitrov)
- **Bob Dickinson** (TeamSpark) [@BobDickinson](https://github.com/BobDickinson)
- **Preeti (Pree) Dewani** (Ravenmail) [@pree-dew](https://github.com/pree-dew)
## Contributing
We use multiple channels for collaboration - see [modelcontextprotocol.io/community/communication](https://modelcontextprotocol.io/community/communication).
Often (but not always) ideas flow through this pipeline:
- **[Discord](https://modelcontextprotocol.io/community/communication)** - Real-time community discussions
- **[Discussions](https://github.com/modelcontextprotocol/registry/discussions)** - Propose and discuss product/technical requirements
- **[Issues](https://github.com/modelcontextprotocol/registry/issues)** - Track well-scoped technical work
- **[Pull Requests](https://github.com/modelcontextprotocol/registry/pulls)** - Contribute work towards issues
### Quick start:
#### Pre-requisites
- **Docker**
- **Go 1.24.x**
- **ko** - Container image builder for Go ([installation instructions](https://ko.build/install/))
- **golangci-lint v2.4.0**
#### Running the server
```bash
# Start full development environment
make dev-compose
```
This starts the registry at [`localhost:8080`](http://localhost:8080) with PostgreSQL. The database uses ephemeral storage and is reset each time you restart the containers, ensuring a clean state for development and testing.
**Note:** The registry uses [ko](https://ko.build) to build container images. The `make dev-compose` command automatically builds the registry image with ko and loads it into your local Docker daemon before starting the services.
By default, the registry seeds from the production API with a filtered subset of servers (to keep startup fast). This ensures your local environment mirrors production behavior and all seed data passes validation. For offline development you can seed from a file without validation with `MCP_REGISTRY_SEED_FROM=data/seed.json MCP_REGISTRY_ENABLE_REGISTRY_VALIDATION=false make dev-compose`.
The setup can be configured with environment variables in [docker-compose.yml](./docker-compose.yml) - see [.env.example](./.env.example) for a reference.
<details>
<summary>Alternative: Running a pre-built Docker image</summary>
Pre-built Docker images are automatically published to GitHub Container Registry. Note that the image does not bundle PostgreSQL, so you need to run your own and point the registry at it via `MCP_REGISTRY_DATABASE_URL` (see [docker-compose.yml](./docker-compose.yml) for a working example):
```bash
# Run latest stable release
docker run -p 8080:8080 ghcr.io/modelcontextprotocol/registry:latest
# Run latest from main branch (continuous deployment)
docker run -p 8080:8080 ghcr.io/modelcontextprotocol/registry:main
# Run specific release version
docker run -p 8080:8080 ghcr.io/modelcontextprotocol/registry:v1.0.0
# Run development build from main branch
docker run -p 8080:8080 ghcr.io/modelcontextprotocol/registry:main-20250906-abc123d
```
**Available tags:**
- **Releases**: `latest`, `v1.0.0`, `v1.1.0`, etc.
- **Continuous**: `main` (latest main branch build)
- **Development**: `main-<date>-<sha>` (specific commit builds)
</details>
#### Publishing a server
To publish a server, we've built a simple CLI. You can use it with:
```bash
# Build the latest CLI
make publisher
# Use it!
./bin/mcp-publisher --help
```
See [the publisher guide](./docs/modelcontextprotocol-io/quickstart.mdx) for more details.
#### Other commands
```bash
# Run lint, unit tests and integration tests
make check
```
There are also a few more helpful commands for development. Run `make help` to learn more, or look in [Makefile](./Makefile).
<!--
For Claude and other AI tools: Always prefer make targets over custom commands where possible.
-->
## Architecture
### Project Structure
```
├── cmd/ # Application entry points
│ └── publisher/ # Server publishing tool
├── data/ # Seed data
├── deploy/ # Deployment configuration (Pulumi)
├── docs/ # Documentation
├── internal/ # Private application code
│ ├── api/ # HTTP handlers and routing
│ ├── auth/ # Authentication (GitHub OAuth, JWT, namespace blocking)
│ ├── config/ # Configuration management
│ ├── database/ # Data persistence (PostgreSQL)
│ ├── service/ # Business logic
│ ├── telemetry/ # Metrics and monitoring
│ └── validators/ # Input validation
├── pkg/ # Public packages
│ ├── api/ # API types and structures
│ │ └── v0/ # Version 0 API types
│ └── model/ # Data models for server.json
├── scripts/ # Development and testing scripts
├── tests/ # Integration tests
└── tools/ # CLI tools and utilities
└── validate-*.sh # Schema validation tools
```
### Authentication
Publishing supports multiple authentication methods:
- **GitHub OAuth** - For publishing by logging into GitHub
- **GitHub OIDC** - For publishing from GitHub Actions
- **DNS verification** - For proving ownership of a domain and its subdomains
- **HTTP verification** - For proving ownership of a domain
The registry validates namespace ownership when publishing. E.g. to publish...:
- `io.github.domdomegg/my-cool-mcp` you must login to GitHub as `domdomegg`, or be in a GitHub Action on domdomegg's repos
- `me.adamjones/my-cool-mcp` you must prove ownership of `adamjones.me` via DNS or HTTP challenge
## Community Projects
Check out [community projects](docs/community-projects.md) to explore notable registry-related work created by the community.
## More documentation
See the [documentation](./docs) for more details if your question has not been answered here!
## /SECURITY.md
# Security Policy
Thank you for helping keep the Model Context Protocol and its ecosystem secure.
## Reporting Security Issues
If you discover a security vulnerability in this repository, please report it through
the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
for this repository.
Please **do not** report security vulnerabilities through public GitHub issues, discussions,
or pull requests.
## What to Include
To help us triage and respond quickly, please include:
- A description of the vulnerability
- Steps to reproduce the issue
- The potential impact
- Any suggested fixes (optional)
## /cmd/publisher/README.md
# MCP Publisher Tool - Development
CLI tool for publishing MCP servers to the registry.
> These docs are for contributors. See the [Publisher User Guide](../../docs/modelcontextprotocol-io/quickstart.mdx) for end-user documentation.
## Quick Development Setup
```bash
# Build the tool
make publisher
# Test locally
make dev-compose # Start local registry
./bin/mcp-publisher init
./bin/mcp-publisher login none --registry=http://localhost:8080
./bin/mcp-publisher publish --registry=http://localhost:8080
```
## Architecture
### Commands
- **`init`** - Generate server.json templates with auto-detection
- **`login`** - Handle authentication (github, dns, http, none)
- **`publish`** - Validate and upload servers to registry
- **`status`** - Update server lifecycle status (active, deprecated, deleted)
- **`logout`** - Clear stored credentials
### Authentication Providers
- **`github`** - Interactive OAuth flow
- **`github-oidc`** - CI/CD with GitHub Actions
- **`dns`** - Domain verification via DNS TXT records
- **`http`** - Domain verification via HTTPS endpoints
- **`none`** - No auth (testing only)
### Signing Providers
Optional: enables `dns` and `http` methods to sign out-of-process without direct access to the private key.
- **`google-kms`** - Google KMS signing
- **`azure-key-vault`** - Azure Key Vault signing
## Key Files
- **`main.go`** - CLI setup and command routing
- **`commands/`** - Command implementations with auto-detection logic
- **`auth/`** - Authentication provider implementations
- **`build.sh`** - Cross-platform build script
## /cmd/publisher/auth/azurekeyvault/common.go
```go path="/cmd/publisher/auth/azurekeyvault/common.go"
package azurekeyvault
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/sha512"
"fmt"
"math/big"
"os"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys"
"github.com/modelcontextprotocol/registry/cmd/publisher/auth"
)
func GetSignatureProvider(vaultName, keyName string) (auth.Signer, error) {
if vaultName == "" {
return nil, fmt.Errorf("--vault option (vault name) is required")
}
if keyName == "" {
return nil, fmt.Errorf("--key option (key name) is required")
}
return Signer{
vaultName: vaultName,
keyName: keyName,
}, nil
}
type Signer struct {
vaultName string
keyName string
}
func (d Signer) GetSignedTimestamp(ctx context.Context) (*string, []byte, error) {
fmt.Fprintf(os.Stdout, "Signing using Azure Key Vault %s and key %s\n", d.vaultName, d.keyName)
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
return nil, nil, fmt.Errorf("authentication to Azure failed: %w", err)
}
vaultURL := fmt.Sprintf("https://%s.vault.azure.net/", d.vaultName)
client, err := azkeys.NewClient(vaultURL, cred, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to create Key Vault client: %w", err)
}
keyResp, err := client.GetKey(ctx, d.keyName, "", nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to retrieve key for public parameters: %w", err)
}
if *keyResp.Key.Kty != azkeys.KeyTypeEC && *keyResp.Key.Kty != azkeys.KeyTypeECHSM {
return nil, nil, fmt.Errorf("unsupported key type: kty: %s (only EC or EC-HSM keys are supported)", *keyResp.Key.Kty)
}
if *keyResp.Key.Crv != azkeys.CurveNameP384 {
return nil, nil, fmt.Errorf("unsupported curve: %s (only P-384 is supported)", *keyResp.Key.Crv)
}
fmt.Fprintln(os.Stdout, "Successfully read the public key from Key Vault.")
auth.PrintEcdsaP384KeyInfo(ecdsa.PublicKey{
Curve: elliptic.P384(),
X: new(big.Int).SetBytes(keyResp.Key.X),
Y: new(big.Int).SetBytes(keyResp.Key.Y),
})
timestamp := auth.GetTimestamp()
digest := sha512.Sum384([]byte(timestamp))
alg := azkeys.SignatureAlgorithmES384
fmt.Fprintln(os.Stdout, "Executing the sign request...")
signResp, err := client.Sign(ctx, d.keyName, "", azkeys.SignParameters{
Algorithm: &alg,
Value: digest[:],
}, nil)
if err != nil {
return nil, nil, fmt.Errorf("failed to sign message: %w", err)
}
return ×tamp, signResp.Result, nil
}
```
## /cmd/publisher/auth/common.go
```go path="/cmd/publisher/auth/common.go"
package auth
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"os"
"time"
)
type CryptoAlgorithm string
const (
AlgorithmEd25519 CryptoAlgorithm = "ed25519"
// ECDSA with NIST P-384 curve
// public key is in compressed format
// signature is in R || S format
AlgorithmECDSAP384 CryptoAlgorithm = "ecdsap384"
)
// CryptoProvider provides common functionality for DNS and HTTP authentication
type CryptoProvider struct {
registryURL string
domain string
signer Signer
authMethod string
}
type Signer interface {
GetSignedTimestamp(ctx context.Context) (*string, []byte, error)
}
func GetTimestamp() string {
return time.Now().UTC().Format(time.RFC3339)
}
func NewInProcessSigner(privateKey string, algorithm CryptoAlgorithm) (Signer, error) {
if privateKey == "" {
return nil, fmt.Errorf("%s private key (hex) is required", algorithm)
}
// Decode private key from hex
privateKeyBytes, err := hex.DecodeString(privateKey)
if err != nil {
return nil, fmt.Errorf("invalid hex private key format: %w", err)
}
return &InProcessSigner{
privateKey: privateKeyBytes,
cryptoAlgorithm: algorithm,
}, nil
}
// GetToken retrieves the registry JWT token using cryptographic authentication
func (c *CryptoProvider) GetToken(ctx context.Context) (string, error) {
if c.domain == "" {
return "", fmt.Errorf("%s domain is required", c.authMethod)
}
// Generate current timestamp
timestamp, signedTimestamp, err := c.signer.GetSignedTimestamp(ctx)
if err != nil {
return "", fmt.Errorf("failed to sign timestamp: %w", err)
}
signedTimestampHex := hex.EncodeToString(signedTimestamp)
// Exchange signature for registry token
registryToken, err := c.exchangeTokenForRegistry(ctx, c.domain, *timestamp, signedTimestampHex)
if err != nil {
return "", fmt.Errorf("failed to exchange %s signature: %w", c.authMethod, err)
}
return registryToken, nil
}
type InProcessSigner struct {
privateKey []byte
cryptoAlgorithm CryptoAlgorithm
}
func (c *InProcessSigner) GetSignedTimestamp(_ context.Context) (*string, []byte, error) {
fmt.Fprintf(os.Stdout, "Signing in process using key algorithm %s\n", c.cryptoAlgorithm)
timestamp := GetTimestamp()
switch c.cryptoAlgorithm {
case AlgorithmEd25519:
if len(c.privateKey) != ed25519.SeedSize {
return nil, nil, fmt.Errorf("invalid seed length: expected %d bytes, got %d", ed25519.SeedSize, len(c.privateKey))
}
privateKey := ed25519.NewKeyFromSeed(c.privateKey)
PrintEd25519KeyInfo(privateKey.Public().(ed25519.PublicKey))
signature := ed25519.Sign(privateKey, []byte(timestamp))
return ×tamp, signature, nil
case AlgorithmECDSAP384:
if len(c.privateKey) != 48 {
return nil, nil, fmt.Errorf("invalid seed length for ECDSA P-384: expected 48 bytes, got %d", len(c.privateKey))
}
digest := sha512.Sum384([]byte(timestamp))
curve := elliptic.P384()
// Parse the raw private key (compatible with Go 1.24)
privateKey, err := parseRawPrivateKey(curve, c.privateKey)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse ECDSA private key: %w", err)
}
PrintEcdsaP384KeyInfo(privateKey.PublicKey)
r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest[:])
if err != nil {
return nil, nil, fmt.Errorf("failed to sign message: %w", err)
}
signature := append(r.Bytes(), s.Bytes()...)
return ×tamp, signature, nil
default:
return nil, nil, fmt.Errorf("unsupported crypto algorithm: %s", c.cryptoAlgorithm)
}
}
// parseRawPrivateKey parses a raw ECDSA private key from bytes.
// This mimics crypto/ecdsa.ParseRawPrivateKey from Go 1.25+ for compatibility with Go 1.24.
func parseRawPrivateKey(curve elliptic.Curve, privateKeyBytes []byte) (*ecdsa.PrivateKey, error) {
if curve == nil {
return nil, fmt.Errorf("nil curve")
}
expectedBytes := (curve.Params().N.BitLen() + 7) / 8
if len(privateKeyBytes) != expectedBytes {
return nil, fmt.Errorf("invalid private key length: expected %d bytes, got %d", expectedBytes, len(privateKeyBytes))
}
// Only standard NIST curves supported
switch curve {
case elliptic.P224(), elliptic.P256(), elliptic.P384(), elliptic.P521():
// ok
default:
return nil, fmt.Errorf("unsupported curve")
}
d := new(big.Int).SetBytes(privateKeyBytes)
params := curve.Params()
if d.Sign() <= 0 || d.Cmp(params.N) >= 0 {
return nil, fmt.Errorf("invalid private scalar")
}
x, y := curve.ScalarBaseMult(d.Bytes()) //nolint:staticcheck // SA1019: needs crypto/ecdh refactor
return &ecdsa.PrivateKey{
PublicKey: ecdsa.PublicKey{
Curve: curve,
X: x,
Y: y,
},
D: d,
}, nil
}
// Login is not needed for cryptographic auth since authentication is cryptographic
func (c *CryptoProvider) Login(_ context.Context) error {
return nil
}
func PrintEd25519KeyInfo(pubKey ed25519.PublicKey) {
pubKeyString := base64.StdEncoding.EncodeToString(pubKey)
fmt.Fprint(os.Stdout, "Expected proof record:\n")
fmt.Fprintf(os.Stdout, "v=MCPv1; k=ed25519; p=%s\n", pubKeyString)
}
func PrintEcdsaP384KeyInfo(pubKey ecdsa.PublicKey) {
printEcdsaKeyInfo("ecdsap384", pubKey)
}
func printEcdsaKeyInfo(k string, pubKey ecdsa.PublicKey) {
compressed := elliptic.MarshalCompressed(pubKey.Curve, pubKey.X, pubKey.Y) //nolint:staticcheck // SA1019: needs crypto/ecdh refactor
pubKeyString := base64.StdEncoding.EncodeToString(compressed)
fmt.Fprint(os.Stdout, "Expected proof record:\n")
fmt.Fprintf(os.Stdout, "v=MCPv1; k=%s; p=%s\n", k, pubKeyString)
}
// exchangeTokenForRegistry exchanges signature for a registry JWT token
func (c *CryptoProvider) exchangeTokenForRegistry(ctx context.Context, domain, timestamp, signedTimestamp string) (string, error) {
if c.registryURL == "" {
return "", fmt.Errorf("registry URL is required for token exchange")
}
// Prepare the request body
payload := map[string]string{
"domain": domain,
"timestamp": timestamp,
"signed_timestamp": signedTimestamp,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
// Make the token exchange request
exchangeURL := fmt.Sprintf("%s/v0/auth/%s", c.registryURL, c.authMethod)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body)
}
var tokenResp RegistryTokenResponse
err = json.Unmarshal(body, &tokenResp)
if err != nil {
return "", fmt.Errorf("failed to unmarshal response: %w", err)
}
return tokenResp.RegistryToken, nil
}
```
## /cmd/publisher/auth/dns.go
```go path="/cmd/publisher/auth/dns.go"
package auth
type DNSProvider struct {
*CryptoProvider
}
// NewDNSProvider creates a new DNS-based auth provider
func NewDNSProvider(registryURL, domain string, signer *Signer) Provider {
return &DNSProvider{
CryptoProvider: &CryptoProvider{
registryURL: registryURL,
domain: domain,
signer: *signer,
authMethod: "dns",
},
}
}
// Name returns the name of this auth provider
func (d *DNSProvider) Name() string {
return "dns"
}
```
## /cmd/publisher/auth/github-at.go
```go path="/cmd/publisher/auth/github-at.go"
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const (
// GitHub OAuth URLs
GitHubDeviceCodeURL = "https://github.com/login/device/code" // #nosec:G101
GitHubAccessTokenURL = "https://github.com/login/oauth/access_token" // #nosec:G101
)
// DeviceCodeResponse represents the response from GitHub's device code endpoint
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// AccessTokenResponse represents the response from GitHub's access token endpoint
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error,omitempty"`
}
// RegistryTokenResponse represents the response from registry's token exchange endpoint
type RegistryTokenResponse struct {
RegistryToken string `json:"registry_token"`
ExpiresAt int64 `json:"expires_at"`
}
// GitHubATProvider implements the Provider interface using GitHub's device flow
type GitHubATProvider struct {
clientID string
registryURL string
providedToken string // Token provided via --token flag or MCP_GITHUB_TOKEN env var
githubToken string // In-memory GitHub token set by Login()
}
// ServerHealthResponse represents the response from the health endpoint
type ServerHealthResponse struct {
Status string `json:"status"`
GitHubClientID string `json:"github_client_id"`
}
// NewGitHubATProvider creates a new GitHub OAuth provider
func NewGitHubATProvider(registryURL, token string) Provider {
// Check for token from flag or environment variable
if token == "" {
token = os.Getenv("MCP_GITHUB_TOKEN")
}
return &GitHubATProvider{
registryURL: registryURL,
providedToken: token,
}
}
// GetToken retrieves the registry JWT token (exchanges GitHub token if needed)
func (g *GitHubATProvider) GetToken(ctx context.Context) (string, error) {
if g.githubToken == "" {
return "", fmt.Errorf("no GitHub token available; run Login() first")
}
// Exchange GitHub token for registry token
registryToken, _, err := g.exchangeTokenForRegistry(ctx, g.githubToken)
// Clear the GitHub token from memory after exchange
g.githubToken = ""
if err != nil {
return "", fmt.Errorf("failed to exchange token: %w", err)
}
return registryToken, nil
}
// Login performs the GitHub device flow authentication
func (g *GitHubATProvider) Login(ctx context.Context) error {
// If a token was provided via --token or MCP_GITHUB_TOKEN, store it in memory and skip device flow
if g.providedToken != "" {
g.githubToken = g.providedToken
return nil
}
// If clientID is not set, try to retrieve it from the server's health endpoint
if g.clientID == "" {
clientID, err := getClientID(ctx, g.registryURL)
if err != nil {
return fmt.Errorf("error getting GitHub Client ID: %w", err)
}
g.clientID = clientID
}
// Device flow login logic using GitHub's device flow
// First, request a device code
deviceCode, userCode, verificationURI, err := g.requestDeviceCode(ctx)
if err != nil {
return fmt.Errorf("error requesting device code: %w", err)
}
// Display instructions to the user
_, _ = fmt.Fprintln(os.Stdout, "\nTo authenticate, please:")
_, _ = fmt.Fprintln(os.Stdout, "1. Go to:", verificationURI)
_, _ = fmt.Fprintln(os.Stdout, "2. Enter code:", userCode)
_, _ = fmt.Fprintln(os.Stdout, "3. Authorize this application")
// Poll for the token
_, _ = fmt.Fprintln(os.Stdout, "Waiting for authorization...")
token, err := g.pollForToken(ctx, deviceCode)
if err != nil {
return fmt.Errorf("error polling for token: %w", err)
}
// Store the token in memory
g.githubToken = token
_, _ = fmt.Fprintln(os.Stdout, "Successfully authenticated!")
return nil
}
// Name returns the name of this auth provider
func (g *GitHubATProvider) Name() string {
return "github"
}
// requestDeviceCode initiates the device authorization flow
func (g *GitHubATProvider) requestDeviceCode(ctx context.Context) (string, string, string, error) {
if g.clientID == "" {
return "", "", "", fmt.Errorf("GitHub Client ID is required for device flow login")
}
payload := map[string]string{
"client_id": g.clientID,
"scope": "read:org read:user",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", "", "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubDeviceCodeURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", "", "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", "", err
}
if resp.StatusCode != http.StatusOK {
return "", "", "", fmt.Errorf("request device code failed: %s", body)
}
var deviceCodeResp DeviceCodeResponse
err = json.Unmarshal(body, &deviceCodeResp)
if err != nil {
return "", "", "", err
}
return deviceCodeResp.DeviceCode, deviceCodeResp.UserCode, deviceCodeResp.VerificationURI, nil
}
// pollForToken polls for access token after user completes authorization
func (g *GitHubATProvider) pollForToken(ctx context.Context, deviceCode string) (string, error) {
if g.clientID == "" {
return "", fmt.Errorf("GitHub Client ID is required for device flow login")
}
payload := map[string]string{
"client_id": g.clientID,
"device_code": deviceCode,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", err
}
// Default polling interval and expiration time
interval := 5 // seconds
expiresIn := 900 // 15 minutes
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
for time.Now().Before(deadline) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, GitHubAccessTokenURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return "", err
}
var tokenResp AccessTokenResponse
err = json.Unmarshal(body, &tokenResp)
if err != nil {
return "", err
}
if tokenResp.Error == "authorization_pending" {
// User hasn't authorized yet, wait and retry
time.Sleep(time.Duration(interval) * time.Second)
continue
}
if tokenResp.Error != "" {
return "", fmt.Errorf("token request failed: %s", tokenResp.Error)
}
if tokenResp.AccessToken != "" {
return tokenResp.AccessToken, nil
}
// If we reach here, something unexpected happened
return "", fmt.Errorf("failed to obtain access token")
}
return "", fmt.Errorf("device code authorization timed out")
}
func getClientID(ctx context.Context, registryURL string) (string, error) {
// This function should retrieve the GitHub Client ID from the registry URL
// For now, we will return a placeholder value
// In a real implementation, this would likely involve querying the registry or configuration
if registryURL == "" {
return "", fmt.Errorf("registry URL is required to get GitHub Client ID")
}
// get the clientID from the server's health endpoint
healthURL := registryURL + "/v0/health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return "", err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("health endpoint returned status %d: %s", resp.StatusCode, body)
}
var healthResponse ServerHealthResponse
err = json.NewDecoder(resp.Body).Decode(&healthResponse)
if err != nil {
return "", err
}
if healthResponse.GitHubClientID == "" {
return "", fmt.Errorf("GitHub Client ID is not set in the server's health response")
}
githubClientID := healthResponse.GitHubClientID
return githubClientID, nil
}
// exchangeTokenForRegistry exchanges a GitHub token for a registry JWT token
func (g *GitHubATProvider) exchangeTokenForRegistry(ctx context.Context, githubToken string) (string, int64, error) {
if g.registryURL == "" {
return "", 0, fmt.Errorf("registry URL is required for token exchange")
}
// Prepare the request body
payload := map[string]string{
"github_token": githubToken,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", 0, fmt.Errorf("failed to marshal request: %w", err)
}
// Make the token exchange request
exchangeURL := g.registryURL + "/v0/auth/github-at"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", 0, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", 0, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", 0, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body)
}
var tokenResp RegistryTokenResponse
err = json.Unmarshal(body, &tokenResp)
if err != nil {
return "", 0, fmt.Errorf("failed to unmarshal response: %w", err)
}
return tokenResp.RegistryToken, tokenResp.ExpiresAt, nil
}
```
## /cmd/publisher/auth/github-oidc.go
```go path="/cmd/publisher/auth/github-oidc.go"
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
)
type GitHubOIDCProvider struct {
registryURL string
}
// NewGitHubOIDCProvider creates a new GitHub OIDC provider
func NewGitHubOIDCProvider(registryURL string) Provider {
return &GitHubOIDCProvider{
registryURL: registryURL,
}
}
// GetToken retrieves the registry JWT token using GitHub Actions OIDC token
func (o *GitHubOIDCProvider) GetToken(ctx context.Context) (string, error) {
audience, err := audienceFromRegistryURL(o.registryURL)
if err != nil {
return "", fmt.Errorf("invalid --registry URL: %w", err)
}
// Get OIDC token from GitHub Actions endpoint
oidcToken, err := o.getOIDCTokenFromGitHub(ctx, audience)
if err != nil {
return "", fmt.Errorf("failed to get OIDC token from GitHub: %w", err)
}
// Exchange OIDC token for registry token
registryToken, err := o.exchangeOIDCTokenForRegistry(ctx, oidcToken)
if err != nil {
return "", fmt.Errorf("failed to exchange OIDC token: %w", err)
}
return registryToken, nil
}
// Login is not needed for OIDC since tokens are provided by GitHub Actions
func (o *GitHubOIDCProvider) Login(_ context.Context) error {
// No interactive login needed for OIDC
return nil
}
// Name returns the name of this auth provider
func (o *GitHubOIDCProvider) Name() string {
return "github-oidc"
}
// exchangeOIDCTokenForRegistry exchanges a GitHub OIDC token for a registry JWT token
func (o *GitHubOIDCProvider) exchangeOIDCTokenForRegistry(ctx context.Context, oidcToken string) (string, error) {
if o.registryURL == "" {
return "", fmt.Errorf("registry URL is required for token exchange")
}
// Prepare the request body
payload := map[string]string{
"oidc_token": oidcToken,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
// Make the token exchange request
exchangeURL := o.registryURL + "/v0/auth/github-oidc"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, exchangeURL, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, body)
}
var tokenResp RegistryTokenResponse
err = json.Unmarshal(body, &tokenResp)
if err != nil {
return "", fmt.Errorf("failed to unmarshal response: %w", err)
}
return tokenResp.RegistryToken, nil
}
// getOIDCTokenFromGitHub fetches the OIDC token from GitHub Actions endpoint.
func (o *GitHubOIDCProvider) getOIDCTokenFromGitHub(ctx context.Context, audience string) (string, error) {
// Check for required environment variables
requestToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
if requestToken == "" {
return "", fmt.Errorf("ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variable not found - are you running in GitHub Actions with id-token: write permissions?")
}
requestURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL")
if requestURL == "" {
return "", fmt.Errorf("ACTIONS_ID_TOKEN_REQUEST_URL environment variable not found - are you running in GitHub Actions with id-token: write permissions?")
}
// Build the full URL with audience parameter
fullURL := requestURL + "&audience=" + url.QueryEscape(audience)
// Create the request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) //nolint:gosec // G704: URL is from GitHub Actions ACTIONS_ID_TOKEN_REQUEST_URL env var
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
// Set the authorization header
req.Header.Set("Authorization", "Bearer "+requestToken)
req.Header.Set("Accept", "application/json")
// Make the request
client := &http.Client{}
resp, err := client.Do(req) //nolint:gosec // G704: URL is from GitHub Actions env var
if err != nil {
return "", fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// Read the response
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GitHub OIDC token request failed with status %d: %s", resp.StatusCode, body)
}
// Parse the response to extract the token value
var tokenResp struct {
Value string `json:"value"`
}
err = json.Unmarshal(body, &tokenResp)
if err != nil {
return "", fmt.Errorf("failed to unmarshal OIDC token response: %w", err)
}
if tokenResp.Value == "" {
return "", fmt.Errorf("OIDC token value is empty in response")
}
return tokenResp.Value, nil
}
// audienceFromRegistryURL returns scheme + lowercased host for the given URL.
func audienceFromRegistryURL(registryURL string) (string, error) {
registryURL = strings.TrimSpace(registryURL)
if registryURL == "" {
return "", fmt.Errorf("registry URL is empty")
}
u, err := url.Parse(registryURL)
if err != nil {
return "", fmt.Errorf("parse %q: %w", registryURL, err)
}
if u.Scheme == "" || u.Host == "" {
return "", fmt.Errorf("registry URL must include scheme and host: %q", registryURL)
}
return u.Scheme + "://" + strings.ToLower(u.Host), nil
}
```
## /cmd/publisher/auth/github-oidc_internal_test.go
```go path="/cmd/publisher/auth/github-oidc_internal_test.go"
package auth
import "testing"
func TestAudienceFromRegistryURL(t *testing.T) {
tests := []struct {
input string
want string
wantErr bool
}{
// Canonical forms
{"https://registry.modelcontextprotocol.io", "https://registry.modelcontextprotocol.io", false},
{"https://staging.registry.modelcontextprotocol.io", "https://staging.registry.modelcontextprotocol.io", false},
// Trailing slash and path are stripped
{"https://registry.modelcontextprotocol.io/", "https://registry.modelcontextprotocol.io", false},
{"https://registry.modelcontextprotocol.io/api/v0", "https://registry.modelcontextprotocol.io", false},
// Host is lowercased
{"https://Registry.Example.COM", "https://registry.example.com", false},
// Whitespace is tolerated
{" https://registry.example ", "https://registry.example", false},
// Invalid inputs
{"", "", true},
{"registry.example", "", true}, // missing scheme
{"https://", "", true}, // missing host
{"://nothing", "", true}, // missing scheme
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
got, err := audienceFromRegistryURL(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q, got %q", tc.input, got)
}
return
}
if err != nil {
t.Fatalf("unexpected error for %q: %v", tc.input, err)
}
if got != tc.want {
t.Errorf("audienceFromRegistryURL(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
```
## /cmd/publisher/auth/github_at_test.go
```go path="/cmd/publisher/auth/github_at_test.go"
package auth_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/modelcontextprotocol/registry/cmd/publisher/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewGitHubATProvider_Name(t *testing.T) {
p := auth.NewGitHubATProvider("https://registry.example.com", "")
assert.Equal(t, "github", p.Name())
}
func TestNewGitHubATProvider_WithEnvToken(t *testing.T) {
t.Setenv("MCP_GITHUB_TOKEN", "env-token")
registry := newMockExchangeServer(t, "env-token")
p := auth.NewGitHubATProvider(registry.URL, "")
// Login should use the env var token
err := p.Login(context.Background())
require.NoError(t, err)
// GetToken should exchange it successfully
token, err := p.GetToken(context.Background())
require.NoError(t, err)
assert.Equal(t, "registry-jwt", token)
}
func TestNewGitHubATProvider_ExplicitTokenTakesPrecedence(t *testing.T) {
t.Setenv("MCP_GITHUB_TOKEN", "env-token")
registry := newMockExchangeServer(t, "explicit-token")
p := auth.NewGitHubATProvider(registry.URL, "explicit-token")
err := p.Login(context.Background())
require.NoError(t, err)
token, err := p.GetToken(context.Background())
require.NoError(t, err)
assert.Equal(t, "registry-jwt", token)
}
func TestGitHubATProvider_LoginWithProvidedToken(t *testing.T) {
registry := newMockExchangeServer(t, "my-token")
p := auth.NewGitHubATProvider(registry.URL, "my-token")
err := p.Login(context.Background())
require.NoError(t, err)
// Verify the token exchange works (proves Login stored the token)
token, err := p.GetToken(context.Background())
require.NoError(t, err)
assert.Equal(t, "registry-jwt", token)
}
func TestGitHubATProvider_LoginDoesNotWriteFiles(t *testing.T) {
cwd, err := os.Getwd()
require.NoError(t, err)
p := auth.NewGitHubATProvider("https://registry.example.com", "my-token")
err = p.Login(context.Background())
require.NoError(t, err)
// Verify no .mcpregistry_* files were created in cwd
for _, name := range []string{".mcpregistry_github_token", ".mcpregistry_registry_token"} {
_, statErr := os.Stat(filepath.Join(cwd, name))
assert.True(t, os.IsNotExist(statErr), "Login should not create %s in cwd", name)
}
}
func TestGitHubATProvider_GetTokenWithoutLogin(t *testing.T) {
p := auth.NewGitHubATProvider("https://registry.example.com", "my-token")
// GetToken without Login should fail
_, err := p.GetToken(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "no GitHub token available")
}
func TestGitHubATProvider_GetTokenClearsTokenAfterExchange(t *testing.T) {
registry := newMockExchangeServer(t, "my-token")
p := auth.NewGitHubATProvider(registry.URL, "my-token")
err := p.Login(context.Background())
require.NoError(t, err)
// First GetToken should succeed
token, err := p.GetToken(context.Background())
require.NoError(t, err)
assert.Equal(t, "registry-jwt", token)
// Second GetToken should fail (GitHub token was cleared after exchange)
_, err = p.GetToken(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "no GitHub token available")
}
func TestGitHubATProvider_GetTokenClearsTokenOnError(t *testing.T) {
// Mock registry that always returns an error
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "server error", http.StatusInternalServerError)
}))
t.Cleanup(registry.Close)
p := auth.NewGitHubATProvider(registry.URL, "my-token")
err := p.Login(context.Background())
require.NoError(t, err)
// GetToken should fail
_, err = p.GetToken(context.Background())
require.Error(t, err)
// Token should be cleared — second call also fails with "no GitHub token"
_, err = p.GetToken(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "no GitHub token available")
}
func TestGitHubATProvider_GetTokenExchangeFailure_NoURL(t *testing.T) {
p := auth.NewGitHubATProvider("", "my-token")
err := p.Login(context.Background())
require.NoError(t, err)
_, err = p.GetToken(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to exchange token")
}
func TestGitHubATProvider_LoginDeviceFlow_FetchesClientID(t *testing.T) {
// Mock registry health endpoint
registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v0/health" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"github_client_id": "test-client-id",
})
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
t.Cleanup(registry.Close)
p := auth.NewGitHubATProvider(registry.URL, "")
// Login without a provided token triggers device flow, which calls
// the real GitHub device code URL. This will fail, but only after
// fetching the client ID from our mock health endpoint.
err := p.Login(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "error requesting device code")
}
func TestGitHubATProvider_ImplementsProviderInterface(_ *testing.T) {
// Compile-time check
var _ = auth.NewGitHubATProvider("https://example.com", "token")
}
// newMockExchangeServer creates an httptest.Server that mocks the registry's
// token exchange endpoint. It accepts a specific GitHub token and returns
// "registry-jwt" as the registry token.
func newMockExchangeServer(t *testing.T, expectedGitHubToken string) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v0/auth/github-at" && r.Method == http.MethodPost {
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if body["github_token"] == expectedGitHubToken {
w.Header().Set("Content-Type", "application/json")
resp := map[string]interface{}{
"registry_token": "registry-jwt",
"expires_at": 9999999999,
}
_ = json.NewEncoder(w).Encode(resp)
return
}
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
http.Error(w, "not found", http.StatusNotFound)
}))
t.Cleanup(server.Close)
return server
}
```
## /cmd/publisher/auth/googlekms/common.go
```go path="/cmd/publisher/auth/googlekms/common.go"
package googlekms
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/sha512"
"crypto/x509"
"encoding/asn1"
"encoding/pem"
"errors"
"fmt"
"math/big"
"os"
kms "cloud.google.com/go/kms/apiv1"
"cloud.google.com/go/kms/apiv1/kmspb"
"github.com/modelcontextprotocol/registry/cmd/publisher/auth"
)
// GetSignatureProvider validates inputs and returns a GoogleKMSSigner implementing auth.Signer.
func GetSignatureProvider(resourceName string) (auth.Signer, error) {
if resourceName == "" {
return nil, fmt.Errorf("--resource is required, e.g. projects/my-project/locations/global/keyRings/fellowship/cryptoKeys/bilbo/cryptoKeyVersions/1")
}
return &Signer{
resource: resourceName,
}, nil
}
type Signer struct {
resource string
}
type ecdsaSignature struct {
R, S *big.Int
}
func derToRS(der []byte, curve elliptic.Curve) ([]byte, error) {
var sig ecdsaSignature
if _, err := asn1.Unmarshal(der, &sig); err != nil {
return nil, fmt.Errorf("invalid DER ECDSA signature: %w", err)
}
if sig.R == nil || sig.S == nil || sig.R.Sign() <= 0 || sig.S.Sign() <= 0 {
return nil, fmt.Errorf("invalid ECDSA signature components")
}
size := (curve.Params().BitSize + 7) / 8
rBytes := sig.R.Bytes()
sBytes := sig.S.Bytes()
if len(rBytes) > size || len(sBytes) > size {
return nil, fmt.Errorf("ECDSA signature component too large")
}
out := make([]byte, 2*size)
copy(out[size-len(rBytes):size], rBytes)
copy(out[2*size-len(sBytes):], sBytes)
return out, nil
}
func (g *Signer) GetSignedTimestamp(ctx context.Context) (*string, []byte, error) {
client, err := kms.NewKeyManagementClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to create KMS client: %w", err)
}
defer client.Close()
// Fetch public key (PEM) so we can output expected proof record.
algo, err := g.showPublicKeyAndGetAlgorithm(ctx, client)
if err != nil {
return nil, nil, err
}
timestamp := auth.GetTimestamp()
fmt.Fprintln(os.Stdout, "Executing the sign request...")
switch algo {
case auth.AlgorithmEd25519:
signReq := &kmspb.AsymmetricSignRequest{
Name: g.resource,
Data: []byte(timestamp),
}
signResp, err := client.AsymmetricSign(ctx, signReq)
if err != nil {
return nil, nil, fmt.Errorf("failed to sign with KMS: %w", err)
}
return ×tamp, signResp.Signature, nil
case auth.AlgorithmECDSAP384:
digest := sha512.Sum384([]byte(timestamp))
signReq := &kmspb.AsymmetricSignRequest{
Name: g.resource,
Digest: &kmspb.Digest{Digest: &kmspb.Digest_Sha384{Sha384: digest[:]}},
}
signResp, err := client.AsymmetricSign(ctx, signReq)
if err != nil {
return nil, nil, fmt.Errorf("failed to sign with KMS: %w", err)
}
sigBytes, err := derToRS(signResp.Signature, elliptic.P384())
if err != nil {
return nil, nil, fmt.Errorf("failed to convert DER signature: %w", err)
}
return ×tamp, sigBytes, nil
}
return nil, nil, fmt.Errorf("unsupported algorithm: %s", algo)
}
func (g *Signer) showPublicKeyAndGetAlgorithm(ctx context.Context, client *kms.KeyManagementClient) (auth.CryptoAlgorithm, error) {
pubResp, err := client.GetPublicKey(ctx, &kmspb.GetPublicKeyRequest{Name: g.resource})
if err != nil {
return "", fmt.Errorf("failed to get public key: %w", err)
}
block, _ := pem.Decode([]byte(pubResp.Pem))
if block == nil {
return "", errors.New("failed to decode PEM public key from KMS")
}
parsed, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse public key: %w", err)
}
switch pubResp.Algorithm { //nolint:exhaustive
case kmspb.CryptoKeyVersion_EC_SIGN_ED25519:
pk, ok := parsed.(ed25519.PublicKey)
if !ok {
return "", errors.New("KMS reported ED25519 but parsed key is different type")
}
auth.PrintEd25519KeyInfo(pk)
return auth.AlgorithmEd25519, nil
case kmspb.CryptoKeyVersion_EC_SIGN_P384_SHA384:
pk, ok := parsed.(*ecdsa.PublicKey)
if !ok || pk.Curve != elliptic.P384() {
return "", errors.New("KMS reported P-384 but parsed key mismatch")
}
auth.PrintEcdsaP384KeyInfo(*pk)
return auth.AlgorithmECDSAP384, nil
default:
return "", fmt.Errorf("unsupported KMS key algorithm: %s", pubResp.Algorithm.String())
}
}
```
## /cmd/publisher/auth/http.go
```go path="/cmd/publisher/auth/http.go"
package auth
type HTTPProvider struct {
*CryptoProvider
}
// NewHTTPProvider creates a new HTTP-based auth provider
func NewHTTPProvider(registryURL, domain string, signer *Signer) Provider {
return &HTTPProvider{
CryptoProvider: &CryptoProvider{
registryURL: registryURL,
domain: domain,
signer: *signer,
authMethod: "http",
},
}
}
// Name returns the name of this auth provider
func (h *HTTPProvider) Name() string {
return "http"
}
```
## /cmd/publisher/auth/interface.go
```go path="/cmd/publisher/auth/interface.go"
package auth
import "context"
// Provider defines the interface for authentication mechanisms
type Provider interface {
// GetToken retrieves or generates an authentication token
// It returns the token string and any error encountered
GetToken(ctx context.Context) (string, error)
// Login performs the authentication flow
// This might involve user interaction, device flows, etc.
Login(ctx context.Context) error
// Name returns the name of the authentication provider
Name() string
}
```
## /cmd/publisher/auth/none.go
```go path="/cmd/publisher/auth/none.go"
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type NoneProvider struct {
registryURL string
token string
}
type TokenResponse struct {
RegistryToken string `json:"registry_token"`
ExpiresAt int64 `json:"expires_at"`
}
func NewNoneProvider(registryURL string) Provider {
return &NoneProvider{
registryURL: registryURL,
}
}
func (p *NoneProvider) GetToken(ctx context.Context) (string, error) {
if p.token != "" {
return p.token, nil
}
// Get anonymous token from registry
if !strings.HasSuffix(p.registryURL, "/") {
p.registryURL += "/"
}
tokenURL := p.registryURL + "v0/auth/none"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %w", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error getting anonymous token: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("failed to get anonymous token (status %d): %s", resp.StatusCode, body)
}
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return "", fmt.Errorf("error decoding token response: %w", err)
}
p.token = tokenResp.RegistryToken
return p.token, nil
}
func (p *NoneProvider) Login(_ context.Context) error {
// No login needed for anonymous auth
return nil
}
func (p *NoneProvider) Name() string {
return "none"
}
```
## /cmd/publisher/commands/init.go
```go path="/cmd/publisher/commands/init.go"
package commands
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
)
func InitCommand() error {
// Check if server.json already exists
if _, err := os.Stat("server.json"); err == nil {
return errors.New("server.json already exists")
}
// Detect if we're in a subdirectory of the git repository
subfolder := detectSubfolder()
// Try to detect values from environment
name := detectServerName(subfolder)
description := detectDescription()
version := getVersionFromPackageJSON()
if version == "" {
version = "1.0.0"
}
repoURL := detectRepoURL()
repoSource := MethodGitHub
if repoURL != "" && !strings.Contains(repoURL, "github.com") {
if strings.Contains(repoURL, "gitlab.com") {
repoSource = "gitlab"
} else {
repoSource = "git"
}
}
packageType := detectPackageType()
packageIdentifier := detectPackageIdentifier(name, packageType)
// Create example environment variables
envVars := []model.KeyValueInput{
{
Name: "YOUR_API_KEY",
InputWithVariables: model.InputWithVariables{
Input: model.Input{
Description: "Your API key for the service",
IsRequired: true,
IsSecret: true,
Format: model.FormatString,
},
},
},
}
// Create the server structure
server := createServerJSON(
model.CurrentSchemaURL, name, description, version, repoURL, repoSource, subfolder,
packageType, packageIdentifier, version, envVars,
)
// Write to file
jsonData, err := json.MarshalIndent(server, "", " ")
if err != nil {
return fmt.Errorf("error marshaling JSON: %w", err)
}
err = os.WriteFile("server.json", jsonData, 0600)
if err != nil {
return fmt.Errorf("error writing file: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, "Created server.json")
_, _ = fmt.Fprintln(os.Stdout, "\nEdit server.json to update:")
_, _ = fmt.Fprintln(os.Stdout, " • Server name and description")
_, _ = fmt.Fprintln(os.Stdout, " • Package details")
_, _ = fmt.Fprintln(os.Stdout, " • Environment variables")
_, _ = fmt.Fprintln(os.Stdout, "\nThen publish with:")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login github # or your preferred auth method")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher publish")
return nil
}
func detectSubfolder() string {
// Get current working directory
cwd, err := os.Getwd()
if err != nil {
return ""
}
// Find git repository root
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
cmd.Dir = cwd
output, err := cmd.Output()
if err != nil {
// Not in a git repository
return ""
}
gitRoot := strings.TrimSpace(string(output))
// Clean the paths to ensure proper comparison
gitRoot = filepath.Clean(gitRoot)
cwd = filepath.Clean(cwd)
// If we're in the root, no subfolder
if gitRoot == cwd {
return ""
}
// Check if cwd is actually within gitRoot
if !strings.HasPrefix(cwd, gitRoot) {
return ""
}
// Calculate relative path from git root to current directory
relPath, err := filepath.Rel(gitRoot, cwd)
if err != nil {
return ""
}
// Convert to forward slashes for consistency (important for cross-platform)
return filepath.ToSlash(relPath)
}
// getMcpNameFromPackageJSON returns the `mcpName` field from package.json, or
// "" if not set. mcpName is the authoritative MCP server name when present —
// see docs/modelcontextprotocol-io/quickstart.mdx.
func getMcpNameFromPackageJSON() string {
data, err := os.ReadFile("package.json")
if err != nil {
return ""
}
var pkg map[string]any
if err := json.Unmarshal(data, &pkg); err != nil {
return ""
}
if mcpName, ok := pkg["mcpName"].(string); ok && mcpName != "" {
return mcpName
}
return ""
}
func getNameFromPackageJSON() string {
data, err := os.ReadFile("package.json")
if err != nil {
return ""
}
var pkg map[string]any
if err := json.Unmarshal(data, &pkg); err != nil {
return ""
}
name, ok := pkg["name"].(string)
if !ok || name == "" {
return ""
}
// Convert npm package name to MCP server name
// @org/package -> io.github.org/package
if strings.HasPrefix(name, "@") {
parts := strings.Split(name[1:], "/")
if len(parts) == 2 {
return fmt.Sprintf("io.github.%s/%s", parts[0], parts[1])
}
}
return fmt.Sprintf("io.github.<your-username>/%s", name)
}
func getVersionFromPackageJSON() string {
data, err := os.ReadFile("package.json")
if err != nil {
return ""
}
var pkg map[string]any
if err := json.Unmarshal(data, &pkg); err != nil {
return ""
}
version, ok := pkg["version"].(string)
if !ok || version == "" {
return ""
}
return version
}
func detectServerName(subfolder string) string {
// mcpName in package.json is the authoritative server name when set, so it
// takes precedence over names inferred from the git remote or the npm
// `name` field.
if name := getMcpNameFromPackageJSON(); name != "" {
return name
}
// Try to get from git remote
repoURL := detectRepoURL()
if repoURL != "" && strings.Contains(repoURL, "github.com") {
name := buildGitHubServerName(repoURL, subfolder)
if name != "" {
return name
}
}
// Try to get from package.json
name := getNameFromPackageJSON()
if name != "" {
return name
}
// Use current directory name as fallback
if cwd, err := os.Getwd(); err == nil {
return fmt.Sprintf("com.example/%s", filepath.Base(cwd))
}
return "com.example/my-mcp-server"
}
func buildGitHubServerName(repoURL, subfolder string) string {
parts := strings.Split(repoURL, "/")
if len(parts) < 5 {
return ""
}
owner := parts[3]
repo := strings.TrimSuffix(parts[4], ".git")
// If we're in a subdirectory, use the current folder name
if subfolder != "" {
folderName := filepath.Base(subfolder)
return fmt.Sprintf("io.github.%s/%s", owner, folderName)
}
return fmt.Sprintf("io.github.%s/%s", owner, repo)
}
func detectDescription() string {
// Try to get from package.json
if data, err := os.ReadFile("package.json"); err == nil {
var pkg map[string]any
if json.Unmarshal(data, &pkg) == nil {
if desc, ok := pkg["description"].(string); ok && desc != "" {
return desc
}
}
}
return "An MCP server that provides [describe what your server does]"
}
func detectRepoURL() string {
sanitizeURL := func(url string) string {
return strings.TrimPrefix(url, "git+")
}
// Try git remote
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
if output, err := cmd.Output(); err == nil {
url := strings.TrimSpace(string(output))
// Convert SSH URL to HTTPS if needed
if strings.HasPrefix(url, "git@github.com:") {
url = strings.Replace(url, "git@github.com:", "https://github.com/", 1)
}
url = strings.TrimSuffix(url, ".git")
return url
}
// Try package.json repository field
if data, err := os.ReadFile("package.json"); err == nil {
var pkg map[string]any
if json.Unmarshal(data, &pkg) == nil {
if repo, ok := pkg["repository"].(map[string]any); ok {
if url, ok := repo["url"].(string); ok {
return sanitizeURL(strings.TrimSuffix(url, ".git"))
}
}
if repo, ok := pkg["repository"].(string); ok {
return sanitizeURL(strings.TrimSuffix(repo, ".git"))
}
}
}
return ""
}
func detectPackageType() string {
// Check for package.json
if _, err := os.Stat("package.json"); err == nil {
return model.RegistryTypeNPM
}
// Check for pyproject.toml or setup.py
if _, err := os.Stat("pyproject.toml"); err == nil {
return model.RegistryTypePyPI
}
if _, err := os.Stat("setup.py"); err == nil {
return model.RegistryTypePyPI
}
// Check for Dockerfile
if _, err := os.Stat("Dockerfile"); err == nil {
return model.RegistryTypeOCI
}
// Default to npm as most common
return model.RegistryTypeNPM
}
func detectPackageIdentifier(serverName string, packageType string) string {
switch packageType {
case model.RegistryTypeNPM:
// Try to get from package.json
if data, err := os.ReadFile("package.json"); err == nil {
var pkg map[string]any
if json.Unmarshal(data, &pkg) == nil {
if name, ok := pkg["name"].(string); ok && name != "" {
return name
}
}
}
// Convert server name to npm package name
if strings.HasPrefix(serverName, "io.github.") {
parts := strings.Split(serverName, "/")
if len(parts) == 2 {
owner := strings.TrimPrefix(parts[0], "io.github.")
return fmt.Sprintf("@%s/%s", owner, parts[1])
}
}
return "@your-org/your-package"
case model.RegistryTypePyPI:
// Try to get from pyproject.toml or setup.py
if data, err := os.ReadFile("pyproject.toml"); err == nil {
// Simple extraction - could be improved with proper TOML parser
lines := strings.Split(string(data), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "name") && strings.Contains(line, "=") {
parts := strings.Split(line, "=")
if len(parts) >= 2 {
name := strings.Trim(parts[1], " \"'")
if name != "" {
return name
}
}
}
}
}
return "your-package"
case model.RegistryTypeOCI:
// Use a sensible default
if strings.Contains(serverName, "/") {
parts := strings.Split(serverName, "/")
return parts[len(parts)-1]
}
return "your-image"
default:
return "your-package"
}
}
func createServerJSON(
currentSchema, name, description, version, repoURL, repoSource, subfolder,
packageType, packageIdentifier, packageVersion string,
envVars []model.KeyValueInput,
) apiv0.ServerJSON {
// Create package based on type
var pkg model.Package
switch packageType {
case model.RegistryTypeNPM:
pkg = model.Package{
RegistryType: model.RegistryTypeNPM,
Identifier: packageIdentifier,
Version: packageVersion,
EnvironmentVariables: envVars,
Transport: model.Transport{
Type: model.TransportTypeStdio,
},
}
case model.RegistryTypePyPI:
pkg = model.Package{
RegistryType: model.RegistryTypePyPI,
Identifier: packageIdentifier,
Version: packageVersion,
EnvironmentVariables: envVars,
Transport: model.Transport{
Type: model.TransportTypeStdio,
},
}
case model.RegistryTypeOCI:
// OCI packages use canonical references: registry/namespace/image:tag
// Format: docker.io/username/image:version
canonicalRef := fmt.Sprintf("docker.io/%s:%s", packageIdentifier, packageVersion)
pkg = model.Package{
RegistryType: model.RegistryTypeOCI,
Identifier: canonicalRef,
// No Version field for OCI - it's embedded in the canonical reference
EnvironmentVariables: envVars,
Transport: model.Transport{
Type: model.TransportTypeStdio,
},
}
case "url":
pkg = model.Package{
RegistryType: "url",
Identifier: packageIdentifier,
Version: packageVersion,
EnvironmentVariables: envVars,
Transport: model.Transport{
Type: model.TransportTypeStdio,
},
}
default:
pkg = model.Package{
RegistryType: packageType,
Identifier: packageIdentifier,
Version: packageVersion,
EnvironmentVariables: envVars,
Transport: model.Transport{
Type: model.TransportTypeStdio,
},
}
}
// Create repository with optional subfolder
var repo *model.Repository
if repoURL != "" && repoSource != "" {
repo = &model.Repository{
URL: repoURL,
Source: repoSource,
}
// Only set subfolder if we're actually in a subdirectory
if subfolder != "" {
repo.Subfolder = subfolder
}
}
// Create server structure
return apiv0.ServerJSON{
Schema: currentSchema,
Name: name,
Description: description,
Repository: repo,
Version: version,
Packages: []model.Package{pkg},
}
}
```
## /cmd/publisher/commands/init_test.go
```go path="/cmd/publisher/commands/init_test.go"
package commands_test
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInitCommand_PackageJSON(t *testing.T) {
tests := []struct {
name string
pkgJSON string
expectedName string
expectedVersion string
}{
{
name: "mcpName is returned as-is without transformation",
pkgJSON: `{"name": "@acme/weather", "mcpName": "io.github.acme/weather-mcp", "version": "2.3.4", "repository": "https://gitlab.com/acme/weather"}`,
expectedName: "io.github.acme/weather-mcp",
expectedVersion: "2.3.4",
},
{
name: "empty mcpName falls back to scoped name transformation",
pkgJSON: `{"name": "@acme/weather", "mcpName": "", "version": "1.2.3", "repository": "https://gitlab.com/acme/weather"}`,
expectedName: "io.github.acme/weather",
expectedVersion: "1.2.3",
},
{
name: "missing mcpName falls back to scoped name transformation",
pkgJSON: `{"name": "@acme/weather", "version": "1.2.3", "repository": "https://gitlab.com/acme/weather"}`,
expectedName: "io.github.acme/weather",
expectedVersion: "1.2.3",
},
{
name: "missing version falls back to 1.0.0",
pkgJSON: `{"name": "@acme/weather", "mcpName": "io.github.acme/weather", "repository": "https://gitlab.com/acme/weather"}`,
expectedName: "io.github.acme/weather",
expectedVersion: "1.0.0",
},
{
name: "mcpName takes precedence over GitHub repository URL",
pkgJSON: `{"name": "weather", "mcpName": "io.github.acme/weather-mcp", "version": "1.0.0", "repository": "https://github.com/someone-else/some-repo"}`,
expectedName: "io.github.acme/weather-mcp",
expectedVersion: "1.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := withIsolatedPackageJSON(t, tt.pkgJSON)
require.NoError(t, commands.InitCommand())
data, err := os.ReadFile(filepath.Join(dir, "server.json"))
require.NoError(t, err)
var got apiv0.ServerJSON
require.NoError(t, json.Unmarshal(data, &got))
assert.Equal(t, tt.expectedName, got.Name)
assert.Equal(t, tt.expectedVersion, got.Version)
require.Len(t, got.Packages, 1)
assert.Equal(t, tt.expectedVersion, got.Packages[0].Version,
"package version should match server version")
})
}
}
// withIsolatedPackageJSON creates a temp dir outside any git repository,
// writes package.json with the given contents, chdirs into it, and returns
// the directory path. The HOME override prevents init from detecting the
// host repo's git state.
func withIsolatedPackageJSON(t *testing.T, contents string) string {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "package.json"), []byte(contents), 0600))
originalDir, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(originalDir) })
require.NoError(t, os.Chdir(dir))
return dir
}
```
## /cmd/publisher/commands/login.go
```go path="/cmd/publisher/commands/login.go"
package commands
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/modelcontextprotocol/registry/cmd/publisher/auth"
"github.com/modelcontextprotocol/registry/cmd/publisher/auth/azurekeyvault"
"github.com/modelcontextprotocol/registry/cmd/publisher/auth/googlekms"
)
const (
DefaultRegistryURL = "https://registry.modelcontextprotocol.io"
LegacyTokenFileName = ".mcp_publisher_token" //nolint:gosec // Not a credential, just a filename
MethodGitHub = "github"
MethodGitHubOIDC = "github-oidc"
MethodDNS = "dns"
MethodHTTP = "http"
MethodNone = "none"
)
// tokenFilePath returns the path to the token file in the user's config directory
// (~/.config/mcp-publisher/token.json). It does not create the directory.
func tokenFilePath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, ".config", "mcp-publisher", "token.json"), nil
}
// notAuthenticatedError returns an error guiding the user to log in,
// with a hint about the token location change for users upgrading.
func notAuthenticatedError() error {
_, _ = fmt.Fprintln(os.Stderr, "hint: token storage moved to ~/.config/mcp-publisher/. If you recently upgraded, please re-login.")
return errors.New("not authenticated, run 'mcp-publisher login <method>' first")
}
// ensureTokenDir creates the token directory (~/.config/mcp-publisher/) if needed.
func ensureTokenDir() error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get home directory: %w", err)
}
dir := filepath.Join(homeDir, ".config", "mcp-publisher")
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
return nil
}
type CryptoAlgorithm auth.CryptoAlgorithm
type SignerType string
type LoginFlags struct {
Domain string
PrivateKey string
RegistryURL string
KvVault string
KvKeyName string
KmsResource string
Token Token
CryptoAlgorithm CryptoAlgorithm
SignerType SignerType
ArgOffset int
}
const (
InProcessSignerType SignerType = "in-process"
AzureKeyVaultSignerType SignerType = "azure-key-vault"
GoogleKMSSignerType SignerType = "google-kms"
NoSignerType SignerType = "none"
)
func (c *CryptoAlgorithm) String() string {
return string(*c)
}
func (c *CryptoAlgorithm) Set(v string) error {
switch v {
case string(auth.AlgorithmEd25519), string(auth.AlgorithmECDSAP384):
*c = CryptoAlgorithm(v)
return nil
}
return fmt.Errorf("invalid algorithm: %q (allowed: ed25519, ecdsap384)", v)
}
type Token string
func parseLoginFlags(method string, args []string) (LoginFlags, error) {
var flags LoginFlags
loginFlags := flag.NewFlagSet("login", flag.ExitOnError)
flags.CryptoAlgorithm = CryptoAlgorithm(auth.AlgorithmEd25519)
flags.SignerType = NoSignerType
flags.ArgOffset = 1
loginFlags.StringVar(&flags.RegistryURL, "registry", DefaultRegistryURL, "Registry URL")
// Add --token flag for GitHub authentication
var token string
if method == MethodGitHub {
loginFlags.StringVar(&token, "token", "", "GitHub Personal Access Token")
}
if method == "dns" || method == "http" {
loginFlags.StringVar(&flags.Domain, "domain", "", "Domain name")
if len(args) > 1 {
switch args[1] {
case string(AzureKeyVaultSignerType):
flags.SignerType = AzureKeyVaultSignerType
loginFlags.StringVar(&flags.KvVault, "vault", "", "The name of the Azure Key Vault resource")
loginFlags.StringVar(&flags.KvKeyName, "key", "", "Name of the signing key in the Azure Key Vault")
flags.ArgOffset = 2
case string(GoogleKMSSignerType):
flags.SignerType = GoogleKMSSignerType
loginFlags.StringVar(&flags.KmsResource, "resource", "", "Google Cloud KMS resource name (e.g. projects/lotr/locations/global/keyRings/fellowship/cryptoKeys/frodo/cryptoKeyVersions/1)")
flags.ArgOffset = 2
}
}
if flags.SignerType == NoSignerType {
flags.SignerType = InProcessSignerType
loginFlags.StringVar(&flags.PrivateKey, "private-key", "", "Private key (hex)")
loginFlags.Var(&flags.CryptoAlgorithm, "algorithm", "Cryptographic algorithm (ed25519, ecdsap384)")
}
}
err := loginFlags.Parse(args[flags.ArgOffset:])
if err == nil {
flags.RegistryURL = strings.TrimRight(flags.RegistryURL, "/")
}
// Store the token in flags if it was provided
if method == MethodGitHub {
flags.Token = Token(token)
}
return flags, err
}
func createSigner(flags LoginFlags) (auth.Signer, error) {
switch flags.SignerType {
case AzureKeyVaultSignerType:
return azurekeyvault.GetSignatureProvider(flags.KvVault, flags.KvKeyName)
case GoogleKMSSignerType:
return googlekms.GetSignatureProvider(flags.KmsResource)
case InProcessSignerType:
return auth.NewInProcessSigner(flags.PrivateKey, auth.CryptoAlgorithm(flags.CryptoAlgorithm))
case NoSignerType:
return nil, errors.New("no signing provider specified")
default:
return nil, errors.New("unknown signing provider specified")
}
}
func createAuthProvider(method, registryURL, domain string, token Token, signer auth.Signer) (auth.Provider, error) {
switch method {
case MethodGitHub:
return auth.NewGitHubATProvider(registryURL, string(token)), nil
case MethodGitHubOIDC:
return auth.NewGitHubOIDCProvider(registryURL), nil
case MethodDNS:
if domain == "" {
return nil, errors.New("dns authentication requires --domain")
}
return auth.NewDNSProvider(registryURL, domain, &signer), nil
case MethodHTTP:
if domain == "" {
return nil, errors.New("http authentication requires --domain")
}
return auth.NewHTTPProvider(registryURL, domain, &signer), nil
case MethodNone:
return auth.NewNoneProvider(registryURL), nil
default:
return nil, fmt.Errorf("unknown authentication method: %s\nFor a list of available methods, run: mcp-publisher login", method)
}
}
func LoginCommand(args []string) error {
if len(args) < 1 {
return errors.New(`authentication method required
Usage: mcp-publisher login <method> [<signing provider>]
Methods:
github Interactive GitHub authentication
github-oidc GitHub Actions OIDC authentication
dns DNS-based authentication (requires --domain)
http HTTP-based authentication (requires --domain)
none Anonymous authentication (for testing)
Signing providers:
azure-key-vault Sign using Azure Key Vault
google-kms Sign using Google Cloud KMS
The dns and http methods require a --private-key for in-process signing. For
out-of-process signing, use one of the supported signing providers. Signing is
needed for an authentication challenge with the registry.
The github and github-oidc methods do not support signing providers and
authenticate using the GitHub as an identity provider.
Examples:
# Interactive GitHub login, using device code flow
mcp-publisher login github
# Sign in using a specific Ed25519 private key for DNS authentication
mcp-publisher login dns -algorithm ed25519 -domain example.com -private-key <64 hex chars>
# Sign in using a specific ECDSA P-384 private key for DNS authentication
mcp-publisher login dns -algorithm ecdsap384 -domain example.com -private-key <96 hex chars>
# Sign in with gcloud CLI, use Google Cloud KMS for signing in DNS authentication
gcloud auth application-default login
mcp-publisher login dns google-kms -domain example.com -resource projects/lotr/locations/global/keyRings/fellowship/cryptoKeys/frodo/cryptoKeyVersions/1
# Sign in with az CLI, use Azure Key Vault for signing in HTTP authentication
az login
mcp-publisher login http azure-key-vault -domain example.com -vault myvault -key mysigningkey
`)
}
method := args[0]
flags, err := parseLoginFlags(method, args)
if err != nil {
return err
}
var signer auth.Signer
if flags.SignerType != NoSignerType {
signer, err = createSigner(flags)
if err != nil {
return err
}
}
authProvider, err := createAuthProvider(method, flags.RegistryURL, flags.Domain, flags.Token, signer)
if err != nil {
return err
}
ctx := context.Background()
_, _ = fmt.Fprintf(os.Stdout, "Logging in with %s...\n", method)
if err := authProvider.Login(ctx); err != nil {
return fmt.Errorf("login failed: %w", err)
}
token, err := authProvider.GetToken(ctx)
if err != nil {
return fmt.Errorf("failed to get token: %w", err)
}
if err := ensureTokenDir(); err != nil {
return err
}
tokenPath, err := tokenFilePath()
if err != nil {
return err
}
tokenData := map[string]string{
"token": token,
"method": method,
"registry": flags.RegistryURL,
}
jsonData, err := json.Marshal(tokenData)
if err != nil {
return fmt.Errorf("failed to marshal token data: %w", err)
}
if err := os.WriteFile(tokenPath, jsonData, 0600); err != nil {
return fmt.Errorf("failed to save token: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully logged in")
return nil
}
```
## /cmd/publisher/commands/login_test.go
```go path="/cmd/publisher/commands/login_test.go"
package commands_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoginCommand_WritesTokenToConfigDir(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
// Mock registry that returns a token for "none" auth
server := setupNoneAuthServer(t)
err := commands.LoginCommand([]string{"none", "--registry", server.URL})
require.NoError(t, err)
// Verify token written to new location
tokenPath := filepath.Join(tempHome, ".config", "mcp-publisher", "token.json")
data, err := os.ReadFile(tokenPath)
require.NoError(t, err)
var tokenInfo map[string]string
require.NoError(t, json.Unmarshal(data, &tokenInfo))
assert.Equal(t, "none", tokenInfo["method"])
assert.Equal(t, server.URL, tokenInfo["registry"])
assert.NotEmpty(t, tokenInfo["token"])
}
func TestLoginCommand_CreatesDirectoryWithCorrectPerms(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
server := setupNoneAuthServer(t)
err := commands.LoginCommand([]string{"none", "--registry", server.URL})
require.NoError(t, err)
// Verify directory permissions
dir := filepath.Join(tempHome, ".config", "mcp-publisher")
info, err := os.Stat(dir)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0700), info.Mode().Perm())
// Verify file permissions
tokenPath := filepath.Join(dir, "token.json")
info, err = os.Stat(tokenPath)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
}
func TestLoginCommand_DoesNotWriteLegacyFiles(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
server := setupNoneAuthServer(t)
err := commands.LoginCommand([]string{"none", "--registry", server.URL})
require.NoError(t, err)
// Verify no legacy file at $HOME
_, err = os.Stat(filepath.Join(tempHome, ".mcp_publisher_token"))
assert.True(t, os.IsNotExist(err), "should not create legacy ~/.mcp_publisher_token")
// Verify no .mcpregistry_* files in cwd
cwd, err := os.Getwd()
require.NoError(t, err)
for _, name := range []string{".mcpregistry_github_token", ".mcpregistry_registry_token"} {
_, err := os.Stat(filepath.Join(cwd, name))
assert.True(t, os.IsNotExist(err), "should not create %s in cwd", name)
}
}
// setupNoneAuthServer creates a mock registry that handles the "none" auth flow.
func setupNoneAuthServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v0/auth/none", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"registry_token": "test-registry-jwt",
"expires_at": 9999999999,
})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return server
}
```
## /cmd/publisher/commands/logout.go
```go path="/cmd/publisher/commands/logout.go"
package commands
import (
"fmt"
"os"
"path/filepath"
)
func LogoutCommand() error {
tokenPath, err := tokenFilePath()
if err != nil {
return err
}
// Check if token file exists at new location or legacy location
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = "" // degrade gracefully for legacy cleanup
}
legacyTokenPath := ""
if homeDir != "" {
legacyTokenPath = filepath.Join(homeDir, LegacyTokenFileName)
}
newExists := fileExists(tokenPath)
legacyExists := legacyTokenPath != "" && fileExists(legacyTokenPath)
if !newExists && !legacyExists {
_, _ = fmt.Fprintln(os.Stdout, "Not logged in")
return nil
}
// Remove token file from new location
os.Remove(tokenPath)
// Remove from legacy location
if legacyTokenPath != "" {
os.Remove(legacyTokenPath)
}
// Clean up legacy intermediate token files from $HOME and cwd
legacyIntermediateFiles := []string{
".mcpregistry_github_token",
".mcpregistry_registry_token",
}
for _, file := range legacyIntermediateFiles {
// Clean from $HOME
if homeDir != "" {
os.Remove(filepath.Join(homeDir, file))
}
// Clean from cwd (the original bug location)
os.Remove(file)
}
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully logged out")
return nil
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
```
## /cmd/publisher/commands/logout_test.go
```go path="/cmd/publisher/commands/logout_test.go"
package commands_test
import (
"os"
"path/filepath"
"testing"
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLogoutCommand_RemovesNewToken(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
// Create token at new location
dir := filepath.Join(tempHome, ".config", "mcp-publisher")
require.NoError(t, os.MkdirAll(dir, 0700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "token.json"), []byte(`{"token":"t"}`), 0600))
err := commands.LogoutCommand()
require.NoError(t, err)
_, err = os.Stat(filepath.Join(dir, "token.json"))
assert.True(t, os.IsNotExist(err))
}
func TestLogoutCommand_RemovesLegacyToken(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
// Create token at legacy location only
require.NoError(t, os.WriteFile(filepath.Join(tempHome, ".mcp_publisher_token"), []byte(`{"token":"t"}`), 0600))
err := commands.LogoutCommand()
require.NoError(t, err)
_, err = os.Stat(filepath.Join(tempHome, ".mcp_publisher_token"))
assert.True(t, os.IsNotExist(err))
}
func TestLogoutCommand_RemovesBothOldAndNew(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
// Create tokens at both locations
dir := filepath.Join(tempHome, ".config", "mcp-publisher")
require.NoError(t, os.MkdirAll(dir, 0700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "token.json"), []byte(`{"token":"new"}`), 0600))
require.NoError(t, os.WriteFile(filepath.Join(tempHome, ".mcp_publisher_token"), []byte(`{"token":"old"}`), 0600))
err := commands.LogoutCommand()
require.NoError(t, err)
_, err = os.Stat(filepath.Join(dir, "token.json"))
assert.True(t, os.IsNotExist(err), "new token should be removed")
_, err = os.Stat(filepath.Join(tempHome, ".mcp_publisher_token"))
assert.True(t, os.IsNotExist(err), "legacy token should be removed")
}
func TestLogoutCommand_CleansUpCwdLegacyFiles(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
// Create a token so logout doesn't say "Not logged in"
dir := filepath.Join(tempHome, ".config", "mcp-publisher")
require.NoError(t, os.MkdirAll(dir, 0700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "token.json"), []byte(`{"token":"t"}`), 0600))
// Create legacy intermediate files in a temp cwd
tempCwd := t.TempDir()
origDir, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(origDir) })
require.NoError(t, os.Chdir(tempCwd))
require.NoError(t, os.WriteFile(".mcpregistry_github_token", []byte("gh-token"), 0600))
require.NoError(t, os.WriteFile(".mcpregistry_registry_token", []byte("reg-token"), 0600))
err = commands.LogoutCommand()
require.NoError(t, err)
_, err = os.Stat(filepath.Join(tempCwd, ".mcpregistry_github_token"))
assert.True(t, os.IsNotExist(err), ".mcpregistry_github_token should be removed from cwd")
_, err = os.Stat(filepath.Join(tempCwd, ".mcpregistry_registry_token"))
assert.True(t, os.IsNotExist(err), ".mcpregistry_registry_token should be removed from cwd")
}
func TestLogoutCommand_CleansUpHomeLegacyFiles(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
// Create a token so logout doesn't say "Not logged in"
dir := filepath.Join(tempHome, ".config", "mcp-publisher")
require.NoError(t, os.MkdirAll(dir, 0700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "token.json"), []byte(`{"token":"t"}`), 0600))
// Create legacy intermediate files in $HOME
require.NoError(t, os.WriteFile(filepath.Join(tempHome, ".mcpregistry_github_token"), []byte("gh-token"), 0600))
require.NoError(t, os.WriteFile(filepath.Join(tempHome, ".mcpregistry_registry_token"), []byte("reg-token"), 0600))
err := commands.LogoutCommand()
require.NoError(t, err)
_, err = os.Stat(filepath.Join(tempHome, ".mcpregistry_github_token"))
assert.True(t, os.IsNotExist(err), ".mcpregistry_github_token should be removed from $HOME")
_, err = os.Stat(filepath.Join(tempHome, ".mcpregistry_registry_token"))
assert.True(t, os.IsNotExist(err), ".mcpregistry_registry_token should be removed from $HOME")
}
func TestLogoutCommand_NotLoggedIn(t *testing.T) {
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
// No token files exist anywhere
err := commands.LogoutCommand()
// Should not error, just print "Not logged in"
require.NoError(t, err)
}
```
## /cmd/publisher/commands/publish.go
```go path="/cmd/publisher/commands/publish.go"
package commands
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
)
func PublishCommand(args []string) error {
// Check for server.json file
serverFile := "server.json"
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
serverFile = args[0]
}
// Read server.json
serverData, err := os.ReadFile(serverFile)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("server.json not found. Run 'mcp-publisher init' to create one")
}
return fmt.Errorf("failed to read server.json: %w", err)
}
// Validate JSON
var serverJSON apiv0.ServerJSON
if err := json.Unmarshal(serverData, &serverJSON); err != nil {
return fmt.Errorf("invalid server.json: %w", err)
}
// Load saved token
tokenPath, err := tokenFilePath()
if err != nil {
return err
}
tokenData, err := os.ReadFile(tokenPath)
if err != nil {
if os.IsNotExist(err) {
return notAuthenticatedError()
}
return fmt.Errorf("failed to read token: %w", err)
}
var tokenInfo map[string]string
if err := json.Unmarshal(tokenData, &tokenInfo); err != nil {
return fmt.Errorf("invalid token data: %w", err)
}
token := tokenInfo["token"]
registryURL := tokenInfo["registry"]
if registryURL == "" {
registryURL = DefaultRegistryURL
}
// Publish to registry
_, _ = fmt.Fprintf(os.Stdout, "Publishing to %s...\n", registryURL)
response, statusCode, err := publishToRegistry(registryURL, serverData, token)
if err != nil {
// If publish failed with 422, call validate endpoint to show detailed errors
if statusCode == http.StatusUnprocessableEntity {
_, _ = fmt.Fprintln(os.Stdout, "Validation failed. Checking detailed validation errors...")
_, _ = fmt.Fprintln(os.Stdout)
// Call validate endpoint (same as validate command does)
result, validateErr := validateViaAPI(registryURL, serverData)
if validateErr != nil {
// If validate also fails, return original publish error
return fmt.Errorf("publish failed: %w", err)
}
// Print validation results using shared formatting logic
formattedErrorMsg := printValidationIssues(result, &serverJSON)
if !result.Valid {
// Return error with formatted message if available
if formattedErrorMsg != "" {
return fmt.Errorf("%s", formattedErrorMsg)
}
return fmt.Errorf("validation failed")
}
}
// For non-422 errors, return the original error
return fmt.Errorf("publish failed: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully published")
_, _ = fmt.Fprintf(os.Stdout, "✓ Server %s version %s\n", response.Server.Name, response.Server.Version)
return nil
}
func publishToRegistry(registryURL string, serverData []byte, token string) (*apiv0.ServerResponse, int, error) {
// Parse the server JSON data
var serverJSON apiv0.ServerJSON
err := json.Unmarshal(serverData, &serverJSON)
if err != nil {
return nil, 0, fmt.Errorf("error parsing server.json file: %w", err)
}
// Convert to JSON
jsonData, err := json.Marshal(serverJSON)
if err != nil {
return nil, 0, fmt.Errorf("error serializing request: %w", err)
}
// Ensure URL ends with the publish endpoint
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}
publishURL := registryURL + "v0/publish"
// Create and send request
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, publishURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, 0, fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("error reading response: %w", err)
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return nil, resp.StatusCode, fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}
var serverResponse apiv0.ServerResponse
if err := json.Unmarshal(body, &serverResponse); err != nil {
return nil, resp.StatusCode, err
}
return &serverResponse, resp.StatusCode, nil
}
```
## /cmd/publisher/commands/publish_test.go
```go path="/cmd/publisher/commands/publish_test.go"
package commands_test
import (
"encoding/json"
"io"
"net/http"
"testing"
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
"github.com/modelcontextprotocol/registry/internal/validators"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublishCommand_Success(t *testing.T) {
// Setup mock server that returns success
server := SetupMockRegistryServer(t, nil, nil)
// Setup token
SetupTestToken(t, server.URL, "test-token")
// Create valid server.json
serverJSON := apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
}
CreateTestServerJSON(t, serverJSON)
// Run publish command
err := commands.PublishCommand([]string{})
// Should succeed
assert.NoError(t, err)
}
func TestPublishCommand_422ValidationFlow(t *testing.T) {
validateCallCount := 0
publishCallCount := 0
// Setup mock server
server := SetupMockRegistryServer(t,
// Publish handler: return 422 for invalid schema
func(w http.ResponseWriter, _ *http.Request) {
publishCallCount++
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"Failed to publish server, invalid schema: call /validate for details"}`))
},
// Validate handler: return validation errors
func(w http.ResponseWriter, r *http.Request) {
validateCallCount++
w.Header().Set("Content-Type", "application/json")
body, _ := io.ReadAll(r.Body)
var req apiv0.ServerJSON
_ = json.Unmarshal(body, &req)
// Return validation result with deprecated schema error
result := validators.ValidationResult{
Valid: false,
Issues: []validators.ValidationIssue{
{
Type: validators.ValidationIssueTypeSemantic,
Path: "schema",
Message: "schema version 2025-07-09 is not the current version",
Severity: validators.ValidationIssueSeverityWarning,
Reference: "schema-version-deprecated",
},
},
}
_ = json.NewEncoder(w).Encode(result)
},
)
// Setup token
SetupTestToken(t, server.URL, "test-token")
// Create server.json with deprecated schema
serverJSON := apiv0.ServerJSON{
Schema: "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
}
CreateTestServerJSON(t, serverJSON)
// Run publish command
err := commands.PublishCommand([]string{})
// Should fail with validation error
require.Error(t, err)
assert.Contains(t, err.Error(), "schema version 2025-07-09")
assert.Contains(t, err.Error(), "Migration checklist:")
assert.Contains(t, err.Error(), "Full changelog with examples:")
// Verify both endpoints were called
assert.Equal(t, 1, publishCallCount, "publish endpoint should be called once")
assert.Equal(t, 1, validateCallCount, "validate endpoint should be called once after 422")
}
func TestPublishCommand_422WithMultipleIssues(t *testing.T) {
validateCallCount := 0
// Setup mock server
server := SetupMockRegistryServer(t,
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"Failed to publish server, invalid schema"}`))
},
func(w http.ResponseWriter, _ *http.Request) {
validateCallCount++
w.Header().Set("Content-Type", "application/json")
result := validators.ValidationResult{
Valid: false,
Issues: []validators.ValidationIssue{
{
Type: validators.ValidationIssueTypeSemantic,
Path: "version",
Message: "version must be a specific version, not a range",
Severity: validators.ValidationIssueSeverityError,
Reference: "semantic-version-range",
},
{
Type: validators.ValidationIssueTypeSchema,
Path: "name",
Message: "name is required",
Severity: validators.ValidationIssueSeverityError,
Reference: "schema-field-required",
},
},
}
_ = json.NewEncoder(w).Encode(result)
},
)
SetupTestToken(t, server.URL, "test-token")
serverJSON := apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "^1.0.0", // Invalid version range
}
CreateTestServerJSON(t, serverJSON)
err := commands.PublishCommand([]string{})
require.Error(t, err)
assert.Equal(t, 1, validateCallCount, "validate endpoint should be called")
}
func TestPublishCommand_NoToken(t *testing.T) {
// Don't create a token file
serverJSON := apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
}
CreateTestServerJSON(t, serverJSON)
err := commands.PublishCommand([]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "not authenticated")
}
func TestPublishCommand_Non422Error(t *testing.T) {
publishCallCount := 0
server := SetupMockRegistryServer(t,
func(w http.ResponseWriter, _ *http.Request) {
publishCallCount++
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"Unauthorized"}`))
},
nil, // No validate handler needed
)
SetupTestToken(t, server.URL, "invalid-token")
serverJSON := apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
}
CreateTestServerJSON(t, serverJSON)
err := commands.PublishCommand([]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "publish failed")
assert.Equal(t, 1, publishCallCount, "publish endpoint should be called")
}
func TestPublishCommand_DeprecatedSchema(t *testing.T) {
tests := []struct {
name string
schema string
publishStatus int
validationOpts func(req apiv0.ServerJSON) validators.ValidationResult
expectError bool
errorSubstr string
checkLinks bool
}{
{
name: "deprecated 2025-07-09 schema should show warning",
schema: "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
publishStatus: http.StatusUnprocessableEntity,
validationOpts: func(_ apiv0.ServerJSON) validators.ValidationResult {
return validators.ValidationResult{
Valid: false,
Issues: []validators.ValidationIssue{
{
Type: validators.ValidationIssueTypeSemantic,
Path: "schema",
Message: "schema version 2025-07-09 is not the current version",
Severity: validators.ValidationIssueSeverityWarning,
Reference: "schema-version-deprecated",
},
},
}
},
expectError: true,
errorSubstr: "schema version 2025-07-09",
checkLinks: true,
},
{
name: "current 2025-12-11 schema should pass validation",
schema: "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
publishStatus: http.StatusCreated,
validationOpts: func(_ apiv0.ServerJSON) validators.ValidationResult {
// Should not be called since publish succeeds
return validators.ValidationResult{Valid: true}
},
expectError: false,
},
{
name: "empty schema should fail validation",
schema: "",
publishStatus: http.StatusUnprocessableEntity,
validationOpts: func(_ apiv0.ServerJSON) validators.ValidationResult {
return validators.ValidationResult{
Valid: false,
Issues: []validators.ValidationIssue{
{
Type: validators.ValidationIssueTypeSemantic,
Path: "schema",
Message: "$schema field is required",
Severity: validators.ValidationIssueSeverityError,
Reference: "schema-field-required",
},
},
}
},
expectError: true,
errorSubstr: "$schema field is required",
checkLinks: true,
},
{
name: "custom schema without valid version should fail validation",
schema: "https://example.com/custom.schema.json",
publishStatus: http.StatusUnprocessableEntity,
validationOpts: func(_ apiv0.ServerJSON) validators.ValidationResult {
return validators.ValidationResult{
Valid: false,
Issues: []validators.ValidationIssue{
{
Type: validators.ValidationIssueTypeSchema,
Path: "schema",
Message: "failed to extract schema version from URL",
Severity: validators.ValidationIssueSeverityError,
Reference: "schema-version-extraction-error",
},
},
}
},
expectError: true,
errorSubstr: "failed to extract schema version from URL",
checkLinks: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
validateCallCount := 0
publishCallCount := 0
// Setup mock server
server := SetupMockRegistryServer(t,
// Publish handler
func(w http.ResponseWriter, _ *http.Request) {
publishCallCount++
if tt.publishStatus == http.StatusCreated {
w.WriteHeader(http.StatusCreated)
response := apiv0.ServerResponse{
Server: apiv0.ServerJSON{
Name: "com.example/test-server",
Version: "1.0.0",
},
}
_ = json.NewEncoder(w).Encode(response)
} else {
w.WriteHeader(tt.publishStatus)
_, _ = w.Write([]byte(`{"message":"Failed to publish server, invalid schema: call /validate for details"}`))
}
},
// Validate handler (only called on 422)
func(w http.ResponseWriter, r *http.Request) {
validateCallCount++
w.Header().Set("Content-Type", "application/json")
body, _ := io.ReadAll(r.Body)
var req apiv0.ServerJSON
_ = json.Unmarshal(body, &req)
result := tt.validationOpts(req)
_ = json.NewEncoder(w).Encode(result)
},
)
SetupTestToken(t, server.URL, "test-token")
// Create server.json with specific schema
serverJSON := apiv0.ServerJSON{
Schema: tt.schema,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
}
CreateTestServerJSON(t, serverJSON)
err := commands.PublishCommand([]string{})
if tt.expectError {
require.Error(t, err, "Expected error for test case: %s", tt.name)
if tt.errorSubstr != "" {
assert.Contains(t, err.Error(), tt.errorSubstr, "Error should contain expected substring")
}
if tt.checkLinks {
assert.Contains(t, err.Error(), "Migration checklist:", "Error should contain migration checklist link")
assert.Contains(t, err.Error(), "Full changelog with examples:", "Error should contain changelog link")
}
if tt.publishStatus == http.StatusUnprocessableEntity {
assert.Equal(t, 1, publishCallCount, "publish endpoint should be called once")
assert.Equal(t, 1, validateCallCount, "validate endpoint should be called after 422")
}
} else {
assert.NoError(t, err, "Expected success for test case: %s", tt.name)
assert.Equal(t, 1, publishCallCount, "publish endpoint should be called once")
assert.Equal(t, 0, validateCallCount, "validate endpoint should not be called on success")
}
})
}
}
```
## /cmd/publisher/commands/status.go
```go path="/cmd/publisher/commands/status.go"
package commands
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
)
// StatusUpdateRequest represents the request body for status update endpoints
type StatusUpdateRequest struct {
Status string `json:"status"`
StatusMessage *string `json:"statusMessage,omitempty"`
}
// AllVersionsStatusResponse represents the response from the all-versions status endpoint
type AllVersionsStatusResponse struct {
UpdatedCount int `json:"updatedCount"`
}
// VersionInfo holds version and status for display
type VersionInfo struct {
Version string
Status string
}
// ServerResponseMeta represents the _meta field in API responses
type ServerResponseMeta struct {
Official *struct {
Status string `json:"status"`
} `json:"io.modelcontextprotocol.registry/official,omitempty"`
}
// SingleServerResponse represents the response from a single server version endpoint
type SingleServerResponse struct {
Server struct {
Version string `json:"version"`
} `json:"server"`
Meta ServerResponseMeta `json:"_meta"`
}
// ServerListResponse represents the response from the versions list endpoint
type ServerListResponse struct {
Servers []SingleServerResponse `json:"servers"`
}
func StatusCommand(args []string) error {
// Parse command flags
fs := flag.NewFlagSet("status", flag.ExitOnError)
status := fs.String("status", "", "New status: active, deprecated, or deleted (required)")
message := fs.String("message", "", "Optional status message explaining the change")
allVersions := fs.Bool("all-versions", false, "Apply status change to all versions of the server")
yes := fs.Bool("yes", false, "Skip confirmation prompt for bulk operations")
fs.BoolVar(yes, "y", false, "Skip confirmation prompt for bulk operations (shorthand)")
if err := fs.Parse(args); err != nil {
return err
}
// Validate required arguments
if *status == "" {
return errors.New("--status flag is required (active, deprecated, or deleted)")
}
// Validate status value
validStatuses := map[string]bool{"active": true, "deprecated": true, "deleted": true}
if !validStatuses[*status] {
return fmt.Errorf("invalid status '%s'. Must be one of: active, deprecated, deleted", *status)
}
// Get server name from positional args
remainingArgs := fs.Args()
if len(remainingArgs) < 1 {
return errors.New("server name is required\n\nUsage: mcp-publisher status --status <active|deprecated|deleted> [flags] <server-name> [version]")
}
serverName := remainingArgs[0]
var version string
// Get version if provided (required unless --all-versions is set)
if !*allVersions {
if len(remainingArgs) < 2 {
return errors.New("version is required unless --all-versions flag is set\n\nUsage: mcp-publisher status --status <active|deprecated|deleted> [flags] <server-name> <version>")
}
version = remainingArgs[1]
}
// Load saved token
tokenPath, err := tokenFilePath()
if err != nil {
return err
}
tokenData, err := os.ReadFile(tokenPath)
if err != nil {
if os.IsNotExist(err) {
return notAuthenticatedError()
}
return fmt.Errorf("failed to read token: %w", err)
}
var tokenInfo map[string]string
if err := json.Unmarshal(tokenData, &tokenInfo); err != nil {
return fmt.Errorf("invalid token data: %w", err)
}
token := tokenInfo["token"]
registryURL := tokenInfo["registry"]
if registryURL == "" {
registryURL = DefaultRegistryURL
}
// Update status
if *allVersions {
return updateAllVersionsStatus(registryURL, serverName, *status, *message, token, *yes)
}
return updateVersionStatus(registryURL, serverName, version, *status, *message, token)
}
func updateVersionStatus(registryURL, serverName, version, status, statusMessage, token string) error {
// Fetch current status to show "from → to"
currentStatus, err := fetchVersionStatus(registryURL, serverName, version, token)
if err != nil {
return fmt.Errorf("failed to fetch current status: %w", err)
}
_, _ = fmt.Fprintf(os.Stdout, "Updating %s version %s: %s → %s\n", serverName, version, currentStatus, status)
if err := updateServerStatus(registryURL, serverName, version, status, statusMessage, token); err != nil {
return fmt.Errorf("failed to update status: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully updated status")
return nil
}
func updateAllVersionsStatus(registryURL, serverName, status, statusMessage, token string, skipConfirm bool) error {
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}
// Fetch all versions to show current statuses and get count for confirmation
versions, err := fetchAllVersionsStatus(registryURL, serverName, token)
if err != nil {
return fmt.Errorf("failed to fetch current versions: %w", err)
}
if len(versions) == 0 {
return errors.New("no versions found for this server")
}
// Show what will be updated
_, _ = fmt.Fprintf(os.Stdout, "This will update %d version(s) of %s:\n", len(versions), serverName)
for _, v := range versions {
_, _ = fmt.Fprintf(os.Stdout, " %s: %s → %s\n", v.Version, v.Status, status)
}
// Prompt for confirmation unless -y/--yes was provided
if !skipConfirm {
_, _ = fmt.Fprint(os.Stdout, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
return errors.New("operation cancelled")
}
}
// Build the request body
requestBody := StatusUpdateRequest{
Status: status,
}
if statusMessage != "" {
requestBody.StatusMessage = &statusMessage
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("error serializing request: %w", err)
}
// URL encode the server name
encodedServerName := url.PathEscape(serverName)
statusURL := registryURL + "v0/servers/" + encodedServerName + "/status"
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, statusURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}
// Parse response to get updated count
var response AllVersionsStatusResponse
if err := json.Unmarshal(body, &response); err != nil {
// If we can't parse the response, just report success
_, _ = fmt.Fprintln(os.Stdout, "✓ Successfully updated all versions")
return nil
}
_, _ = fmt.Fprintf(os.Stdout, "✓ Successfully updated %d version(s)\n", response.UpdatedCount)
return nil
}
func updateServerStatus(registryURL, serverName, version, status, statusMessage, token string) error {
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}
// Build the request body
requestBody := StatusUpdateRequest{
Status: status,
}
if statusMessage != "" {
requestBody.StatusMessage = &statusMessage
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("error serializing request: %w", err)
}
// URL encode the server name and version
encodedServerName := url.PathEscape(serverName)
encodedVersion := url.PathEscape(version)
statusURL := registryURL + "v0/servers/" + encodedServerName + "/versions/" + encodedVersion + "/status"
req, err := http.NewRequestWithContext(context.Background(), http.MethodPatch, statusURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}
return nil
}
func fetchVersionStatus(registryURL, serverName, version, token string) (string, error) {
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}
encodedServerName := url.PathEscape(serverName)
encodedVersion := url.PathEscape(version)
fetchURL := registryURL + "v0/servers/" + encodedServerName + "/versions/" + encodedVersion + "?include_deleted=true"
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fetchURL, nil)
if err != nil {
return "", fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}
// Parse the response to extract status
var response SingleServerResponse
if err := json.Unmarshal(body, &response); err != nil {
return "", fmt.Errorf("error parsing response: %w", err)
}
if response.Meta.Official == nil {
return "", errors.New("server response missing status information")
}
return response.Meta.Official.Status, nil
}
func fetchAllVersionsStatus(registryURL, serverName, token string) ([]VersionInfo, error) {
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}
encodedServerName := url.PathEscape(serverName)
fetchURL := registryURL + "v0/servers/" + encodedServerName + "/versions?include_deleted=true"
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fetchURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}
var response ServerListResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("error parsing response: %w", err)
}
var versions []VersionInfo
for _, s := range response.Servers {
status := "unknown"
if s.Meta.Official != nil {
status = s.Meta.Official.Status
}
versions = append(versions, VersionInfo{
Version: s.Server.Version,
Status: status,
})
}
return versions, nil
}
```
## /cmd/publisher/commands/status_test.go
```go path="/cmd/publisher/commands/status_test.go"
package commands_test
import (
"strings"
"testing"
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
)
func TestStatusCommand_Validation(t *testing.T) {
tests := []struct {
name string
args []string
expectError bool
errorSubstr string
}{
{
name: "missing --status flag",
args: []string{"io.github.user/my-server", "1.0.0"},
expectError: true,
errorSubstr: "--status flag is required",
},
{
name: "invalid status value",
args: []string{"--status", "invalid", "io.github.user/my-server", "1.0.0"},
expectError: true,
errorSubstr: "invalid status 'invalid'",
},
{
name: "missing server name",
args: []string{"--status", "deprecated"},
expectError: true,
errorSubstr: "server name is required",
},
{
name: "missing version without --all-versions",
args: []string{"--status", "deprecated", "io.github.user/my-server"},
expectError: true,
errorSubstr: "version is required unless --all-versions",
},
{
name: "valid args passes validation",
args: []string{"--status", "deprecated", "io.github.user/my-server", "1.0.0"},
expectError: false,
},
{
name: "valid args with --all-versions passes validation",
args: []string{"--status", "deprecated", "--all-versions", "io.github.user/my-server"},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := commands.StatusCommand(tt.args)
if tt.expectError {
if err == nil {
t.Errorf("Expected error but got none")
return
}
if !strings.Contains(err.Error(), tt.errorSubstr) {
t.Errorf("Expected error containing '%s', got: %v", tt.errorSubstr, err)
}
} else if err != nil {
// For valid args, we expect it to pass validation
// It may fail later at auth or API level, which is acceptable
// Just check it's not a validation error
if strings.Contains(err.Error(), "invalid status") ||
strings.Contains(err.Error(), "server name is required") ||
strings.Contains(err.Error(), "version is required unless") ||
strings.Contains(err.Error(), "--status flag is required") {
t.Errorf("Validation failed unexpectedly: %v", err)
}
}
})
}
}
func TestStatusCommand_ServerNameValidation(t *testing.T) {
tests := []struct {
name string
serverName string
}{
{
name: "valid github server name",
serverName: "io.github.user/my-server",
},
{
name: "valid domain server name",
serverName: "com.example/my-server",
},
{
name: "server name with dashes",
serverName: "io.github.user/my-cool-server",
},
{
name: "server name with underscores",
serverName: "io.github.user/my_server",
},
{
name: "server name with dots",
serverName: "io.github.user/my.server",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := []string{"--status", "deprecated", tt.serverName, "1.0.0"}
err := commands.StatusCommand(args)
// Should pass validation (server name format is not validated by CLI)
if err != nil && strings.Contains(err.Error(), "server name is required") {
t.Errorf("Server name '%s' was rejected", tt.serverName)
}
})
}
}
func TestStatusCommand_VersionValidation(t *testing.T) {
tests := []struct {
name string
version string
}{
{
name: "semver version",
version: "1.0.0",
},
{
name: "semver with patch",
version: "1.2.3",
},
{
name: "semver with prerelease",
version: "1.0.0-alpha",
},
{
name: "semver with build metadata",
version: "1.0.0+20130313144700",
},
{
name: "semver with prerelease and build",
version: "1.0.0-beta.1+exp.sha.5114f85",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := []string{"--status", "deprecated", "io.github.user/my-server", tt.version}
err := commands.StatusCommand(args)
// Should pass validation (version format is not validated by CLI)
if err != nil && strings.Contains(err.Error(), "version is required") {
t.Errorf("Version '%s' was rejected", tt.version)
}
})
}
}
func TestStatusCommand_AllVersionsFlag(t *testing.T) {
tests := []struct {
name string
args []string
expectError bool
errorSubstr string
}{
{
name: "all-versions without version arg passes validation",
args: []string{"--status", "deprecated", "--all-versions", "io.github.user/my-server"},
expectError: false,
},
{
name: "all-versions with extra version arg still works",
args: []string{"--status", "deprecated", "--all-versions", "io.github.user/my-server", "1.0.0"},
expectError: false,
},
{
name: "missing server name with all-versions",
args: []string{"--status", "deprecated", "--all-versions"},
expectError: true,
errorSubstr: "server name is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := commands.StatusCommand(tt.args)
if tt.expectError {
if err == nil {
t.Errorf("Expected error but got none")
return
}
if !strings.Contains(err.Error(), tt.errorSubstr) {
t.Errorf("Expected error containing '%s', got: %v", tt.errorSubstr, err)
}
} else if err != nil {
// Should pass validation
// Just check it's not a validation error
if strings.Contains(err.Error(), "invalid status") ||
strings.Contains(err.Error(), "server name is required") ||
strings.Contains(err.Error(), "version is required unless") ||
strings.Contains(err.Error(), "--status flag is required") {
t.Errorf("Validation failed unexpectedly: %v", err)
}
}
})
}
}
func TestStatusCommand_FlagCombinations(t *testing.T) {
tests := []struct {
name string
args []string
}{
{
name: "status with message",
args: []string{"--status", "deprecated", "--message", "Please upgrade to v2", "io.github.user/my-server", "1.0.0"},
},
{
name: "all-versions with message",
args: []string{"--status", "deprecated", "--all-versions", "--message", "All versions deprecated", "io.github.user/my-server"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := commands.StatusCommand(tt.args)
// All these should pass CLI validation
// They may fail at auth or API level which is acceptable
if err != nil {
// Just check it's not a validation error we can detect
if strings.Contains(err.Error(), "invalid status") ||
strings.Contains(err.Error(), "server name is required") ||
strings.Contains(err.Error(), "version is required unless") ||
strings.Contains(err.Error(), "--status flag is required") {
t.Errorf("Validation failed unexpectedly: %v", err)
}
}
})
}
}
func TestStatusCommand_MissingStatus(t *testing.T) {
// Test various ways status flag can be missing
tests := []struct {
name string
args []string
}{
{
name: "no status flag at all",
args: []string{"io.github.user/my-server", "1.0.0"},
},
{
name: "empty status value",
args: []string{"--status", "", "io.github.user/my-server", "1.0.0"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := commands.StatusCommand(tt.args)
if err == nil {
t.Errorf("Expected error for missing status but got none")
return
}
if !strings.Contains(err.Error(), "--status flag is required") {
t.Errorf("Expected '--status flag is required' error, got: %v", err)
}
})
}
}
func TestStatusCommand_YesFlag(t *testing.T) {
tests := []struct {
name string
args []string
}{
{
name: "all-versions with --yes flag",
args: []string{"--status", "deprecated", "--all-versions", "--yes", "io.github.user/my-server"},
},
{
name: "all-versions with -y shorthand",
args: []string{"--status", "deprecated", "--all-versions", "-y", "io.github.user/my-server"},
},
{
name: "yes flag with single version (flag accepted but not used)",
args: []string{"--status", "deprecated", "--yes", "io.github.user/my-server", "1.0.0"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := commands.StatusCommand(tt.args)
// All these should pass CLI validation
// They may fail at auth or API level which is acceptable
if err != nil {
// Just check it's not a validation error
if strings.Contains(err.Error(), "invalid status") ||
strings.Contains(err.Error(), "server name is required") ||
strings.Contains(err.Error(), "version is required unless") ||
strings.Contains(err.Error(), "--status flag is required") ||
strings.Contains(err.Error(), "flag provided but not defined") {
t.Errorf("Validation failed unexpectedly: %v", err)
}
}
})
}
}
```
## /cmd/publisher/commands/testutil_test.go
```go path="/cmd/publisher/commands/testutil_test.go"
package commands_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/modelcontextprotocol/registry/internal/validators"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/stretchr/testify/require"
)
// SetupMockRegistryServer creates an httptest.Server that mocks the registry API
func SetupMockRegistryServer(t *testing.T, publishHandler func(w http.ResponseWriter, r *http.Request), validateHandler func(w http.ResponseWriter, r *http.Request)) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// Default handlers if not provided
if publishHandler == nil {
publishHandler = func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
response := apiv0.ServerResponse{
Server: apiv0.ServerJSON{
Name: "com.example/test",
Version: "1.0.0",
},
}
_ = json.NewEncoder(w).Encode(response)
}
}
if validateHandler == nil {
validateHandler = func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
result := validators.ValidationResult{Valid: true}
_ = json.NewEncoder(w).Encode(result)
}
}
mux.HandleFunc("/v0/publish", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
publishHandler(w, r)
})
mux.HandleFunc("/v0/validate", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
validateHandler(w, r)
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
return server
}
// SetupTestToken creates a token file pointing to the test server.
// It overrides $HOME to a temp directory so tests don't touch real credentials.
func SetupTestToken(t *testing.T, registryURL, token string) string {
t.Helper()
// Override $HOME so tokenFilePath() resolves to a temp directory
tempHome := t.TempDir()
t.Setenv("HOME", tempHome)
dir := filepath.Join(tempHome, ".config", "mcp-publisher")
require.NoError(t, os.MkdirAll(dir, 0700))
tokenPath := filepath.Join(dir, "token.json")
tokenData := map[string]string{
"token": token,
"registry": registryURL,
}
data, err := json.Marshal(tokenData)
require.NoError(t, err)
err = os.WriteFile(tokenPath, data, 0600)
require.NoError(t, err)
return tokenPath
}
// CreateTestServerJSON creates a server.json file in a temp directory and changes to it
func CreateTestServerJSON(t *testing.T, serverJSON apiv0.ServerJSON) (string, string) {
t.Helper()
tempDir, err := os.MkdirTemp("", "mcp-publisher-test")
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(tempDir) })
jsonData, err := json.MarshalIndent(serverJSON, "", " ")
require.NoError(t, err)
serverFile := filepath.Join(tempDir, "server.json")
err = os.WriteFile(serverFile, jsonData, 0600)
require.NoError(t, err)
// Change to temp directory
originalDir, err := os.Getwd()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Chdir(originalDir) })
err = os.Chdir(tempDir)
require.NoError(t, err)
return tempDir, serverFile
}
```
## /cmd/publisher/commands/validate.go
```go path="/cmd/publisher/commands/validate.go"
package commands
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/modelcontextprotocol/registry/internal/validators"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
)
// printSchemaValidationErrors prints nicely formatted error messages for schema validation issues
// (empty schema or non-current schema) with migration guidance to stdout.
// Returns the formatted error message string if any schema errors were printed, empty string otherwise.
func printSchemaValidationErrors(result *validators.ValidationResult, serverJSON *apiv0.ServerJSON) string {
currentSchemaURL := model.CurrentSchemaURL
migrationURL := "https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/server-json/CHANGELOG.md"
checklistURL := migrationURL + "#migration-checklist-for-publishers"
var formattedMsg strings.Builder
for _, issue := range result.Issues {
switch issue.Reference {
case "schema-field-required":
// Empty/missing schema
_, _ = fmt.Fprintf(os.Stdout, "$schema field is required.\n")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintf(os.Stdout, "Expected current schema: %s\n", currentSchemaURL)
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Run 'mcp-publisher init' to create a new server.json with the correct schema, or update your existing server.json file.")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintf(os.Stdout, "📋 Migration checklist: %s\n", checklistURL)
_, _ = fmt.Fprintf(os.Stdout, "📖 Full changelog with examples: %s\n", migrationURL)
_, _ = fmt.Fprintln(os.Stdout)
// Build formatted error message
_, _ = fmt.Fprintf(&formattedMsg, "$schema field is required. Expected current schema: %s. 📋 Migration checklist: %s 📖 Full changelog with examples: %s", currentSchemaURL, checklistURL, migrationURL)
return formattedMsg.String() // Only one schema error at a time
case "schema-version-deprecated":
// Non-current schema
if issue.Severity == validators.ValidationIssueSeverityWarning {
// Warning format (for validate command)
_, _ = fmt.Fprintf(os.Stdout, "⚠️ Deprecated schema detected: %s\n", serverJSON.Schema)
} else {
// Error format (for publish command)
_, _ = fmt.Fprintf(os.Stdout, "deprecated schema detected: %s.\n", serverJSON.Schema)
}
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintf(os.Stdout, "Expected current schema: %s\n", currentSchemaURL)
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Migrate to the current schema format for new servers.")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintf(os.Stdout, "📋 Migration checklist: %s\n", checklistURL)
_, _ = fmt.Fprintf(os.Stdout, "📖 Full changelog with examples: %s\n", migrationURL)
_, _ = fmt.Fprintln(os.Stdout)
// Build formatted error message - include the original issue message for test compatibility
_, _ = fmt.Fprintf(&formattedMsg, "%s. deprecated schema detected: %s. Expected current schema: %s. Migrate to the current schema format for new servers. 📋 Migration checklist: %s 📖 Full changelog with examples: %s", issue.Message, serverJSON.Schema, currentSchemaURL, checklistURL, migrationURL)
return formattedMsg.String() // Only one schema error at a time
case "schema-version-extraction-error":
// Invalid schema URL format - also include migration links for consistency
// Build formatted error message with migration links
_, _ = fmt.Fprintf(&formattedMsg, "%s. 📋 Migration checklist: %s 📖 Full changelog with examples: %s", issue.Message, checklistURL, migrationURL)
return formattedMsg.String()
}
}
return ""
}
// printValidationIssues prints schema validation errors and all other validation issues.
// Returns the formatted error message string for schema validation errors (empty string if none).
func printValidationIssues(result *validators.ValidationResult, serverJSON *apiv0.ServerJSON) string {
// Print schema validation errors/warnings with friendly messages
formattedErrorMsg := printSchemaValidationErrors(result, serverJSON)
if result.Valid {
return formattedErrorMsg
}
// Print all issues
_, _ = fmt.Fprintf(os.Stdout, "❌ Validation failed with %d issue(s):\n", len(result.Issues))
_, _ = fmt.Fprintln(os.Stdout)
// Track which schema issues we've already printed to avoid duplicates
issueNum := 1
for _, issue := range result.Issues {
// Skip schema issues that were already printed (they're printed by printSchemaValidationErrors above)
if issue.Reference == "schema-field-required" || issue.Reference == "schema-version-deprecated" {
continue
}
// Print other issues normally
_, _ = fmt.Fprintf(os.Stdout, "%d. [%s] %s (%s)\n", issueNum, issue.Severity, issue.Path, issue.Type)
_, _ = fmt.Fprintf(os.Stdout, " %s\n", issue.Message)
if issue.Reference != "" {
_, _ = fmt.Fprintf(os.Stdout, " Reference: %s\n", issue.Reference)
}
_, _ = fmt.Fprintln(os.Stdout)
issueNum++
}
return formattedErrorMsg
}
func ValidateCommand(args []string) error {
// Parse arguments
serverFile := "server.json"
for _, arg := range args {
if arg == "--help" || arg == "-h" {
_, _ = fmt.Fprintln(os.Stdout, "Usage: mcp-publisher validate [file]")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Validate a server.json file without publishing.")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Arguments:")
_, _ = fmt.Fprintln(os.Stdout, " file Path to server.json file (default: ./server.json)")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "The validate command performs exhaustive validation, reporting all issues at once.")
_, _ = fmt.Fprintln(os.Stdout, "It validates JSON syntax, schema compliance, and semantic rules.")
return nil
}
if !strings.HasPrefix(arg, "-") {
serverFile = arg
}
}
// Read server file
serverData, err := os.ReadFile(serverFile)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("%s not found, please check the file path", serverFile)
}
return fmt.Errorf("failed to read %s: %w", serverFile, err)
}
// Validate JSON
var serverJSON apiv0.ServerJSON
if err := json.Unmarshal(serverData, &serverJSON); err != nil {
return fmt.Errorf("invalid JSON: %w", err)
}
// Get registry URL (same pattern as publish)
registryURL := DefaultRegistryURL
// Try to read registry URL from token file (if it exists)
if tokenPath, err := tokenFilePath(); err == nil {
if tokenData, err := os.ReadFile(tokenPath); err == nil {
var tokenInfo map[string]string
if err := json.Unmarshal(tokenData, &tokenInfo); err == nil {
if url := tokenInfo["registry"]; url != "" {
registryURL = url
}
}
}
}
// Validate via API
_, _ = fmt.Fprintf(os.Stdout, "Validating against %s...\n", registryURL)
result, err := validateViaAPI(registryURL, serverData)
if err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Print validation results using shared formatting logic
formattedErrorMsg := printValidationIssues(result, &serverJSON)
if result.Valid {
_, _ = fmt.Fprintln(os.Stdout, "✅ server.json is valid")
return nil
}
// Return error with formatted message if available
if formattedErrorMsg != "" {
return fmt.Errorf("%s", formattedErrorMsg)
}
return fmt.Errorf("validation failed")
}
// validateViaAPI calls the /validate endpoint on the registry
func validateViaAPI(registryURL string, serverData []byte) (*validators.ValidationResult, error) {
// Parse the server JSON data to ensure it's valid JSON
var serverJSON apiv0.ServerJSON
err := json.Unmarshal(serverData, &serverJSON)
if err != nil {
return nil, fmt.Errorf("error parsing server.json file: %w", err)
}
// Convert to JSON
jsonData, err := json.Marshal(serverJSON)
if err != nil {
return nil, fmt.Errorf("error serializing request: %w", err)
}
// Ensure URL ends with / and add validate endpoint
if !strings.HasSuffix(registryURL, "/") {
registryURL += "/"
}
validateURL := registryURL + "v0/validate"
// Create and send request
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, validateURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
// Read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned status %d: %s", resp.StatusCode, body)
}
// Parse response - Huma returns ValidationResult directly
var result validators.ValidationResult
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("error parsing response: %w", err)
}
return &result, nil
}
```
## /cmd/publisher/commands/validate_test.go
```go path="/cmd/publisher/commands/validate_test.go"
package commands_test
import (
"encoding/json"
"io"
"net/http"
"os"
"testing"
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
"github.com/modelcontextprotocol/registry/internal/validators"
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
"github.com/modelcontextprotocol/registry/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateCommand_Success(t *testing.T) {
validateCallCount := 0
server := SetupMockRegistryServer(t,
nil, // No publish handler
func(w http.ResponseWriter, _ *http.Request) {
validateCallCount++
w.Header().Set("Content-Type", "application/json")
result := validators.ValidationResult{
Valid: true,
Issues: []validators.ValidationIssue{},
}
_ = json.NewEncoder(w).Encode(result)
},
)
SetupTestToken(t, server.URL, "test-token")
serverJSON := apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
}
CreateTestServerJSON(t, serverJSON)
err := commands.ValidateCommand([]string{})
assert.NoError(t, err)
assert.Equal(t, 1, validateCallCount, "validate endpoint should be called")
}
func TestValidateCommand_WithErrors(t *testing.T) {
validateCallCount := 0
server := SetupMockRegistryServer(t,
nil,
func(w http.ResponseWriter, _ *http.Request) {
validateCallCount++
w.Header().Set("Content-Type", "application/json")
result := validators.ValidationResult{
Valid: false,
Issues: []validators.ValidationIssue{
{
Type: validators.ValidationIssueTypeSemantic,
Path: "version",
Message: "version must be a specific version, not a range",
Severity: validators.ValidationIssueSeverityError,
Reference: "semantic-version-range",
},
},
}
_ = json.NewEncoder(w).Encode(result)
},
)
SetupTestToken(t, server.URL, "test-token")
serverJSON := apiv0.ServerJSON{
Schema: model.CurrentSchemaURL,
Name: "com.example/test-server",
Description: "A test server",
Version: "^1.0.0", // Invalid
}
CreateTestServerJSON(t, serverJSON)
err := commands.ValidateCommand([]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "validation failed")
assert.Equal(t, 1, validateCallCount, "validate endpoint should be called")
}
func TestValidateCommand_DeprecatedSchema(t *testing.T) {
server := SetupMockRegistryServer(t,
nil,
func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
body, _ := io.ReadAll(r.Body)
var req apiv0.ServerJSON
_ = json.Unmarshal(body, &req)
result := validators.ValidationResult{
Valid: false,
Issues: []validators.ValidationIssue{
{
Type: validators.ValidationIssueTypeSemantic,
Path: "schema",
Message: "schema version 2025-07-09 is not the current version",
Severity: validators.ValidationIssueSeverityWarning,
Reference: "schema-version-deprecated",
},
},
}
_ = json.NewEncoder(w).Encode(result)
},
)
SetupTestToken(t, server.URL, "test-token")
serverJSON := apiv0.ServerJSON{
Schema: "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
Name: "com.example/test-server",
Description: "A test server",
Version: "1.0.0",
}
CreateTestServerJSON(t, serverJSON)
err := commands.ValidateCommand([]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "schema version 2025-07-09")
assert.Contains(t, err.Error(), "Migration checklist:")
}
func TestValidateCommand_NoServerFile(t *testing.T) {
server := SetupMockRegistryServer(t, nil, nil)
SetupTestToken(t, server.URL, "test-token")
// Don't create server.json
tempDir, err := os.MkdirTemp("", "mcp-publisher-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalDir, err := os.Getwd()
require.NoError(t, err)
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(tempDir)
err = commands.ValidateCommand([]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestValidateCommand_InvalidJSON(t *testing.T) {
server := SetupMockRegistryServer(t, nil, nil)
SetupTestToken(t, server.URL, "test-token")
tempDir, err := os.MkdirTemp("", "mcp-publisher-test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
originalDir, err := os.Getwd()
require.NoError(t, err)
defer func() { _ = os.Chdir(originalDir) }()
_ = os.Chdir(tempDir)
// Create invalid JSON file
err = os.WriteFile("server.json", []byte("{ invalid json }"), 0600)
require.NoError(t, err)
err = commands.ValidateCommand([]string{})
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid JSON")
}
```
## /cmd/publisher/main.go
```go path="/cmd/publisher/main.go"
package main
import (
"fmt"
"log"
"os"
"github.com/modelcontextprotocol/registry/cmd/publisher/commands"
)
// Version info for the MCP Publisher tool
// These variables are injected at build time via ldflags by goreleaser
var (
// Version is the current version of the MCP Publisher tool
Version = "dev"
// BuildTime is the time at which the binary was built
BuildTime = "unknown"
// GitCommit is the git commit that was compiled
GitCommit = "unknown"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
// Check for help flag for subcommands
if len(os.Args) >= 3 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
printCommandHelp(os.Args[1])
return
}
var err error
switch os.Args[1] {
case "init":
err = commands.InitCommand()
case "login":
err = commands.LoginCommand(os.Args[2:])
case "logout":
err = commands.LogoutCommand()
case "publish":
err = commands.PublishCommand(os.Args[2:])
case "status":
err = commands.StatusCommand(os.Args[2:])
case "validate":
err = commands.ValidateCommand(os.Args[2:])
case "--version", "-v", "version":
log.Printf("mcp-publisher %s (commit: %s, built: %s)", Version, GitCommit, BuildTime)
return
case "--help", "-h", "help":
printUsage()
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", os.Args[1])
printUsage()
os.Exit(1)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func printUsage() {
_, _ = fmt.Fprintln(os.Stdout, "MCP Registry Publisher Tool")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Usage:")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher <command> [arguments]")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Commands:")
_, _ = fmt.Fprintln(os.Stdout, " init Create a server.json file template")
_, _ = fmt.Fprintln(os.Stdout, " login Authenticate with the registry")
_, _ = fmt.Fprintln(os.Stdout, " logout Clear saved authentication")
_, _ = fmt.Fprintln(os.Stdout, " publish Publish server.json to the registry")
_, _ = fmt.Fprintln(os.Stdout, " status Update the status of a server version")
_, _ = fmt.Fprintln(os.Stdout, " validate Validate server.json without publishing")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Use 'mcp-publisher <command> --help' for more information about a command.")
}
func printCommandHelp(command string) {
switch command {
case "init":
_, _ = fmt.Fprintln(os.Stdout, "Create a server.json file template")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Usage:")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher init")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "This command creates a server.json file in the current directory with")
_, _ = fmt.Fprintln(os.Stdout, "auto-detected values from your project (package.json, git remote, etc.).")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "After running init, edit the generated server.json to customize your")
_, _ = fmt.Fprintln(os.Stdout, "server's metadata before publishing.")
case "login":
_, _ = fmt.Fprintln(os.Stdout, "Authenticate with the registry")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Usage:")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login <method> [options]")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Methods:")
_, _ = fmt.Fprintln(os.Stdout, " github Interactive GitHub authentication")
_, _ = fmt.Fprintln(os.Stdout, " github-oidc GitHub Actions OIDC authentication")
_, _ = fmt.Fprintln(os.Stdout, " dns DNS-based authentication (requires --domain)")
_, _ = fmt.Fprintln(os.Stdout, " http HTTP-based authentication (requires --domain)")
_, _ = fmt.Fprintln(os.Stdout, " none Anonymous authentication (for testing)")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Examples:")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login github")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login dns --domain example.com --private-key <key>")
case "logout":
_, _ = fmt.Fprintln(os.Stdout, "Clear saved authentication")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Usage:")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher logout")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "This command removes the saved authentication token from your system.")
case "publish":
_, _ = fmt.Fprintln(os.Stdout, "Publish server.json to the registry")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Usage:")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher publish [server.json]")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Arguments:")
_, _ = fmt.Fprintln(os.Stdout, " server.json Path to the server.json file (default: ./server.json)")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "You must be logged in before publishing. Run 'mcp-publisher login' first.")
case "status":
_, _ = fmt.Fprintln(os.Stdout, "Update the status of a server version")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Usage:")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher status --status <active|deprecated|deleted> [flags] <server-name> [version]")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Flags (must come before positional arguments):")
_, _ = fmt.Fprintln(os.Stdout, " --status string New status: active, deprecated, or deleted (required)")
_, _ = fmt.Fprintln(os.Stdout, " --message string Optional message explaining the status change")
_, _ = fmt.Fprintln(os.Stdout, " --all-versions Apply status change to all versions of the server")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Arguments:")
_, _ = fmt.Fprintln(os.Stdout, " server-name Full server name (e.g., io.github.user/my-server)")
_, _ = fmt.Fprintln(os.Stdout, " version Server version to update (required unless --all-versions is set)")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "Examples:")
_, _ = fmt.Fprintln(os.Stdout, " # Deprecate a specific version")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher status --status deprecated --message \"Please upgrade to 2.0.0\" \\")
_, _ = fmt.Fprintln(os.Stdout, " io.github.user/my-server 1.0.0")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, " # Delete a version with security issues")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher status --status deleted --message \"Critical security vulnerability\" \\")
_, _ = fmt.Fprintln(os.Stdout, " io.github.user/my-server 1.0.0")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, " # Restore a version to active")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher status --status active io.github.user/my-server 1.0.0")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, " # Deprecate all versions")
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher status --status deprecated --all-versions --message \"Project archived\" \\")
_, _ = fmt.Fprintln(os.Stdout, " io.github.user/my-server")
_, _ = fmt.Fprintln(os.Stdout)
_, _ = fmt.Fprintln(os.Stdout, "You must be logged in before updating status. Run 'mcp-publisher login' first.")
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
printUsage()
}
}
```
## /cmd/registry/main.go
```go path="/cmd/registry/main.go"
package main
import (
"context"
"errors"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/modelcontextprotocol/registry/internal/api"
v0 "github.com/modelcontextprotocol/registry/internal/api/handlers/v0"
"github.com/modelcontextprotocol/registry/internal/config"
"github.com/modelcontextprotocol/registry/internal/database"
"github.com/modelcontextprotocol/registry/internal/importer"
"github.com/modelcontextprotocol/registry/internal/service"
"github.com/modelcontextprotocol/registry/internal/telemetry"
)
// Version info for the MCP Registry application
// These variables are injected at build time via ldflags
var (
// Version is the current version of the MCP Registry application
Version = "dev"
// BuildTime is the time at which the binary was built
BuildTime = "unknown"
// GitCommit is the git commit that was compiled
GitCommit = "unknown"
)
func main() {
// Parse command line flags
showVersion := flag.Bool("version", false, "Display version information")
flag.Parse()
// Show version information if requested
if *showVersion {
log.Printf("MCP Registry %s\n", Version)
log.Printf("Git commit: %s\n", GitCommit)
log.Printf("Build time: %s\n", BuildTime)
return
}
log.Printf("Starting MCP Registry Application v%s (commit: %s)", Version, GitCommit)
var (
registryService service.RegistryService
db database.Database
err error
)
// Initialize configuration
cfg := config.NewConfig()
// Connect to PostgreSQL with bounded retry. The DB endpoint can flap briefly
// during voluntary disruptions (node drains, rollouts) and without retry the
// pod exits cleanly, restarts, then races with the still-recovering service
// — turning a transient blip into a multi-minute outage.
const (
maxStartupAttempts = 8
perAttemptTimeout = 10 * time.Second
initialBackoff = 1 * time.Second
maxBackoff = 8 * time.Second
)
backoff := initialBackoff
for attempt := 1; ; attempt++ {
attemptCtx, attemptCancel := context.WithTimeout(context.Background(), perAttemptTimeout)
db, err = database.NewPostgreSQL(attemptCtx, cfg.DatabaseURL)
attemptCancel()
if err == nil {
break
}
if attempt >= maxStartupAttempts {
log.Printf("Failed to connect to PostgreSQL after %d attempts: %v", attempt, err)
return
}
log.Printf("PostgreSQL not reachable (attempt %d/%d): %v, retrying in %s", attempt, maxStartupAttempts, err, backoff)
time.Sleep(backoff)
backoff = min(backoff*2, maxBackoff)
}
// Store the PostgreSQL instance for later cleanup
defer func() {
if err := db.Close(); err != nil {
log.Printf("Error closing PostgreSQL connection: %v", err)
} else {
log.Println("PostgreSQL connection closed successfully")
}
}()
registryService = service.NewRegistryService(db, cfg)
// Import seed data if seed source is provided
if cfg.SeedFrom != "" {
log.Printf("Importing data from %s...", cfg.SeedFrom)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
importerService := importer.NewService(registryService)
if err := importerService.ImportFromPath(ctx, cfg.SeedFrom); err != nil {
log.Printf("Failed to import seed data: %v", err)
}
}
shutdownTelemetry, metrics, err := telemetry.InitMetrics(cfg.Version)
if err != nil {
log.Printf("Failed to initialize metrics: %v", err)
return
}
defer func() {
if err := shutdownTelemetry(context.Background()); err != nil {
log.Printf("Failed to shutdown telemetry: %v", err)
}
}()
// Prepare version information
versionInfo := &v0.VersionBody{
Version: Version,
GitCommit: GitCommit,
BuildTime: BuildTime,
}
// Initialize HTTP server
server := api.NewServer(cfg, registryService, metrics, versionInfo)
// Start server in a goroutine so it doesn't block signal handling
go func() {
if err := server.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("Failed to start server: %v", err)
os.Exit(1)
}
}()
// Wait for interrupt signal to gracefully shutdown the server
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Create context with timeout for shutdown
sctx, scancel := context.WithTimeout(context.Background(), 10*time.Second)
defer scancel()
// Gracefully shutdown the server
if err := server.Shutdown(sctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
log.Println("Server exiting")
}
```
## /data/seed.json
```json path="/data/seed.json"
[
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.domdomegg/airtable-mcp-server",
"description": "Read and write access to Airtable database schemas, tables, and records.",
"repository": {
"url": "https://github.com/domdomegg/airtable-mcp-server.git",
"source": "github"
},
"version": "1.7.2",
"icons": [
{
"src": "https://airtable.com/images/favicon/favicon-32x32.png",
"mimeType": "image/png",
"sizes": [
"32x32"
]
}
],
"packages": [
{
"registryType": "npm",
"identifier": "airtable-mcp-server",
"version": "1.7.2",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
},
"environmentVariables": [
{
"description": "Airtable personal access token (e.g., pat123.abc123). Create at https://airtable.com/create/tokens/new with scopes: schema.bases:read, data.records:read, and optionally schema.bases:write and data.records:write.",
"isRequired": true,
"isSecret": true,
"name": "AIRTABLE_API_KEY"
}
]
},
{
"registryType": "oci",
"identifier": "docker.io/domdomegg/airtable-mcp-server:1.7.2",
"runtimeHint": "docker",
"transport": {
"type": "stdio"
},
"environmentVariables": [
{
"description": "Airtable personal access token (e.g., pat123.abc123). Create at https://airtable.com/create/tokens/new with scopes: schema.bases:read, data.records:read, and optionally schema.bases:write and data.records:write.",
"isRequired": true,
"isSecret": true,
"name": "AIRTABLE_API_KEY"
}
]
},
{
"registryType": "mcpb",
"identifier": "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.2/airtable-mcp-server.mcpb",
"fileSha256": "8220de07a08ebe908f04da139ea03dbfe29758141347e945da60535fb7bcca20",
"transport": {
"type": "stdio"
}
}
]
},
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.domdomegg/airtable-mcp-server",
"description": "Read and write access to Airtable database schemas, tables, and records.",
"repository": {
"url": "https://github.com/domdomegg/airtable-mcp-server.git",
"source": "github"
},
"version": "1.7.3",
"packages": [
{
"registryType": "npm",
"identifier": "airtable-mcp-server",
"version": "1.7.3",
"runtimeHint": "npx",
"transport": {
"type": "stdio"
},
"environmentVariables": [
{
"description": "Airtable personal access token (e.g., pat123.abc123). Create at https://airtable.com/create/tokens/new with scopes: schema.bases:read, data.records:read, and optionally schema.bases:write and data.records:write.",
"isRequired": true,
"isSecret": true,
"name": "AIRTABLE_API_KEY"
}
]
},
{
"registryType": "oci",
"identifier": "docker.io/domdomegg/airtable-mcp-server:1.7.3",
"runtimeHint": "docker",
"transport": {
"type": "stdio"
},
"environmentVariables": [
{
"description": "Airtable personal access token (e.g., pat123.abc123). Create at https://airtable.com/create/tokens/new with scopes: schema.bases:read, data.records:read, and optionally schema.bases:write and data.records:write.",
"isRequired": true,
"isSecret": true,
"name": "AIRTABLE_API_KEY"
}
]
},
{
"registryType": "mcpb",
"identifier": "https://github.com/domdomegg/airtable-mcp-server/releases/download/v1.7.3/airtable-mcp-server.mcpb",
"fileSha256": "0f28a9129cfebd262dfb77854c872355d21401bb3e056575b3027081f5d570ca",
"transport": {
"type": "stdio"
}
}
]
},
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.domdomegg/time-mcp-nuget",
"description": "Get the current UTC time in RFC 3339 format.",
"repository": {
"url": "https://github.com/domdomegg/time-mcp-nuget.git",
"source": "github"
},
"version": "1.0.8",
"packages": [
{
"registryType": "nuget",
"identifier": "TimeMcpServer",
"version": "1.0.8",
"runtimeHint": "dnx",
"transport": {
"type": "stdio"
}
}
]
},
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.domdomegg/time-mcp-pypi",
"description": "Get the current UTC time in RFC 3339 format.",
"repository": {
"url": "https://github.com/domdomegg/time-mcp-pypi.git",
"source": "github"
},
"version": "1.0.6",
"packages": [
{
"registryType": "pypi",
"identifier": "time-mcp-pypi",
"version": "1.0.6",
"runtimeHint": "python",
"transport": {
"type": "stdio"
}
}
]
}
]
```
## /deploy/.gitignore
```gitignore path="/deploy/.gitignore"
# Pulumi
*.pyc
.pulumi/
Pulumi.*.yaml.bak
# Go
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
vendor/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Build artifacts
infra
# Secrets
passphrase.prod.txt
passphrase.staging.txt
```
## /deploy/Makefile
``` path="/deploy/Makefile"
.PHONY: help build local-login local-preview local-up staging-login staging-preview staging-up prod-login prod-preview prod-up
# Default target
help: ## Show this help message
@echo "Available targets:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $1, $2}'
# Build the Go binary
build: ## Build the Pulumi Go program
go build
# Local stack commands
local-login: ## Login to local Pulumi backend
pulumi login --local
PULUMI_CONFIG_PASSPHRASE="" pulumi stack select local --create
local-preview: build local-login ## Preview local infrastructure changes
PULUMI_CONFIG_PASSPHRASE="" pulumi preview --stack local
local-up: build local-login ## Deploy local infrastructure
PULUMI_CONFIG_PASSPHRASE="" pulumi up --yes --stack local
local-destroy: local-login ## Destroy local infrastructure
pulumi stack rm local --force --yes --preserve-config
echo "Make sure to also delete your k8s cluster, e.g. minikube delete"
# Staging stack commands
staging-login: ## Login to staging Pulumi backend
pulumi login gs://mcp-registry-staging-pulumi-state
staging-preview: build staging-login ## Preview staging infrastructure changes
PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.staging.txt pulumi preview --stack gcpStaging
staging-up: build staging-login ## Deploy staging infrastructure
PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.staging.txt pulumi up --yes --stack gcpStaging
# Production stack commands
prod-login: ## Login to production Pulumi backend
pulumi login gs://mcp-registry-prod-pulumi-state
prod-preview: build prod-login ## Preview production infrastructure changes
PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt pulumi preview --stack gcpProd
prod-up: build prod-login ## Deploy production infrastructure
PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt pulumi up --yes --stack gcpProd
```
## /deploy/Pulumi.gcpProd.yaml
```yaml path="/deploy/Pulumi.gcpProd.yaml"
config:
mcp-registry:environment: prod
mcp-registry:provider: gcp
mcp-registry:imageTag: 1.7.9 # Set specific image tag for production (change this to deploy different versions)
gcp:project: mcp-registry-prod
mcp-registry:githubClientId: Iv23liUydBbI7Z2Q9bOZ
mcp-registry:githubClientSecret:
secure: v1:mSVikc0wDjoN8jCF:ytoI2gZ5WRJN3Fd6s5SRd2fnzirqtFBPSdIshIa2RfnF0OdtDpmucfs5KRw3HoJVGfDTbkrG+Sk=
gcp:credentials:
secure: v1:hyZWlpeMTFDnMcz2:X2bc8Gy8Gq5O83re7/uVZKX4phPHD0AuAwQgGxvT/5Tkg5slsb48dxX8QwZowOBNR7+7rfiDKijciAvbXqevzKgRPkTnEyxEbG1q+GuDfioGBex8yWPzBuC31xnAUMXxHoZqcBgzgVRU7kGIgyWwVWR420hAtDz0ISiliYtWyL6GY+VmPncfq+THMEDHCf6kySwt8rbNg5wPuGSEbS6VYhlveM+v9X5Tn7e8kGlUOIHcccMyhTIXcEk5AjhsQCg78CzAW7Y7PWj4JdZQGk88vSe+tJAlNdvkfyrlyrxP3/Rto84y9OhfrIrhkLVTDywRC1fI6sA73o8esk9EAtnw9HxySYqAYk1HwegwIPcob3YeC8Xa817NCg4vXqAeRIVp5iOuvD/tPaXMGvQpFOjvYUiz941UdIbE2F87ujLu2/JydAkAEFaZzc0iSHna9Ih+ss/I/00jrpSxOLoIOHLmiEuhN7XWyQRqWTHapS8TTI5cnCJdwC7ZIQQHAsBhXQiEVBnaZO2bdrwCwD1NyRaFDXs7egDwDbOF49qkO8D2KqPeWgC7LrD2/xfy4EqxRiLCmljIA1CGfGm+vqngl6LYbC3lnFJlZl3Y/ZssmDZt/71Duc7EENRNB9alYMhzSsP2/e7nxKxD20hdUmc90vtXpLCUO5oAoixclw1utWkxCQ4rLh7KHkFYitaL2S2GflOuzikCgY9g6NqhC5G+8tROGSSXXSkH/ERRAUcshqOGwlA1X3Il7Xnh5TkjH35t8JTPvKRKaBfIyXbAvetxk1fKpLfjMr9WXTxql9WyhWNQ+R6d6G1wIYeQ2d/75ANhwLZ5gPHWsr5Q6h2ezLBXoHePV2ISDjwa5ufi1Rf8ufvO8nuKJrBfV4F5TczWdOen8fuQTyvz16AMPk9oSvVe+davY9ABuK5KQweok267Kli/JbqAl5k48lbR70W6tyxTtsyOBw9j1AlmblT8KN36MsTO2zd3wcPhWWbrC4UlPPcUX6cmDUkhqvtygOxhdKq3NiVkqhKrXySeA+5hkjO5atHMmR0FH5g0GXIVHdltxUukCVb2Ur7m3Dn5t0euOW2TuHmfwxy5Azyt4uDmlsrByDqFS0sqvlBiCG/h8awqGXPbAKUtm6ASND1yjugyKF8Qs6G9BmW1lGNJvGlRGgfguHVy3za7DO4dKO1MKlOwxmg4Bx3GUr316/3gZU8bd4v5a7nWqOR9Hinz2dZ6MlcXl9YeLK+gbiyVHSDXp5Vwtwydp/mWfxBE5CAOoxbE+7R+tSlNfW9WIWRVRNuGoagFQoE/1dZr+iKnK4SqNYErLc7oFhHkgSBTi+RfqBMV88aEI3Vbzh5i/16Skh3LEmj0cIDCHCjw8z5tZUMH4Vg5FUUTZHwz/LsFwN8G6zijy1IA/DKLoYYCgv1M89Ih/s2UFC7ckzQ9swRL6/HfuS6dKOv3vtuZ7yMgslPKACwVZ/W+woA++S2CLwUTgxJUbt6wNXq6qkWaEe8ITRT+U5INCMELMAFFiNEYmRZXuNS5kMufBT1FtYGCAF/VGP9dtk+skRUxK2nhbz7qmeqHAr4QL8pQm4LlZtixSQftaoTWDmHXphVNh/k9MjgOL1rXrahPLxy7M02ijOEZtT8v61A0fwcWvy/ddB8Y3E3Q1RcA/W6OthnmwiWZl5bkt8izKrQyu3MKfQmWeb7FZCu2YluO0bx2NFvCsxbtc4KN2xFU5lzRZG7pQ6q1rBwTgyTHjHKZmR/UDP20682O5IAx76fTtTHToPK0fj/MM97feTh5x1qDDnOTX/WHb79XB4Djy3jQd5S3ql9nVp8mJbyw4hG/p6h5wIN0pAjErg9ghh3PcVWK0vYJQzamBcCC7ZBoNpCEz64yiEorn56ahB3FT7hCi/WYiLU/AIPI5mEagG6dIKrqfrpQwEuM0tKRNTl60jUV2tX3fptJD+krnkqN2r1Vijiqu3+2RzGrlgEVDgoPg9cPCC3dfEpDqtFZF/OHKpRiZ2PFmARAFVgLf/aOOJ94VOAwW5LNEP/s29cbivlyJFwSwDXW/0aN8N8Fo4W12ZUdul8XhGeiwGD3omuGqvZ/U1hVxV3FYctViR3kmB1O51j0SV8OUUrjSGlNiI3HjMqJLmLAdCngVtWU8Op9VS2uM1WdJWpP0Bsf9qH/RdgViSfO/siyvn7VA1NGbEkSY3os/KSqjmIAwKrIqMyaVvTlpund2Y55QVPVQsJ/g2tYJyBsvQQHJ+PG7V1EL0pxYGlDvzWYkgz+Yh3CC8Z9grNP4sD/3LcGer2AaMTwGRvKd5A0F7d4v7Fv+B5c9vLlW5TQPYJFVgXRpSoGaYDqmbV0Z/LN6bIkqH5poD1etYk5BVxEsZTVZXfI8a/dkpq2YjKc8C+tvquLyR+l9ykFZ8e1yaqd165jhj8aymskKiiz7LqJ4haBU1IiBAvY8fRp/hgQxtZKN8h7zI/VS1sQt75mTwgh/dx7Gzsfwhb2wuN0DUm8zYbqdOI4I8HonkgbgMiUKLSgoDqHpXzEtVglTixnnA2zUTZwJ0bzfCkvkheKM7IZ3h53Fbg1EqCrtUVgU6rIhiaM4uZVpTK/v0Ws7GbECpNF4HS7Ge5b+z9Ajzmf3VMOoZytJCbZWCN+Rtgfu0hODRyv2+BrKyz3r3aUAEA+2bClC6yPBjpY/VuyA2ulgoe5S2ZdZ69o8vH2aokoGHkZvqgMoKEMNR9enU/9qzoV+Gl+y1Y6qB0bKOHqU4ywBaVme2tUFm1djRSIyec3oVFwOi2fADwklrOd3hBAxdgYNHWrhhHF7G+L9pIb5E/TRTlO3s0wbCuo+F/CNX5Fx6lejcAyXsri9mS4+Qs0znFXt54hdjPoKaJiwDahJg86VKlav5cm1GsQAzpm0ITC4Ck1czNK1CJBfgp9eiofDm85cI6Ha7QsU2/O9Et7C9/+/sFJMgfaYzHokKG3cCFKuxSPpOg+tbyguxXTN5j+chKDATM0JNgxp1iHlDVcVlxqviIae7y5bcllRja4xvUG9RB1zoWAGf9OLIbd0AnPdC4cwXXQxkRJyKfXS8WaahY629zq6Kmbtlh+viEIqCCtqIBDsaDfjYOI+BTJ2UVprAt4R4M7hWLgyg3bYD8UTjLsyr3StkpfBtlGOYIbCwk/Ym97xvEz3IdfEqNY8Uaa6w1tYvePbOlzLVyu10AwVQc1wGvCYmUbQER6zhLaxrNoHjHv1olcjr5nXCIYl32m4H8hvODeCCGIU682f2nbkco498KA9lx+PFHLzTkJ5XY5KoJbnFMg6EIS9A4gaFIxhFL6QsOCKXM2qT10DSOH8VpcazFHyfJIjcQLXPyN4n66QPa6fMKJcoEIjsRkZtTLtOn3RcGnl2SG6o5iQ+OWnIxt3EQcaWNChW1jwKEO4rpyLlbFk51u1YU+1OywvyAmsXu5WAU4BANUfhCDB/Lt7/P6ucyQEN67ioorg71YtfGsQuyfy1VZb2Daasqup/giY+jMtz1NHKtwiCSnY6A0lYPz6vTHUlJiX9MXZh8xJENhAVFsD2SI2FyIkKTdh5Z9H8EbZoa6KFewGZ5GWOk3j9F4ITHvtvveYHi4OtBl7uDh0lNK9ZYEGyR44Wy46Cj4wy27pl6a0H09vFfJTePBudHqsMXgKuTxuGdp6Lk5CbCkASiW1pqLiCOd5nSp/iOQ2ntlOkfmd9XYxZ61Qr/zimFcU998B/qjdipwBsIndp0Fmv44czodto9qmBQi4Ei/ydHbe+eriBUHMSyHbtD3UQ/35EU0zmyA2QMBKnnd/zkL2MmDKDfhaSjOMUZe7rv5njXQNC8m3TT6CNfe+HXrx7wWFGMKKA8ojt1SXexQjCsM4gA4SMzKC9nZaEcyHCXCi2yzGdVtXqj+bzTgqG6huBuhFfeA+B5o03yy1DUy4B1mNXbra+IY1Qgs9fQ9hIoDKl/0TxCOTBDVXIGfBdam7xTPDC72uKlFSou9LTB1VMmqxsRGIWvKOBoIdC+iwcPB27lGBlYEzhT0dQd9erPLre38Cah/cZNz59PlIj8/zhKnIpKBrTSJRzi0qX65UZUjDi7CuwHOEId/h2xZlv01cr61a0NdL98wyF4QPCp8MzywEFNoSfwLHYNsNcxdIyVW32dQxgC+KfEytHriG8RwTNwuqAhj0NUiJuDUsSNAVFB+CXoh/AylDi3NBAY33/pDOc7PRz2CLJNju93DCmU9inJnW+OUCA5nJHRHsg==
mcp-registry:jwtPrivateKey:
secure: v1:0QwJl5e504ECQfjb:j8b4v1KxxqS8g9E8JTL0Wq5EEDbk/xPUglyg4/hyuL8go+pH2EE6skjf+7D5aAsld1SDRGlhXQBOZIVnjLBffsVN7cBhVlKy2qwTCC6QH5Q=
mcp-registry:googleOauthClientSecret:
secure: v1:6AUQwlFpA6TjYPTa:MUqOWbXufz2tr2s+OvapEsAWFl3VbDE0VxDRTcJ1qbAQM/phmDj/BVCutozvlolmurHD
encryptionsalt: v1:0funtAX4m9k=:v1:yMCnBXyBO+q4+/yy:AqWJTzwwIWXmUK0JGCzqbeg0RUr8Jw==
```
## /deploy/Pulumi.gcpStaging.yaml
```yaml path="/deploy/Pulumi.gcpStaging.yaml"
config:
mcp-registry:environment: staging
mcp-registry:provider: gcp
gcp:project: mcp-registry-staging
mcp-registry:githubClientId: Iv23liB2r5oUw6PxiMOC
mcp-registry:githubClientSecret:
secure: v1:WRAquCFbMSoG7+iD:LCiTHniEP6eCHeE440q+lI/KqyYvK+0gZ4s4NseM1pN9JgWFt31wRttiQ7nWeHQpf0QEkZFALZY=
gcp:credentials:
secure: v1:RaHpGsBp37XO/EhJ:Dlk6YtSghGCtEKUUbxGr6KZvNFbttpPWUB79meTCy6gnV8xSKCys9HNaIjmSHfJeBEaqHsF8qZLL5coFU7Bd8b2ScthFCCPLx9Ra7/TuJx44oiQxgZwWm1h1epTFWrjCAAZlO7fLDnvtiGx/ErpY44U08uclx22RdWlbUu6d4ytFr/1SR3dmTUoM9kcmqFOL2Z12N3YCEMlBI0ant4iU0wv6PjP5JPAGeVhCg96oPvmCflrbhyjGWWLFIl+7oaEC2AnX6xBIk/s6yf9+kpFVLmQNE67TKk3ENMmJNxR8hXcc9mf//sdq2AgLViR8WiBMmzp0j/DA+oaS4AggsG9TTsGOe4YW+W9qiybZdJWzDUe5XQ76mZUFmOHlKkSnHE/jPPDoPGGqcqhbXQ8LXJJVJVthzYstoxMCnpTI8IRrnax38+nJAZnOW34mjaEFqu0PxNIyt8tuCn9jYYyYCtVs+8fbJb3yKWSUh+K+Oe/y1U5Lvtlox0r0kQ3t/vpKYolg2v+haab27FguagSo6jrqMC7CNL8Kx5k9nxLHJeLd41GU8ufep6CZRL+XFFcOpknDWlQl83RMlabXwbMM12Yj7wcpnPq7DStG37os2laLaaXDZbyXEyZc6HmZgWqbdH7Fs4Itn5l8dZgoHrJZCU5Xk2qixCQdTZf1WK1tnTVuasW6zUkMPai+np9yKTiy4yY8exGd4vZjARzFBZgoIeqUcaVcbFoABbGQSZmADNmLF3sIK4cEA24YGr4BhqC7buOkiUTg6U78jD7U6LFA9/rfITCry/wXlEJlA11Bdfc9OGlG3e6jTUy5rRM4C8sgVcpL/qHZlnIlSkyF2u6WSdnr4dBHHqFSWh+g0J4tO6xT8/x3XgONFLHnw+5o57oLVgY7rxa9V6tmQsOqfcGJFTCqmB4HJ+c9RQ62sXSkQdgKSkIujyEDRoxPiZCDkvJEYnjoJHMM5aA+L0HrcfZ1WN4uAUwzfJtJJjMjtLhsbF5tXinfVIvCPa9gxATjtUFUX9nFsWvdaW7/mwe+nyXXPbrhICuXckgSHp/ImyHubzYLlNZbH5vrgGPROvxCJP1ldorMGLPzBZN6vEP9xYYAIbi0jrftihbAXPtbT9+/VbYPtrtXomA56haD6BSO0ZDaR80di6Vmj8EPv1PkLO8MUVmUrnsxj+uOSmcfyzOx0nu7YXvXCgwqmIUK+f1BGebXuR7a6JT9LzKWKkQylSl759y0pl9i2lMS64e1kdU7XfhuPZnFZgebWB6ajcP+hXoAvpS/MSretsVnyPMdubSiPlIHNJfe4XoUzGOMGaB1NOU4tRnLsfe+J5+VreIKwU+c6/E5jKS86G2XTo2I0ZdBuarS7QGEwSVddHxm0ev51iQB9+wKbFLWFxV8bl3CwRqgF7xLYDx8DhA7SOku09lMB2xKq0Ba2spuHAM6vSEm0GMiVl01eKfgc3KAhHELmNkakFjRUsW7t2Z3ZKAVR5WYGiInZO/jqSKFLLRfrddin0rcl0Q3pkncJbbJqnpSxbQA3oofGA7SD6nFinpP7cBf+qOPyGHBh6zng8sTtAKBAtpGoQ6RVDDIvSaxri+UvGvi+cNdIkLF7Zpf5Ckk2fY8ez348zaJtvgv6Fbfs+nvNtdpX7+vy9yFYT2WJAd131+r6BPF795H1Spz35ZinIghoGxPDlA4Q9bKXQn/yUTDUsHW8H0hQnfYVCSjCh0t90QeZGdDzWDKFubTsH9OeKQUrX5CrVBvJZ3qwf94LU0DeYLR5NsYcbj5d3TNMyl8Ss371u7qIwFXgT25tsFiPbo4Cmi1bRf+rB4yB9Xj/HeV7IuLs7zOMcPDP2D7D+dX2A2rab5F/KAFBxXKSeWPgKp6II+UtvN/fsBkk3eFJ3Yg+HYzqxrKKcfitOy4vUHRIGo45rFthSqxBQ8YfACpZyata2Agm+1CkNuNzq6G1U3rcTDJ5k7sSfdp8eCsc77mpSKpPDWGjkJO9X7UjDt14kqP/FxONm5lhI9/MHEk7F0yjjUUw6oa0XaNn4li6odZbPRtLcNBfRgOGEVs61fydOS7H51tG65MfIYLx93h8stItPsiGmYmk2zGH/dS3n0IEUSZPcM+b8qY4KoznqyAjyCjvpQI5rqa1M5lvO6XNHrhBJPOZtSeuMmOedb8NGHuNtxPGsLq05kN0CIYgjnwW+PqUlPWknsOKrwhtmkyVQIrKx55BAng7D32779JsWQrdppJESuPDwcaKq943euopdXbNp94HhcmGWecpQQrRyINvitRmE7OTxB0ksBsMCv1fL5SyZS2YtSra3d9ITaWb/T742KgJV+1mZfbexJvO2nCJEKtLjjJ40EXWIhXiTis1NO25eO+Zm9BWNQ7ekXi6MNJEORY1SscWwfT46twjWmLwUldv/KHNGtgoCGvNsvzoLWuLX0h1iY1JZBnxR+bBj02Am4T+Fc/kmTa0x+MN1ax4Jva5/1YQ2t1oLcz7bb6Lum8FwLXvJM4gCtO5lURFcIeNPCPggjZO7zFTd6zGU2RWELBTIkrxbLJ9JP4gk3gTzrXBhWIzgkhSjDt6ZKf3nXKF08+atjkVbeWbTox1+vGdg+KaSItqFCVCtJixv8zEV3Ad+DppBFn9DkyPKb1k9ZJxyTrRTPzIrp5mLV6PD+VPBPc/zJcBkn40JvLcqZhIdQVtbadmVxP4+5vaZFl/C/Jrfp4YzdDHGoLW1g5RAMwbzpm2v7O06pfPYaXn/lI4UBYKq1i7aCEzXp9bEtJwb7KNfhsQnzzAmSousNiJnZUz6NovkydFC8F1YPCrFRVJpkpOP2WUfg9uWnMFuNZjBmsGky0ANj+1nUewXU+JpIPkgZF9C5WltnUB6jCon5zBNzNW5ewBPLM3Zea5Uk3NjUTCFuaC21So5kZX7XAZ2T9eBOMYntzan6EZUggN1sj5ofKGa5Shb/q53UuUO6kH7pMii35nlvpbK95yR16snUqhOMOuu+QhdqpQmvTZ+mLejXs18B1dm62qMiwDRHAB9NdgKFS7oqFE7BXXKl3XJLFywv9akXLsd9sd8EzVE9gT+HujbT+r30M0fNjaCt/Ik7A/bqMXQtkfyow+kLrHu4YvKXkukwySiisRtXqFBXNPbgPDN0uEwRaThe2iVYoZWjTO6bgVK+TVxyec+ACqlc9sZUj1n3SdXpRMIZziqku03UidqXFfut4+VFU3Xc+2XL9SZnWPEzzEr3aW2wTtZZPj9ebiJWkoEc4edZBYoGTb9pco6FbfV5FjJ+CwL4LoNRLicnXkII5wOMJuSm6c9bNqksxZgFLMA/JWAtoPsIWWntPFjUMR47ioWmvq7DeDXlVz546FMAbIdtHNMoM/PbanEsZzBfX8qWHr0l9igKZq4CsqbPa26B5JtQUHlOu+9DvqJd29iawLwS5QZexOlvj+3+LHBiuM1zGipYJ/8+pwpeNhvzB5lbbR8ZV0W1zFbyPmYkRE/R4lXdnfD6njSp1wbWoHNdXFQ8PR+t3LuGygUImRHGUCSn9pAEVCbu4WTWwCGgIyO1t+yyGwkWaHSpwaeLAcxeglN6z5p3mDt6WVySHC/mFkfpBywAxOyLFQJu09sMqe/1DN9Wc7EVDiQfjcItywWdjj1VXRURTLcf4P71TH/gfyGnG7K4Mi76geMaeUEfbQIvF33L5tppUnI3i39PoYO/dVvxLRLTed2XA3Ph+eK0dQCyyIhEg92dFrH/yP6LnyG0QHxvFrMUpKxi3E7jTbE/wyfAwNPKHUNA7NPTDovc0itnE3yc/JEy5o3RO6AMXsR5YcUxwgdYu6OoEBKIs9rlrdfQw2CTFbjM6kP7gqK8vhZbpnP4RQYPQOuDzu0B0EnULBocN9SCdv+dSFPOU5RPrsQYRgoXKmqhTc57VndgMD1p0KVWDjnzbVm8GV2OR16L+repVpqLCxGf7xxp0tenWAlZAMVBPbRltER+zTul5fe85f/PNFUI0dgaIPkKFJcOMoayNFr5VBSt+em+mWLIoyNLeEygBkyAJ7sa2Th9fMplBfq+FXyDNRj2mzP3G62BA3m1Ojx3hvDsJNi8GClTgKzlG+eXAc2YgjknS9/59scVvKRlaMOu7qDwOs0ImiCQcT3LQKhDZQxJ6wgPaR76bzX9SoDDiReWdqgTF68GQg3q6L+La8p+Nhm0j1Y4NGfLRhVka70WUNyanthA5FxuWpmbmttcJ4Nf3jw44IACQ1OGVja6KYA69oj/tTpKltQ==
mcp-registry:jwtPrivateKey:
secure: v1:0NSeI0qWrdcHeVfh:P56IEP/700/e839TSbF7Ns2j2orZnD5cRNXohjxCPOKyIDYn+0bmiTuk8pyFnFk8WibS/w7M2FFgv3/BL0Djo0XMVe9tq+7HjLN4tPEJnCU=
mcp-registry:googleOauthClientSecret:
secure: v1:MfA48Ri4VssQwUi7:TUF9sBGIgTnBUhePfEF7L+h1VVn5e6j2uGLbOwbGbjyBO9Z72SKC+z/21xHmJtqUSr2t
encryptionsalt: v1:EKBwmTmss1c=:v1:JLd4a7cM0X8Jroh+:z9/q6RSCEFTDzMV6X6h5Tpbw0tnpkA==
```
## /deploy/Pulumi.local.yaml
```yaml path="/deploy/Pulumi.local.yaml"
config:
mcp-registry:environment: local
mcp-registry:provider: local
mcp-registry:githubClientId: Iv23licy3GSiM9Km5jtd
mcp-registry:githubClientSecret: 0e8db54879b02c29adef51795586f3c510a9341d
mcp-registry:jwtPrivateKey: bb2c6b424005acd5df47a9e2c87f446def86dd740c888ea3efb825b23f7ef47c
mcp-registry:googleOauthClientSecret:
secure: v1:DUSCqfRvRSVmSihj:JIYznEU+T1vbmVF8MkdSUtqUM50498sxBWsfwNaS1r5jkF/+gtltWfJoKWb1OLgcX8lb
encryptionsalt: v1:ijIHaqhbXVA=:v1:7voX1Kv+Bunz33iN:fyVHMOhlGIymzJ+ILgUBy3ExTwUUnA==
```
## /deploy/Pulumi.yaml
```yaml path="/deploy/Pulumi.yaml"
name: mcp-registry-infra
runtime:
name: go
options:
binary: ./infra
description: MCP Registry Kubernetes Infrastructure
```
## /deploy/README.md
# MCP Registry Kubernetes Deployment
This directory contains Pulumi infrastructure code to deploy the MCP Registry service to a Kubernetes cluster. It supports deploying the infrastructure locally (using an existing kubeconfig, e.g. with minikube) or to Google Cloud Platform (GCP).
## Quick Start
### Local Development
Pre-requisites:
- [Pulumi CLI installed](https://www.pulumi.com/docs/iac/download-install/)
- Access to a Kubernetes cluster via kubeconfig. You can run a cluster locally with [minikube](https://minikube.sigs.k8s.io/docs/start/).
1. Ensure your kubeconfig is configured at the cluster you want to use. For minikube, run `minikube start && minikube tunnel`.
2. Run `make local-up` to deploy the stack.
3. Access the repository via the ingress load balancer. You can find its external IP with `kubectl get svc ingress-nginx-controller -n ingress-nginx`. Then run `curl -H "Host: local.registry.modelcontextprotocol.io" -k https://<EXTERNAL-IP>/v0/ping` to check that the service is up.
#### To change config
The stack is configured out of the box for local development. But if you want to make changes, run commands like:
```bash
PULUMI_CONFIG_PASSPHRASE="" pulumi config set mcp-registry:environment local
PULUMI_CONFIG_PASSPHRASE="" pulumi config set mcp-registry:githubClientSecret --secret <some-secret-value>
```
#### To delete the stack
`make local-destroy` and deleting the cluster (with minikube: `minikube delete`) will reset you back to a clean state.
### Production Deployment (GCP)
**Note:** Deployments are automatically handled by GitHub Actions with separate workflows for staging and production:
- **Staging:** All merges to the `main` branch automatically build and deploy the latest code to the `staging` environment via [deploy-staging.yml](../.github/workflows/deploy-staging.yml). Staging always uses the `:main` Docker image tag.
- **Production:** Deployment requires explicit configuration of the Docker image tag in `Pulumi.gcpProd.yaml` and is triggered automatically via [deploy-production.yml](../.github/workflows/deploy-production.yml) when this config file is pushed to `main`. This GitOps-style approach provides manual control and an audit trail for production versions.
**To deploy a specific version to production:**
1. Cut a release (if deploying a new version):
```bash
# Via GitHub UI: https://github.com/modelcontextprotocol/registry/releases
# Or via gh CLI:
gh release create v1.2.3 --generate-notes
```
This builds Docker images tagged as `1.2.3` and `latest` (note: image tags do not include the 'v' prefix)
2. Update the production image tag in `Pulumi.gcpProd.yaml`:
```bash
# Edit deploy/Pulumi.gcpProd.yaml
# Change line: mcp-registry:imageTag: 1.2.3 (note: no 'v' prefix for Docker image tags)
git add deploy/Pulumi.gcpProd.yaml
git commit -m "Deploy version 1.2.3 to production"
git push
```
3. The production deployment workflow will automatically trigger and deploy the specified version
**Manual Override:** The steps below are preserved if a manual deployment override is needed.
Pre-requisites:
- [Pulumi CLI installed](https://www.pulumi.com/docs/iac/download-install/)
- A Google Cloud Platform (GCP) account
- A GCP Service Account with appropriate permissions
1. Create a project: `gcloud projects create mcp-registry-prod`
2. Set the project: `gcloud config set project mcp-registry-prod`
3. Enable the bootstrap APIs needed before Pulumi can run. The remaining APIs
(`cloudresourcemanager`, `compute`, `container`, `logging`, `monitoring`) are
adopted as Pulumi-managed `projects.Service` resources by `ensureRequiredAPIs`
in `pkg/providers/gcp/provider.go`, so they self-heal against drift but still
need to be reachable on the very first deploy:
```bash
gcloud services enable storage.googleapis.com # for the Pulumi state bucket below
gcloud services enable cloudresourcemanager.googleapis.com # for the IAM bindings Pulumi creates
gcloud services enable container.googleapis.com # for the GKE cluster
```
4. Create the Pulumi service account and grant it the roles required to manage
the deploy. `projectIamAdmin` is needed because the deploy creates IAM bindings
on the project (for the default compute SA). The other four are general
resource management:
```bash
gcloud iam service-accounts create pulumi-svc
sleep 10
gcloud projects add-iam-policy-binding mcp-registry-prod --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/container.admin"
gcloud projects add-iam-policy-binding mcp-registry-prod --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/compute.admin"
gcloud projects add-iam-policy-binding mcp-registry-prod --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/storage.admin"
gcloud projects add-iam-policy-binding mcp-registry-prod --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/storage.hmacKeyAdmin"
gcloud projects add-iam-policy-binding mcp-registry-prod --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/resourcemanager.projectIamAdmin"
gcloud iam service-accounts add-iam-policy-binding $(gcloud projects describe mcp-registry-prod --format="value(projectNumber)")-compute@developer.gserviceaccount.com --member="serviceAccount:pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com" --role="roles/iam.serviceAccountUser"
gcloud iam service-accounts keys create sa-key.json --iam-account=pulumi-svc@mcp-registry-prod.iam.gserviceaccount.com
```
5. Create a GCS bucket for Pulumi state: `gsutil mb gs://mcp-registry-prod-pulumi-state`
6. Set Pulumi's backend to GCS: `pulumi login gs://mcp-registry-prod-pulumi-state`
7. Get the passphrase file `passphrase.prod.txt` from the registry maintainers
8. Init the GCP stack: `PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt pulumi stack init gcpProd`
9. Set the GCP credentials in Pulumi config:
```bash
# Base64 encode the service account key and set it
pulumi config set --secret gcp:credentials "$(base64 < sa-key.json)"
```
10. Deploy: `make prod-up`
11. Access the repository via the ingress load balancer. You can find its external IP with: `kubectl get svc ingress-nginx-controller -n ingress-nginx`.
Then run `curl -H "Host: prod.registry.modelcontextprotocol.io" -k https://<EXTERNAL-IP>/v0/ping` to check that the service is up.
## Structure
```
├── main.go # Pulumi program entry point
├── Pulumi.yaml # Project configuration
├── Pulumi.local.yaml # Local stack configuration
├── Pulumi.gcpProd.yaml # GCP production stack configuration
├── Pulumi.gcpStaging.yaml # GCP staging stack configuration
├── Makefile # Build and deployment targets
├── go.mod # Go module dependencies
├── go.sum # Go module checksums
└── pkg/ # Infrastructure packages
├── k8s/ # Kubernetes deployment components
│ ├── backup.go # Database backup configuration
│ ├── cert_manager.go # SSL certificate management
│ ├── deploy.go # Deployment orchestration
│ ├── ingress.go # Ingress controller setup
│ ├── monitoring.go # Metrics and monitoring setup
│ ├── postgres.go # PostgreSQL database deployment
│ └── registry.go # MCP Registry deployment
└── providers/ # Kubernetes cluster providers
├── types.go # Provider interface definitions
├── gcp/ # Google Kubernetes Engine provider
└── local/ # Local kubeconfig provider
```
### Architecture Overview
#### Deployment Flow
1. Pulumi program starts in `main.go`
2. Configuration is loaded from Pulumi config files
3. Provider factory creates appropriate cluster provider (GCP or local)
4. Cluster provider sets up Kubernetes access
5. `k8s.DeployAll()` orchestrates complete deployment:
- Certificate manager for SSL/TLS
- Ingress controller for external access
- Database for data persistence
- Backup infrastructure for database
- Monitoring and metrics collection
- MCP Registry application
## Configuration
| Parameter | Description | Required |
|-----------|-------------|----------|
| `environment` | Deployment environment (local/staging/prod) | Yes |
| `provider` | Kubernetes provider (local/gcp) | No (default: local) |
| `githubClientId` | GitHub OAuth Client ID | Yes |
| `githubClientSecret` | GitHub OAuth Client Secret | Yes |
| `imageTag` | Docker image tag for production environment | Yes (prod only) |
| `gcpProjectId` | GCP Project ID (required when provider=gcp) | No |
| `gcpRegion` | GCP Region (default: us-central1) | No |
## Database Backups
The deployment uses [K8up](https://k8up.io/) (a Kubernetes backup operator) that uses [Restic](https://restic.net/) under the hood.
When running locally they are stored in a Minio bucket. In staging and production, backups are stored in a GCS bucket.
### Accessing Backup Files
#### Local Development (MinIO)
```bash
# Expose MinIO web console
kubectl port-forward -n minio svc/minio 9000:9000 9001:9001
```
Then open [localhost:9001](http://localhost:9001), login with username `minioadmin` and password `minioadmin`, and navigate to the k8up-backups bucket.
##### Staging and Production (GCS)
- [Staging](https://console.cloud.google.com/storage/browser/mcp-registry-staging-backups?project=mcp-registry-staging)
- [Production](https://console.cloud.google.com/storage/browser/mcp-registry-prod-backups?project=mcp-registry-prod)
#### Decrypting and Restoring Backups
Backups are encrypted using Restic. To access the backup data:
1. **Download the backup files from the bucket:**
```bash
# Local (MinIO) - ensure port-forward is active: kubectl port-forward -n minio svc/minio 9000:9000 9001:9001
AWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin \
aws --endpoint-url http://localhost:9000 s3 sync s3://k8up-backups/ ./backup-files/
# GCS (staging/production)
gsutil -m cp -r gs://mcp-registry-{staging|prod}-backups/* ./backup-files/
```
2. **[Install Restic](https://restic.readthedocs.io/en/latest/020_installation.html)**
3. **Restore the backup:**
```bash
RESTIC_PASSWORD=password restic -r ./backup-files restore latest --target ./restored-files
```
PostgreSQL data will be in `./restored-files/data/registry-pg-1/pgdata/`
## Troubleshooting
To configure kubectl to access an existing GKE cluster:
```bash
# Login
gcloud auth login
gcloud auth application-default login
# For production
gcloud container clusters get-credentials mcp-registry-prod --zone us-central1-b --project mcp-registry-prod
# For staging
gcloud container clusters get-credentials mcp-registry-staging --zone us-central1-b --project mcp-registry-staging
```
### Check Status
```bash
kubectl get pods
kubectl get deployment
kubectl get svc
kubectl get ingress
kubectl get svc -n ingress-nginx
```
### View Logs
```bash
kubectl logs -l app=mcp-registry
kubectl logs -l app=postgres
```
### Check Backup Status
```bash
kubectl describe schedule.k8up.io
kubectl get backup
```
## /deploy/go.mod
```mod path="/deploy/go.mod"
module github.com/modelcontextprotocol/registry/deploy/infra
go 1.26
require (
github.com/pulumi/pulumi-gcp/sdk/v8 v8.41.1
github.com/pulumi/pulumi-kubernetes/sdk/v4 v4.31.1
github.com/pulumi/pulumi/sdk/v3 v3.244.0
gopkg.in/yaml.v2 v2.4.0
)
require (
dario.cat/mergo v1.0.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/blang/semver v3.5.1+incompatible // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbles v1.0.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/cheggaaa/pb v1.0.29 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/djherbis/times v1.5.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-git/go-git/v5 v5.19.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/glog v1.2.5 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/hcl/v2 v2.22.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/opentracing/basictracer-go v1.1.0 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/pgavlin/fx v0.1.6 // indirect
github.com/pgavlin/fx/v2 v2.0.12 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/term v1.1.0 // indirect
github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 // indirect
github.com/pulumi/esc v0.24.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/texttheater/golang-levenshtein v1.0.1 // indirect
github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect
github.com/uber/jaeger-lib v2.4.1+incompatible // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zclconf/go-cty v1.13.2 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/collector/featuregate v1.53.0 // indirect
go.opentelemetry.io/collector/pdata v1.53.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/frand v1.5.1 // indirect
)
```
## /deploy/main.go
```go path="/deploy/main.go"
package main
import (
"fmt"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi/config"
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/k8s"
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers"
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers/gcp"
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers/local"
)
// createProvider creates the appropriate cluster provider based on configuration
func createProvider(ctx *pulumi.Context) (providers.ClusterProvider, error) {
conf := config.New(ctx, "mcp-registry")
providerName := conf.Get("provider")
if providerName == "" {
providerName = "local" // Default to local provider
}
switch providerName {
case "gcp":
return &gcp.Provider{}, nil
case "local":
return &local.Provider{}, nil
default:
return nil, fmt.Errorf("unsupported provider: %s", providerName)
}
}
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
// Get configuration
conf := config.New(ctx, "mcp-registry")
environment := conf.Require("environment")
// Create provider
provider, err := createProvider(ctx)
if err != nil {
return err
}
// Create cluster
cluster, err := provider.CreateCluster(ctx, environment)
if err != nil {
return err
}
// Create backup storage
storage, err := provider.CreateBackupStorage(ctx, cluster, environment)
if err != nil {
return err
}
// Deploy to Kubernetes
_, err = k8s.DeployAll(ctx, cluster, storage, environment)
if err != nil {
return err
}
// Export outputs
ctx.Export("clusterName", cluster.Name)
return nil
})
}
```
## /deploy/pkg/k8s/backup.go
```go path="/deploy/pkg/k8s/backup.go"
package k8s
import (
"fmt"
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apiextensions"
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"
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/yaml"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers"
)
// DeployK8up installs the k8up backup operator and configures scheduled backups
func DeployK8up(ctx *pulumi.Context, cluster *providers.ProviderInfo, environment string, storage *providers.BackupStorageInfo) error {
if storage == nil {
ctx.Log.Info("No backup storage configured, skipping k8up deployment", nil)
return nil
}
// Install the k8up CRDs before the helm chart
// Related: https://github.com/k8up-io/k8up/issues/1050
k8upCRDs, err := yaml.NewConfigFile(ctx, "k8up-crds", &yaml.ConfigFileArgs{
File: "https://github.com/k8up-io/k8up/releases/download/k8up-4.8.4/k8up-crd.yaml",
}, pulumi.Provider(cluster.Provider))
if err != nil {
return fmt.Errorf("failed to install k8up CRDs: %w", err)
}
// Install k8up operator
k8upValues := pulumi.Map{
"k8up": pulumi.Map{
"backupCommandAnnotation": pulumi.String("k8up.io/backup-command"),
"fileExtensionAnnotation": pulumi.String("k8up.io/file-extension"),
},
}
k8up, err := helm.NewChart(ctx, "k8up", helm.ChartArgs{
Chart: pulumi.String("k8up"),
Version: pulumi.String("4.8.4"),
FetchArgs: helm.FetchArgs{
Repo: pulumi.String("https://k8up-io.github.io/k8up"),
},
Values: k8upValues,
}, pulumi.Provider(cluster.Provider), pulumi.DependsOn([]pulumi.Resource{k8upCRDs}))
if err != nil {
return fmt.Errorf("failed to install k8up: %w", err)
}
// Create restic repository password secret
repoPassword, err := corev1.NewSecret(ctx, "k8up-repo-password", &corev1.SecretArgs{
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("k8up-repo-password"),
Namespace: pulumi.String("default"),
Labels: pulumi.StringMap{
"k8up.io/backup": pulumi.String("true"),
},
},
Type: pulumi.String("Opaque"),
StringData: pulumi.StringMap{
"password": pulumi.String("password"), // In production we use GCS, which is already encrypted
},
}, pulumi.Provider(cluster.Provider))
if err != nil {
return fmt.Errorf("failed to create repository password secret: %w", err)
}
// Determine schedule based on environment
backupSchedule := "46 4 * * *" // Daily at 4:46 AM
pruneSchedule := "46 5 * * *" // Daily at 5:46 AM
keepDaily := 28 // Keep daily backups for 28 days
if environment == "local" || environment == "dev" {
backupSchedule = "* * * * *" // Every minute for testing
pruneSchedule = "*/5 * * * *" // Every 5 minutes
keepDaily = 1
}
// Create Schedule for automated backups
_, err = apiextensions.NewCustomResource(ctx, "k8up-schedule", &apiextensions.CustomResourceArgs{
ApiVersion: pulumi.String("k8up.io/v1"),
Kind: pulumi.String("Schedule"),
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("backup-schedule"),
Namespace: pulumi.String("default"),
Labels: pulumi.StringMap{
"environment": pulumi.String(environment),
},
},
OtherFields: map[string]any{
"spec": map[string]any{
"backend": map[string]any{
"repoPasswordSecretRef": map[string]any{
"name": repoPassword.Metadata.Name().Elem(),
"key": "password",
},
"s3": map[string]any{
"endpoint": storage.Endpoint,
"bucket": storage.BucketName,
"accessKeyIDSecretRef": map[string]any{
"name": storage.Credentials.Metadata.Name().Elem(),
"key": "AWS_ACCESS_KEY_ID",
},
"secretAccessKeySecretRef": map[string]any{
"name": storage.Credentials.Metadata.Name().Elem(),
"key": "AWS_SECRET_ACCESS_KEY",
},
},
},
"backup": map[string]any{
"schedule": backupSchedule,
"podSecurityContext": map[string]any{
"runAsUser": 0, // Run as root to access all files
},
"successfulJobsHistoryLimit": 3,
"failedJobsHistoryLimit": 3,
},
"prune": map[string]any{
"schedule": pruneSchedule,
"retention": map[string]any{
"keepDaily": keepDaily,
},
"successfulJobsHistoryLimit": 1,
"failedJobsHistoryLimit": 1,
},
},
},
}, pulumi.Provider(cluster.Provider), pulumi.DependsOn([]pulumi.Resource{k8up, storage.Credentials, repoPassword}))
if err != nil {
return fmt.Errorf("failed to create k8up schedule: %w", err)
}
return nil
}
```
## /deploy/pkg/k8s/cert_manager.go
```go path="/deploy/pkg/k8s/cert_manager.go"
package k8s
import (
"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"
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers"
)
// SetupCertManager sets up cert-manager for TLS certificates
func SetupCertManager(ctx *pulumi.Context, cluster *providers.ProviderInfo) error {
// Create namespace for cert-manager
certManagerNamespace, err := v1.NewNamespace(ctx, "cert-manager", &v1.NamespaceArgs{
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("cert-manager"),
},
}, pulumi.Provider(cluster.Provider))
if err != nil {
return err
}
// Install cert-manager for TLS certificates
certManager, err := helm.NewChart(ctx, "cert-manager", helm.ChartArgs{
Chart: pulumi.String("cert-manager"),
Version: pulumi.String("v1.18.2"),
FetchArgs: helm.FetchArgs{
Repo: pulumi.String("https://charts.jetstack.io"),
},
Namespace: certManagerNamespace.Metadata.Name().Elem(),
Values: pulumi.Map{
"installCRDs": pulumi.Bool(true),
"ingressShim": pulumi.Map{
"defaultIssuerName": pulumi.String("letsencrypt-prod"),
"defaultIssuerKind": pulumi.String("ClusterIssuer"),
},
},
}, pulumi.Provider(cluster.Provider))
if err != nil {
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), pulumi.DependsOnInputs(certManager.Ready))
if err != nil {
return err
}
return nil
}
```
## /deploy/pkg/k8s/deploy.go
```go path="/deploy/pkg/k8s/deploy.go"
package k8s
import (
corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers"
)
// DeployAll orchestrates the complete deployment of the MCP Registry to Kubernetes
func DeployAll(ctx *pulumi.Context, cluster *providers.ProviderInfo, backupStorage *providers.BackupStorageInfo, environment string) (service *corev1.Service, err error) {
// Setup cert-manager
err = SetupCertManager(ctx, cluster)
if err != nil {
return nil, err
}
// Setup ingress controller
ingressNginx, err := SetupIngressController(ctx, cluster, environment)
if err != nil {
return nil, err
}
// Deploy PostgreSQL databases
pgCluster, err := DeployPostgresDatabases(ctx, cluster, environment)
if err != nil {
return nil, err
}
// Deploy k8up backup operator
err = DeployK8up(ctx, cluster, environment, backupStorage)
if err != nil {
return nil, err
}
// Deploy MCP Registry
service, err = DeployMCPRegistry(ctx, cluster, environment, ingressNginx, pgCluster)
if err != nil {
return nil, err
}
// Deploy monitoring stack
err = DeployMonitoringStack(ctx, cluster, environment, ingressNginx)
if err != nil {
return nil, err
}
return service, nil
}
```
## /deploy/pkg/providers/types.go
```go path="/deploy/pkg/providers/types.go"
package providers
import (
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes"
corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
// ProviderInfo represents the information returned by a cluster provider
type ProviderInfo struct {
Name pulumi.StringOutput
Provider *kubernetes.Provider
}
// BackupStorageInfo represents S3-compatible backup storage configuration
type BackupStorageInfo struct {
Endpoint string // S3 endpoint (e.g., https://storage.googleapis.com or http://minio:9000)
BucketName string
Credentials *corev1.Secret // Should contain AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY
}
// ClusterProvider defines the interface that all cluster providers must implement
type ClusterProvider interface {
// CreateCluster creates a Kubernetes cluster and returns provider info
CreateCluster(ctx *pulumi.Context, environment string) (*ProviderInfo, error)
// CreateBackupStorage creates backup storage infrastructure
CreateBackupStorage(ctx *pulumi.Context, cluster *ProviderInfo, environment string) (*BackupStorageInfo, error)
}
```
## /docs/README.md
# MCP Registry Documentation
The MCP registry provides MCP clients with a list of MCP servers, like an app store for MCP servers.
## I want to...
- **📤 Publish my MCP server** → [Publishing Guide](modelcontextprotocol-io/quickstart.mdx)
- **📥 Consume registry data** → [API Usage Guide](modelcontextprotocol-io/registry-aggregators.mdx)
- **🔌 Understand the registry's purpose** → [Ecosystem vision](design/ecosystem-vision.md)
- **📋 Look up specific information** → [server.json spec](reference/server-json/generic-server-json.md) | [API spec](reference/api/generic-registry-api.md) | [CLI reference](reference/cli/commands.md)
- **🤝 Add reference to my community project** → [Community Projects](community-projects.md)
## Documentation Index
- 📄 [Public-facing docs](./modelcontextprotocol-io/) - Published on modelcontextprotocol.io
- 🏗️ [Design documentation](./design/) - Architecture, vision, and roadmap
- 📖 [Reference](./reference/) - Technical specifications
- 🔧 [Contributing guides](./contributing/) - How to contribute
- 🔒 [Administration](./administration/) - Admin operations
## /docs/design/dev-summit-2025-05-registry-goals-presentation.pdf
Binary file available at https://raw.githubusercontent.com/modelcontextprotocol/registry/refs/heads/main/docs/design/dev-summit-2025-05-registry-goals-presentation.pdf
## /docs/design/dev-summit-2025-10-registry-status-presentation.pdf
Binary file available at https://raw.githubusercontent.com/modelcontextprotocol/registry/refs/heads/main/docs/design/dev-summit-2025-10-registry-status-presentation.pdf
The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.