Publishing Packages
8 min read
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:
- Build the bundle (tar.gz archive)
- Create the manifest (JSON contract)
- Compute SHA-256 digests
- Initiate the publish request
- Upload the bundle
- 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:publishscope - 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
}
}
The registry validates that:
package.idmatches the publish URL path ({org}/{name})package.versionmatches the version in the publish requestschema_versionis a supported version- All required fields (
package,runtime,entrypoint) are present
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
| Field | Type | Required | Description |
|---|---|---|---|
version | string | Yes | Semantic version (e.g., 1.0.0) |
bundle_digest | string | Yes | SHA-256 digest of the bundle (sha256:...) |
bundle_size_bytes | integer | Yes | Size of the bundle in bytes |
manifest_json | object | Yes | Inline manifest JSON (or provide manifest_digest if uploading separately) |
git_sha | string | Yes | Source commit hash |
repo_url | string | Yes | Source repository URL |
repo_visibility | string | Yes | public or private |
repo_provider | string | Yes | Git provider (github, gitlab, bitbucket) |
repo_ref | string | Yes | Git ref (tag or branch name) |
repo_commit | string | Yes | Full commit hash |
certification_level | integer | No | Certification level (0-3, default 0) |
evidence_digests | array | No | Array 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
| Rule | Description | Effect |
|---|---|---|
| Allow Domains | Whitelist 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 Patterns | URL path patterns to block (substring matching) | If the repo_url path contains any deny pattern, the version is quarantined |
| Allow Orgs | Whitelist of allowed repository owners/organizations | If non-empty, the first path segment of repo_url must match |
Policy Evaluation Order
- If
allow_domainsis non-empty, the domain must match - The path must not contain any
deny_patterns - If
allow_orgsis non-empty, the repository org must match
Policy violations do not reject the publish request. Instead, the version is created with status quarantined. Always check the version status after publishing to confirm it was not quarantined. Quarantined versions are not resolvable through the normal resolve endpoint.
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 Status | Target Status | Description |
|---|---|---|
ingested | published | Mark version available for resolution |
ingested | quarantined | Auto-applied if repository policy fails |
published | revoked | Withdraw 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:
- Create a new patch version (e.g.,
1.0.1) - Publish the corrected bundle to the new version
- 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:publishscope - The token’s
resourcesclaim includes the target package (e.g.,org/acme/mcp/weather-service) - The token has not expired