Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/modelcontextprotocol-io/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ GitHub authentication always grants your personal namespace, `io.github.<your-us

To publish under an **organization** namespace (`io.github.<orgname>/*`), you must be an **Owner** of that organization. Ordinary org membership is no longer sufficient: the registry checks your membership role and only grants the org namespace to admins. This prevents anyone who merely belongs to an org from publishing — or overwriting — servers under the org's name.

If you authenticate with a Personal Access Token (for example in CI), the token must include the `read:org` scope for the registry to see your organization role. A token without `read:org` can still publish to your personal namespace, but org publishing will be silently unavailable. The token needs **no** repository scopes — the registry never reads or writes your code.
If you authenticate with a Personal Access Token (for example in CI), the token must let the registry read your organization role. A token that can't will still publish to your personal namespace, but org publishing will be silently unavailable. The exact requirement depends on the token type:

- **Classic PAT**: grant the `read:org` scope.
- **Fine-grained PAT**: grant the **Organization permissions → Members → Read-only** permission (the fine-grained equivalent of `read:org`). Without it, GitHub returns no organization membership for the token and you'll get your personal namespace only. Note that a fine-grained PAT is bound to a single resource owner, so it can only see the organization it was created for.

Either way the token needs **no** repository scopes — the registry never reads or writes your code.

## DNS Authentication

Expand Down
6 changes: 5 additions & 1 deletion docs/modelcontextprotocol-io/github-actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,11 @@ jobs:
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 (PAT) that has the `read:org` scope. `read:org` is what lets the registry confirm you are an **Owner** of the organization before granting its namespace; the token needs **no** repository scopes (the registry never reads or writes your code), and no other scopes are required — `read:user` is not needed, and a bare token still authenticates and publishes to your personal namespace. 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.
- **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.
Expand Down
13 changes: 9 additions & 4 deletions internal/api/handlers/v0/auth/github_at.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,10 +255,15 @@ func (h *GitHubHandler) fetchOrgMembershipsPage(ctx context.Context, token strin
if resp.Header.Get("X-RateLimit-Remaining") == "0" || resp.Header.Get("Retry-After") != "" {
return nil, fmt.Errorf("GitHub API rate limit exceeded while listing org memberships (status 403): %s", readErrorBody(resp.Body))
}
// A SAML/SSO-enforced org returns 403 with an X-GitHub-SSO header when the
// token has not been SSO-authorized. That is an Owner being blocked, not a
// missing scope, so fail closed rather than silently dropping the org grant
// (which would degrade a legitimate Owner to personal-only with no signal).
// Best-effort defense: GitHub uses the X-GitHub-SSO header to signal that a
// resource is behind SAML/SSO the token hasn't been authorized for. If we see
// it, treat that as an Owner being blocked (not a missing scope) and fail
// closed rather than silently degrading them to personal-only. NOTE: we have
// not confirmed that the memberships *list* endpoint emits this header on SSO
// enforcement (it may instead just omit the SSO-protected org); this check is
// safe either way — if the header is absent we fall through to the same
// graceful degrade as before — but it is not a proven guarantee that every
// SSO-blocked Owner is caught here.
if resp.Header.Get("X-GitHub-SSO") != "" {
return nil, fmt.Errorf("GitHub org memberships require SSO authorization for this token (status 403): %s", readErrorBody(resp.Body))
}
Expand Down
28 changes: 28 additions & 0 deletions internal/api/handlers/v0/auth/github_at_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,34 @@ func TestGitHubHandler_ExchangeToken_403FailClosed(t *testing.T) {
})
}

func TestGitHubHandler_ExchangeToken_PaginationCap(t *testing.T) {
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
require.NoError(t, err)

cfg := &config.Config{JWTPrivateKey: hex.EncodeToString(testSeed)}

t.Run("all-full pages past the cap fails closed", func(t *testing.T) {
// Every page comes back full (100), so the loop never sees a short final page
// and runs to maxOrgMembershipPages. Rather than return a possibly-truncated
// org set, the exchange must fail closed (error, no token).
server := newMockGitHubServer(func(w http.ResponseWriter, _ *http.Request) {
page := make([]orgMembership, 0, 100)
for i := 0; i < 100; i++ {
page = append(page, orgMembership{
State: "active", Role: "member",
Organization: v0auth.GitHubUserOrOrg{Login: fmt.Sprintf("org-%d", i), ID: i},
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(page) //nolint:errcheck
})
defer server.Close()

assertExchangeFailsClosed(t, cfg, server)
})
}

func TestJWTTokenValidation(t *testing.T) {
testSeed := make([]byte, ed25519.SeedSize)
_, err := rand.Read(testSeed)
Expand Down
Loading