Add npm/PyPI package publishing access management (config + drift detection) (#169)

* Add npm/PyPI package publishing access management (config + drift detection)

Declare expected npm org membership, per-package maintainers and trusted
publishers, and PyPI project rosters in src/config/packageAccess.ts, with
npm/pypi identity fields on members in users.ts.

Deliberately outside the Pulumi resource graph: npm governance writes
require an interactive 2FA challenge since August 2026 (read endpoints
still work with a granular access token), and PyPI has no management API
at all. Drift against the live npm registry is detected read-only by
scripts/check-package-drift.ts (weekly via package-drift.yml, no-op when
the NPM_READ_TOKEN secret is absent), which emits a remediation plan of
npm CLI commands for a maintainer to run in one 2FA-approved session.
PyPI procedures are documented as a manual runbook in the README.

* Encode all slashes when escaping package names in registry URLs

Fixes the CodeQL finding: String.replace with a string pattern only
replaces the first occurrence. Use a shared helper with a global regex,
following the npm CLI convention (keep '@' literal, encode every '/').

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
claude[bot] 2026-08-19 12:12:46 +01:00 • committed by GitHub
commit f17de4044a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 766 additions and 0 deletions

41
.github/workflows/package-drift.yml vendored Normal file
View file

@ -0,0 +1,41 @@
name: Package Drift
# Read-only drift detection for npm package publishing access, comparing the
# live registry against src/config/packageAccess.ts. Mutations are applied by
# a human via the generated remediation plan (npm requires an interactive 2FA
# challenge for all governance writes); PyPI has no API and is not checked.
#
# Requires the NPM_READ_TOKEN secret: a read-only npm granular access token
# with organization read access to "modelcontextprotocol". When the secret is
# absent the check prints a skip notice and passes (same optional-credential
# pattern as the Discord integration).
on:
schedule:
- cron: '0 14 * * 1' # Mondays 14:00 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
npm-drift:
name: npm package access drift
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Check npm package access drift
env:
NPM_TOKEN: ${{ secrets.NPM_READ_TOKEN }}
run: npm run check-package-drift

View file

@ -12,6 +12,7 @@ Infrastructure as Code for managing access to MCP community resources using Pulu
- **Google Workspace Groups**: Automatically syncs group memberships for @modelcontextprotocol.io email accounts
- **Email Groups**: Groups with `isEmailGroup: true` accept emails from anyone (including external users) and notify all members. External posts are moderated for security.
- **Google Workspace User Accounts**: Provisions @modelcontextprotocol.io accounts for members of roles with `provisionUser: true` (directly, or via a role nested under one through `github.parent` — e.g. SDK teams under `sdk-maintainers`, working groups under `working-groups`)
- **npm & PyPI Package Publishing Access** (declared, not applied): Expected registry access is declared in [`src/config/packageAccess.ts`](src/config/packageAccess.ts) and drift against the live npm registry is detected by CI — but changes are applied manually by a maintainer. See [npm & PyPI Package Publishing Access](#npm--pypi-package-publishing-access) below for why and how.
### Opting in to a Google Workspace account (maintainers)
@ -30,6 +31,34 @@ If you're a maintainer — explicitly or implicitly (SDK maintainers, working gr
Once merged, Pulumi provisions the account. An admin will share your initial password (retrievable via `pulumi stack output --show-secrets newGWSUserPasswords`).
## npm & PyPI Package Publishing Access
Publishing access to the `modelcontextprotocol` npm organization and to the MCP PyPI projects is **config-as-code with human-applied changes** — deliberately outside the Pulumi resource graph:
- **npm** has an official management API ([api-docs.npmjs.com](https://api-docs.npmjs.com/)), but since August 2026 every governance mutation (org/team membership, maintainer add/remove, trusted-publisher config, token management) requires an **interactive 2FA challenge** — tokens, even with "bypass 2FA", get `403`. Reads still work headless with a granular access token, so drift is detected automatically and remediated manually.
- **PyPI** has **no management API at all**: collaborators, trusted publishers, and organizations are web-UI only, and maintainer invites must be accepted by email. The PyPI section of the config is declared state for audit purposes plus the manual procedures below.
What lives where:
- [`src/config/packageAccess.ts`](src/config/packageAccess.ts) — expected npm org membership (derived from members' `npm` field in `users.ts`), per-package maintainers and trusted publishers for the key packages, a default policy for the rest of the org's packages, and declared PyPI project rosters.
- [`scripts/check-package-drift.ts`](scripts/check-package-drift.ts) — read-only npm drift check: `NPM_TOKEN=<token> npm run check-package-drift`. Prints a drift report and a remediation plan of `npm` CLI commands; exits nonzero on drift, and skips gracefully when `NPM_TOKEN` is unset.
- [`.github/workflows/package-drift.yml`](.github/workflows/package-drift.yml) — runs the check weekly and on demand, using the optional `NPM_READ_TOKEN` secret (a read-only npm granular access token with organization read access; note write-capable npm tokens expire after at most 90 days, so keep this one read-only).
### Applying npm changes (runbook)
1. Edit `src/config/packageAccess.ts` / `users.ts` to the desired state and merge the PR.
2. Run the drift check locally with a read-only token: `NPM_TOKEN=<token> npm run check-package-drift`.
3. Review the printed remediation plan — especially any removal commands.
4. As an npm org owner, execute the plan in **one interactive session**: log in with `npm login`, trigger a 2FA prompt (e.g. run the first command), and choose **"Don't ask again for 5 minutes"** on the npmjs.com challenge. npm's own bulk guidance is to script the commands with a `sleep 2` between calls — roughly 80 operations fit in one approval window.
5. Re-run the drift check to confirm it exits clean.
### PyPI procedures (manual, web UI only)
- **Add a maintainer**: Manage project → Collaborators → invite by PyPI username with the Maintainer (upload only) or Owner role. The invitee must accept the invitation email before the role takes effect. Afterwards, record the account in `packageAccess.ts` and on the member's `pypi` field.
- **Trusted publisher**: Manage project → Publishing → add the GitHub repository + workflow (multiple publishers per project are allowed). Prefer trusted publishing over project-scoped API tokens.
- **Recommended follow-up**: apply for a free [PyPI community organization](https://docs.pypi.org/organization-accounts/) so projects are org-owned and access is managed via teams rather than per-project role edits.
- **Naming constraint**: the PyPI project name `modelcontextprotocol` is registered by an unrelated third party, so MCP's Python packages live under `mcp*` names. Any future consolidation under that name would require a [PEP 541](https://peps.python.org/pep-0541/) name-transfer request or simply keeping the `mcp*` naming.
## Deployment
### Production Deployment (Automated)

View file

@ -7,6 +7,7 @@
"build": "tsc",
"validate": "npx ts-node scripts/validate-config.ts",
"test": "npx ts-node scripts/test-config.ts",
"check-package-drift": "npx ts-node scripts/check-package-drift.ts",
"check": "npm run format:check && npm run validate && npm run test",
"format": "prettier --write .",
"format:check": "prettier --check ."

View file

@ -0,0 +1,313 @@
#!/usr/bin/env npx ts-node
/**
* Compares live npm registry state against the expected state declared in
* src/config/packageAccess.ts and prints a drift report plus a remediation
* plan of npm CLI commands for a maintainer to run in a single interactive
* 2FA-authenticated session (see "npm & PyPI Package Publishing Access" in
* the README).
*
* Read-only: this script never mutates anything. All npm write operations
* require an interactive 2FA challenge (since August 2026), which is why the
* remediation is a human-executed plan instead of automation. PyPI has no
* management API at all and is not checked here.
*
* Run with: NPM_TOKEN=<read-only granular access token> npx ts-node scripts/check-package-drift.ts
*
* - Without NPM_TOKEN: prints a skip notice and exits 0 (so CI without the
* secret is a graceful no-op, mirroring the optional Discord credentials).
* - With NPM_TOKEN: exits 1 when drift is found, 0 when clean.
*/
import {
NPM_ORG,
NPM_PACKAGES,
NPM_DEFAULT_POLICY,
getNpmPackageAccess,
getExpectedNpmOrgMembers,
} from '../src/config/packageAccess';
const REGISTRY = 'https://registry.npmjs.org';
const REQUEST_DELAY_MS = 150;
const token = process.env.NPM_TOKEN;
interface Drift {
description: string;
/** Remediation lines; lines starting with '#' are guidance, not commands */
commands: readonly string[];
}
const drifts: Drift[] = [];
const warnings: string[] = [];
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function registryGet(path: string): Promise<{ status: number; body: unknown }> {
const url = `${REGISTRY}${path}`;
let lastError: unknown;
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt > 0) await sleep(2000 * attempt);
try {
const response = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
});
if (response.status >= 500) {
lastError = new Error(`HTTP ${response.status} from ${url}`);
continue;
}
let body: unknown = null;
try {
body = await response.json();
} catch {
// Non-JSON body (e.g. empty 404) — leave as null
}
return { status: response.status, body };
} catch (e) {
lastError = e;
}
}
throw lastError;
}
/**
* Escape a package name for use as a registry URL path segment. Follows the
* npm CLI's convention for scoped packages: keep the leading '@' literal and
* percent-encode every '/'.
*/
function escapePackageName(packageName: string): string {
return packageName.replace(/\//g, '%2F');
}
function diffSets(
expected: readonly string[],
actual: readonly string[]
): { missing: string[]; unexpected: string[] } {
const expectedSet = new Set(expected);
const actualSet = new Set(actual);
return {
missing: expected.filter((e) => !actualSet.has(e)).sort(),
unexpected: actual.filter((a) => !expectedSet.has(a)).sort(),
};
}
async function checkOrgMembership(): Promise<void> {
const { status, body } = await registryGet(`/-/org/${NPM_ORG}/user`);
if (status !== 200 || typeof body !== 'object' || body === null) {
warnings.push(
`Could not read org roster (GET /-/org/${NPM_ORG}/user returned ${status}). ` +
`The token likely lacks organization read access — org membership was NOT checked.`
);
return;
}
const roster = body as Record<string, string>;
const actual = Object.keys(roster);
if (actual.length === 0) {
warnings.push(
`Org roster came back empty — unauthenticated requests see an empty roster, ` +
`so the token likely lacks organization access. Org membership was NOT checked.`
);
return;
}
const { missing, unexpected } = diffSets(getExpectedNpmOrgMembers(), actual);
for (const user of missing) {
drifts.push({
description: `Org member missing: "${user}" is expected in the "${NPM_ORG}" org but is not a member`,
commands: [`npm org set ${NPM_ORG} ${user} developer`],
});
}
for (const user of unexpected) {
drifts.push({
description:
`Unexpected org member: "${user}" (role: ${roster[user]}) is in the "${NPM_ORG}" org ` +
`but not declared in packageAccess.ts/users.ts`,
commands: [
`# Either add npm: '${user}' to the right member in src/config/users.ts (or UNMAPPED_NPM_USERS), or remove them:`,
`npm org rm ${NPM_ORG} ${user}`,
],
});
}
}
async function listOrgPackages(): Promise<string[]> {
const { status, body } = await registryGet(`/-/org/${NPM_ORG}/package`);
if (status === 200 && typeof body === 'object' && body !== null) {
return Object.keys(body as Record<string, string>).sort();
}
warnings.push(
`Could not enumerate org packages (GET /-/org/${NPM_ORG}/package returned ${status}); ` +
`falling back to the ${NPM_PACKAGES.length} explicitly declared packages.`
);
return NPM_PACKAGES.map((p) => p.package);
}
/** Extract candidate trusted-publisher configs from a /trust response, defensively. */
function extractTrustConfigs(body: unknown): Array<Record<string, unknown>> {
if (body === null || typeof body !== 'object') return [];
if (Array.isArray(body)) return body.filter((c) => typeof c === 'object' && c !== null);
const obj = body as Record<string, unknown>;
for (const key of ['objects', 'configurations', 'trustedPublishers', 'trust']) {
if (Array.isArray(obj[key])) {
return (obj[key] as unknown[]).filter(
(c): c is Record<string, unknown> => typeof c === 'object' && c !== null
);
}
}
return [obj];
}
async function checkPackage(packageName: string): Promise<void> {
const expected = getNpmPackageAccess(packageName);
const isExplicit = NPM_PACKAGES.some((p) => p.package === packageName);
// 1) Maintainers, from the public package document
const { status, body } = await registryGet(`/${escapePackageName(packageName)}`);
if (status !== 200 || typeof body !== 'object' || body === null) {
warnings.push(`Could not read package document for ${packageName} (HTTP ${status}).`);
return;
}
const packument = body as {
maintainers?: Array<{ name?: string }>;
'dist-tags'?: Record<string, string>;
versions?: Record<string, { _npmUser?: { trustedPublisher?: unknown } }>;
};
const actualMaintainers = (packument.maintainers ?? [])
.map((m) => m.name)
.filter((name): name is string => typeof name === 'string');
const { missing, unexpected } = diffSets(expected.maintainers, actualMaintainers);
for (const user of missing) {
drifts.push({
description: `Maintainer missing on ${packageName}: "${user}"`,
commands: [`npm owner add ${user} ${packageName}`],
});
}
for (const user of unexpected) {
drifts.push({
description:
`Unexpected maintainer on ${packageName}: "${user}" ` +
`(not in its ${isExplicit ? 'declared maintainers' : 'default-policy maintainers'})`,
commands: [
`# Either declare "${user}" for this package in src/config/packageAccess.ts, or remove them:`,
`npm owner rm ${user} ${packageName}`,
],
});
}
// 2) Trusted publishing of the latest release (default policy)
const latestVersion = packument['dist-tags']?.latest;
const latest = latestVersion ? packument.versions?.[latestVersion] : undefined;
if (NPM_DEFAULT_POLICY.requireTrustedPublishing && latest && !latest._npmUser?.trustedPublisher) {
drifts.push({
description:
`${packageName}@${latestVersion} was not published via trusted publishing ` +
`(policy: all org packages publish via OIDC)`,
commands: [
`# Configure a trusted publisher for ${packageName} (web UI: package Settings -> Trusted publishing,`,
`# or \`npm trust\` on npm >= 11.15.0), then stop using publish tokens for it.`,
],
});
}
// 3) Declared trusted-publisher configuration (explicit packages only)
if (expected.trustedPublisher) {
const want = expected.trustedPublisher;
const trust = await registryGet(`/-/package/${escapePackageName(packageName)}/trust`);
if (trust.status === 401 || trust.status === 403) {
warnings.push(
`Cannot read trusted-publisher config for ${packageName} (HTTP ${trust.status}); ` +
`token lacks permission — declared publisher (${want.repository} ${want.workflow}) was NOT verified.`
);
} else if (trust.status === 404 || extractTrustConfigs(trust.body).length === 0) {
drifts.push({
description: `No trusted publisher configured on ${packageName} (expected ${want.repository} via ${want.workflow})`,
commands: [
`# Configure trusted publishing for ${packageName}: GitHub Actions, repository ${want.repository},`,
`# workflow ${want.workflow} (web UI: package Settings -> Trusted publishing, or \`npm trust\`).`,
],
});
} else if (trust.status === 200) {
const configs = extractTrustConfigs(trust.body);
const matches = configs.some((c) => {
const text = JSON.stringify(c);
const repoOk =
text.includes(want.repository) ||
(text.includes(want.repository.split('/')[0]) &&
text.includes(want.repository.split('/')[1]));
const workflowFile = want.workflow.split('/').pop() ?? want.workflow;
return repoOk && text.includes(workflowFile);
});
if (!matches) {
drifts.push({
description:
`Trusted publisher mismatch on ${packageName}: expected ${want.repository} via ${want.workflow}, ` +
`live config differs: ${JSON.stringify(configs)}`,
commands: [
`# Update trusted publishing for ${packageName} to repository ${want.repository},`,
`# workflow ${want.workflow} (web UI: package Settings -> Trusted publishing, or \`npm trust\`).`,
],
});
}
}
}
}
async function main(): Promise<void> {
if (!token) {
console.log('NPM_TOKEN is not set — skipping npm package access drift check.');
console.log(
'Provide a read-only granular access token with organization read access to enable it.'
);
process.exit(0);
}
console.log(`Checking npm package access drift for org "${NPM_ORG}"...\n`);
await checkOrgMembership();
const packages = await listOrgPackages();
console.log(`Checking ${packages.length} packages...`);
const declared = new Set(NPM_PACKAGES.map((p) => p.package));
const notInOrg = [...declared].filter((p) => !packages.includes(p));
for (const pkg of notInOrg) {
warnings.push(`Declared package ${pkg} was not found in the org package list.`);
}
for (const pkg of packages) {
await checkPackage(pkg);
await sleep(REQUEST_DELAY_MS);
}
// Report
if (warnings.length > 0) {
console.log('\n--- Warnings (not drift) ---');
for (const warning of warnings) console.log(` ! ${warning}`);
}
if (drifts.length === 0) {
console.log('\nNo drift detected. Live npm state matches src/config/packageAccess.ts.');
process.exit(0);
}
console.log(`\n--- Drift report (${drifts.length} finding${drifts.length === 1 ? '' : 's'}) ---`);
for (const drift of drifts) console.log(` ✗ ${drift.description}`);
console.log('\n--- Remediation plan ---');
console.log('# Review, then run in ONE interactive npm session (logged in as an org owner).');
console.log('# Approve a 2FA challenge on npmjs.com and choose "Don\'t ask again for 5 minutes"');
console.log('# to batch these; add `sleep 2` between commands in longer batches.');
console.log('# See "npm & PyPI Package Publishing Access" in the README.\n');
for (const drift of drifts) {
for (const command of drift.commands) console.log(command);
}
process.exit(1);
}
main().catch((e) => {
console.error(`Drift check failed: ${e}`);
process.exit(1);
});

View file

@ -9,6 +9,14 @@ import { ROLES, buildRoleLookup, getRolesForPlatform } from '../src/config/roles
import { ROLE_IDS, isValidRoleId } from '../src/config/roleIds';
import { MEMBERS } from '../src/config/users';
import { hasProvisionUserRole } from '../src/config/utils';
import {
NPM_ORG,
NPM_PACKAGES,
PYPI_PROJECTS,
getExpectedNpmOrgMembers,
getNpmPackageAccess,
NPM_DEFAULT_POLICY,
} from '../src/config/packageAccess';
let passed = 0;
let failed = 0;
@ -124,6 +132,46 @@ test('Some members in provisionUser roles have Google user fields', () => {
return membersInProvisionRoles.length > 0 && provisioned.length > 0;
});
// Test package registry access config
test('NPM_ORG is modelcontextprotocol', () => NPM_ORG === 'modelcontextprotocol');
test('NPM_PACKAGES is not empty and all packages are org-scoped', () =>
NPM_PACKAGES.length > 0 && NPM_PACKAGES.every((p) => p.package.startsWith(`@${NPM_ORG}/`)));
test('All NPM_PACKAGES have at least one maintainer', () =>
NPM_PACKAGES.every((p) => p.maintainers.length > 0));
test('npm usernames on members are unique', () => {
const usernames = MEMBERS.filter((m) => m.npm).map((m) => m.npm);
return usernames.length === new Set(usernames).size;
});
test('pypi usernames on members are unique', () => {
const usernames = MEMBERS.filter((m) => m.pypi).map((m) => m.pypi);
return usernames.length === new Set(usernames).size;
});
test('Expected npm org membership is non-empty, sorted, and unique', () => {
const orgMembers = getExpectedNpmOrgMembers();
const sorted = [...orgMembers].sort((a, b) => a.localeCompare(b));
return (
orgMembers.length > 0 &&
orgMembers.length === new Set(orgMembers).size &&
orgMembers.every((username, i) => username === sorted[i])
);
});
test('getNpmPackageAccess falls back to the default policy', () => {
const access = getNpmPackageAccess(`@${NPM_ORG}/some-undeclared-package`);
return access.maintainers === NPM_DEFAULT_POLICY.maintainers && !access.trustedPublisher;
});
test('getNpmPackageAccess returns explicit entries', () => {
const access = getNpmPackageAccess(`@${NPM_ORG}/sdk`);
return !!access.trustedPublisher;
});
test('PYPI_PROJECTS includes the mcp project with accounts', () => {
const mcp = PYPI_PROJECTS.find((p) => p.project === 'mcp');
return !!mcp && mcp.accounts.length > 0;
});
test('PyPI project names are unique', () => {
const names = PYPI_PROJECTS.map((p) => p.project);
return names.length === new Set(names).size;
});
// Summary
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);

View file

@ -7,6 +7,12 @@
import { ROLES, buildRoleLookup } from '../src/config/roles';
import { REPOSITORY_ACCESS } from '../src/config/repoAccess';
import {
NPM_PACKAGES,
PYPI_PROJECTS,
UNMAPPED_NPM_USERS,
UNMAPPED_PYPI_USERS,
} from '../src/config/packageAccess';
import { MEMBERS } from '../src/config/users';
import { hasProvisionUserRole, resolveGoogleMemberEmail } from '../src/config/utils';
import type { RoleId } from '../src/config/roleIds';
@ -193,6 +199,111 @@ for (const member of MEMBERS) {
}
}
// Validate npm/PyPI identities and packageAccess.ts references
console.log('Validating package registry references in packageAccess.ts...');
{
// npm/pypi usernames must be unique across members
const npmUsers = new Map<string, string>();
const pypiUsers = new Map<string, string>();
for (const member of MEMBERS) {
const memberId = member.github || member.email || 'unknown';
for (const [field, seen] of [
['npm', npmUsers],
['pypi', pypiUsers],
] as const) {
const username = member[field];
if (!username) continue;
const existing = seen.get(username);
if (existing) {
console.error(
`ERROR: ${field} username "${username}" is used by both "${existing}" and "${memberId}"`
);
hasErrors = true;
} else {
seen.set(username, memberId);
}
}
}
// Unmapped lists must not overlap with mapped members (stale entries)
for (const username of UNMAPPED_NPM_USERS) {
if (npmUsers.has(username)) {
console.error(
`ERROR: npm username "${username}" is in UNMAPPED_NPM_USERS but is already mapped ` +
`to member "${npmUsers.get(username)}" — remove it from the unmapped list`
);
hasErrors = true;
}
}
for (const username of UNMAPPED_PYPI_USERS) {
if (pypiUsers.has(username)) {
console.error(
`ERROR: PyPI username "${username}" is in UNMAPPED_PYPI_USERS but is already mapped ` +
`to member "${pypiUsers.get(username)}" — remove it from the unmapped list`
);
hasErrors = true;
}
}
// Every npm maintainer referenced by a package must be a known account
const knownNpm = new Set([...npmUsers.keys(), ...UNMAPPED_NPM_USERS]);
const npmPackageNames = new Set<string>();
for (const pkg of NPM_PACKAGES) {
if (npmPackageNames.has(pkg.package)) {
console.error(`ERROR: Package "${pkg.package}" is declared twice in packageAccess.ts`);
hasErrors = true;
}
npmPackageNames.add(pkg.package);
if (!pkg.package.startsWith('@modelcontextprotocol/')) {
console.error(`ERROR: Package "${pkg.package}" is not in the @modelcontextprotocol scope`);
hasErrors = true;
}
for (const username of pkg.maintainers) {
if (!knownNpm.has(username)) {
console.error(
`ERROR: Package "${pkg.package}" references npm user "${username}" which is not ` +
`declared on any member in users.ts (npm field) or in UNMAPPED_NPM_USERS`
);
hasErrors = true;
}
}
if (
pkg.trustedPublisher &&
!pkg.trustedPublisher.repository.startsWith('modelcontextprotocol/')
) {
console.error(
`ERROR: Package "${pkg.package}" declares trusted publisher repository ` +
`"${pkg.trustedPublisher.repository}" outside the modelcontextprotocol GitHub org`
);
hasErrors = true;
}
}
// Every PyPI account referenced by a project must be a known account
const knownPypi = new Set([...pypiUsers.keys(), ...UNMAPPED_PYPI_USERS]);
const pypiProjectNames = new Set<string>();
for (const project of PYPI_PROJECTS) {
if (pypiProjectNames.has(project.project)) {
console.error(
`ERROR: PyPI project "${project.project}" is declared twice in packageAccess.ts`
);
hasErrors = true;
}
pypiProjectNames.add(project.project);
for (const username of project.accounts) {
if (!knownPypi.has(username)) {
console.error(
`ERROR: PyPI project "${project.project}" references PyPI user "${username}" which is not ` +
`declared on any member in users.ts (pypi field) or in UNMAPPED_PYPI_USERS`
);
hasErrors = true;
}
}
}
}
// Validate parent role references in roles.ts
console.log('Validating parent role references in roles.ts...');
for (const role of ROLES) {

199
src/config/packageAccess.ts Normal file
View file

@ -0,0 +1,199 @@
// npm & PyPI package publishing access configuration
//
// This file declares the *expected* state of package-registry access so it can
// be reviewed and audited in one place. Unlike GitHub/Google/Discord, none of
// it is applied by Pulumi:
//
// - npm: since August 2026 every mutating org/team/package-access/trust
// operation requires an interactive 2FA challenge (tokens — even with
// "bypass 2FA" — get 403), so unattended reconciliation is impossible.
// Read endpoints still work with a granular access token, so drift is
// *detected* by scripts/check-package-drift.ts (run weekly by
// .github/workflows/package-drift.yml) and *applied* by a human following
// the runbook in the README ("npm & PyPI Package Publishing Access").
// - PyPI: there is no management API at all (collaborators, trusted
// publishers and organizations are web-UI only), so the PyPI section below
// is declared state for audit plus a manual runbook — nothing is automated.
//
// This module must stay import-safe for scripts/validate-config.ts and
// scripts/test-config.ts: pure data and helpers, no network, no Pulumi.
import { MEMBERS } from './users';
/** The npm organization (scope) that owns @modelcontextprotocol/* packages. */
export const NPM_ORG = 'modelcontextprotocol';
/**
* A package's npm trusted publishing configuration (OIDC from GitHub Actions).
* npm allows exactly one trusted publisher per package.
*/
export interface NpmTrustedPublisher {
/** GitHub repository allowed to publish, as "owner/repo" */
repository: string;
/** Workflow file path within the repository, e.g. ".github/workflows/main.yml" */
workflow: string;
}
/** Expected access for a single npm package. */
export interface NpmPackageAccess {
/** Full package name, e.g. "@modelcontextprotocol/sdk" */
package: string;
/**
* npm usernames expected to have publish rights (the registry
* "maintainers" list). Every entry must be the `npm` field of a member in
* users.ts or listed in UNMAPPED_NPM_USERS (enforced by validate-config).
*/
maintainers: readonly string[];
/** Expected trusted publishing configuration, if declared */
trustedPublisher?: NpmTrustedPublisher;
}
/**
* npm usernames that currently hold access but have not been verifiably
* mapped to a member in users.ts yet. Keep this list shrinking: when a
* mapping is confirmed, set `npm` on the member and remove the entry here.
*/
export const UNMAPPED_NPM_USERS: readonly string[] = [
// Registry email ashwin@anthropic.com; no matching entry in users.ts.
'ashwin-ant',
];
/**
* The baseline maintainer set for org packages: the npm accounts of the core
* publishing group (see users.ts: jspahrsummers, pcarleton, felixweinberger,
* dsp-ant, ochafik, plus the unmapped ashwin-ant).
*/
export const NPM_BASE_MAINTAINERS: readonly string[] = [
'ashwin-ant',
'fweinberger',
'jspahrsummers',
'ochafik-ant',
'pcarleton',
'thedsp',
];
/**
* Packages with explicitly declared access. Packages in the org that are not
* listed here fall under NPM_DEFAULT_POLICY.
*/
export const NPM_PACKAGES: readonly NpmPackageAccess[] = [
{
package: '@modelcontextprotocol/sdk',
maintainers: NPM_BASE_MAINTAINERS,
trustedPublisher: {
repository: 'modelcontextprotocol/typescript-sdk',
workflow: '.github/workflows/main.yml',
},
},
{
package: '@modelcontextprotocol/inspector',
maintainers: [...NPM_BASE_MAINTAINERS, 'cliffhall'],
trustedPublisher: {
repository: 'modelcontextprotocol/inspector',
workflow: '.github/workflows/main.yml',
},
},
{
package: '@modelcontextprotocol/server-everything',
maintainers: [...NPM_BASE_MAINTAINERS, 'cliffhall'],
trustedPublisher: {
repository: 'modelcontextprotocol/servers',
workflow: '.github/workflows/release.yml',
},
},
];
/**
* Policy applied to every org package without an explicit NPM_PACKAGES entry
* (the org has ~54 packages; the drift checker enumerates them live).
*/
export const NPM_DEFAULT_POLICY = {
/** Expected maintainers for packages without an explicit entry */
maintainers: NPM_BASE_MAINTAINERS,
/**
* All org packages should publish via trusted publishing (OIDC), not
* tokens. The drift checker reports packages whose latest release was not
* trusted-published; the exact repository/workflow is only pinned for
* packages with an explicit NPM_PACKAGES entry.
*/
requireTrustedPublishing: true,
} as const;
/** Expected access for the given package: its explicit entry or the default policy. */
export function getNpmPackageAccess(packageName: string): NpmPackageAccess {
return (
NPM_PACKAGES.find((p) => p.package === packageName) ?? {
package: packageName,
maintainers: NPM_DEFAULT_POLICY.maintainers,
}
);
}
/**
* Expected npm org membership: every npm username declared on a member in
* users.ts, plus the not-yet-mapped accounts. Sorted, de-duplicated.
*/
export function getExpectedNpmOrgMembers(): string[] {
const usernames = new Set<string>(UNMAPPED_NPM_USERS);
for (const member of MEMBERS) {
if (member.npm) usernames.add(member.npm);
}
return [...usernames].sort((a, b) => a.localeCompare(b));
}
// ---------------------------------------------------------------------------
// PyPI (declared state only — no API exists, nothing is automated)
// ---------------------------------------------------------------------------
/** Expected access for a single PyPI project. */
export interface PyPiProjectAccess {
/** Project name on pypi.org */
project: string;
/**
* PyPI accounts with a role on the project. PyPI does not publicly expose
* whether an account is Owner or Maintainer, so this is a single list.
* Every entry must be the `pypi` field of a member in users.ts or listed
* in UNMAPPED_PYPI_USERS (enforced by validate-config).
*/
accounts: readonly string[];
/** Source repository expected to publish via trusted publishing */
repository?: string;
/** Caveats about this entry (e.g. unverified rosters) */
notes?: string;
}
/** PyPI usernames with access that are not yet mapped to a member in users.ts. */
export const UNMAPPED_PYPI_USERS: readonly string[] = [];
export const PYPI_PROJECTS: readonly PyPiProjectAccess[] = [
{
project: 'mcp',
accounts: ['Kludex', 'dsp', 'jspahrsummers', 'maxisbey'],
repository: 'modelcontextprotocol/python-sdk',
},
{
project: 'mcp-server-git',
accounts: [],
repository: 'modelcontextprotocol/servers',
notes:
'Account roster not yet audited — PyPI exposes rosters only in the ' +
'web UI, and this page could not be verified. A project owner should ' +
'fill this in from pypi.org/manage/project/mcp-server-git/collaboration/.',
},
{
project: 'mcp-server-fetch',
accounts: [],
repository: 'modelcontextprotocol/servers',
notes:
'Account roster not yet audited — see mcp-server-git note; source: ' +
'pypi.org/manage/project/mcp-server-fetch/collaboration/.',
},
{
project: 'mcp-server-time',
accounts: [],
repository: 'modelcontextprotocol/servers',
notes:
'Account roster not yet audited — see mcp-server-git note; source: ' +
'pypi.org/manage/project/mcp-server-time/collaboration/.',
},
];

View file

@ -150,6 +150,7 @@ export const MEMBERS: readonly Member[] = [
github: 'cliffhall',
email: 'cliff@futurescale.com',
discord: '501498061965754380',
npm: 'cliffhall',
firstName: 'Cliff',
lastName: 'Hall',
googleEmailPrefix: 'cliff',
@ -240,6 +241,7 @@ export const MEMBERS: readonly Member[] = [
},
{
github: 'dsp',
pypi: 'dsp',
skipGoogleUserProvisioning: true,
memberOf: [
ROLE_IDS.AUTH_MAINTAINERS,
@ -260,6 +262,7 @@ export const MEMBERS: readonly Member[] = [
github: 'dsp-ant',
email: 'david@modelcontextprotocol.io',
discord: '166107790262272000',
npm: 'thedsp',
firstName: 'David',
lastName: 'Soria Parra',
googleEmailPrefix: 'david',
@ -321,6 +324,7 @@ export const MEMBERS: readonly Member[] = [
{
github: 'felixweinberger',
discord: '1377138523492057212',
npm: 'fweinberger',
firstName: 'Felix',
lastName: 'Weinberger',
googleEmailPrefix: 'felix',
@ -434,6 +438,8 @@ export const MEMBERS: readonly Member[] = [
{
github: 'jspahrsummers',
email: 'justin@modelcontextprotocol.io',
npm: 'jspahrsummers',
pypi: 'jspahrsummers',
firstName: 'Justin',
lastName: 'Spahr-Summers',
googleEmailPrefix: 'justin',
@ -469,6 +475,7 @@ export const MEMBERS: readonly Member[] = [
{
github: 'Kludex',
discord: '247021664624312322',
pypi: 'Kludex',
firstName: 'Marcelo',
lastName: 'Trylesinski',
googleEmailPrefix: 'marcelo',
@ -569,6 +576,7 @@ export const MEMBERS: readonly Member[] = [
{
github: 'maxisbey',
discord: '1404871241738748058',
pypi: 'maxisbey',
firstName: 'Max',
lastName: 'Isbey',
googleEmailPrefix: 'max',
@ -618,6 +626,7 @@ export const MEMBERS: readonly Member[] = [
{
github: 'ochafik',
discord: '1004897332069925024',
npm: 'ochafik-ant',
firstName: 'Olivier',
lastName: 'Chafik',
googleEmailPrefix: 'ochafik',
@ -661,6 +670,7 @@ export const MEMBERS: readonly Member[] = [
{
github: 'pcarleton',
discord: '1354465170969067852',
npm: 'pcarleton',
firstName: 'Paul',
lastName: 'Carleton',
googleEmailPrefix: 'paul',

View file

@ -13,6 +13,20 @@ export interface Member {
email?: string;
/** Discord user ID (snowflake) */
discord?: string;
/**
* npm username (npmjs.com). Only set when the mapping to this member has
* been verified (e.g. the npm account's registry email matches this member,
* or the username is identical and the person is a known maintainer).
* Referenced by src/config/packageAccess.ts and
* scripts/check-package-drift.ts; not managed by Pulumi.
*/
npm?: string;
/**
* PyPI username (pypi.org). Only set when the mapping to this member has
* been verified. Declared for audit purposes only — PyPI has no management
* API, so this is never automated (see packageAccess.ts).
*/
pypi?: string;
/** Roles this member belongs to */
memberOf: readonly RoleId[];
/** First name (required for Google Workspace user provisioning) */