Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
}
83 changes: 80 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 44 additions & 6 deletions docs/ci-and-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
19 changes: 19 additions & 0 deletions gemini-extension.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
}
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions scripts/build-mcpb.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down
3 changes: 3 additions & 0 deletions scripts/generate-moz-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
47 changes: 47 additions & 0 deletions scripts/sync-manifest-versions.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
}
52 changes: 52 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
Loading