From d51ce8ed32514d53cf045304de8c221f9158be6e Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sat, 12 Sep 2026 22:45:37 -0700 Subject: [PATCH 1/3] chore(release): authenticate both registries before packing A stale credential used to surface at the very end of a publish. The last release spent several minutes on tests, the engine-asset probe and the pack before `npm publish` failed on an expired session, and `mcp-publisher` failed after that - so every one of those steps had to be repeated. Both logins now run first. Each has to succeed for the publish to happen anyway, so checking them up front costs nothing and turns a late failure into an immediate one. Gated on --publish. Packing needs no credentials, and this script also runs as a plain build step and inside the validation gate, where prompting for a login would hang it. npm is only prompted for when `npm whoami` already fails, so an existing session is left alone; it stays interactive because the account has 2FA. The mcp-publisher login uses `gh auth token` because the MCP Registry decides which namespaces a token may publish to by calling GET /user/memberships/orgs, which needs the read:org scope. Its own device flow mints a token without that scope, GitHub answers 403, and the registry treats the 403 as "no admin orgs" rather than an error - so publishing silently degrades to io.github./* and then fails on io.github.codegraph-ai/* with a message blaming organization membership, which is not the cause. That cost a release cycle to diagnose, so the reasoning is recorded next to the call. CODEGRAPH_MCP_TOKEN overrides it for anyone preferring a PAT scoped to read:org alone, since gh's token also carries repo and workflow. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017rVbt7rENTwXkdHt3Bpgb5 --- scripts/package-npm.sh | 58 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/scripts/package-npm.sh b/scripts/package-npm.sh index fa16300..2b06643 100755 --- a/scripts/package-npm.sh +++ b/scripts/package-npm.sh @@ -31,6 +31,64 @@ BIN_DIR="$PKG_DIR/bin" echo "=== CodeGraph npm package builder ===" echo "" +# ------------------------------------------------------------------ auth +# +# Both registries are authenticated here, before the tests, the asset probe and +# the pack - not at the point of use. Every one of those has to pass anyway, and +# discovering an expired credential after them means doing them again. The last +# release failed exactly there: `npm publish` ran after several minutes of work +# and `mcp-publisher` after that, so a stale token surfaced at the end. +# +# Only for --publish. Packing needs no credentials, and this script also runs as +# a plain build step, where prompting for a login would hang it. +if [ "${1:-}" = "--publish" ]; then + echo "Checking publish credentials..." + + # npm's own session. Left interactive on purpose: the account has 2FA, so this + # needs a human and a TTY, and that is better spent now than after the pack. + if npm whoami >/dev/null 2>&1; then + echo " ✓ npm authenticated as $(npm whoami)" + else + echo " npm: not logged in - starting login (2FA expected)" + npm login || { echo " ✗ npm login failed - not packaging" >&2; exit 1; } + echo " ✓ npm authenticated as $(npm whoami)" + fi + + # The MCP Registry decides which namespaces a token may publish to by calling + # GET /user/memberships/orgs, which requires the read:org scope. Its own device + # flow mints a token without it, GitHub answers 403, and the registry treats + # that as "no admin orgs" rather than an error - so publishing silently + # degrades to io.github./* and then 403s on io.github.codegraph-ai/* + # with a message about organization membership that is not the actual cause. + # + # `gh auth token` already carries read:org. It also carries repo and workflow, + # which is broader than the registry needs; a PAT limited to read:org can be + # substituted by setting CODEGRAPH_MCP_TOKEN. + if command -v mcp-publisher >/dev/null 2>&1; then + MCP_TOKEN="${CODEGRAPH_MCP_TOKEN:-}" + if [ -z "$MCP_TOKEN" ] && command -v gh >/dev/null 2>&1; then + MCP_TOKEN="$(gh auth token 2>/dev/null || true)" + fi + if [ -n "$MCP_TOKEN" ]; then + if mcp-publisher login github -token "$MCP_TOKEN" >/dev/null 2>&1; then + echo " ✓ mcp-publisher authenticated" + else + echo " ✗ mcp-publisher login failed - not packaging" >&2 + echo " Check that the token has read:org and that the account is an" >&2 + echo " owner of the codegraph-ai organisation." >&2 + exit 1 + fi + else + echo " ✗ no GitHub token for mcp-publisher - not packaging" >&2 + echo " Run 'gh auth login', or set CODEGRAPH_MCP_TOKEN to a PAT with read:org." >&2 + exit 1 + fi + else + echo " ⚠ mcp-publisher not on PATH - the MCP Registry step will be skipped" + fi + echo "" +fi + echo "Removing any bundled binaries (the engine is fetched at install time)..." for stale in "$BIN_DIR"/codegraph-server-* "$BIN_DIR/onnxruntime.dll"; do if [ -e "$stale" ]; then From fbe28c995cea6af4d85e6b87e5b53b2620b330fe Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sat, 12 Sep 2026 22:56:25 -0700 Subject: [PATCH 2/3] no-mistakes(review): mint MCP token at publish, preflight GitHub org ownership --- scripts/package-npm.sh | 133 ++++++++++++++++++++++++++++++++++------- 1 file changed, 111 insertions(+), 22 deletions(-) diff --git a/scripts/package-npm.sh b/scripts/package-npm.sh index 2b06643..6230051 100755 --- a/scripts/package-npm.sh +++ b/scripts/package-npm.sh @@ -33,33 +33,45 @@ echo "" # ------------------------------------------------------------------ auth # -# Both registries are authenticated here, before the tests, the asset probe and -# the pack - not at the point of use. Every one of those has to pass anyway, and -# discovering an expired credential after them means doing them again. The last +# The credentials are checked here, before the tests, the asset probe and the +# pack - not at the point of use. Every one of those has to pass anyway, and +# discovering an unusable credential after them means doing them again. The last # release failed exactly there: `npm publish` ran after several minutes of work # and `mcp-publisher` after that, so a stale token surfaced at the end. # +# What is checked here is deliberately not what is minted here. The npm session +# is long-lived, so logging in now is the whole fix for that half. The MCP +# Registry's token lives 300 seconds - less than the rest of this script takes - +# so it is minted at the publish step instead, and what is checked now is the +# GitHub token it will be minted from, which is the credential that goes stale. +# # Only for --publish. Packing needs no credentials, and this script also runs as # a plain build step, where prompting for a login would hang it. +MCP_TOKEN="" if [ "${1:-}" = "--publish" ]; then echo "Checking publish credentials..." # npm's own session. Left interactive on purpose: the account has 2FA, so this # needs a human and a TTY, and that is better spent now than after the pack. - if npm whoami >/dev/null 2>&1; then - echo " ✓ npm authenticated as $(npm whoami)" + if npm_user="$(npm whoami 2>/dev/null)"; then + echo " ✓ npm authenticated as $npm_user" else echo " npm: not logged in - starting login (2FA expected)" npm login || { echo " ✗ npm login failed - not packaging" >&2; exit 1; } - echo " ✓ npm authenticated as $(npm whoami)" + npm_user="$(npm whoami 2>/dev/null || echo '')" + echo " ✓ npm authenticated as $npm_user" fi # The MCP Registry decides which namespaces a token may publish to by calling - # GET /user/memberships/orgs, which requires the read:org scope. Its own device - # flow mints a token without it, GitHub answers 403, and the registry treats - # that as "no admin orgs" rather than an error - so publishing silently - # degrades to io.github./* and then 403s on io.github.codegraph-ai/* - # with a message about organization membership that is not the actual cause. + # GET /user/memberships/orgs and granting io.github./* for every org the + # account owns; GitHub gates that call behind read:org. A token without the + # scope does not fail to log in - GitHub answers 403, the registry reads that + # as "owns no organisations", and it issues a perfectly valid token scoped to + # io.github./* alone. The 403 then arrives at the publish, carrying a + # message about organisation membership that is not the actual cause. + # + # A successful login therefore cannot tell the two cases apart, so the + # precondition is checked against GitHub directly instead of inferred from one. # # `gh auth token` already carries read:org. It also carries repo and workflow, # which is broader than the registry needs; a PAT limited to read:org can be @@ -69,20 +81,84 @@ if [ "${1:-}" = "--publish" ]; then if [ -z "$MCP_TOKEN" ] && command -v gh >/dev/null 2>&1; then MCP_TOKEN="$(gh auth token 2>/dev/null || true)" fi - if [ -n "$MCP_TOKEN" ]; then - if mcp-publisher login github -token "$MCP_TOKEN" >/dev/null 2>&1; then - echo " ✓ mcp-publisher authenticated" - else - echo " ✗ mcp-publisher login failed - not packaging" >&2 - echo " Check that the token has read:org and that the account is an" >&2 - echo " owner of the codegraph-ai organisation." >&2 - exit 1 - fi - else - echo " ✗ no GitHub token for mcp-publisher - not packaging" >&2 + if [ -z "$MCP_TOKEN" ]; then + echo " ✗ no GitHub token for the MCP Registry - not packaging" >&2 echo " Run 'gh auth login', or set CODEGRAPH_MCP_TOKEN to a PAT with read:org." >&2 exit 1 fi + + gh_body="$(mktemp)" + trap 'rm -f "$gh_body"' EXIT + gh_api() { + curl -sS -o "$gh_body" -w '%{http_code}' \ + -H "Authorization: Bearer $MCP_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/$1" 2>/dev/null || echo 000 + } + # GitHub's error bodies carry no trailing newline, which would otherwise run + # the hint that follows onto the last line of the JSON. + gh_body_err() { printf '%s\n' "$(sed 's/^/ /' "$gh_body")" >&2; } + + gh_status="$(gh_api user)" + if [ "$gh_status" != "200" ]; then + echo " ✗ GitHub rejected the token (HTTP $gh_status) - not packaging" >&2 + gh_body_err + echo " Run 'gh auth login', or set CODEGRAPH_MCP_TOKEN to a live PAT with read:org." >&2 + exit 1 + fi + gh_login="$(node -e " + console.log(JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).login); + " "$gh_body")" + + # Read the namespace from server.json rather than naming it here: that file is + # what the publish is authorised against, so renaming the server must not be + # able to leave this check passing against the namespace it used to use. + MCP_NAMESPACE="$(node -e "console.log(require('$PKG_DIR/server.json').name.split('/')[0])")" + case "$MCP_NAMESPACE" in + io.github.*) mcp_owner="${MCP_NAMESPACE#io.github.}" ;; + *) mcp_owner="" ;; + esac + + if [ -z "$mcp_owner" ]; then + echo " ⚠ $MCP_NAMESPACE is not an io.github.* namespace - ownership not checked" + elif [ "$(printf '%s' "$mcp_owner" | tr 'A-Z' 'a-z')" = "$(printf '%s' "$gh_login" | tr 'A-Z' 'a-z')" ]; then + echo " ✓ $MCP_NAMESPACE is the token's own user namespace ($gh_login)" + else + gh_status="$(gh_api 'user/memberships/orgs?per_page=100')" + if [ "$gh_status" = "403" ]; then + echo " ✗ the GitHub token cannot read organisation membership - not packaging" >&2 + echo " GitHub answered 403 for GET /user/memberships/orgs, which needs read:org." >&2 + echo " Without it the Registry sees no organisations and refuses $MCP_NAMESPACE." >&2 + echo " Use 'gh auth token', or set CODEGRAPH_MCP_TOKEN to a PAT with read:org." >&2 + exit 1 + fi + if [ "$gh_status" != "200" ]; then + echo " ✗ could not read organisation membership from GitHub (HTTP $gh_status)" >&2 + gh_body_err + exit 1 + fi + # The Registry grants io.github./* to owners only, which this endpoint + # reports as role "admin". An active plain membership is refused at the + # publish just as a missing one is, so both are refused here. + membership="$(node -e " + const want = process.argv[2].toLowerCase(); + const orgs = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')); + const m = (Array.isArray(orgs) ? orgs : []).find( + (o) => ((o.organization || {}).login || '').toLowerCase() === want); + console.log(m ? m.role + '/' + m.state : 'none/none'); + " "$gh_body" "$mcp_owner")" + if [ "$membership" != "admin/active" ]; then + echo " ✗ $gh_login does not own the $mcp_owner organisation - not packaging" >&2 + echo " GET /user/memberships/orgs reports role/state: $membership" >&2 + echo " The Registry grants $MCP_NAMESPACE to owners (role admin) only." >&2 + exit 1 + fi + echo " ✓ $gh_login owns $mcp_owner - $MCP_NAMESPACE is publishable" + fi + + rm -f "$gh_body" + trap - EXIT else echo " ⚠ mcp-publisher not on PATH - the MCP Registry step will be skipped" fi @@ -230,6 +306,19 @@ if [ "${1:-}" = "--publish" ]; then echo "" echo "Updating MCP Registry..." if command -v mcp-publisher &>/dev/null; then + # The Registry's token is minted here, not in the preflight above: it lives + # 300 seconds, and the tests, the asset probe, the pack and an interactive + # npm 2FA prompt all happen in between. The preflight established that this + # GitHub token can reach the namespace, so this is expected to succeed - it + # is checked anyway, because the npm publish above is already irreversible. + if ! login_log="$(mcp-publisher login github -token "${MCP_TOKEN:-}" 2>&1)"; then + printf '%s\n' "$login_log" >&2 + echo "✗ mcp-publisher login failed - npmjs.com has $PKG_VERSION but the MCP" >&2 + echo " Registry does not. Nothing else is needed; re-run just that step:" >&2 + echo " cd mcp-package && mcp-publisher login github -token \"\$(gh auth token)\" \\" >&2 + echo " && mcp-publisher publish --server-json server.json" >&2 + exit 1 + fi mcp-publisher publish --server-json server.json echo "✓ MCP Registry updated" else From e9b6579ea3d67d4a135c6636f152ac3c38c4f1c9 Mon Sep 17 00:00:00 2001 From: Andrey Vasilevsky Date: Sat, 12 Sep 2026 23:06:51 -0700 Subject: [PATCH 3/3] no-mistakes(document): document --publish credential prerequisites; fix shellcheck tr classes --- scripts/package-npm.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/package-npm.sh b/scripts/package-npm.sh index 6230051..6cdda9a 100755 --- a/scripts/package-npm.sh +++ b/scripts/package-npm.sh @@ -14,6 +14,12 @@ # Usage: # ./scripts/package-npm.sh # pack only # ./scripts/package-npm.sh --publish # also publish to npmjs.com +# +# --publish checks both registries' credentials before doing any work, so it +# needs a terminal: an expired npm session is renewed with an interactive +# `npm login` (the account has 2FA). The MCP Registry is reached with the token +# from `gh auth token`, which must carry read:org; set CODEGRAPH_MCP_TOKEN to +# use a PAT limited to that scope instead. Packing alone needs no credentials. set -euo pipefail @@ -122,7 +128,7 @@ if [ "${1:-}" = "--publish" ]; then if [ -z "$mcp_owner" ]; then echo " ⚠ $MCP_NAMESPACE is not an io.github.* namespace - ownership not checked" - elif [ "$(printf '%s' "$mcp_owner" | tr 'A-Z' 'a-z')" = "$(printf '%s' "$gh_login" | tr 'A-Z' 'a-z')" ]; then + elif [ "$(printf '%s' "$mcp_owner" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$gh_login" | tr '[:upper:]' '[:lower:]')" ]; then echo " ✓ $MCP_NAMESPACE is the token's own user namespace ($gh_login)" else gh_status="$(gh_api 'user/memberships/orgs?per_page=100')"