diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json new file mode 100644 index 0000000..bde7b7d --- /dev/null +++ b/.cursor-plugin/plugin.json @@ -0,0 +1,25 @@ +{ + "name": "firefox-devtools-mcp", + "description": "Give your agent a real Firefox. Navigate pages, fill forms and verify changes in a second engine, and inspect the console, network activity, JavaScript debugger and performance profiler to find the cause of a bug instead of guessing. Supports Firefox for Android.", + "version": "0.10.3", + "author": { + "name": "Mozilla" + }, + "homepage": "https://github.com/mozilla/firefox-devtools-mcp", + "repository": "https://github.com/mozilla/firefox-devtools-mcp", + "license": "MIT OR Apache-2.0", + "mcpServers": { + "firefox-devtools": { + "command": "npx", + "args": [ + "-y", + "@mozilla/firefox-devtools-mcp@latest", + "--auto-profile", + "--tool-preset", + "developer", + "--pref", + "remote.prefs.recommended=false" + ] + } + } +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c06d4a3..bd6fab7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,13 +4,24 @@ on: release: types: [published] workflow_dispatch: + inputs: + npm-publish: + description: 'Publish to npm' + default: false + type: boolean + mcp-publish: + description: 'Publish to the MCP Registry' + default: true + type: boolean + +permissions: + contents: read + id-token: write jobs: publish: runs-on: ubuntu-latest - permissions: - contents: read - id-token: write + if: ${{ github.event_name != 'workflow_dispatch' || inputs.npm-publish }} steps: - name: Checkout repository uses: actions/checkout@v5 @@ -37,3 +48,69 @@ jobs: - name: Publish moz package run: npm run publish:moz -- --access public --provenance + + publish-mcp-registry: + runs-on: ubuntu-latest + needs: publish + # Runs when the npm job succeeded, and also when it was skipped, which is the case + # when re-publishing to the registry alone. Referencing cancelled() replaces the + # implicit success() gate that would otherwise skip this job on a skipped dependency. + if: ${{ !cancelled() && (needs.publish.result == 'success' || needs.publish.result == 'skipped') && (github.event_name != 'workflow_dispatch' || inputs.mcp-publish) }} + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Use Node.js 24.x + uses: actions/setup-node@v5 + with: + node-version: 24 + + - name: Install Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Install mcp-publisher + run: | + OS=$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') + VERSION=v1.8.1 + URL="https://github.com/modelcontextprotocol/registry/releases/download/${VERSION}/mcp-publisher_${OS}.tar.gz" + curl -fL -o mcp-publisher.tar.gz "$URL" + curl -fL -o mcp-publisher.tar.gz.sigstore.json "${URL}.sigstore.json" + cosign verify-blob mcp-publisher.tar.gz \ + --bundle mcp-publisher.tar.gz.sigstore.json \ + --certificate-identity-regexp="https://github.com/modelcontextprotocol/registry/\.github/workflows/.*" \ + --certificate-oidc-issuer="https://token.actions.githubusercontent.com" + tar xzf mcp-publisher.tar.gz mcp-publisher + rm mcp-publisher.tar.gz + + - name: Sync server.json version with package.json + run: | + node -e " + const fs = require('node:fs'); + const { version } = require('./package.json'); + const server = JSON.parse(fs.readFileSync('server.json', 'utf8')); + server.version = version; + server.packages = server.packages.map((pkg) => ({ ...pkg, version })); + fs.writeFileSync('server.json', JSON.stringify(server, null, 2) + '\n'); + " + + - name: Wait for npm package availability + run: | + PKG=$(node -p "require('./package.json').name + '@' + require('./package.json').version") + for attempt in 1 2 3 4 5; do + if npm view "$PKG" version --prefer-online > /dev/null 2>&1; then + echo "$PKG is available on npm." + exit 0 + fi + if [ "$attempt" -lt 5 ]; then + echo "Attempt $attempt: $PKG is not on npm yet, waiting 60s." + sleep 60 + fi + done + echo "Timed out waiting for $PKG to appear on npm." + exit 1 + + - name: Authenticate to MCP Registry + run: ./mcp-publisher login github-oidc + + - name: Publish to MCP Registry + run: ./mcp-publisher publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcc7a75..1e03019 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,4 +49,4 @@ jobs: - name: Trigger npm publish env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh workflow run publish.yml --ref ${{ github.ref_name }} + run: gh workflow run publish.yml --ref ${{ github.ref_name }} -f npm-publish=true -f mcp-publish=true diff --git a/docs/ci-and-release.md b/docs/ci-and-release.md index 12aa617..d807492 100644 --- a/docs/ci-and-release.md +++ b/docs/ci-and-release.md @@ -23,21 +23,59 @@ Workflows - On tag push `v*`: runs tests, builds `dist/`, creates a GitHub Release with a tarball of `dist` + metadata. - Publish (.github/workflows/publish.yml) - - On GitHub Release published or on tag push `v*.*.*` (and via manual dispatch): builds and publishes to npm with provenance. - - Requires `NPM_TOKEN` repository secret. + - On GitHub Release published, or via manual dispatch: builds and publishes to npm with provenance. + - Two jobs: `publish` pushes both npm packages, then `publish-mcp-registry` syncs `server.json` + to the `package.json` version and publishes to the official MCP Registry. + - Manual dispatch takes `npm-publish` (default false) and `mcp-publish` (default true), so a + failed registry publish can be re-run on its own without npm rejecting an already published + version. `release.yml` passes both explicitly when it triggers the workflow. + - The registry verifies the package on npm, so `publish-mcp-registry` first polls `npm view` + for the version it is about to register, up to five minutes, and fails if it never appears. + - To re-run the registry publish on its own, use the Actions UI, whose checkboxes send real + booleans: leave `npm-publish` unchecked and **select the release tag as the ref**. The job + publishes whatever version the checked-out ref declares, so dispatching from the default + branch would register the wrong one. Secrets -- `NPM_TOKEN`: npm access token with publish rights to the package name (`firefox-devtools-mcp`). +- Publishing needs no secret. Both npm and the MCP Registry authenticate through OIDC using the + workflow's `id-token: write` permission: npm via trusted publishing with `--provenance`, the + registry via `mcp-publisher login github-oidc`. - `CODECOV_TOKEN` (optional): used by Codecov upload step (CI). The step is skipped if the token or coverage file is missing. Release flow 1) Bump version in `package.json` (keep 0.x until API is stable): - - `npm version patch` (or minor) - - Commit the change + - `npm version patch` (or minor), or edit `package.json` by hand + - Run `npm run sync:manifests` to carry the version into the manifests that duplicate it + - Update `CHANGELOG.md` and commit the change 2) Create and push the tag (must match package.json): - `git tag v0.2.0 && git push origin v0.2.0` 3) The `version-check` job validates the tag vs. package.json. -4) `release` creates a GitHub Release; `publish` publishes to npm. +4) `release` creates a GitHub Release; `publish` publishes to npm and the MCP Registry. + +MCP Registry +- `server.json` is the registry manifest. `npm run sync:manifests` keeps its `version` and + `packages[].version` in step with `package.json`, and the publish job syncs them again in its + own checkout so a release cannot register a mismatched version. +- The registry proves package ownership through the `mcpName` field in `package.json`, which must + keep matching the `name` in `server.json` (`io.github.mozilla/firefox-devtools-mcp`). +- The `io.github.mozilla/*` namespace comes from owning the `mozilla` GitHub organization, so no + DNS verification is involved. +- `mcp-publisher` is pinned to a release and its sigstore bundle is checked with `cosign + verify-blob` before it runs, since it executes in a job holding `id-token: write`. Bumping the + pinned `VERSION` in the workflow is a deliberate step. +- `scripts/generate-moz-package.mjs` and `scripts/build-mcpb.mjs` both strip `mcpName` from the + `package.json` they ship, since neither artifact is the package registered under that name. + +Client plugin manifests +- `plugins/*/.claude-plugin/` (Claude), `.cursor-plugin/plugin.json` (Cursor) and + `gemini-extension.json` (Gemini CLI) let each client install the server from this + repository. The root `.claude-plugin/marketplace.json` is the marketplace listing, not a + plugin manifest. Gemini reads its file directly; Cursor additionally requires submission + to the Cursor marketplace before the plugin is discoverable. +- The Claude manifests carry no `version`. The other two do, and `npm run sync:manifests` + copies it from `package.json`, along with `manifest.mcpb.json`, `server.json` and the two + version fields in `package-lock.json`. Since each manifest launches the server with `@latest`, + the field is metadata only and does not pin what gets installed. Notes - If you want Codecov upload to run, switch CI test step to `npm run test:coverage` or generate `coverage/lcov.info`. diff --git a/gemini-extension.json b/gemini-extension.json new file mode 100644 index 0000000..68ad84a --- /dev/null +++ b/gemini-extension.json @@ -0,0 +1,19 @@ +{ + "name": "firefox-devtools-mcp", + "version": "0.10.3", + "description": "Control Firefox for browsing, web testing, and debugging. Fill forms, capture network and console activity, take screenshots, run scripts, and profile performance. Supports Android devices.", + "mcpServers": { + "firefox-devtools": { + "command": "npx", + "args": [ + "-y", + "@mozilla/firefox-devtools-mcp@latest", + "--auto-profile", + "--tool-preset", + "developer", + "--pref", + "remote.prefs.recommended=false" + ] + } + } +} diff --git a/package.json b/package.json index b7040d9..82c6fd0 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@mozilla/firefox-devtools-mcp", "version": "0.10.3", "description": "Model Context Protocol (MCP) server for Firefox DevTools automation", + "mcpName": "io.github.mozilla/firefox-devtools-mcp", "author": "Mozilla", "license": "MIT OR Apache-2.0", "type": "module", @@ -20,6 +21,8 @@ "start": "node dist/index.js", "setup": "node scripts/setup-mcp-config.js", "docs:tools": "tsx scripts/generate-tools-doc.ts", + "sync:manifests": "node scripts/sync-manifest-versions.mjs", + "version": "npm run sync:manifests && git add -u", "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "typecheck": "tsc", "typecheck:tests": "tsc -p tests/tsconfig.json", diff --git a/scripts/build-mcpb.mjs b/scripts/build-mcpb.mjs index ef8505c..115b069 100644 --- a/scripts/build-mcpb.mjs +++ b/scripts/build-mcpb.mjs @@ -26,6 +26,14 @@ try { cpSync(resolve(root, file), resolve(stagingDir, file)); } + // mcpName claims the MCP registry entry of the npm package, not of this bundle + const stagedPkg = JSON.parse(readFileSync(resolve(stagingDir, 'package.json'), 'utf8')); + delete stagedPkg.mcpName; + writeFileSync( + resolve(stagingDir, 'package.json'), + JSON.stringify(stagedPkg, null, 2) + '\n' + ); + console.log('Installing production dependencies...'); execSync('npm ci --omit=dev', { cwd: stagingDir, stdio: 'inherit' }); diff --git a/scripts/generate-moz-package.mjs b/scripts/generate-moz-package.mjs index 559e6ae..bc0956f 100644 --- a/scripts/generate-moz-package.mjs +++ b/scripts/generate-moz-package.mjs @@ -30,6 +30,9 @@ const moz = { // Remove scripts that don't apply to the moz package delete moz.scripts; +// mcpName claims the MCP registry entry of the public package, not this one +delete moz.mcpName; + const outPath = resolve(root, 'package.moz.json'); writeFileSync(outPath, JSON.stringify(moz, null, 2) + '\n'); console.log(`Written ${outPath}`); diff --git a/scripts/sync-manifest-versions.mjs b/scripts/sync-manifest-versions.mjs new file mode 100644 index 0000000..e7d10cd --- /dev/null +++ b/scripts/sync-manifest-versions.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +/** + * Syncs every file that duplicates the package.json version. + * Run it from the release-prep commit via `npm run sync:manifests`. It also + * runs from the `version` npm lifecycle script for anyone using `npm version`. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve, dirname } from 'node:path'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const { version } = JSON.parse( + readFileSync(resolve(root, 'package.json'), 'utf8') +); + +const setVersion = (doc) => ({ ...doc, version }); + +/** Each entry maps a file to the update it needs, since several carry the version more than once. */ +const targets = [ + ['.cursor-plugin/plugin.json', setVersion], + ['gemini-extension.json', setVersion], + ['manifest.mcpb.json', setVersion], + [ + 'server.json', + (doc) => ({ + ...doc, + version, + packages: doc.packages.map((pkg) => ({ ...pkg, version })), + }), + ], + [ + 'package-lock.json', + (doc) => ({ + ...doc, + version, + packages: { ...doc.packages, '': { ...doc.packages[''], version } }, + }), + ], +]; + +for (const [relativePath, update] of targets) { + const path = resolve(root, relativePath); + const doc = JSON.parse(readFileSync(path, 'utf8')); + writeFileSync(path, JSON.stringify(update(doc), null, 2) + '\n'); + console.log(`Synced ${relativePath} to ${version}`); +} diff --git a/server.json b/server.json new file mode 100644 index 0000000..30038ed --- /dev/null +++ b/server.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.mozilla/firefox-devtools-mcp", + "title": "Firefox DevTools MCP", + "description": "Control and debug Firefox: navigate, fill forms, inspect network and console, profile.", + "version": "0.10.3", + "websiteUrl": "https://github.com/mozilla/firefox-devtools-mcp", + "repository": { + "url": "https://github.com/mozilla/firefox-devtools-mcp", + "source": "github" + }, + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "@mozilla/firefox-devtools-mcp", + "version": "0.10.3", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "named", + "name": "--tool-preset", + "description": "Tool modules to expose. Higher presets widen what the agent can do.", + "isRequired": false, + "format": "string", + "default": "developer", + "choices": [ + "slim", + "basic", + "developer" + ] + } + ], + "environmentVariables": [ + { + "name": "FIREFOX_HEADLESS", + "description": "Set to true to run Firefox without a visible window.", + "isRequired": false, + "format": "boolean" + }, + { + "name": "START_URL", + "description": "URL to open when the server starts.", + "isRequired": false, + "format": "string" + } + ] + } + ] +}