From 3237f764be6fdf263cb33a6e86f4418bf276c628 Mon Sep 17 00:00:00 2001 From: Radoslav Dimitrov Date: Mon, 27 Jul 2026 17:04:03 +0300 Subject: [PATCH] fix(publish): correct misleading org-namespace guidance in 403 and docs The 403 returned for an unauthorized `io.github.*` publish advised making your GitHub organization membership public. That stopped being how org namespaces are authorized in #1383, which replaced the public-membership lookup with a check on the caller's organization *role*. The advice now sends reporters off to re-check GitHub settings that were never the problem -- see #1468, where it cost a week of back-and-forth. Replace it with the two real requirements (Owner role, plus a credential that can read that role) and name the credentials that actually can: GitHub Actions OIDC, or a PAT supplied via --token. Document the underlying limitation in authentication.mdx. The interactive device flow authenticates against a GitHub App, and GitHub App user tokens cannot read organization memberships -- GitHub returns 200 with an empty list, so the registry concludes you own no organizations. Verified against production: an OAuth token with read:org returns 16 orgs (8 as Owner) for the same account where the device-flow token returns 0. The page previously walked through the device flow and then described org publishing as though it worked, qualifying only the PAT cases. Authorization logic is untouched; this changes message and doc text only. Co-Authored-By: Claude --- .../authentication.mdx | 13 +++- internal/api/handlers/v0/publish.go | 16 ++++- .../api/handlers/v0/publish_internal_test.go | 72 +++++++++++++++++++ 3 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 internal/api/handlers/v0/publish_internal_test.go diff --git a/docs/modelcontextprotocol-io/authentication.mdx b/docs/modelcontextprotocol-io/authentication.mdx index 9812fcc13..94b02fdd3 100644 --- a/docs/modelcontextprotocol-io/authentication.mdx +++ b/docs/modelcontextprotocol-io/authentication.mdx @@ -53,7 +53,18 @@ 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 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: + + **`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 `, meeting the requirements below. + + +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. diff --git a/internal/api/handlers/v0/publish.go b/internal/api/handlers/v0/publish.go index 5bd371ec9..7b15c60d2 100644 --- a/internal/api/handlers/v0/publish.go +++ b/internal/api/handlers/v0/publish.go @@ -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 { @@ -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 '." + + " See " + orgNamespaceDocsURL } return errorMsg diff --git a/internal/api/handlers/v0/publish_internal_test.go b/internal/api/handlers/v0/publish_internal_test.go new file mode 100644 index 000000000..b783c30cb --- /dev/null +++ b/internal/api/handlers/v0/publish_internal_test.go @@ -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) + } +}