@@ -33,33 +33,45 @@ echo ""
3333
3434# ------------------------------------------------------------------ auth
3535#
36- # Both registries are authenticated here, before the tests, the asset probe and
37- # the pack - not at the point of use. Every one of those has to pass anyway, and
38- # discovering an expired credential after them means doing them again. The last
36+ # The credentials are checked here, before the tests, the asset probe and the
37+ # pack - not at the point of use. Every one of those has to pass anyway, and
38+ # discovering an unusable credential after them means doing them again. The last
3939# release failed exactly there: `npm publish` ran after several minutes of work
4040# and `mcp-publisher` after that, so a stale token surfaced at the end.
4141#
42+ # What is checked here is deliberately not what is minted here. The npm session
43+ # is long-lived, so logging in now is the whole fix for that half. The MCP
44+ # Registry's token lives 300 seconds - less than the rest of this script takes -
45+ # so it is minted at the publish step instead, and what is checked now is the
46+ # GitHub token it will be minted from, which is the credential that goes stale.
47+ #
4248# Only for --publish. Packing needs no credentials, and this script also runs as
4349# a plain build step, where prompting for a login would hang it.
50+ MCP_TOKEN=" "
4451if [ " ${1:- } " = " --publish" ]; then
4552 echo " Checking publish credentials..."
4653
4754 # npm's own session. Left interactive on purpose: the account has 2FA, so this
4855 # needs a human and a TTY, and that is better spent now than after the pack.
49- if npm whoami > /dev/null 2>&1 ; then
50- echo " ✓ npm authenticated as $( npm whoami ) "
56+ if npm_user= " $( npm whoami 2 > /dev/null) " ; then
57+ echo " ✓ npm authenticated as $npm_user "
5158 else
5259 echo " npm: not logged in - starting login (2FA expected)"
5360 npm login || { echo " ✗ npm login failed - not packaging" >&2 ; exit 1; }
54- echo " ✓ npm authenticated as $( npm whoami) "
61+ npm_user=" $( npm whoami 2> /dev/null || echo ' <unknown>' ) "
62+ echo " ✓ npm authenticated as $npm_user "
5563 fi
5664
5765 # The MCP Registry decides which namespaces a token may publish to by calling
58- # GET /user/memberships/orgs, which requires the read:org scope. Its own device
59- # flow mints a token without it, GitHub answers 403, and the registry treats
60- # that as "no admin orgs" rather than an error - so publishing silently
61- # degrades to io.github.<user>/* and then 403s on io.github.codegraph-ai/*
62- # with a message about organization membership that is not the actual cause.
66+ # GET /user/memberships/orgs and granting io.github.<org>/* for every org the
67+ # account owns; GitHub gates that call behind read:org. A token without the
68+ # scope does not fail to log in - GitHub answers 403, the registry reads that
69+ # as "owns no organisations", and it issues a perfectly valid token scoped to
70+ # io.github.<user>/* alone. The 403 then arrives at the publish, carrying a
71+ # message about organisation membership that is not the actual cause.
72+ #
73+ # A successful login therefore cannot tell the two cases apart, so the
74+ # precondition is checked against GitHub directly instead of inferred from one.
6375 #
6476 # `gh auth token` already carries read:org. It also carries repo and workflow,
6577 # which is broader than the registry needs; a PAT limited to read:org can be
@@ -69,20 +81,84 @@ if [ "${1:-}" = "--publish" ]; then
6981 if [ -z " $MCP_TOKEN " ] && command -v gh > /dev/null 2>&1 ; then
7082 MCP_TOKEN=" $( gh auth token 2> /dev/null || true) "
7183 fi
72- if [ -n " $MCP_TOKEN " ]; then
73- if mcp-publisher login github -token " $MCP_TOKEN " > /dev/null 2>&1 ; then
74- echo " ✓ mcp-publisher authenticated"
75- else
76- echo " ✗ mcp-publisher login failed - not packaging" >&2
77- echo " Check that the token has read:org and that the account is an" >&2
78- echo " owner of the codegraph-ai organisation." >&2
79- exit 1
80- fi
81- else
82- echo " ✗ no GitHub token for mcp-publisher - not packaging" >&2
84+ if [ -z " $MCP_TOKEN " ]; then
85+ echo " ✗ no GitHub token for the MCP Registry - not packaging" >&2
8386 echo " Run 'gh auth login', or set CODEGRAPH_MCP_TOKEN to a PAT with read:org." >&2
8487 exit 1
8588 fi
89+
90+ gh_body=" $( mktemp) "
91+ trap ' rm -f "$gh_body"' EXIT
92+ gh_api () {
93+ curl -sS -o " $gh_body " -w ' %{http_code}' \
94+ -H " Authorization: Bearer $MCP_TOKEN " \
95+ -H " Accept: application/vnd.github+json" \
96+ -H " X-GitHub-Api-Version: 2022-11-28" \
97+ " https://api.github.com/$1 " 2> /dev/null || echo 000
98+ }
99+ # GitHub's error bodies carry no trailing newline, which would otherwise run
100+ # the hint that follows onto the last line of the JSON.
101+ gh_body_err () { printf ' %s\n' " $( sed ' s/^/ /' " $gh_body " ) " >&2 ; }
102+
103+ gh_status=" $( gh_api user) "
104+ if [ " $gh_status " != " 200" ]; then
105+ echo " ✗ GitHub rejected the token (HTTP $gh_status ) - not packaging" >&2
106+ gh_body_err
107+ echo " Run 'gh auth login', or set CODEGRAPH_MCP_TOKEN to a live PAT with read:org." >&2
108+ exit 1
109+ fi
110+ gh_login=" $( node -e "
111+ console.log(JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).login);
112+ " " $gh_body " ) "
113+
114+ # Read the namespace from server.json rather than naming it here: that file is
115+ # what the publish is authorised against, so renaming the server must not be
116+ # able to leave this check passing against the namespace it used to use.
117+ MCP_NAMESPACE=" $( node -e " console.log(require('$PKG_DIR /server.json').name.split('/')[0])" ) "
118+ case " $MCP_NAMESPACE " in
119+ io.github.* ) mcp_owner=" ${MCP_NAMESPACE# io.github.} " ;;
120+ * ) mcp_owner=" " ;;
121+ esac
122+
123+ if [ -z " $mcp_owner " ]; then
124+ echo " ⚠ $MCP_NAMESPACE is not an io.github.* namespace - ownership not checked"
125+ elif [ " $( printf ' %s' " $mcp_owner " | tr ' A-Z' ' a-z' ) " = " $( printf ' %s' " $gh_login " | tr ' A-Z' ' a-z' ) " ]; then
126+ echo " ✓ $MCP_NAMESPACE is the token's own user namespace ($gh_login )"
127+ else
128+ gh_status=" $( gh_api ' user/memberships/orgs?per_page=100' ) "
129+ if [ " $gh_status " = " 403" ]; then
130+ echo " ✗ the GitHub token cannot read organisation membership - not packaging" >&2
131+ echo " GitHub answered 403 for GET /user/memberships/orgs, which needs read:org." >&2
132+ echo " Without it the Registry sees no organisations and refuses $MCP_NAMESPACE ." >&2
133+ echo " Use 'gh auth token', or set CODEGRAPH_MCP_TOKEN to a PAT with read:org." >&2
134+ exit 1
135+ fi
136+ if [ " $gh_status " != " 200" ]; then
137+ echo " ✗ could not read organisation membership from GitHub (HTTP $gh_status )" >&2
138+ gh_body_err
139+ exit 1
140+ fi
141+ # The Registry grants io.github.<org>/* to owners only, which this endpoint
142+ # reports as role "admin". An active plain membership is refused at the
143+ # publish just as a missing one is, so both are refused here.
144+ membership=" $( node -e "
145+ const want = process.argv[2].toLowerCase();
146+ const orgs = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
147+ const m = (Array.isArray(orgs) ? orgs : []).find(
148+ (o) => ((o.organization || {}).login || '').toLowerCase() === want);
149+ console.log(m ? m.role + '/' + m.state : 'none/none');
150+ " " $gh_body " " $mcp_owner " ) "
151+ if [ " $membership " != " admin/active" ]; then
152+ echo " ✗ $gh_login does not own the $mcp_owner organisation - not packaging" >&2
153+ echo " GET /user/memberships/orgs reports role/state: $membership " >&2
154+ echo " The Registry grants $MCP_NAMESPACE to owners (role admin) only." >&2
155+ exit 1
156+ fi
157+ echo " ✓ $gh_login owns $mcp_owner - $MCP_NAMESPACE is publishable"
158+ fi
159+
160+ rm -f " $gh_body "
161+ trap - EXIT
86162 else
87163 echo " ⚠ mcp-publisher not on PATH - the MCP Registry step will be skipped"
88164 fi
@@ -230,6 +306,19 @@ if [ "${1:-}" = "--publish" ]; then
230306 echo " "
231307 echo " Updating MCP Registry..."
232308 if command -v mcp-publisher & > /dev/null; then
309+ # The Registry's token is minted here, not in the preflight above: it lives
310+ # 300 seconds, and the tests, the asset probe, the pack and an interactive
311+ # npm 2FA prompt all happen in between. The preflight established that this
312+ # GitHub token can reach the namespace, so this is expected to succeed - it
313+ # is checked anyway, because the npm publish above is already irreversible.
314+ if ! login_log=" $( mcp-publisher login github -token " ${MCP_TOKEN:- } " 2>&1 ) " ; then
315+ printf ' %s\n' " $login_log " >&2
316+ echo " ✗ mcp-publisher login failed - npmjs.com has $PKG_VERSION but the MCP" >&2
317+ echo " Registry does not. Nothing else is needed; re-run just that step:" >&2
318+ echo " cd mcp-package && mcp-publisher login github -token \"\$ (gh auth token)\" \\ " >&2
319+ echo " && mcp-publisher publish --server-json server.json" >&2
320+ exit 1
321+ fi
233322 mcp-publisher publish --server-json server.json
234323 echo " ✓ MCP Registry updated"
235324 else
0 commit comments