From 1f42cc379d5ef114651fbaf07977b70ff4a6d528 Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Sun, 26 Jul 2026 04:18:42 -0700 Subject: [PATCH 1/4] feat(mcp): add npm wrapper and MCP Registry metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bomly ships a Go binary, and the official MCP Registry only accepts npm, PyPI, NuGet, Cargo, OCI, or MCPB packages. That gap is the only thing keeping Bomly out of the registry and out of the community MCP directories that key off an `npx` install line. Adds a thin `bomly-mcp` npm package whose postinstall downloads the release archive for the host platform, verifies it against that release's SHA256SUMS, and unpacks the binary. `bin/bomly-mcp.js` execs `bomly mcp serve` with stdio inherited, so stdout stays pure JSON-RPC. A checksum mismatch fails the install; there is no unverified fallback. Adds `server.json` (schema 2025-12-11) naming the package, and a `mcp-registry` job that publishes both after the release is published — draft-release assets are not publicly downloadable, so an earlier npm publish would ship a package that cannot install. The job is inert until NPM_TOKEN and the PUBLISH_MCP_REGISTRY variable are set. Registry auth is GitHub OIDC and needs no secret. Co-Authored-By: Claude Opus 5 --- .github/workflows/auto-version.yml | 13 ++- .github/workflows/release.yml | 85 ++++++++++++++++++ .gitignore | 5 ++ README.md | 6 ++ docs/MCP.md | 18 ++++ npm/README.md | 65 ++++++++++++++ npm/bin/bomly-mcp.js | 48 ++++++++++ npm/package.json | 49 ++++++++++ npm/scripts/postinstall.js | 138 +++++++++++++++++++++++++++++ server.json | 24 +++++ 10 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 npm/README.md create mode 100644 npm/bin/bomly-mcp.js create mode 100644 npm/package.json create mode 100644 npm/scripts/postinstall.js create mode 100644 server.json diff --git a/.github/workflows/auto-version.yml b/.github/workflows/auto-version.yml index bc4d41f1..e6c7c051 100644 --- a/.github/workflows/auto-version.yml +++ b/.github/workflows/auto-version.yml @@ -108,7 +108,16 @@ jobs: run: | set -euo pipefail sed -i -E "s/^var version = \"[^\"]+\"/var version = \"${VERSION}\"/" cmd/bomly/main.go - git diff -- cmd/bomly/main.go + + # The npm wrapper and the MCP Registry entry both name a release + # version. The release workflow re-derives them from the tag, so this + # is about keeping the committed files readable and honest. + jq --arg v "${VERSION}" '.version = $v' npm/package.json > npm/package.json.tmp + mv npm/package.json.tmp npm/package.json + jq --arg v "${VERSION}" '.version = $v | .packages[0].version = $v' server.json > server.json.tmp + mv server.json.tmp server.json + + git diff -- cmd/bomly/main.go npm/package.json server.json - name: Commit version bump and tag shell: bash @@ -120,7 +129,7 @@ jobs: git config user.name "${{ steps.app-token.outputs.app-slug }}[bot]" git config user.email "${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com" - git add cmd/bomly/main.go + git add cmd/bomly/main.go npm/package.json server.json git commit -m "chore(release): ${TAG} [skip ci]" git tag -a "${TAG}" -m "Release ${TAG}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cce0a1a..37269cc0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -226,3 +226,88 @@ jobs: > /dev/null gh release edit "$RELEASE_TAG" --draft=false --repo bomly-dev/bomly-cli + + mcp-registry: + # Publishes the `bomly-mcp` npm wrapper and then the server entry in the + # official MCP Registry (https://registry.modelcontextprotocol.io). + # + # Must run after `publish`: the wrapper's postinstall step downloads the + # release archive and its SHA256SUMS from this tag, and draft-release + # assets are not publicly downloadable — so publishing npm any earlier + # ships a package that cannot install. + # + # Inert until Ahmed turns it on. Enable with: + # gh secret set NPM_TOKEN -R bomly-dev/bomly-cli + # gh variable set PUBLISH_MCP_REGISTRY -R bomly-dev/bomly-cli --body true + # Registry auth is GitHub OIDC, so it needs no secret of its own; the + # `io.github.bomly-dev/*` namespace is granted from this repo's identity. + needs: publish + if: startsWith(github.ref, 'refs/tags/') && vars.PUBLISH_MCP_REGISTRY == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write # required for mcp-publisher's OIDC login + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Stamp the release version into npm/package.json and server.json + # The tag is authoritative. Auto Version keeps the committed files in + # sync too, but deriving here means a missed bump cannot publish a + # wrapper that points at a release that does not exist. + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + + jq --arg v "$version" '.version = $v' npm/package.json > npm/package.json.tmp + mv npm/package.json.tmp npm/package.json + + jq --arg v "$version" '.version = $v | .packages[0].version = $v' server.json > server.json.tmp + mv server.json.tmp server.json + + - name: Publish bomly-mcp to npm + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + if [ -z "${NPM_TOKEN}" ]; then + echo "::error::PUBLISH_MCP_REGISTRY is true but NPM_TOKEN is not set. Run: gh secret set NPM_TOKEN -R bomly-dev/bomly-cli" + exit 1 + fi + echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc + npm publish --provenance --access public ./npm + + - name: Wait for the npm package to be resolvable + # The registry verifies package ownership by fetching the package, so + # publishing the server entry before npm's CDN has the new version + # fails validation. + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + for attempt in $(seq 1 30); do + if curl -fsSL "https://registry.npmjs.org/bomly-mcp/${version}" > /dev/null 2>&1; then + echo "bomly-mcp@${version} is resolvable." + exit 0 + fi + echo "Attempt ${attempt}: bomly-mcp@${version} not visible yet, waiting." + sleep 10 + done + echo "::error::bomly-mcp@${version} did not become resolvable on npm within 5 minutes" + exit 1 + + - name: Install mcp-publisher + run: | + set -euo pipefail + curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" \ + | tar xz mcp-publisher + + - name: Publish the server entry to the MCP Registry + run: | + set -euo pipefail + ./mcp-publisher login github-oidc + ./mcp-publisher publish diff --git a/.gitignore b/.gitignore index d0c5b998..8680eaf4 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,8 @@ qodana.yaml # Local scratch notes (not tracked) /notes + +# npm wrapper artifacts (the binary is fetched by its postinstall step) +/npm/vendor +/npm/node_modules +/npm/*.tgz diff --git a/README.md b/README.md index ed86d287..63299c1e 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,12 @@ Bomly can run as a local MCP server so AI agents can call the same dependency gr bomly mcp serve ``` +If you have not installed the CLI, the `bomly-mcp` npm wrapper starts the same server: + +```bash +npx -y bomly-mcp +``` + Add Bomly to an MCP-aware agent such as Claude Code, Cursor, VS Code, or a custom tool, and the agent receives structured JSON it can summarize or reason over. See [MCP Server](docs/MCP.md) for setup recipes and the tool reference. ## Configure and Extend diff --git a/docs/MCP.md b/docs/MCP.md index cdda9f34..1847e543 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -27,6 +27,18 @@ bomly mcp serve The command writes its startup banner to stderr and then waits for an MCP client on stdio. It is not meant to be used as an interactive shell command after startup. +## Without Installing The CLI First + +If you would rather not install Bomly separately, the [`bomly-mcp`](https://www.npmjs.com/package/bomly-mcp) npm package starts the same server: + +```bash +npx -y bomly-mcp +``` + +Its install step downloads the release archive for your platform from GitHub Releases, checks it against that release's `SHA256SUMS`, and unpacks the binary inside the package. A checksum mismatch fails the install. Set `BOMLY_MCP_VERSION` to pin a different CLI version. + +Anywhere below that a config uses `"command": "bomly"` with `"args": ["mcp", "serve"]`, you can use `"command": "npx"` with `"args": ["-y", "bomly-mcp"]` instead. If `bomly` is already on `PATH`, prefer it: the server starts faster and you update it with your package manager. + ## Claude Code Add Bomly as a local stdio server: @@ -35,6 +47,12 @@ Add Bomly as a local stdio server: claude mcp add --transport stdio bomly -- bomly mcp serve ``` +Or, without a separate CLI install: + +```bash +claude mcp add --transport stdio bomly -- npx -y bomly-mcp +``` + For a project-scoped config you can also commit a `.mcp.json` file: ```json diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 00000000..8dccc331 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,65 @@ + + +# bomly-mcp + +Give your coding agent the dependency graph it is about to change. + +`bomly-mcp` starts the [Bomly](https://github.com/bomly-dev/bomly-cli) MCP server over stdio, so an MCP-aware agent can generate, diff, explain, and audit dependencies itself instead of asking you to paste scan output into chat. + +Free and open source, no account, no login. + +## Use it + +```bash +npx -y bomly-mcp +``` + +Claude Code: + +```bash +claude mcp add --transport stdio bomly -- npx -y bomly-mcp +``` + +Cursor or VS Code, in `.cursor/mcp.json` or `.vscode/mcp.json`: + +```json +{ + "mcpServers": { + "bomly": { + "type": "stdio", + "command": "npx", + "args": ["-y", "bomly-mcp"] + } + } +} +``` + +If you already have the `bomly` CLI on `PATH`, you do not need this package — point your client at `bomly mcp serve` instead. + +## Tools + +| Tool | Use it for | +| --- | --- | +| `bomly_scan` | Scan a path, Git URL, container image, or SBOM and return a compact dependency summary. | +| `bomly_explain` | Show why one package is present, with full advisory details when enriched. | +| `bomly_diff` | Compare dependencies between Git refs, container images, or SBOM files. | +| `bomly_plugins` | List built-in and installed plugins with their enabled state. | + +Full setup, arguments, and troubleshooting: [docs/MCP.md](https://github.com/bomly-dev/bomly-cli/blob/main/docs/MCP.md). + +## How it installs + +The postinstall step downloads the Bomly release archive for your platform from GitHub Releases, checks it against that release's `SHA256SUMS`, and unpacks the binary into the package. A checksum mismatch fails the install; there is no unverified fallback. + +Environment overrides: + +- `BOMLY_MCP_VERSION` — download a different CLI version. +- `BOMLY_MCP_SKIP_DOWNLOAD=1` — skip the download (for lint or CI installs that never run the server). + +Supported: macOS, Linux, and Windows on x64 and arm64. + +## Network behavior + +The server runs as you, on your machine, and reads the project files the Bomly process can read. Vulnerability, license, lifecycle, and scorecard lookups are opt-in per call via `enrich`. Some detectors invoke package-manager commands, and those tools can contact package registries as part of normal dependency resolution. See [Detectors](https://github.com/bomly-dev/bomly-cli/blob/main/docs/DETECTORS.md#network-behavior). + +Apache-2.0. diff --git a/npm/bin/bomly-mcp.js b/npm/bin/bomly-mcp.js new file mode 100644 index 00000000..57e824a3 --- /dev/null +++ b/npm/bin/bomly-mcp.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node +// Starts the Bomly MCP server over stdio. +// +// This process must stay transparent: stdout carries the JSON-RPC stream and +// nothing else. Diagnostics go to stderr, which is where `bomly mcp serve` +// already writes its startup banner. + +"use strict"; + +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const binary = process.platform === "win32" ? "bomly.exe" : "bomly"; +const vendored = path.join(__dirname, "..", "vendor", binary); + +if (!fs.existsSync(vendored)) { + console.error( + "bomly-mcp: the Bomly binary is missing. Reinstall the package so its postinstall step can run:\n" + + " npm install bomly-mcp\n" + + "Or install the CLI directly and run `bomly mcp serve` instead:\n" + + " https://github.com/bomly-dev/bomly-cli/blob/main/docs/INSTALLATION.md", + ); + process.exit(1); +} + +const child = spawn(vendored, ["mcp", "serve", ...process.argv.slice(2)], { + stdio: "inherit", +}); + +child.on("error", (error) => { + console.error(`bomly-mcp: could not start ${vendored}: ${error.message}`); + process.exit(1); +}); + +// Forward the signals an MCP client uses to stop a stdio server, so the child +// shuts down with us rather than being orphaned. +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => child.kill(signal)); +} + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code === null ? 1 : code); +}); diff --git a/npm/package.json b/npm/package.json new file mode 100644 index 00000000..21981138 --- /dev/null +++ b/npm/package.json @@ -0,0 +1,49 @@ +{ + "name": "bomly-mcp", + "version": "0.20.1", + "description": "Run the Bomly MCP server with npx. Downloads the matching Bomly CLI release binary and starts `bomly mcp serve`.", + "mcpName": "io.github.bomly-dev/bomly-cli", + "license": "Apache-2.0", + "author": "Ahmed ElMallah ", + "homepage": "https://bomly.dev/cli", + "repository": { + "type": "git", + "url": "git+https://github.com/bomly-dev/bomly-cli.git", + "directory": "npm" + }, + "bugs": { + "url": "https://github.com/bomly-dev/bomly-cli/issues" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "sbom", + "dependencies", + "cyclonedx", + "spdx", + "bomly" + ], + "bin": { + "bomly-mcp": "bin/bomly-mcp.js" + }, + "files": [ + "bin/", + "scripts/", + "README.md" + ], + "engines": { + "node": ">=18" + }, + "os": [ + "darwin", + "linux", + "win32" + ], + "cpu": [ + "x64", + "arm64" + ], + "scripts": { + "postinstall": "node scripts/postinstall.js" + } +} diff --git a/npm/scripts/postinstall.js b/npm/scripts/postinstall.js new file mode 100644 index 00000000..7b6a3214 --- /dev/null +++ b/npm/scripts/postinstall.js @@ -0,0 +1,138 @@ +#!/usr/bin/env node +// Downloads the Bomly CLI release archive that matches this package version, +// verifies it against the release's SHA256SUMS, and unpacks the `bomly` +// binary into vendor/. bin/bomly-mcp.js then execs that binary. +// +// The download is verified or it fails. There is no unverified fallback. + +"use strict"; + +const { createHash } = require("node:crypto"); +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const pkg = require("../package.json"); + +const ROOT = path.join(__dirname, ".."); +const VENDOR_DIR = path.join(ROOT, "vendor"); +const RELEASE_BASE = "https://github.com/bomly-dev/bomly-cli/releases/download"; + +// Same names GoReleaser writes: bomly___.. +const GOOS = { darwin: "darwin", linux: "linux", win32: "windows" }; +const GOARCH = { x64: "amd64", arm64: "arm64" }; + +function fail(message) { + console.error(`bomly-mcp: ${message}`); + process.exit(1); +} + +function target() { + const goos = GOOS[process.platform]; + const goarch = GOARCH[process.arch]; + if (!goos || !goarch) { + fail( + `no Bomly release build for ${process.platform}/${process.arch}. ` + + "Install the CLI another way (https://github.com/bomly-dev/bomly-cli/blob/main/docs/INSTALLATION.md) " + + "and point your MCP client at `bomly mcp serve`.", + ); + } + const ext = goos === "windows" ? "zip" : "tar.gz"; + return { goos, goarch, ext, binary: goos === "windows" ? "bomly.exe" : "bomly" }; +} + +async function download(url) { + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok) { + fail(`GET ${url} returned HTTP ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +function sha256(buffer) { + return createHash("sha256").update(buffer).digest("hex"); +} + +// SHA256SUMS is ` ` per line, as produced by GoReleaser. +function expectedSum(sums, filename) { + for (const line of sums.split("\n")) { + const [hex, name] = line.trim().split(/\s+/); + if (name === filename) { + return hex; + } + } + return null; +} + +function unpack(archivePath, destination, ext) { + if (ext === "zip") { + // PowerShell ships with every supported Windows version; `tar` does too on + // Windows 10 1803+, but Expand-Archive is the safer floor for zip. + execFileSync( + "powershell", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${destination}' -Force`, + ], + { stdio: "inherit" }, + ); + return; + } + execFileSync("tar", ["-xzf", archivePath, "-C", destination], { stdio: "inherit" }); +} + +async function main() { + if (process.env.BOMLY_MCP_SKIP_DOWNLOAD === "1") { + console.error("bomly-mcp: BOMLY_MCP_SKIP_DOWNLOAD=1, skipping binary download."); + return; + } + + const version = process.env.BOMLY_MCP_VERSION || pkg.version; + const { ext, goos, goarch, binary } = target(); + const archiveName = `bomly_${version}_${goos}_${goarch}.${ext}`; + const tag = `v${version}`; + + const sums = (await download(`${RELEASE_BASE}/${tag}/SHA256SUMS`)).toString("utf8"); + const expected = expectedSum(sums, archiveName); + if (!expected) { + fail(`${archiveName} is not listed in the SHA256SUMS for ${tag}`); + } + + const archive = await download(`${RELEASE_BASE}/${tag}/${archiveName}`); + const actual = sha256(archive); + if (actual !== expected) { + fail( + `checksum mismatch for ${archiveName}\n` + + ` expected ${expected}\n` + + ` actual ${actual}\n` + + "Refusing to install. Please report this at https://github.com/bomly-dev/bomly-cli/issues.", + ); + } + + const staging = fs.mkdtempSync(path.join(os.tmpdir(), "bomly-mcp-")); + try { + const archivePath = path.join(staging, archiveName); + fs.writeFileSync(archivePath, archive); + unpack(archivePath, staging, ext); + + const extracted = path.join(staging, binary); + if (!fs.existsSync(extracted)) { + fail(`${archiveName} did not contain ${binary}`); + } + + fs.mkdirSync(VENDOR_DIR, { recursive: true }); + const installed = path.join(VENDOR_DIR, binary); + fs.copyFileSync(extracted, installed); + if (goos !== "windows") { + fs.chmodSync(installed, 0o755); + } + console.error(`bomly-mcp: installed bomly ${version} (${goos}/${goarch}), checksum verified.`); + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + } +} + +main().catch((error) => fail(error && error.message ? error.message : String(error))); diff --git a/server.json b/server.json new file mode 100644 index 00000000..b2c0c75e --- /dev/null +++ b/server.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.bomly-dev/bomly-cli", + "title": "Bomly", + "description": "Give your coding agent the dependency graph it is about to change: scan, diff, explain, audit", + "version": "0.20.1", + "websiteUrl": "https://bomly.dev/cli", + "repository": { + "url": "https://github.com/bomly-dev/bomly-cli", + "source": "github", + "id": "1206422286" + }, + "packages": [ + { + "registryType": "npm", + "identifier": "bomly-mcp", + "version": "0.20.1", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + } + } + ] +} From 04a9b2e2578aff4ccd5072ba01c9a606275d9feb Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Sun, 26 Jul 2026 04:25:55 -0700 Subject: [PATCH 2/4] chore(mcp): add glama.json indexing metadata Glama clones the repo and indexes tools and schemas from it. glama.json controls the display name, description, and keywords it shows, instead of letting it infer them. Co-Authored-By: Claude Opus 5 --- glama.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 glama.json diff --git a/glama.json b/glama.json new file mode 100644 index 00000000..772f4db7 --- /dev/null +++ b/glama.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": [ + "bomly-guy" + ], + "name": "bomly", + "description": "Give your coding agent the dependency graph it is about to change. Scan a source tree, SBOM, Git ref, or container image; explain why a package is present; diff two graphs; check findings against policy. Vulnerability and license lookups are opt-in per call.", + "keywords": [ + "dependencies", + "sbom", + "cyclonedx", + "spdx", + "supply-chain-security", + "vulnerability-scanning", + "sca", + "policy", + "devsecops", + "local-first" + ] +} From 9619fb0bd7b1a9d1d6ec11370768030ae1b3b197 Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Thu, 30 Jul 2026 22:43:54 -0700 Subject: [PATCH 3/4] fix(mcp): harden the npm publish path and cover it with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the npm wrapper, plus the version bump to 0.21.1 (npm/package.json and server.json; the CLI moved while this sat open). Release workflow - Split `mcp-registry` into `publish-npm` and `publish-mcp-registry`. npm versions are immutable, so a failure after a successful publish left the old single job unrerunnable — it died on the duplicate version before ever reaching the registry. The npm job now treats "this exact version is already live" as success, and the registry half can be re-run on its own. The registry job also never sees NPM_TOKEN. - Stop writing the token to ~/.npmrc. A step-scoped `env:` does not scope a file, so it stayed readable by every later step, including the downloaded mcp-publisher binary. It now goes to a job-temp npmrc selected by NPM_CONFIG_USERCONFIG, written with umask 077 and removed by a trap. - Pin mcp-publisher to v1.8.0 and verify it before execution, by pinned SHA-256 and by cosign against the Sigstore bundle, asserting the archive came from the registry's own tagged release workflow. `releases/latest` was a moving target executed inside a job holding an OIDC identity that can publish under our namespace. - persist-credentials: false on both checkouts. npm wrapper - Ship LICENSE and NOTICE in the package (copied at prepack), and keep the release archive's LICENSE, NOTICE, and licenses/ next to the vendored binary. Both were being dropped, which redistributed Bomly and its bundled components with the notices stripped. - Extract zips via a -File script with the paths as real argv. The old -Command string broke on any path holding an apostrophe (C:\Users\O'Brien\...) and let a path influence the command. - Remove our own signal listener before re-raising. Registering it replaced Node's terminate-on-signal default, so re-raising re-entered the handler, the loop drained, and the wrapper exited 0 — reporting a clean shutdown to its supervisor when the server had been killed. Tests - 16 tests under npm/test, run by a new `npm wrapper` CI job: platform mapping incl. unsupported targets, SHA256SUMS parsing (prefix-colliding names, missing entry, empty file), checksum sensitivity, archive extraction, notice propagation, launcher arg pass-through, a clean stdout, exit-code propagation, both signal paths, and the packed tarball's contents. The signal test was checked against the pre-fix code and fails there. - CI also asserts npm/package.json, server.json, and mcpName agree. docs/MCP.md now says what SHA256SUMS is on first use. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 35 ++++++++ .github/workflows/release.yml | 109 ++++++++++++++++++---- .gitignore | 4 + docs/MCP.md | 2 +- npm/bin/bomly-mcp.js | 18 +++- npm/package.json | 10 ++- npm/scripts/postinstall.js | 66 +++++++++++--- npm/scripts/prepack.js | 27 ++++++ npm/test/launcher.test.js | 165 ++++++++++++++++++++++++++++++++++ npm/test/postinstall.test.js | 156 ++++++++++++++++++++++++++++++++ server.json | 4 +- 11 files changed, 561 insertions(+), 35 deletions(-) create mode 100644 npm/scripts/prepack.js create mode 100644 npm/test/launcher.test.js create mode 100644 npm/test/postinstall.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bfb850c..65e307e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,3 +129,38 @@ jobs: with: args: --offline --include-fragments --no-progress 'docs/**/*.md' README.md CONTRIBUTING.md fail: true + + npm-wrapper: + name: npm wrapper + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test the bomly-mcp launcher and installer + # No dependencies to install: the package is dependency-free and the + # tests use node:test. BOMLY_MCP_SKIP_DOWNLOAD keeps CI from pulling a + # ~90 MB release archive it does not need. + env: + BOMLY_MCP_SKIP_DOWNLOAD: "1" + working-directory: npm + run: npm test + - name: Check the packed tarball is installable and self-consistent + working-directory: npm + run: | + set -euo pipefail + npm pack --dry-run + node -e ' + const pkg = require("./package.json"); + const server = require("../server.json"); + if (pkg.version !== server.version) { + throw new Error(`npm/package.json ${pkg.version} != server.json ${server.version}`); + } + if (pkg.version !== server.packages[0].version) { + throw new Error(`npm/package.json ${pkg.version} != server.json package ${server.packages[0].version}`); + } + if (pkg.mcpName !== server.name) { + throw new Error(`mcpName ${pkg.mcpName} != server.json name ${server.name}`); + } + ' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37269cc0..858982a4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -227,32 +227,37 @@ jobs: gh release edit "$RELEASE_TAG" --draft=false --repo bomly-dev/bomly-cli - mcp-registry: - # Publishes the `bomly-mcp` npm wrapper and then the server entry in the - # official MCP Registry (https://registry.modelcontextprotocol.io). + publish-npm: + # Publishes the `bomly-mcp` npm wrapper, which is the package the official + # MCP Registry entry points at. # # Must run after `publish`: the wrapper's postinstall step downloads the - # release archive and its SHA256SUMS from this tag, and draft-release + # release archive and its checksum file from this tag, and draft-release # assets are not publicly downloadable — so publishing npm any earlier # ships a package that cannot install. # + # Split from `publish-mcp-registry` so a failure in either half can be + # re-run on its own. npm versions are immutable, so this job also treats + # "this exact version is already published" as success rather than as an + # error that would block the registry step behind it. + # # Inert until Ahmed turns it on. Enable with: # gh secret set NPM_TOKEN -R bomly-dev/bomly-cli # gh variable set PUBLISH_MCP_REGISTRY -R bomly-dev/bomly-cli --body true - # Registry auth is GitHub OIDC, so it needs no secret of its own; the - # `io.github.bomly-dev/*` namespace is granted from this repo's identity. needs: publish if: startsWith(github.ref, 'refs/tags/') && vars.PUBLISH_MCP_REGISTRY == 'true' runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read - id-token: write # required for mcp-publisher's OIDC login + id-token: write # required for npm publish --provenance steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - name: Stamp the release version into npm/package.json and server.json + - name: Stamp the release version into npm/package.json # The tag is authoritative. Auto Version keeps the committed files in # sync too, but deriving here means a missed bump cannot publish a # wrapper that points at a release that does not exist. @@ -261,24 +266,75 @@ jobs: run: | set -euo pipefail version="${RELEASE_TAG#v}" - jq --arg v "$version" '.version = $v' npm/package.json > npm/package.json.tmp mv npm/package.json.tmp npm/package.json - jq --arg v "$version" '.version = $v | .packages[0].version = $v' server.json > server.json.tmp - mv server.json.tmp server.json - - name: Publish bomly-mcp to npm + # The token is written to a job-temp npmrc selected by + # NPM_CONFIG_USERCONFIG, not to ~/.npmrc. A step-scoped `env:` does not + # scope a file: anything ~/.npmrc would stay readable by every later + # step, including the downloaded mcp-publisher binary. The trap removes + # it even when npm fails. env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + RELEASE_TAG: ${{ github.ref_name }} run: | set -euo pipefail + version="${RELEASE_TAG#v}" + if [ -z "${NPM_TOKEN}" ]; then echo "::error::PUBLISH_MCP_REGISTRY is true but NPM_TOKEN is not set. Run: gh secret set NPM_TOKEN -R bomly-dev/bomly-cli" exit 1 fi - echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc - npm publish --provenance --access public ./npm + + # Idempotent: a re-run after a partial failure must not die here on + # an immutable version that is already live. + if curl -fsSL "https://registry.npmjs.org/bomly-mcp/${version}" > /dev/null 2>&1; then + echo "bomly-mcp@${version} is already published; skipping npm publish." + exit 0 + fi + + npmrc="${RUNNER_TEMP}/.npmrc" + trap 'rm -f "${npmrc}"' EXIT + (umask 077 && printf '//registry.npmjs.org/:_authToken=%s\n' "${NPM_TOKEN}" > "${npmrc}") + + NPM_CONFIG_USERCONFIG="${npmrc}" npm publish --provenance --access public ./npm + + publish-mcp-registry: + # Publishes the server entry in the official MCP Registry + # (https://registry.modelcontextprotocol.io). + # + # Registry auth is GitHub OIDC, so it needs no secret of its own; the + # `io.github.bomly-dev/*` namespace is granted from this repo's identity. + # This job never sees NPM_TOKEN — that is why it is a separate job. + needs: publish-npm + if: startsWith(github.ref, 'refs/tags/') && vars.PUBLISH_MCP_REGISTRY == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write # required for mcp-publisher's OIDC login + env: + # Pinned deliberately. `releases/latest` would download and execute a + # moving target inside a job that holds an OIDC identity able to publish + # under our registry namespace. Bump this together with the digest and + # the signer identity below, after reading the release diff. + MCP_PUBLISHER_VERSION: v1.8.0 + MCP_PUBLISHER_SHA256: 1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Stamp the release version into server.json + env: + RELEASE_TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + jq --arg v "$version" '.version = $v | .packages[0].version = $v' server.json > server.json.tmp + mv server.json.tmp server.json - name: Wait for the npm package to be resolvable # The registry verifies package ownership by fetching the package, so @@ -300,11 +356,30 @@ jobs: echo "::error::bomly-mcp@${version} did not become resolvable on npm within 5 minutes" exit 1 - - name: Install mcp-publisher + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Download and verify mcp-publisher + # Two independent checks before anything is extracted or executed: + # the pinned SHA-256, and the Sigstore bundle proving the archive was + # built by the registry's own tagged release workflow. run: | set -euo pipefail - curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" \ - | tar xz mcp-publisher + + base="https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}" + archive="mcp-publisher_linux_amd64.tar.gz" + + curl -fsSL -o "${archive}" "${base}/${archive}" + curl -fsSL -o "${archive}.sigstore.json" "${base}/${archive}.sigstore.json" + + echo "${MCP_PUBLISHER_SHA256} ${archive}" | sha256sum --check --strict + + cosign verify-blob "${archive}" \ + --bundle "${archive}.sigstore.json" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + --certificate-identity "https://github.com/modelcontextprotocol/registry/.github/workflows/release.yml@refs/tags/${MCP_PUBLISHER_VERSION}" + + tar xzf "${archive}" mcp-publisher - name: Publish the server entry to the MCP Registry run: | diff --git a/.gitignore b/.gitignore index 8680eaf4..8d7659f4 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,7 @@ qodana.yaml /npm/vendor /npm/node_modules /npm/*.tgz + +# Copied from the repo root by npm/scripts/prepack.js at pack time +/npm/LICENSE +/npm/NOTICE diff --git a/docs/MCP.md b/docs/MCP.md index f4f94dda..6cb6bea5 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -35,7 +35,7 @@ If you would rather not install Bomly separately, the [`bomly-mcp`](https://www. npx -y bomly-mcp ``` -Its install step downloads the release archive for your platform from GitHub Releases, checks it against that release's `SHA256SUMS`, and unpacks the binary inside the package. A checksum mismatch fails the install. Set `BOMLY_MCP_VERSION` to pin a different CLI version. +Its install step downloads the release archive for your platform from GitHub Releases and checks it against `SHA256SUMS`, the file published with each release that lists the SHA-256 checksum of every archive in it. If the archive's checksum does not match the one listed there, the install fails and nothing is unpacked. Otherwise the binary is unpacked inside the package, together with the `LICENSE`, `NOTICE`, and `licenses/` files the archive carries. Set `BOMLY_MCP_VERSION` to pin a different CLI version. Anywhere below that a config uses `"command": "bomly"` with `"args": ["mcp", "serve"]`, you can use `"command": "npx"` with `"args": ["-y", "bomly-mcp"]` instead. If `bomly` is already on `PATH`, prefer it: the server starts faster and you update it with your package manager. diff --git a/npm/bin/bomly-mcp.js b/npm/bin/bomly-mcp.js index 57e824a3..995b1fd8 100644 --- a/npm/bin/bomly-mcp.js +++ b/npm/bin/bomly-mcp.js @@ -35,14 +35,28 @@ child.on("error", (error) => { // Forward the signals an MCP client uses to stop a stdio server, so the child // shuts down with us rather than being orphaned. +const forwarders = new Map(); for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { - process.on(signal, () => child.kill(signal)); + const forward = () => child.kill(signal); + forwarders.set(signal, forward); + process.on(signal, forward); } child.on("exit", (code, signal) => { if (signal) { + // Adding a listener above replaced Node's default terminate-on-signal + // behavior. Re-raising while it is still attached only re-enters our own + // handler; the event loop then drains and we exit 0 — telling whatever + // supervises us that the server shut down cleanly when it was actually + // killed. Removing the listener first lets the signal reach the default + // handler, so we die of the same cause the child did. process.exit is the + // backstop for platforms where re-raising does not terminate us. + const forward = forwarders.get(signal); + if (forward) { + process.removeListener(signal, forward); + } process.kill(process.pid, signal); - return; + process.exit(1); } process.exit(code === null ? 1 : code); }); diff --git a/npm/package.json b/npm/package.json index 21981138..eadbbb97 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "bomly-mcp", - "version": "0.20.1", + "version": "0.21.1", "description": "Run the Bomly MCP server with npx. Downloads the matching Bomly CLI release binary and starts `bomly mcp serve`.", "mcpName": "io.github.bomly-dev/bomly-cli", "license": "Apache-2.0", @@ -29,7 +29,9 @@ "files": [ "bin/", "scripts/", - "README.md" + "README.md", + "LICENSE", + "NOTICE" ], "engines": { "node": ">=18" @@ -44,6 +46,8 @@ "arm64" ], "scripts": { - "postinstall": "node scripts/postinstall.js" + "prepack": "node scripts/prepack.js", + "postinstall": "node scripts/postinstall.js", + "test": "node --test \"test/*.test.js\"" } } diff --git a/npm/scripts/postinstall.js b/npm/scripts/postinstall.js index 7b6a3214..21bbd2cc 100644 --- a/npm/scripts/postinstall.js +++ b/npm/scripts/postinstall.js @@ -28,12 +28,14 @@ function fail(message) { process.exit(1); } -function target() { - const goos = GOOS[process.platform]; - const goarch = GOARCH[process.arch]; +// Throws rather than exiting so it stays testable; main() turns the throw +// into the user-facing message. +function resolveTarget(platform, arch) { + const goos = GOOS[platform]; + const goarch = GOARCH[arch]; if (!goos || !goarch) { - fail( - `no Bomly release build for ${process.platform}/${process.arch}. ` + + throw new Error( + `no Bomly release build for ${platform}/${arch}. ` + "Install the CLI another way (https://github.com/bomly-dev/bomly-cli/blob/main/docs/INSTALLATION.md) " + "and point your MCP client at `bomly mcp serve`.", ); @@ -42,6 +44,11 @@ function target() { return { goos, goarch, ext, binary: goos === "windows" ? "bomly.exe" : "bomly" }; } +// Same names GoReleaser writes. +function archiveNameFor(version, goos, goarch, ext) { + return `bomly_${version}_${goos}_${goarch}.${ext}`; +} + async function download(url) { const response = await fetch(url, { redirect: "follow" }); if (!response.ok) { @@ -65,17 +72,37 @@ function expectedSum(sums, filename) { return null; } +// Written to disk and invoked with -File so the paths arrive as real argv +// entries. Interpolating them into a -Command string breaks on any path +// holding an apostrophe (C:\Users\O'Brien\...) and would let a path influence +// the command itself. +const EXPAND_PS1 = `param( + [Parameter(Mandatory=$true)][string]$Archive, + [Parameter(Mandatory=$true)][string]$Destination +) +$ErrorActionPreference = 'Stop' +Expand-Archive -LiteralPath $Archive -DestinationPath $Destination -Force +`; + function unpack(archivePath, destination, ext) { if (ext === "zip") { // PowerShell ships with every supported Windows version; `tar` does too on // Windows 10 1803+, but Expand-Archive is the safer floor for zip. + const scriptPath = path.join(destination, "expand.ps1"); + fs.writeFileSync(scriptPath, EXPAND_PS1); execFileSync( "powershell", [ "-NoProfile", "-NonInteractive", - "-Command", - `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${destination}' -Force`, + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + "-Archive", + archivePath, + "-Destination", + destination, ], { stdio: "inherit" }, ); @@ -84,6 +111,18 @@ function unpack(archivePath, destination, ext) { execFileSync("tar", ["-xzf", archivePath, "-C", destination], { stdio: "inherit" }); } +// The release archive carries LICENSE, NOTICE, and a licenses/ tree of +// third-party license texts. Vendoring the binary without them would +// redistribute Bomly and its bundled components with the notices stripped. +function copyNotices(staging, destination) { + for (const entry of ["LICENSE", "NOTICE", "licenses"]) { + const from = path.join(staging, entry); + if (fs.existsSync(from)) { + fs.cpSync(from, path.join(destination, entry), { recursive: true }); + } + } +} + async function main() { if (process.env.BOMLY_MCP_SKIP_DOWNLOAD === "1") { console.error("bomly-mcp: BOMLY_MCP_SKIP_DOWNLOAD=1, skipping binary download."); @@ -91,8 +130,8 @@ async function main() { } const version = process.env.BOMLY_MCP_VERSION || pkg.version; - const { ext, goos, goarch, binary } = target(); - const archiveName = `bomly_${version}_${goos}_${goarch}.${ext}`; + const { ext, goos, goarch, binary } = resolveTarget(process.platform, process.arch); + const archiveName = archiveNameFor(version, goos, goarch, ext); const tag = `v${version}`; const sums = (await download(`${RELEASE_BASE}/${tag}/SHA256SUMS`)).toString("utf8"); @@ -129,10 +168,17 @@ async function main() { if (goos !== "windows") { fs.chmodSync(installed, 0o755); } + copyNotices(staging, VENDOR_DIR); console.error(`bomly-mcp: installed bomly ${version} (${goos}/${goarch}), checksum verified.`); } finally { fs.rmSync(staging, { recursive: true, force: true }); } } -main().catch((error) => fail(error && error.message ? error.message : String(error))); +if (require.main === module) { + main().catch((error) => fail(error && error.message ? error.message : String(error))); +} + +// Exported for test/. Everything here is pure or filesystem-local; the +// network path is exercised end to end by installing a packed tarball. +module.exports = { resolveTarget, archiveNameFor, expectedSum, sha256, unpack, copyNotices }; diff --git a/npm/scripts/prepack.js b/npm/scripts/prepack.js new file mode 100644 index 00000000..24cd7115 --- /dev/null +++ b/npm/scripts/prepack.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Copies the repository LICENSE and NOTICE into the package before it is +// packed, so the published tarball carries them. +// +// They are copied rather than committed here because a second copy in git +// drifts from the originals. `files` in package.json lists them, and +// .gitignore ignores them inside npm/. + +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +const PACKAGE_DIR = path.join(__dirname, ".."); +const REPO_ROOT = path.join(PACKAGE_DIR, ".."); + +for (const name of ["LICENSE", "NOTICE"]) { + const from = path.join(REPO_ROOT, name); + if (!fs.existsSync(from)) { + console.error( + `bomly-mcp: ${name} is missing at the repository root. ` + + "The package must not be published without it.", + ); + process.exit(1); + } + fs.copyFileSync(from, path.join(PACKAGE_DIR, name)); +} diff --git a/npm/test/launcher.test.js b/npm/test/launcher.test.js new file mode 100644 index 00000000..bd558cc9 --- /dev/null +++ b/npm/test/launcher.test.js @@ -0,0 +1,165 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { test } = require("node:test"); + +const PACKAGE_DIR = path.join(__dirname, ".."); +const LAUNCHER = path.join(PACKAGE_DIR, "bin", "bomly-mcp.js"); +const VENDOR_DIR = path.join(PACKAGE_DIR, "vendor"); + +// The launcher execs vendor/bomly. These tests put a shell shim there so they +// can drive shutdown and pass-through behavior without a 90 MB download. A +// real vendored binary (left by a local postinstall) is moved aside and put +// back, so running the suite never destroys someone's working install. +async function withFakeBinary(script, run) { + const binary = path.join(VENDOR_DIR, "bomly"); + const backup = path.join(VENDOR_DIR, "bomly.test-backup"); + const hadReal = fs.existsSync(binary); + + fs.mkdirSync(VENDOR_DIR, { recursive: true }); + if (hadReal) { + fs.renameSync(binary, backup); + } + fs.writeFileSync(binary, script); + fs.chmodSync(binary, 0o755); + + try { + return await run(binary); + } finally { + fs.rmSync(binary, { force: true }); + if (hadReal) { + fs.renameSync(backup, binary); + } + } +} + +// Same idea for the tests that need vendor/bomly to be absent. +async function withoutBinary(run) { + const binary = path.join(VENDOR_DIR, "bomly"); + const backup = path.join(VENDOR_DIR, "bomly.test-backup"); + const hadReal = fs.existsSync(binary); + + if (hadReal) { + fs.renameSync(binary, backup); + } + try { + return await run(); + } finally { + if (hadReal) { + fs.renameSync(backup, binary); + } + } +} + +function runLauncher(args = [], options = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [LAUNCHER, ...args], { + stdio: ["pipe", "pipe", "pipe"], + ...options, + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => (stdout += chunk)); + child.stderr.on("data", (chunk) => (stderr += chunk)); + child.on("exit", (code, signal) => resolve({ code, signal, stdout, stderr, child })); + if (options.onSpawn) { + options.onSpawn(child); + } + }); +} + +test("a missing vendored binary fails with install guidance, not a stack trace", async () => { + await withoutBinary(async () => { + const result = await runLauncher(); + + assert.equal(result.code, 1); + assert.match(result.stderr, /Bomly binary is missing/); + assert.match(result.stderr, /npm install bomly-mcp/); + // Nothing may reach stdout — an MCP client parses it as JSON-RPC. + assert.equal(result.stdout, ""); + }); +}); + +test("arguments pass through to the child after `mcp serve`", async () => { + await withFakeBinary('#!/bin/sh\necho "$@" >&2\n', async () => { + const result = await runLauncher(["--verbose"]); + assert.equal(result.stderr.trim(), "mcp serve --verbose"); + }); +}); + +test("stdout stays clean so the JSON-RPC stream is not corrupted", async () => { + await withFakeBinary('#!/bin/sh\necho \'{"jsonrpc":"2.0"}\'\necho banner >&2\n', async () => { + const result = await runLauncher(); + assert.equal(result.stdout.trim(), '{"jsonrpc":"2.0"}'); + assert.match(result.stderr, /banner/); + }); +}); + +test("the child's exit code becomes the wrapper's exit code", async () => { + await withFakeBinary("#!/bin/sh\nexit 3\n", async () => { + const result = await runLauncher(); + assert.equal(result.code, 3); + }); +}); + +test("a child killed by a signal kills the wrapper with the same signal", async () => { + // The shim deliberately does not trap TERM, so it dies *by* the signal and + // the wrapper takes its signal branch. + await withFakeBinary("#!/bin/sh\nwhile true; do sleep 0.05; done\n", async () => { + const result = await runLauncher([], { + onSpawn: (child) => setTimeout(() => child.kill("SIGTERM"), 300), + }); + + // This is what the removeListener fix buys. With our own listener still + // attached, re-raising just re-entered the handler, the event loop then + // drained, and the wrapper exited 0 — reporting a clean shutdown to + // whatever supervises it when it had actually been terminated. + assert.equal(result.signal, "SIGTERM"); + assert.equal(result.code, null); + }); +}); + +test("a child that exits cleanly on a signal is still a clean exit", async () => { + await withFakeBinary('#!/bin/sh\ntrap "exit 0" TERM\nwhile true; do sleep 0.05; done\n', async () => { + const result = await runLauncher([], { + onSpawn: (child) => setTimeout(() => child.kill("SIGTERM"), 300), + }); + + assert.equal(result.code, 0); + assert.equal(result.signal, null); + }); +}); + +test("the packed tarball carries the launcher, installer, and notices", async () => { + const { execFileSync } = require("node:child_process"); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bomly-mcp-pack-")); + try { + const json = execFileSync("npm", ["pack", "--dry-run", "--json", PACKAGE_DIR], { + cwd: dir, + encoding: "utf8", + env: { ...process.env, npm_config_loglevel: "error" }, + }); + const files = JSON.parse(json)[0].files.map((f) => f.path); + + for (const required of [ + "package.json", + "README.md", + "LICENSE", + "NOTICE", + "bin/bomly-mcp.js", + "scripts/postinstall.js", + ]) { + assert.ok(files.includes(required), `packed tarball is missing ${required}`); + } + + // The binary is fetched at install time; shipping one would defeat the + // per-platform checksum check. + assert.ok(!files.some((f) => f.startsWith("vendor/")), "vendor/ must not be packed"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/npm/test/postinstall.test.js b/npm/test/postinstall.test.js new file mode 100644 index 00000000..db2db016 --- /dev/null +++ b/npm/test/postinstall.test.js @@ -0,0 +1,156 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { execFileSync } = require("node:child_process"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { test } = require("node:test"); + +const { + resolveTarget, + archiveNameFor, + expectedSum, + sha256, + unpack, + copyNotices, +} = require("../scripts/postinstall.js"); + +function scratch() { + return fs.mkdtempSync(path.join(os.tmpdir(), "bomly-mcp-test-")); +} + +test("platform mapping covers every published release build", () => { + const cases = [ + ["darwin", "arm64", "darwin", "arm64", "tar.gz", "bomly"], + ["darwin", "x64", "darwin", "amd64", "tar.gz", "bomly"], + ["linux", "arm64", "linux", "arm64", "tar.gz", "bomly"], + ["linux", "x64", "linux", "amd64", "tar.gz", "bomly"], + ["win32", "arm64", "windows", "arm64", "zip", "bomly.exe"], + ["win32", "x64", "windows", "amd64", "zip", "bomly.exe"], + ]; + + for (const [platform, arch, goos, goarch, ext, binary] of cases) { + assert.deepEqual(resolveTarget(platform, arch), { goos, goarch, ext, binary }); + } +}); + +test("unsupported platforms throw with a usable next step", () => { + for (const [platform, arch] of [ + ["freebsd", "x64"], + ["linux", "ia32"], + ["linux", "riscv64"], + ]) { + assert.throws(() => resolveTarget(platform, arch), (error) => { + assert.match(error.message, /no Bomly release build/); + assert.match(error.message, /INSTALLATION\.md/); + return true; + }); + } +}); + +test("archive names match what GoReleaser publishes", () => { + assert.equal( + archiveNameFor("0.21.1", "darwin", "arm64", "tar.gz"), + "bomly_0.21.1_darwin_arm64.tar.gz", + ); + assert.equal( + archiveNameFor("0.21.1", "windows", "amd64", "zip"), + "bomly_0.21.1_windows_amd64.zip", + ); +}); + +test("checksum lookup picks the exact filename out of SHA256SUMS", () => { + // Real shape: two spaces, and names that are prefixes of one another. + const sums = [ + "aaaa bomly-lite_0.21.1_linux_amd64.tar.gz", + "bbbb bomly_0.21.1_linux_amd64.tar.gz", + "cccc bomly_0.21.1_linux_arm64.tar.gz", + "", + ].join("\n"); + + assert.equal(expectedSum(sums, "bomly_0.21.1_linux_amd64.tar.gz"), "bbbb"); + assert.equal(expectedSum(sums, "bomly-lite_0.21.1_linux_amd64.tar.gz"), "aaaa"); +}); + +test("checksum lookup returns null when the archive is absent", () => { + const sums = "aaaa bomly_0.21.1_linux_amd64.tar.gz\n"; + + // A missing entry must not silently pass as a match — main() treats null as + // fatal, which is what stops an unverified archive from ever being unpacked. + assert.equal(expectedSum(sums, "bomly_0.21.1_darwin_arm64.tar.gz"), null); + assert.equal(expectedSum("", "bomly_0.21.1_linux_amd64.tar.gz"), null); +}); + +test("sha256 detects a single flipped byte", () => { + const good = Buffer.from("bomly release archive"); + const tampered = Buffer.from("bomly release archivf"); + + assert.equal(sha256(good), crypto.createHash("sha256").update(good).digest("hex")); + assert.notEqual(sha256(good), sha256(tampered)); +}); + +test("unpack extracts the binary from a tar.gz the way the release ships it", { skip: process.platform === "win32" }, () => { + const dir = scratch(); + try { + const source = path.join(dir, "src"); + fs.mkdirSync(path.join(source, "licenses"), { recursive: true }); + fs.writeFileSync(path.join(source, "bomly"), "#!/bin/sh\necho bomly\n"); + fs.writeFileSync(path.join(source, "LICENSE"), "Apache-2.0\n"); + fs.writeFileSync(path.join(source, "NOTICE"), "notice\n"); + fs.writeFileSync(path.join(source, "licenses", "syft.txt"), "syft license\n"); + + const archive = path.join(dir, "bomly_0.21.1_linux_amd64.tar.gz"); + execFileSync("tar", ["-czf", archive, "-C", source, "."]); + + const out = path.join(dir, "out"); + fs.mkdirSync(out); + unpack(archive, out, "tar.gz"); + + assert.ok(fs.existsSync(path.join(out, "bomly"))); + assert.ok(fs.existsSync(path.join(out, "LICENSE"))); + assert.ok(fs.existsSync(path.join(out, "licenses", "syft.txt"))); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("license and notice files travel with the vendored binary", () => { + const dir = scratch(); + try { + const staging = path.join(dir, "staging"); + const vendor = path.join(dir, "vendor"); + fs.mkdirSync(path.join(staging, "licenses"), { recursive: true }); + fs.mkdirSync(vendor); + fs.writeFileSync(path.join(staging, "LICENSE"), "Apache-2.0\n"); + fs.writeFileSync(path.join(staging, "NOTICE"), "notice\n"); + fs.writeFileSync(path.join(staging, "licenses", "grype.txt"), "grype license\n"); + + copyNotices(staging, vendor); + + assert.equal(fs.readFileSync(path.join(vendor, "LICENSE"), "utf8"), "Apache-2.0\n"); + assert.equal(fs.readFileSync(path.join(vendor, "NOTICE"), "utf8"), "notice\n"); + assert.equal( + fs.readFileSync(path.join(vendor, "licenses", "grype.txt"), "utf8"), + "grype license\n", + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("copyNotices tolerates an archive that omits them", () => { + const dir = scratch(); + try { + const staging = path.join(dir, "staging"); + const vendor = path.join(dir, "vendor"); + fs.mkdirSync(staging); + fs.mkdirSync(vendor); + + assert.doesNotThrow(() => copyNotices(staging, vendor)); + assert.deepEqual(fs.readdirSync(vendor), []); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/server.json b/server.json index b2c0c75e..aec883ed 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.bomly-dev/bomly-cli", "title": "Bomly", "description": "Give your coding agent the dependency graph it is about to change: scan, diff, explain, audit", - "version": "0.20.1", + "version": "0.21.1", "websiteUrl": "https://bomly.dev/cli", "repository": { "url": "https://github.com/bomly-dev/bomly-cli", @@ -14,7 +14,7 @@ { "registryType": "npm", "identifier": "bomly-mcp", - "version": "0.20.1", + "version": "0.21.1", "runtimeHint": "npx", "transport": { "type": "stdio" From 5799865ee81004d63cf6811ee4d1d4584c6e211b Mon Sep 17 00:00:00 2001 From: Ahmed ElMallah Date: Thu, 30 Jul 2026 23:11:53 -0700 Subject: [PATCH 4/4] feat(mcp): publish to npm via trusted publishing, not a token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm's token screen warns against long-lived automation tokens, and it is right: the previous design put one in repo secrets and then spent real effort keeping it away from the rest of the release job. Trusted publishing removes the credential instead of containing it — npm exchanges the workflow's OIDC identity for publish rights, so there is no secret to scope, rotate, or leak. - Drops NPM_TOKEN entirely. `id-token: write` was already there for provenance and now does double duty. - Installs npm@^11.5.1: trusted publishing needs it and the runner image bundles an older npm with its Node. - Keeps --provenance explicit. Trusted publishing is documented to imply it, but the implicit path has been reported to no-op. - Publishes from `working-directory: npm` rather than `npm publish ./npm` so npm resolves publishConfig and the repository field unambiguously. Trusted publishing cannot bootstrap a package that does not exist yet — it is configured per package on npmjs.com. That first publish is a one-off manual `npm publish` from a maintainer's machine, which is also why no automation token needs to exist at any point. Documents the whole path in dev-docs/CI.md: the one-time setup, the npm version floor, why there are two jobs, and why mcp-publisher is pinned and signature-verified. The job-split comment claimed token isolation as a reason; with no token left, only the retryability argument stands, and it now says so. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 55 ++++++++++++++++++++++------------- dev-docs/CI.md | 35 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 858982a4..6c770df0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -231,6 +231,10 @@ jobs: # Publishes the `bomly-mcp` npm wrapper, which is the package the official # MCP Registry entry points at. # + # Authentication is npm Trusted Publishing (OIDC) — there is deliberately + # no npm token here, and none should ever be added. npm's own token screen + # warns against long-lived automation tokens for exactly this case. + # # Must run after `publish`: the wrapper's postinstall step downloads the # release archive and its checksum file from this tag, and draft-release # assets are not publicly downloadable — so publishing npm any earlier @@ -241,22 +245,35 @@ jobs: # "this exact version is already published" as success rather than as an # error that would block the registry step behind it. # - # Inert until Ahmed turns it on. Enable with: - # gh secret set NPM_TOKEN -R bomly-dev/bomly-cli - # gh variable set PUBLISH_MCP_REGISTRY -R bomly-dev/bomly-cli --body true + # Inert until Ahmed turns it on. Setup is one-time and documented in + # dev-docs/CI.md; the short version: + # 1. publish bomly-mcp once by hand (trusted publishing cannot create a + # package that does not exist yet) + # 2. set the trusted publisher at + # npmjs.com/package/bomly-mcp/access -> this repo, workflow release.yml + # 3. gh variable set PUBLISH_MCP_REGISTRY -R bomly-dev/bomly-cli --body true needs: publish if: startsWith(github.ref, 'refs/tags/') && vars.PUBLISH_MCP_REGISTRY == 'true' runs-on: ubuntu-latest timeout-minutes: 15 permissions: contents: read - id-token: write # required for npm publish --provenance + id-token: write # mints the OIDC token npm exchanges for publish rights steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Upgrade npm for trusted publishing + # Trusted publishing needs npm >= 11.5.1. The runner image bundles an + # older npm with its Node, so pin an explicit floor rather than + # inheriting whatever the image happens to ship this month. + run: | + set -euo pipefail + npm install -g npm@^11.5.1 + npm --version + - name: Stamp the release version into npm/package.json # The tag is authoritative. Auto Version keeps the committed files in # sync too, but deriving here means a missed bump cannot publish a @@ -270,23 +287,17 @@ jobs: mv npm/package.json.tmp npm/package.json - name: Publish bomly-mcp to npm - # The token is written to a job-temp npmrc selected by - # NPM_CONFIG_USERCONFIG, not to ~/.npmrc. A step-scoped `env:` does not - # scope a file: anything ~/.npmrc would stay readable by every later - # step, including the downloaded mcp-publisher binary. The trap removes - # it even when npm fails. + # No `env:` and no npmrc: npm reads the OIDC token from the Actions + # runtime itself. --provenance is passed explicitly even though trusted + # publishing is documented to imply it, because the implicit path has + # been reported to no-op. env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} RELEASE_TAG: ${{ github.ref_name }} + working-directory: npm run: | set -euo pipefail version="${RELEASE_TAG#v}" - if [ -z "${NPM_TOKEN}" ]; then - echo "::error::PUBLISH_MCP_REGISTRY is true but NPM_TOKEN is not set. Run: gh secret set NPM_TOKEN -R bomly-dev/bomly-cli" - exit 1 - fi - # Idempotent: a re-run after a partial failure must not die here on # an immutable version that is already live. if curl -fsSL "https://registry.npmjs.org/bomly-mcp/${version}" > /dev/null 2>&1; then @@ -294,11 +305,7 @@ jobs: exit 0 fi - npmrc="${RUNNER_TEMP}/.npmrc" - trap 'rm -f "${npmrc}"' EXIT - (umask 077 && printf '//registry.npmjs.org/:_authToken=%s\n' "${NPM_TOKEN}" > "${npmrc}") - - NPM_CONFIG_USERCONFIG="${npmrc}" npm publish --provenance --access public ./npm + npm publish --provenance --access public publish-mcp-registry: # Publishes the server entry in the official MCP Registry @@ -306,7 +313,13 @@ jobs: # # Registry auth is GitHub OIDC, so it needs no secret of its own; the # `io.github.bomly-dev/*` namespace is granted from this repo's identity. - # This job never sees NPM_TOKEN — that is why it is a separate job. + # + # Kept separate from publish-npm so either half can be re-run alone: npm + # versions are immutable, so a combined job that failed here could not be + # retried without tripping over the already-published version. (It was also + # the boundary that kept an npm token away from the downloaded + # mcp-publisher binary; with trusted publishing there is no token left to + # isolate, but the retry argument stands on its own.) needs: publish-npm if: startsWith(github.ref, 'refs/tags/') && vars.PUBLISH_MCP_REGISTRY == 'true' runs-on: ubuntu-latest diff --git a/dev-docs/CI.md b/dev-docs/CI.md index 83ee0771..5752933d 100644 --- a/dev-docs/CI.md +++ b/dev-docs/CI.md @@ -224,11 +224,46 @@ Release packaging is driven by `.goreleaser.yaml`. The release workflow uses GoR 8. The `provenance` job calls `slsa-github-generator` to generate SLSA provenance (`multiple.intoto.jsonl`) as a workflow artifact. It does **not** upload it to the release itself (`upload-assets: false`) — see the caveat below. 9. The `publish` job downloads that provenance artifact, looks up the draft release **by ID** (listing releases and filtering for the matching tag with `draft == true`, not by tag name), uploads the provenance file to it directly via the GitHub REST API, then flips the release from draft to published, using the configured GoReleaser header plus GitHub-native generated release notes. 10. After the release is published, the `Release lifecycle sync` workflow dispatches the landing-page docs and changelog sync with the published timestamp. +11. If the `PUBLISH_MCP_REGISTRY` variable is set, `publish-npm` publishes the `bomly-mcp` wrapper and `publish-mcp-registry` publishes the server entry to the official MCP Registry. See [MCP Registry Publishing](#mcp-registry-publishing). The manual approval point for a release is the `Auto Version` workflow that creates the release tag. The GitHub Release stays a draft until every asset — including SLSA provenance — is attached, then a final job publishes it. This is required by GitHub's [immutable releases](https://docs.github.com/en/code-security/concepts/supply-chain-security/immutable-releases) feature: once a release is published, no further assets can be added by anyone, so the provenance file (generated by a separate downstream job, by design — see [Supply-Chain Hardening](#supply-chain-hardening-openssf-scorecard)) must land before publish, not after. **Why the `provenance` job can't upload directly:** GitHub's "get release by tag" API does not return draft releases — they aren't associated with a tag ref until published. The SLSA generator's built-in `upload-assets` option uses `softprops/action-gh-release`, which resolves the target release by tag; against our draft, it finds nothing and **creates a second, non-draft release for the same tag** instead of failing cleanly. That second release immediately and permanently marks the tag as immutable, even if you delete the bad release afterward — there is no way to free the tag back up. (This happened once in production; recovering meant abandoning the tag and cutting a new one.) The `publish` job avoids the whole failure mode by resolving the release by ID itself and never giving any tool a chance to "helpfully" create a duplicate. +## MCP Registry Publishing + +Two jobs at the end of `release.yml` publish the `bomly-mcp` npm wrapper and the server entry in the [official MCP Registry](https://registry.modelcontextprotocol.io). Both are gated on the `PUBLISH_MCP_REGISTRY` repository variable and do nothing until it is set. + +**There is no npm token, and none should ever be added.** `publish-npm` authenticates with [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers), exchanging the workflow's OIDC identity for publish rights. `publish-mcp-registry` authenticates to the registry the same way, via `mcp-publisher login github-oidc`. Neither needs a secret. + +### One-time setup + +1. **Publish `bomly-mcp` once by hand.** Trusted publishing is configured per package on npmjs.com, so the package has to exist first — it cannot bootstrap itself. From a checkout at the tag of an already-published release: + + ```bash + cd npm && npm publish --access public + ``` + + Do this from a maintainer's machine with `npm login`, not from CI. A one-off interactive publish avoids creating an automation token that would then have to be stored and revoked. + + The version must match a GitHub release that is already published: the package's postinstall step downloads that release's archive, so publishing ahead of it ships a package that cannot install. + +2. **Set the trusted publisher** at `https://www.npmjs.com/package/bomly-mcp/access` → repository `bomly-dev/bomly-cli`, workflow `release.yml`. Configure it on the *package* access page; the account-level packages page does not have this setting. + +3. **Turn the jobs on:** + + ```bash + gh variable set PUBLISH_MCP_REGISTRY -R bomly-dev/bomly-cli --body true + ``` + +### Notes + +- **npm version floor.** Trusted publishing needs npm >= 11.5.1, which is newer than the runner image's bundled npm. The job installs `npm@^11.5.1` explicitly rather than inheriting whatever the image ships. +- **`--provenance` is passed explicitly** even though trusted publishing is documented to imply it, because the implicit path has been reported to silently no-op. +- **Two jobs, not one.** npm versions are immutable, so a single job that failed after publishing to npm could not be re-run — it would die on the duplicate version before reaching the registry. `publish-npm` also treats an already-published exact version as success, so a re-run walks through it. +- **`mcp-publisher` is pinned and verified.** The version, its SHA-256, and the expected Sigstore signer identity are `env:` values at the top of `publish-mcp-registry`. The binary is checked against both the pinned digest and `cosign verify-blob` before extraction, because it runs inside a job holding an OIDC identity that can publish under the `io.github.bomly-dev/*` namespace. Bump all three together, after reading the upstream release diff. +- **Version consistency** between `npm/package.json`, `server.json`, and `cmd/bomly/main.go` is maintained by `Auto Version` and asserted by the `npm wrapper` job in CI. The release jobs also re-derive the version from the tag, so a missed bump cannot publish a wrapper pointing at a release that does not exist. + ## Yanking Releases Deleting or unpublishing a GitHub Release automatically starts the yanking path in the `Release lifecycle sync` workflow. The workflow dispatches a landing-page removal event so the yanked version is removed from the version selector and changelog.