Skip to content

Commit fbe28c9

Browse files
committed
no-mistakes(review): mint MCP token at publish, preflight GitHub org ownership
1 parent d51ce8e commit fbe28c9

1 file changed

Lines changed: 111 additions & 22 deletions

File tree

‎scripts/package-npm.sh‎

Lines changed: 111 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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=""
4451
if [ "${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

Comments
 (0)