Follow-up to #1383, surfaced while validating that change end-to-end against staging. ## Why #1383 hardened org-namespace publishing to require Owner (`admin`) role, read via `GET /user/memberships/orgs`. Validating on staging revealed a gap: the docs only mention the classic `read:org` scope, but a **fine-grained PAT** (GitHub's recommended token type) needs a *different* permission to expose org membership. Without it, GitHub returns no org memberships for the token, so the exchange silently grants only the personal namespace — with no hint why org publishing "doesn't work." This is **not** a code bug (the behavior is fail-closed and correct given GitHub's response); it's a documentation/UX gap. ## What - **Docs** (`authentication.mdx`, `github-actions.mdx`): spell out both token types — - Classic PAT → `read:org` scope - Fine-grained PAT → **Organization permissions → Members → Read-only** (the equivalent), noting a fine-grained PAT is bound to a single resource owner. - Also clarified `read:user` is not required. - **Test** (`github_at_test.go`): cover the `maxOrgMembershipPages` pagination cap — when every page is full, the exchange **fails closed** (no token) rather than returning a possibly-truncated org grant. This path was previously untested. - **Comment** (`github_at.go`): qualify the `X-GitHub-SSO` 403 check as best-effort — we have not confirmed the memberships *list* endpoint emits that header on SSO enforcement (it may just omit the org). The check is safe either way, but the comment no longer overstates it as a guarantee. ## Verification (live, against staging) Confirmed by exchanging real tokens for `rdimitrov` on `staging.registry.modelcontextprotocol.io`: - **Classic `read:org` PAT** across 16 real org memberships → the JWT granted exactly the 8 **admin** orgs + personal namespace, and **zero** of the 8 **member** orgs (incl. `stacklok`, `modelcontextprotocol`) — the core #1383 behavior, working. - **Fine-grained PAT**: without Members:read → personal namespace only (fail-closed); after adding Members:read → the org (Owner) namespace is granted. This is what the new docs describe. No runtime behavior change here beyond the added test; the docs and comment are the substance. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
251 lines
11 KiB
Text
251 lines
11 KiB
Text
---
|
||
title: How to Automate Publishing with GitHub Actions
|
||
sidebarTitle: GitHub Actions
|
||
---
|
||
|
||
<Note>
|
||
The MCP Registry is currently in preview. Breaking changes or data resets may occur before general availability. If you encounter any issues, please report them on [GitHub](https://github.com/modelcontextprotocol/registry/issues).
|
||
</Note>
|
||
|
||
## Step 1: Create a Workflow File
|
||
|
||
In your server project directory, create a `.github/workflows/publish-mcp.yml` file. Here is an example for npm-based local server, but the MCP Registry publishing steps are the same for all package types:
|
||
|
||
<CodeGroup>
|
||
|
||
```yaml OIDC authentication (recommended)
|
||
name: Publish to MCP Registry
|
||
|
||
on:
|
||
push:
|
||
tags: ["v*"] # Triggers on version tags like v1.0.0
|
||
|
||
jobs:
|
||
publish:
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
id-token: write # Required for OIDC authentication
|
||
contents: read
|
||
|
||
steps:
|
||
- name: Checkout code
|
||
uses: actions/checkout@v5
|
||
|
||
### Publish underlying npm package:
|
||
|
||
- name: Set up Node.js
|
||
uses: actions/setup-node@v5
|
||
with:
|
||
node-version: "lts/*"
|
||
|
||
- name: Install dependencies
|
||
run: npm ci
|
||
|
||
- name: Run tests
|
||
run: npm run test --if-present
|
||
|
||
- name: Build package
|
||
run: npm run build --if-present
|
||
|
||
- name: Publish package to npm
|
||
run: npm publish
|
||
env:
|
||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||
|
||
### Publish MCP server:
|
||
|
||
- name: Install mcp-publisher
|
||
run: |
|
||
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
||
|
||
- name: Authenticate to MCP Registry
|
||
run: ./mcp-publisher login github-oidc
|
||
|
||
# Optional:
|
||
# - name: Set version in server.json
|
||
# run: |
|
||
# VERSION=${GITHUB_REF#refs/tags/v}
|
||
# jq --arg v "$VERSION" '.version = $v' server.json > server.tmp && mv server.tmp server.json
|
||
|
||
- name: Publish server to MCP Registry
|
||
run: ./mcp-publisher publish
|
||
```
|
||
|
||
```yaml PAT authentication
|
||
name: Publish to MCP Registry
|
||
|
||
on:
|
||
push:
|
||
tags: ["v*"] # Triggers on version tags like v1.0.0
|
||
|
||
jobs:
|
||
publish:
|
||
runs-on: ubuntu-latest
|
||
# Source MCP_GITHUB_TOKEN from a protected Environment, not a plain repo
|
||
# secret, and restrict that environment to your default branch / release
|
||
# tags. See "Securing your registry token in CI" below for why this matters.
|
||
environment: mcp-registry-publish
|
||
permissions:
|
||
contents: read
|
||
|
||
steps:
|
||
- name: Checkout code
|
||
uses: actions/checkout@v5
|
||
|
||
### Publish underlying npm package:
|
||
|
||
- name: Set up Node.js
|
||
uses: actions/setup-node@v5
|
||
with:
|
||
node-version: "lts/*"
|
||
|
||
- name: Install dependencies
|
||
run: npm ci
|
||
|
||
- name: Run tests
|
||
run: npm run test --if-present
|
||
|
||
- name: Build package
|
||
run: npm run build --if-present
|
||
|
||
- name: Publish package to npm
|
||
run: npm publish
|
||
env:
|
||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||
|
||
### Publish MCP server:
|
||
|
||
- name: Install mcp-publisher
|
||
run: |
|
||
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
||
|
||
- name: Authenticate to MCP Registry
|
||
run: ./mcp-publisher login github --token ${{ secrets.MCP_GITHUB_TOKEN }}
|
||
|
||
# Optional:
|
||
# - name: Set version in server.json
|
||
# run: |
|
||
# VERSION=${GITHUB_REF#refs/tags/v}
|
||
# jq --arg v "$VERSION" '.version = $v' server.json > server.tmp && mv server.tmp server.json
|
||
|
||
- name: Publish server to MCP Registry
|
||
run: ./mcp-publisher publish
|
||
```
|
||
|
||
```yaml DNS authentication
|
||
name: Publish to MCP Registry
|
||
|
||
on:
|
||
push:
|
||
tags: ["v*"] # Triggers on version tags like v1.0.0
|
||
|
||
jobs:
|
||
publish:
|
||
runs-on: ubuntu-latest
|
||
permissions:
|
||
contents: read
|
||
|
||
steps:
|
||
- name: Checkout code
|
||
uses: actions/checkout@v5
|
||
|
||
### Publish underlying npm package:
|
||
|
||
- name: Set up Node.js
|
||
uses: actions/setup-node@v5
|
||
with:
|
||
node-version: "lts/*"
|
||
|
||
- name: Install dependencies
|
||
run: npm ci
|
||
|
||
- name: Run tests
|
||
run: npm run test --if-present
|
||
|
||
- name: Build package
|
||
run: npm run build --if-present
|
||
|
||
- name: Publish package to npm
|
||
run: npm publish
|
||
env:
|
||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||
|
||
### Publish MCP server:
|
||
|
||
- name: Install mcp-publisher
|
||
run: |
|
||
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher
|
||
|
||
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||
# TODO: Replace `example.com` with your domain name
|
||
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||
- name: Authenticate to MCP Registry
|
||
run: ./mcp-publisher login dns --domain example.com --private-key ${{ secrets.MCP_PRIVATE_KEY }}
|
||
|
||
# Optional:
|
||
# - name: Set version in server.json
|
||
# run: |
|
||
# VERSION=${GITHUB_REF#refs/tags/v}
|
||
# jq --arg v "$VERSION" '.version = $v' server.json > server.tmp && mv server.tmp server.json
|
||
|
||
- name: Publish server to MCP Registry
|
||
run: ./mcp-publisher publish
|
||
```
|
||
|
||
</CodeGroup>
|
||
|
||
## Step 2: Add Secrets
|
||
|
||
You may need to add a secret depending on which authentication method you choose:
|
||
|
||
- **GitHub OIDC Authentication**: No dedicated secret necessary.
|
||
- **GitHub PAT Authentication**: Add a `MCP_GITHUB_TOKEN` secret with a GitHub Personal Access Token. The registry only needs to read your organization role to confirm you are an **Owner** before granting its namespace, so grant the minimum for that and **no** repository scopes (the registry never reads or writes your code):
|
||
- **Classic PAT**: the `read:org` scope (`read:user` is *not* needed; a bare token still authenticates and publishes to your personal namespace).
|
||
- **Fine-grained PAT**: the **Organization permissions → Members → Read-only** permission (the equivalent of `read:org`). Without it you'll get your personal namespace only. A fine-grained PAT is scoped to one resource owner, so create it for the organization you're publishing to.
|
||
|
||
For an **organization** token, store it as an **Environment secret** on the `mcp-registry-publish` environment rather than a plain repository secret — see "Securing your registry token in CI" below for why this matters and how to configure the environment.
|
||
- **DNS Authentication**: Add a `MCP_PRIVATE_KEY` secret with your Ed25519 private key.
|
||
|
||
You may also need to add secrets for your package registry. For example, the workflow above needs an `NPM_TOKEN` secret with your npm token.
|
||
|
||
For information about how to add secrets to a repository, see [Using secrets in GitHub Actions](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets).
|
||
|
||
### Securing your registry token in CI
|
||
|
||
Whichever token you use to authenticate, treat it as a high-value credential. When you authenticate to an **organization** namespace, the resulting registry token can publish — and overwrite — **any** server under `io.github.<org>/*`, not just the one in this repository. The relevant question for your threat model is therefore: *who can cause code to run in a job where this secret is exposed?* By default that is **every repository writer** (via branch or tag pushes), not only org Owners — so a plain repo secret quietly widens publish access to everyone with write access.
|
||
|
||
GitHub gives you the tools to close that gap. We recommend, in increasing order of strength:
|
||
|
||
1. **Store the token as an [Environment secret](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments), not a repository or organization secret.** A job can only read an environment secret when it declares `environment:` (as the PAT example above does). Note that the `environment:` key alone protects nothing: if the environment does not exist yet, GitHub **auto-creates it with no protection rules** on first run, and a job that declares `environment:` still also receives ordinary repository and organization secrets. The protection comes from storing the token *as a secret on that environment* and configuring the environment (steps 2–3 below) — not from the `environment:` key by itself.
|
||
2. **Restrict that environment to your default branch and/or release tags** with a [deployment branch rule](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments#deployment-branches-and-tags), and protect that branch with [required pull request reviews](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches). Now only reviewed, maintainer-approved code can ever reach the token.
|
||
3. **(Strongest) Add a [required reviewer](https://docs.github.com/en/actions/how-tos/deploy/configure-and-manage-deployments/manage-environments#required-reviewers) to the environment** so each publish pauses for an explicit human approval.
|
||
|
||
Two pitfalls to avoid regardless of the above:
|
||
|
||
- **Never publish from a `pull_request_target` workflow that checks out PR-head code.** That trigger runs with your secrets in the base-repo context, so an untrusted fork PR could exfiltrate the token. Environment branch rules do **not** protect you here (the ref is still your base branch) — only required reviewers do.
|
||
- **Avoid self-hosted runners for the publish job on public repositories**, where fork PRs may be able to schedule jobs onto them.
|
||
|
||
By default, pull requests **from forks** receive no secrets and cannot push branches, so external contributors cannot reach the token without one of the misconfigurations above.
|
||
|
||
## Step 3: Tag and Release
|
||
|
||
Create and push a version tag to trigger the workflow:
|
||
|
||
```bash
|
||
git tag v1.0.0
|
||
git push origin v1.0.0
|
||
```
|
||
|
||
The workflow will run tests, build the package, publish the package to npm, and publish the server to the MCP Registry.
|
||
|
||
## Troubleshooting
|
||
|
||
{/* prettier-ignore-start */}
|
||
|
||
| Error Message | Action |
|
||
| --- | --- |
|
||
| "Authentication failed" | Ensure `id-token: write` permission is set for OIDC, or check secrets. |
|
||
| "invalid audience" | Your `mcp-publisher` binary is too old for this registry deployment. Re-run the install step shown above so you pick up the latest release. |
|
||
| "Package validation failed" | Verify your package successfully published to the package registry (e.g., npm, PyPI), and that your package has the [necessary verification information](./package-types). |
|
||
|
||
{/* prettier-ignore-end */}
|