diff --git a/docs/modelcontextprotocol-io/authentication.mdx b/docs/modelcontextprotocol-io/authentication.mdx index 98131d5d..9812fcc1 100644 --- a/docs/modelcontextprotocol-io/authentication.mdx +++ b/docs/modelcontextprotocol-io/authentication.mdx @@ -53,7 +53,12 @@ GitHub authentication always grants your personal namespace, `io.github./*`), 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 diff --git a/docs/modelcontextprotocol-io/github-actions.mdx b/docs/modelcontextprotocol-io/github-actions.mdx index dc02ca53..7fcf275d 100644 --- a/docs/modelcontextprotocol-io/github-actions.mdx +++ b/docs/modelcontextprotocol-io/github-actions.mdx @@ -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. diff --git a/internal/api/handlers/v0/auth/github_at.go b/internal/api/handlers/v0/auth/github_at.go index cbfe8ddb..868ea8e7 100644 --- a/internal/api/handlers/v0/auth/github_at.go +++ b/internal/api/handlers/v0/auth/github_at.go @@ -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)) } diff --git a/internal/api/handlers/v0/auth/github_at_test.go b/internal/api/handlers/v0/auth/github_at_test.go index 663248bd..148566dc 100644 --- a/internal/api/handlers/v0/auth/github_at_test.go +++ b/internal/api/handlers/v0/auth/github_at_test.go @@ -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)