Publishing Packages

How to publish MCP packages to the registry, step by step.

This page explains the complete publish protocol: from building your bundle to marking the version as available for download.

Overview

Publishing an MCP package involves six steps:

  1. Build the bundle (tar.gz archive)
  2. Create the manifest (JSON contract)
  3. Compute SHA-256 digests
  4. Initiate the publish request
  5. Upload the bundle
  6. Mark the version as published
  Build bundle          Create manifest        Compute digests
  (tar.gz)              (JSON)                 (SHA-256)
      |                     |                      |
      +----------+----------+----------+-----------+
                 |                     |
                 v                     v
          POST /publish          Upload bundle
          (initiate)             (presigned URL or proxy)
                 |                     |
                 +----------+----------+
                            |
                            v
                     POST /status
                     (mark published)

Prerequisites

  • A running MCP Registry instance
  • Authentication credentials (JWT or API token) with mcp:publish scope
  • Your MCP implementation built as a bundle (tar.gz)
  • A valid manifest describing your bundle

Step 1: Build the Bundle

Create a tar.gz archive containing your MCP implementation.

Node.js

# Build the project
npm run build

# Create bundle directory
mkdir -p bundle/dist
cp -r dist/* bundle/dist/
cp package.json package-lock.json bundle/

# Create tarball
tar -czf bundle.tar.gz -C bundle .

Python

# Create bundle directory
mkdir -p bundle
cp -r src/* bundle/
cp requirements.txt bundle/

# Create tarball
tar -czf bundle.tar.gz -C bundle .

OCI Container

For container-based MCPs, the bundle typically contains only configuration since the image is referenced in the manifest:

docker build -t ghcr.io/myorg/my-mcp:1.0.0 .
docker push ghcr.io/myorg/my-mcp:1.0.0

mkdir -p bundle
cp config.yaml bundle/
tar -czf bundle.tar.gz -C bundle .

Step 2: Create the Manifest

Create a manifest.json file describing your bundle. The manifest must follow schema version 1.

{
  "schema_version": 1,
  "package": {
    "id": "acme/weather-service",
    "version": "1.0.0",
    "description": "Weather data service providing current conditions",
    "license": "Apache-2.0",
    "repository": "https://github.com/acme/weather-service"
  },
  "runtime": {
    "type": "node",
    "version": ">=18.0.0"
  },
  "entrypoint": {
    "command": ["node", "dist/index.js"],
    "env": {
      "NODE_ENV": "production"
    }
  },
  "permissions": {
    "network": {
      "outbound": ["api.weather.com:443"],
      "inbound": false
    },
    "env_vars": ["WEATHER_API_KEY"]
  },
  "resources": {
    "memory_mb": 256,
    "cpu_millicores": 100
  }
}

Step 3: Compute Digests

Compute SHA-256 digests for both the manifest and bundle.

Linux

MANIFEST_DIGEST="sha256:$(sha256sum manifest.json | awk '{print $1}')"
BUNDLE_DIGEST="sha256:$(sha256sum bundle.tar.gz | awk '{print $1}')"
BUNDLE_SIZE=$(stat -c%s bundle.tar.gz)

echo "Manifest digest: $MANIFEST_DIGEST"
echo "Bundle digest:   $BUNDLE_DIGEST"
echo "Bundle size:     $BUNDLE_SIZE bytes"

macOS

MANIFEST_DIGEST="sha256:$(shasum -a 256 manifest.json | awk '{print $1}')"
BUNDLE_DIGEST="sha256:$(shasum -a 256 bundle.tar.gz | awk '{print $1}')"
BUNDLE_SIZE=$(stat -f%z bundle.tar.gz)

Step 4: Initiate the Publish Request

Send a POST request to the publish endpoint with version metadata, the inline manifest, and repository information.

REGISTRY_URL="https://registry.example.com"
TOKEN="your-jwt-or-api-token"

# Gather repository info (from CI environment or git)
GIT_SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
REPO_URL="${GITHUB_REPOSITORY:+https://github.com/$GITHUB_REPOSITORY}"
REPO_REF="${GITHUB_REF_NAME:-$(git describe --tags --always)}"

RESPONSE=$(curl -s -X POST \
  "$REGISTRY_URL/v1/org/acme/mcps/weather-service/publish" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"version\": \"1.0.0\",
    \"bundle_digest\": \"$BUNDLE_DIGEST\",
    \"bundle_size_bytes\": $BUNDLE_SIZE,
    \"manifest_json\": $(cat manifest.json),
    \"git_sha\": \"$GIT_SHA\",
    \"repo_url\": \"$REPO_URL\",
    \"repo_visibility\": \"public\",
    \"repo_provider\": \"github\",
    \"repo_ref\": \"$REPO_REF\",
    \"repo_commit\": \"$GIT_SHA\"
  }")

echo "$RESPONSE" | jq .

Publish Request Fields

FieldTypeRequiredDescription
versionstringYesSemantic version (e.g., 1.0.0)
bundle_digeststringYesSHA-256 digest of the bundle (sha256:...)
bundle_size_bytesintegerYesSize of the bundle in bytes
manifest_jsonobjectYesInline manifest JSON (or provide manifest_digest if uploading separately)
git_shastringYesSource commit hash
repo_urlstringYesSource repository URL
repo_visibilitystringYespublic or private
repo_providerstringYesGit provider (github, gitlab, bitbucket)
repo_refstringYesGit ref (tag or branch name)
repo_commitstringYesFull commit hash
certification_levelintegerNoCertification level (0-3, default 0)
evidence_digestsarrayNoArray of evidence artifact digests

Publish Response

A successful publish returns the version status and a presigned URL for bundle upload:

{
  "version": "1.0.0",
  "status": "ingested",
  "bundle_upload": {
    "url": "https://s3.amazonaws.com/bucket/path?X-Amz-Signature=...",
    "method": "PUT",
    "headers": {
      "Content-Type": "application/octet-stream"
    },
    "expires_in": 300
  }
}

If presigned URLs are disabled (filesystem backend or presign.enabled=false), bundle_upload will be null. In that case, upload the bundle through the registry proxy endpoint instead (see below).

Step 5: Upload the Bundle

Via Presigned URL (S3 Storage)

Upload the bundle directly to the storage backend using the presigned URL from the publish response:

UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.bundle_upload.url')

if [ "$UPLOAD_URL" != "null" ]; then
  curl -X PUT "$UPLOAD_URL" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @bundle.tar.gz
fi

Via Registry Proxy (Filesystem Storage)

When presigned URLs are not available, upload through the registry’s artifact proxy endpoint:

curl -X PUT \
  "$REGISTRY_URL/v1/org/acme/artifacts/$BUNDLE_DIGEST/bundle" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/octet-stream" \
  --data-binary @bundle.tar.gz

Handling Both Cases

UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.bundle_upload.url')

if [ "$UPLOAD_URL" != "null" ]; then
  # Direct upload to S3 via presigned URL
  curl -X PUT "$UPLOAD_URL" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @bundle.tar.gz
else
  # Fallback: upload through registry proxy
  curl -X PUT \
    "$REGISTRY_URL/v1/org/acme/artifacts/$BUNDLE_DIGEST/bundle" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/octet-stream" \
    --data-binary @bundle.tar.gz
fi

Step 6: Mark Version as Published

After the bundle is uploaded, update the version status to make it available for resolution:

curl -X POST \
  "$REGISTRY_URL/v1/org/acme/mcps/weather-service/versions/1.0.0/status" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "published"}'

Response:

{
  "version": "1.0.0",
  "status": "published"
}

The version is now resolvable and downloadable by authorized clients.

Publishing Evidence Artifacts

You can attach evidence artifacts (SBOMs, signatures, scan reports) to a published version. Include the evidence digests in the publish request and upload the evidence separately.

# Generate an SBOM
npm sbom --sbom-format=spdx > sbom.json
SBOM_DIGEST="sha256:$(sha256sum sbom.json | awk '{print $1}')"

# Include evidence_digests in the publish request
# ... "evidence_digests": ["$SBOM_DIGEST"] ...

# Upload evidence artifact
curl -X PUT \
  "$REGISTRY_URL/v1/org/acme/artifacts/$SBOM_DIGEST/evidence/sbom" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @sbom.json

Repository Policies

The registry can enforce policies on the source repositories of published packages. These policies are configured server-side and applied automatically during the publish step.

Policy Rules

RuleDescriptionEffect
Allow DomainsWhitelist of allowed repository domains (e.g., github.com, gitlab.com)If non-empty, the repo_url host must match one of the listed domains
Deny PatternsURL path patterns to block (substring matching)If the repo_url path contains any deny pattern, the version is quarantined
Allow OrgsWhitelist of allowed repository owners/organizationsIf non-empty, the first path segment of repo_url must match

Policy Evaluation Order

  1. If allow_domains is non-empty, the domain must match
  2. The path must not contain any deny_patterns
  3. If allow_orgs is non-empty, the repository org must match

Example Policy Configuration

repo_policy:
  allow_domains:
    - "github.com"
    - "gitlab.com"
  deny_patterns:
    - "/malware-"
    - "/blocked-org/"
  allow_orgs:
    - "acme"
    - "trusted-vendor"

Status Transitions

After the initial publish (which creates the version in ingested state), the following status transitions are available:

Current StatusTarget StatusDescription
ingestedpublishedMark version available for resolution
ingestedquarantinedAuto-applied if repository policy fails
publishedrevokedWithdraw the version

To transition status, send a POST to the status endpoint:

curl -X POST \
  "$REGISTRY_URL/v1/org/{org}/mcps/{name}/versions/{version}/status" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "published"}'

Republishing and Versioning

Published versions are immutable. You cannot modify the manifest, bundle, or metadata of a published version. To fix an issue:

  1. Create a new patch version (e.g., 1.0.1)
  2. Publish the corrected bundle to the new version
  3. Optionally revoke the problematic version
# Revoke the bad version
curl -X POST \
  "$REGISTRY_URL/v1/org/acme/mcps/weather-service/versions/1.0.0/status" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "revoked"}'

# Publish the fixed version (repeat steps 1-6 with version 1.0.1)

GitHub Actions Example

Here is a complete GitHub Actions workflow that publishes an MCP package on every tagged release:

name: Publish MCP Package

on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install and build
        run: |
          npm ci
          npm run build

      - name: Create bundle
        run: |
          mkdir -p bundle/dist
          cp -r dist/* bundle/dist/
          cp package.json package-lock.json bundle/
          tar -czf bundle.tar.gz -C bundle .

      - name: Create manifest
        run: |
          VERSION=${GITHUB_REF_NAME#v}
          cat > manifest.json << EOF
          {
            "schema_version": 1,
            "package": {
              "id": "${{ github.repository_owner }}/$(basename ${{ github.repository }})",
              "version": "$VERSION",
              "description": "My MCP package",
              "repository": "https://github.com/${{ github.repository }}"
            },
            "runtime": {
              "type": "node",
              "version": ">=20.0.0"
            },
            "entrypoint": {
              "command": ["node", "dist/index.js"]
            }
          }
          EOF

      - name: Compute digests
        id: digests
        run: |
          echo "bundle_digest=sha256:$(sha256sum bundle.tar.gz | awk '{print $1}')" >> $GITHUB_OUTPUT
          echo "bundle_size=$(stat -c%s bundle.tar.gz)" >> $GITHUB_OUTPUT

      - name: Publish to registry
        env:
          REGISTRY_URL: ${{ vars.MCP_REGISTRY_URL }}
          REGISTRY_TOKEN: ${{ secrets.MCP_REGISTRY_TOKEN }}
        run: |
          VERSION=${GITHUB_REF_NAME#v}
          ORG=${{ github.repository_owner }}
          NAME=$(basename ${{ github.repository }})

          # Initiate publish
          RESPONSE=$(curl -s -X POST \
            "$REGISTRY_URL/v1/org/$ORG/mcps/$NAME/publish" \
            -H "Authorization: Token $REGISTRY_TOKEN" \
            -H "Content-Type: application/json" \
            -d "{
              \"version\": \"$VERSION\",
              \"bundle_digest\": \"${{ steps.digests.outputs.bundle_digest }}\",
              \"bundle_size_bytes\": ${{ steps.digests.outputs.bundle_size }},
              \"manifest_json\": $(cat manifest.json),
              \"git_sha\": \"${{ github.sha }}\",
              \"repo_url\": \"https://github.com/${{ github.repository }}\",
              \"repo_visibility\": \"public\",
              \"repo_provider\": \"github\",
              \"repo_ref\": \"${{ github.ref_name }}\",
              \"repo_commit\": \"${{ github.sha }}\"
            }")

          # Upload bundle via presigned URL
          UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.bundle_upload.url')
          if [ "$UPLOAD_URL" != "null" ]; then
            curl -X PUT "$UPLOAD_URL" \
              -H "Content-Type: application/octet-stream" \
              --data-binary @bundle.tar.gz
          fi

          # Mark as published
          curl -X POST \
            "$REGISTRY_URL/v1/org/$ORG/mcps/$NAME/versions/$VERSION/status" \
            -H "Authorization: Token $REGISTRY_TOKEN" \
            -H "Content-Type: application/json" \
            -d '{"status": "published"}'

Troubleshooting

Package ID Mismatch

Error: manifest package.id does not match request path

The package.id in your manifest must match the publish URL. If you publish to /v1/org/acme/mcps/weather-service/publish, the manifest must have "id": "acme/weather-service".

Repository Policy Violation (Quarantined)

The version status is quarantined instead of ingested.

Your repository URL violated a policy rule. Check the registry’s repo_policy configuration and verify:

  • The repository domain is in allow_domains
  • The URL path does not match any deny_patterns
  • The repository organization is in allow_orgs (if configured)

Digest Mismatch

Error: digest mismatch

The uploaded file does not match the declared digest. Recompute the digest and ensure you are uploading the exact file you computed the digest from. Do not modify the file after computing the digest.

Insufficient Permissions (403 Forbidden)

Your token lacks the required scope or resource access. Verify:

  • The token has mcp:publish scope
  • The token’s resources claim includes the target package (e.g., org/acme/mcp/weather-service)
  • The token has not expired