From 9aa6293e968d57e92478fe8ae6c672225f62e8a6 Mon Sep 17 00:00:00 2001 From: Joan Leon Date: Wed, 21 Oct 2026 14:44:23 +0200 Subject: [PATCH] ci(cli): publish the CLI with no stored secret, and its MCP server to the MCP Registry The MCP Registry does not host servers, it describes them: cli/server.json tells a client which npm package to install, over which transport, with which argument (mcp) and which variables (CRUX_API_KEY, optional and secret), so the client builds the command without a hand-written configuration. Ownership is proven twice: only a login as nucliweb on GitHub publishes io.github.nucliweb/* names, and the npm package carries that name in mcpName, which the registry reads from npm. publish-cli.yml publishes to npm through trusted publishing: npm takes the OIDC token of the run instead of NPM_TOKEN, and adds the provenance of the package. The same token logs mcp-publisher in to the registry, after npm, and the step retries while npm lists the new version. check-tag-version.js also checks that the version of cli/server.json, the version of its npm package and its name match cli/package.json. --- .github/workflows/publish-cli.yml | 30 ++++++++++++++++++++---- cli/package.json | 1 + cli/server.json | 39 +++++++++++++++++++++++++++++++ docs/RELEASING.md | 15 ++++++++++-- scripts/check-tag-version.js | 26 +++++++++++++++++---- scripts/check-tag-version.test.js | 29 ++++++++++++++++++++--- 6 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 cli/server.json diff --git a/.github/workflows/publish-cli.yml b/.github/workflows/publish-cli.yml index ece7637a..064a789b 100644 --- a/.github/workflows/publish-cli.yml +++ b/.github/workflows/publish-cli.yml @@ -9,8 +9,11 @@ jobs: publish: runs-on: ubuntu-latest environment: npm-publish + # id-token: the OIDC token of the run, which npm (trusted publishing) and the MCP Registry + # accept instead of a stored secret permissions: contents: read + id-token: write steps: - name: Checkout repository @@ -19,9 +22,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: '22' + node-version: '24' cache: 'npm' - registry-url: 'https://registry.npmjs.org' + + # Trusted publishing needs npm 11.5.1 or later + - name: Update npm + run: npm install -g npm@^11.5.1 - name: Check the version of the tag run: node scripts/check-tag-version.js "$GITHUB_REF_NAME" @@ -42,8 +48,24 @@ jobs: - name: Prepare snippets for publishing run: cp -r snippets cli/ + # Trusted publishing: npm exchanges the OIDC token of this workflow for a short-lived one, and adds + # the provenance of the package - name: Publish to npm run: npm publish working-directory: cli - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Install mcp-publisher + run: curl -sSL "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher + + # The registry checks that the npm package of cli/server.json carries its name in mcpName, so it + # waits for npm to list the version it has just published + - name: Publish to the MCP Registry + working-directory: cli + run: | + ../mcp-publisher login github-oidc + for attempt in 1 2 3 4 5 6; do + ../mcp-publisher publish && exit 0 + echo "Attempt $attempt failed, npm may not list the version yet" + sleep 30 + done + exit 1 diff --git a/cli/package.json b/cli/package.json index ecdef8ec..4bbfcc49 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,7 @@ { "name": "webperf-snippets", "version": "1.0.0", + "mcpName": "io.github.nucliweb/webperf-snippets", "description": "Run curated WebPerf Snippets headlessly via Playwright. Diagnose Core Web Vitals beyond what Lighthouse exposes.", "type": "module", "bin": "./src/bin.js", diff --git a/cli/server.json b/cli/server.json new file mode 100644 index 00000000..223012d2 --- /dev/null +++ b/cli/server.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.nucliweb/webperf-snippets", + "title": "WebPerf Snippets", + "description": "Measure Core Web Vitals and debug web performance with 56 snippets in a headless Chromium", + "websiteUrl": "https://webperf-snippets.net", + "repository": { + "url": "https://github.com/nucliweb/webperf-snippets", + "source": "github", + "subfolder": "cli" + }, + "version": "1.0.0", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "webperf-snippets", + "version": "1.0.0", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "positional", + "value": "mcp" + } + ], + "environmentVariables": [ + { + "name": "CRUX_API_KEY", + "description": "Chrome UX Report API key for the field data of get_crux; the other tools work without it", + "isRequired": false, + "isSecret": true, + "format": "string" + } + ] + } + ] +} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 7d462fe2..b654181a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -5,10 +5,10 @@ The repository publishes three parts, each with its own version and tag: | Part | Version in | Tag | Workflow | Publishes | |---|---|---|---|---| | Collection and skills | [package.json](../package.json) | `v*` | [release.yml](../.github/workflows/release.yml) | GitHub Release with the skill zips | -| CLI | [cli/package.json](../cli/package.json) | `cli-v*` | [publish-cli.yml](../.github/workflows/publish-cli.yml) | `webperf-snippets` on npm | +| CLI | [cli/package.json](../cli/package.json) | `cli-v*` | [publish-cli.yml](../.github/workflows/publish-cli.yml) | `webperf-snippets` on npm, and its MCP server in the MCP Registry | | DevTools extension | [extension/manifest.json](../extension/manifest.json) | `extension-v*` | [release-extension.yml](../.github/workflows/release-extension.yml) | GitHub prerelease with the extension zip | -Each workflow first runs `node scripts/check-tag-version.js `, which fails when the tag does not match the version of its file, so a mistyped tag publishes nothing. Run it locally before pushing a tag: +Each workflow first runs `node scripts/check-tag-version.js `, which fails when the tag does not match the version of its file, so a mistyped tag publishes nothing. For the CLI it also checks [cli/server.json](../cli/server.json): its version, the version of the npm package it lists, and its name, which has to be the `mcpName` of `cli/package.json`. Run it locally before pushing a tag: ```bash node scripts/check-tag-version.js cli-v1.1.0 @@ -70,6 +70,16 @@ git push origin cli-v1.1.0 The workflow runs the CLI unit and E2E tests, copies `snippets/` into `cli/` and publishes from there. +It publishes with no stored secret. npm accepts the OIDC token of the run through trusted publishing, configured on npmjs.com for this repository, `publish-cli.yml` and the `npm-publish` environment, and adds the provenance of the package. Then `mcp-publisher` logs in to the MCP Registry with the same token (`login github-oidc`, which allows the `io.github.nucliweb/*` names) and publishes `cli/server.json`. The registry checks that the npm package carries that name in `mcpName`, so the step retries for three minutes while npm lists the new version. + +If npm published and the registry step failed, publish `cli/server.json` from a clone instead of tagging again, since npm rejects a version twice: + +```bash +cd cli +mcp-publisher login github +mcp-publisher publish +``` + ## DevTools extension (`extension-v*`) 1. Update the version in [extension/manifest.json](../extension/manifest.json) and merge the pull request. @@ -90,3 +100,4 @@ The workflow tests the zip and publishes it on a GitHub prerelease, so it never - `_meta.js` entries drift from the actual MDX files - Published snippet counts in `README.md`, `SKILLS.md`, or `skills/webperf/SKILL.md` are outdated - The CLI publish waits forever: the `npm-publish` environment needs its approval. +- The CLI publish fails at npm with an authentication error: the trusted publisher on npmjs.com has to name this repository, `publish-cli.yml` and the `npm-publish` environment. diff --git a/scripts/check-tag-version.js b/scripts/check-tag-version.js index e6c5af07..6456b903 100644 --- a/scripts/check-tag-version.js +++ b/scripts/check-tag-version.js @@ -6,13 +6,29 @@ // cli-v* the CLI on npm cli/package.json // extension-v* the DevTools extension extension/manifest.json // +// The CLI carries its MCP server too: cli/server.json describes it for the MCP Registry, which the +// publish workflow updates after npm. Its version, the one of the npm package it lists and its name +// (the mcpName of cli/package.json, which the registry checks on npm) have to match as well. +// // Run: node scripts/check-tag-version.js [root] const fs = require('node:fs') const path = require('node:path') +const readJson = (root, file) => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')) + +function checkMcpServer(root, version) { + const server = readJson(root, 'cli/server.json') + if (server.version !== version) return `The version ${server.version} of cli/server.json does not match the version ${version} of cli/package.json` + const pkg = (server.packages ?? []).findIndex((p) => p.version !== version) + if (pkg !== -1) return `packages[${pkg}].version ${server.packages[pkg].version} of cli/server.json does not match the version ${version} of cli/package.json` + const { mcpName } = readJson(root, 'cli/package.json') + if (server.name !== mcpName) return `The name ${server.name} of cli/server.json is not the mcpName ${mcpName} of cli/package.json` + return null +} + const PARTS = [ - { prefix: 'cli-v', file: 'cli/package.json' }, + { prefix: 'cli-v', file: 'cli/package.json', also: checkMcpServer }, { prefix: 'extension-v', file: 'extension/manifest.json' }, { prefix: 'v', file: 'package.json' }, ] @@ -21,9 +37,11 @@ function checkTag(tag, root = path.join(__dirname, '..')) { const part = PARTS.find(({ prefix }) => tag.startsWith(prefix)) if (!part) return { ok: false, message: `${tag} is not a release tag (v*, cli-v* or extension-v*)` } - const version = JSON.parse(fs.readFileSync(path.join(root, part.file), 'utf8')).version - if (tag.slice(part.prefix.length) === version) return { ok: true, file: part.file, version } - return { ok: false, message: `The tag ${tag} does not match the version ${version} of ${part.file}` } + const version = readJson(root, part.file).version + if (tag.slice(part.prefix.length) !== version) return { ok: false, message: `The tag ${tag} does not match the version ${version} of ${part.file}` } + const mismatch = part.also?.(root, version) + if (mismatch) return { ok: false, message: mismatch } + return { ok: true, file: part.file, version } } function main([tag, root] = process.argv.slice(2)) { diff --git a/scripts/check-tag-version.test.js b/scripts/check-tag-version.test.js index b7993c82..5b1aa484 100644 --- a/scripts/check-tag-version.test.js +++ b/scripts/check-tag-version.test.js @@ -6,13 +6,19 @@ const path = require('node:path') const { execFileSync } = require('node:child_process') const { checkTag } = require('./check-tag-version') -// A repository with the three versioned parts: the collection, the CLI and the extension -function repo({ collection = '2.0.1', cli = '1.0.0', extension = '0.1.1' } = {}) { +// A repository with the three versioned parts: the collection, the CLI (with the server.json of its +// MCP server) and the extension +const MCP_NAME = 'io.github.nucliweb/webperf-snippets' +function repo({ collection = '2.0.1', cli = '1.0.0', extension = '0.1.1', server = cli, serverPackage = server, serverName = MCP_NAME } = {}) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tag-version-')) fs.mkdirSync(path.join(root, 'cli')) fs.mkdirSync(path.join(root, 'extension')) fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: collection })) - fs.writeFileSync(path.join(root, 'cli', 'package.json'), JSON.stringify({ version: cli })) + fs.writeFileSync(path.join(root, 'cli', 'package.json'), JSON.stringify({ version: cli, mcpName: MCP_NAME })) + fs.writeFileSync( + path.join(root, 'cli', 'server.json'), + JSON.stringify({ name: serverName, version: server, packages: [{ registryType: 'npm', identifier: 'webperf-snippets', version: serverPackage }] }) + ) fs.writeFileSync(path.join(root, 'extension', 'manifest.json'), JSON.stringify({ version: extension })) return root } @@ -27,6 +33,23 @@ test('a cli-v* tag matches the version of cli/package.json, not the one of the c assert.equal(checkTag('cli-v2.0.1', repo()).ok, false) }) +test('a cli-v* tag also matches the version of cli/server.json and of the npm package it lists', () => { + assert.equal(checkTag('cli-v1.1.0', repo({ cli: '1.1.0' })).ok, true) + const server = checkTag('cli-v1.1.0', repo({ cli: '1.1.0', server: '1.0.0' })) + assert.equal(server.ok, false) + assert.match(server.message, /1\.0\.0 of cli\/server\.json/) + const pkg = checkTag('cli-v1.1.0', repo({ cli: '1.1.0', serverPackage: '1.0.0' })) + assert.equal(pkg.ok, false) + assert.match(pkg.message, /packages\[0\]\.version/) +}) + +test('the name of cli/server.json is the mcpName of cli/package.json, which the MCP Registry checks', () => { + const { ok, message } = checkTag('cli-v1.0.0', repo({ serverName: 'io.github.someone/else' })) + assert.equal(ok, false) + assert.match(message, /io\.github\.someone\/else/) + assert.match(message, /mcpName/) +}) + test('an extension-v* tag matches the version of extension/manifest.json', () => { assert.deepEqual(checkTag('extension-v0.1.1', repo()), { ok: true, file: 'extension/manifest.json', version: '0.1.1' }) assert.equal(checkTag('extension-v0.1.2', repo()).ok, false)