Skip to content
Open
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
13 changes: 12 additions & 1 deletion docs/modelcontextprotocol-io/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,18 @@ 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 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:
<Warning>
**`mcp-publisher login github` cannot currently publish to an organization namespace.**

The interactive device flow authenticates against a GitHub App, and GitHub App user tokens cannot read organization memberships — GitHub returns an empty list, so the registry sees no organizations you own and grants only your personal namespace. This affects everyone, regardless of your role in the organization or whether your membership is public. Tracking issue: [#1468](https://github.com/modelcontextprotocol/registry/issues/1468).

To publish an organization server today, use one of the two paths that can prove your organization role:

- **[GitHub Actions](./github-actions)** — publishes via OIDC, which is unaffected.
- **A Personal Access Token** — `mcp-publisher login github --token <YOUR_TOKEN>`, meeting the requirements below.
</Warning>

If you authenticate with a Personal Access Token (in CI, or to work around the limitation above), 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.
Expand Down
16 changes: 14 additions & 2 deletions internal/api/handlers/v0/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ func RegisterPublishEndpoint(api huma.API, pathPrefix string, registry service.R
})
}

// orgNamespaceDocsURL documents which credentials can prove a GitHub organization role,
// and is where an operator should look when an org-namespace publish is refused.
const orgNamespaceDocsURL = "https://modelcontextprotocol.io/registry/authentication"

// buildPermissionErrorMessage creates a detailed error message showing what permissions
// the user has and what they're trying to publish
func buildPermissionErrorMessage(attemptedResource string, permissions []auth.Permission) string {
Expand All @@ -91,9 +95,17 @@ func buildPermissionErrorMessage(attemptedResource string, permissions []auth.Pe
}
errorMsg += ". Attempting to publish: " + attemptedResource

// Add helpful hint for GitHub organization publishing issues
// Add guidance for GitHub namespace failures. An org namespace is granted from the
// caller's org *role*, and only some credential types can prove that role — so the
// actionable advice is which credential to authenticate with, not how to configure
// GitHub. (Publicising org membership, which this hint used to recommend, stopped
// being relevant when the role check replaced the public-membership lookup.)
if strings.HasPrefix(attemptedResource, "io.github.") {
errorMsg += ". If you're trying to publish to a GitHub organization, you may need to make your organization membership public in your GitHub settings: https://docs.github.com/en/account-and-profile/how-tos/organization-membership/publicizing-or-hiding-organization-membership"
errorMsg += ". Publishing under a GitHub organization namespace requires both that you are an Owner of that organization" +
" and that you authenticate with a credential that can read your organization role." +
" The interactive 'mcp-publisher login github' flow cannot read organization roles; instead publish from GitHub Actions" +
" (which uses OIDC), or run 'mcp-publisher login github --token <classic PAT with the read:org scope>'." +
" See " + orgNamespaceDocsURL
Comment on lines +104 to +108
}

return errorMsg
Expand Down
72 changes: 72 additions & 0 deletions internal/api/handlers/v0/publish_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package v0

import (
"strings"
"testing"

"github.com/modelcontextprotocol/registry/internal/auth"
)

// personalOnlyPermissions models what the github-at exchange actually issues for a
// device-flow login: the caller's own namespace and nothing else. This is the exact
// shape that produces the 403 reported in issue #1468.
func personalOnlyPermissions(username string) []auth.Permission {
return []auth.Permission{{
Action: auth.PermissionActionPublish,
ResourcePattern: "io.github." + username + "/*",
}}
}

func TestBuildPermissionErrorMessageDoesNotAdvisePublicisingOrgMembership(t *testing.T) {
msg := buildPermissionErrorMessage("io.github.qatouch/qatouch", personalOnlyPermissions("premnathm"))

// Public org membership stopped being how org namespaces are authorised once the
// registry switched to checking the caller's org *role*. Still advising it sends
// reporters off to re-check GitHub settings that were never the problem.
if strings.Contains(msg, "publicizing-or-hiding-organization-membership") {
t.Errorf("message still links the publicise-membership doc:\n%s", msg)
}
if strings.Contains(msg, "membership public") {
t.Errorf("message still advises making org membership public:\n%s", msg)
}
}

func TestBuildPermissionErrorMessageNamesWorkingOrgAuthPaths(t *testing.T) {
msg := buildPermissionErrorMessage("io.github.qatouch/qatouch", personalOnlyPermissions("premnathm"))

// Both real requirements, and both credentials that can actually satisfy them.
for _, want := range []string{
"Owner",
"read:org",
"--token",
"GitHub Actions",
} {
if !strings.Contains(msg, want) {
t.Errorf("message does not mention %q:\n%s", want, msg)
}
}
}

func TestBuildPermissionErrorMessageKeepsAttemptedAndGrantedNamespaces(t *testing.T) {
msg := buildPermissionErrorMessage("io.github.qatouch/qatouch", personalOnlyPermissions("premnathm"))

// The diagnostic core of the message: what you asked for vs what you hold.
if !strings.Contains(msg, "io.github.qatouch/qatouch") {
t.Errorf("message omits the attempted resource:\n%s", msg)
}
if !strings.Contains(msg, "io.github.premnathm/*") {
t.Errorf("message omits the granted pattern:\n%s", msg)
}
}

func TestBuildPermissionErrorMessageOmitsGitHubGuidanceForOtherNamespaces(t *testing.T) {
msg := buildPermissionErrorMessage("com.example/server", []auth.Permission{{
Action: auth.PermissionActionPublish,
ResourcePattern: "com.other/*",
}})

// DNS/HTTP-verified namespaces have nothing to do with GitHub org roles.
if strings.Contains(msg, "GitHub organization") {
t.Errorf("GitHub org guidance leaked into a non-GitHub namespace:\n%s", msg)
}
}
Loading