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
70 changes: 70 additions & 0 deletions .github/actions/mcpm-verify/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# `mcpm verify` GitHub Action

A fail-closed CI gate that verifies your committed `mcpm-lock.yaml` against npm's
**published** `dist.integrity` record. It runs `mcpm verify` — repo-only, no AI
clients required — so it works on a hosted runner where `mcpm up` cannot.

The step fails (non-zero) on integrity **drift**, an **unverifiable** record, an
integrity **format mismatch**, or a **suspicious missing baseline**, and writes a
job **step summary** from the `--json` model.

> Honesty boundary: a failure means npm's *published record* diverged from (or
> can't be matched against) your lock — **not** that mcpm caught malicious bytes.
> npx/uvx fetch the artifact independently at server launch.

## Usage

Pin the action to a release SHA (or tag):

```yaml
name: mcpm
on: [push, pull_request]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: getmcpm/cli/.github/actions/mcpm-verify@v0.19.0
# with:
# version: latest # @getmcpm/cli version/dist-tag to run
# working-directory: . # dir containing mcpm.yaml / mcpm-lock.yaml
```

Equivalent one-liner (no action):

```yaml
- run: npx --yes @getmcpm/cli verify
```

Pre-commit hook (same verb):

```yaml
# .pre-commit-config.yaml
- repo: local
hooks:
- id: mcpm-verify
name: mcpm verify
entry: npx --yes @getmcpm/cli verify
language: system
pass_filenames: false
```

## Badge

Once the gate is in your CI, advertise it with a static badge:

```markdown
![mcpm verified](https://img.shields.io/badge/mcpm-verified-brightgreen)
```

## Inputs

| Input | Default | Description |
|---|---|---|
| `version` | `latest` | Version or dist-tag of `@getmcpm/cli` to run. |
| `working-directory` | `.` | Directory containing `mcpm.yaml` / `mcpm-lock.yaml`. |

## Exit codes

`0` verified · `1` block (integrity drift / unverifiable / format mismatch /
missing baseline) or no lock file found. See `docs/CONTRACTS.md`.
53 changes: 53 additions & 0 deletions .github/actions/mcpm-verify/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: 'mcpm verify'
description: "Fail-closed lockfile integrity gate — verify mcpm-lock.yaml against npm's published record."
branding:
icon: 'shield'
color: 'green'

inputs:
version:
description: 'Version or dist-tag of @getmcpm/cli to run.'
required: false
default: 'latest'
working-directory:
description: 'Directory containing mcpm.yaml / mcpm-lock.yaml.'
required: false
default: '.'

runs:
using: 'composite'
steps:
- name: Run mcpm verify
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
MCPM_CLI_VERSION: ${{ inputs.version }}
run: |
# Capture the JSON model AND the exit code. `verify` exits non-zero on an
# integrity block, and that becomes this step's (and the job's) exit code.
set +e
out="$(npx --yes "@getmcpm/cli@${MCPM_CLI_VERSION}" verify --json)"
code=$?
set -e
echo "$out"

# Render a job step summary from --json (node is preinstalled on GH runners).
printf '%s' "$out" | node -e '
let d; try { d = JSON.parse(require("fs").readFileSync(0, "utf8")); } catch { process.exit(0); }
const fs = require("fs"); const sum = process.env.GITHUB_STEP_SUMMARY;
if (!sum) process.exit(0);
const L = ["### mcpm verify"];
if (d.error) L.push("", "✗ " + d.error);
else if (d.noBaselines) L.push("", "✗ lock has no integrity baselines — run `mcpm lock` online once.");
else if (d.ok) L.push("", "✓ " + d.verified + " npm server(s) verified against npm’s published record.");
else {
L.push("", "✗ verification failed — " + (d.blocked || []).length + " server(s):", "");
for (const b of d.blocked || []) L.push("- `" + (b.identifier || b.name) + "` — " + b.reason);
}
if ((d.unenforceable || []).length)
L.push("", "_" + d.unenforceable.length + " server(s) (pypi/oci/url) have no baseline mechanism — not enforced._");
L.push("", "_mcpm checks the registry’s published record, not the code your agent runs._");
fs.appendFileSync(sum, L.join("\n") + "\n");
' || true

exit $code
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ All notable changes to this project will be documented in this file.

### Added

- **`mcpm verify` + an official GitHub Action (D2)** — a repo-only, **client-free**
integrity gate for CI. `mcpm verify` loads `mcpm-lock.yaml` and runs the same
fail-closed integrity pass as `mcpm up --frozen` — BLOCK (exit 1) on integrity
drift, an unverifiable record, a format mismatch, or a suspicious missing baseline
— but, unlike `up`, it needs **no AI clients installed**, so it runs on a hosted
runner (where `up` hard-fails on zero detected clients). `--json` emits a
structured model; a composite Action at `.github/actions/mcpm-verify` wraps it with
a job step summary. Pre-commit rides the same verb. Deliberately **one verb**:
provenance (Sigstore) will *extend* `mcpm verify` later, never fork it. v1 scope is
npm `dist.integrity`; pypi/oci/url are reported as unenforceable, and stack-vs-lock
staleness is a deferred follow-up.
- **`mcpm doctor --json` and `--report`** — `doctor`'s checks are now built into a
structured `DoctorModel` (clients with server + guarded counts, runtimes, advisory
cross-client drift, typed issues, `ok`). `--json` emits that model (shape UNSTABLE);
Expand All @@ -27,6 +38,13 @@ All notable changes to this project will be documented in this file.
`url`, the same URL-transport caveat that already applies to non-Cursor clients.
Zero new deps.

### Internal

- **Shared frozen-verify extraction** — `classifyIntegrity` + a new pure
`frozenVerdict` moved from `up.ts` into `src/stack/frozen-verify.ts`, consumed by
both `up --frozen` and the new `mcpm verify`. `up`'s output and block matrix are
byte-identical (its 13 frozen tests pass unchanged).

### Fixed

- **`mcpm_doctor` MCP tool now reports real issues** — it previously returned a
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Build the **open-source, community-owned npm+npm_audit** for MCP:
- **Commands**: `mcpm search`, `mcpm install`, `mcpm list`, `mcpm remove`, `mcpm info`,
`mcpm audit`, `mcpm update`, `mcpm outdated`, `mcpm doctor`, `mcpm init`, `mcpm import`, `mcpm serve`,
`mcpm disable`, `mcpm enable`, `mcpm alias`, `mcpm completions`,
`mcpm export`, `mcpm lock`, `mcpm up`, `mcpm diff`, `mcpm publish`, `mcpm guard`,
`mcpm export`, `mcpm lock`, `mcpm up`, `mcpm verify`, `mcpm diff`, `mcpm publish`, `mcpm guard`,
`mcpm guard doctor-confine`, `mcpm secrets`, `mcpm why`
(`mcpm guard enable --confine` opt-in wraps unwrapped stdio servers in an OS sandbox — macOS-first)

Expand Down Expand Up @@ -500,6 +500,7 @@ the same entry shape).
| 2026-07-03 | v0.17.0 released (Wave-0 adoption credibility floor) + started Wave 1 with the Claude Code adapter (D1) | Following the new **docs/ROADMAP-ADOPTION.md**: v0.17.0 shipped the four Wave-0 items (E3 supply-chain evidence pack, B1a macOS CI leg, E11 `docs/CONTRACTS.md`, E9a registry-delisting gate). Then D1 (#117) added **Claude Code** as a 5th first-class client (`~/.claude.json`, user-global `mcpServers`) — a rootKey-only `BaseAdapter` subclass; **user-global scope only**, per-project `projects[<path>].mcpServers` deliberately deferred. D1 is on `main`, ships next tag. |
| 2026-07-03 | Publish workflow: attach the release SBOM at `gh release create` time, never via a follow-up `gh release upload` | The repo has GitHub **immutable releases** enabled: uploading an asset to an already-created release returns HTTP 422. v0.17.0's publish succeeded on npm but failed the release-asset step (SBOM), so its GitHub release is asset-less and can't be amended. Fix (#116): pass `mcpm.cdx.json` as a positional asset to `gh release create` so it's sealed atomically. Correct from v0.18.0 on. GOTCHA for any future release-pipeline work. |
| 2026-07-03 | Wave-1 D4a: Gemini CLI as a 6th first-class client (`~/.gemini/settings.json`, user-global `mcpServers`) | Structural clone of D1 — another rootKey-only `BaseAdapter` subclass; verified the format against Google's gemini-cli docs (top-level `mcpServers`, home-relative on all platforms). Detector auto-enumerates it, so every client-iterating command works day one. **User-global scope only**, per-project `.gemini/settings.json` deliberately out of scope (same as D1). Caveat: Gemini reads `url`=SSE / `httpUrl`=HTTP; mcpm writes `url`, the existing non-Cursor URL-transport caveat. Merged to `main` (#120), ships next tag. |
| 2026-07-03 | Wave-1 D2: `mcpm verify` (repo-only, client-free CI gate) + a composite GitHub Action; `classifyIntegrity`/`frozenVerdict` extracted to `src/stack/frozen-verify.ts` | The critique premise held: NO existing command runs on a hosted runner (`up` hard-fails at Step 3 on zero detected clients; `sync --check`/`audit` are vacuously green). So `mcpm verify` runs the SAME fail-closed integrity pass as `up --frozen` — extracted `classifyIntegrity` + a NEW pure `frozenVerdict` (structured pass/block decision) into a shared module both consume — but client-free (no detection, no `~/.mcpm`, no writes). Same BLOCK semantics + exit codes (1 on drift/unverifiable/format-mismatch/mixed-missing-baseline; benign refuse on lock-wide no-baseline; pypi/oci/url = unenforceable notice; exit 1 on no-lock, and verify NEVER auto-locks). `up`'s output/block-matrix is byte-identical (13 frozen tests unchanged — the refactor's safety net). `--json` emits a VerifyModel (`schemaVersion:1`). Composite Action `.github/actions/mcpm-verify` (step summary from `--json`, static shields badge, pre-commit snippet). **ONE verb**: B3 later EXTENDS `mcpm verify` with Sigstore provenance, never forks it. v1 = npm `dist.integrity` only; stack-vs-lock staleness deferred. On `main`, ships next tag. |
| 2026-07-03 | Wave-1 D7: `doctor --json` / `--report` via a pure `DoctorModel` builder + redacted report; fixes the `mcpm_doctor` `issues: []` bug | `doctorHandler` split into `buildDoctorModel` (pure, structured — `schemaVersion:1`, clients w/ server+guarded counts, runtimes, advisory drift, typed issues, `ok`) → renderers. `--json` emits the model; the MCP `handleDoctor` now REUSES the model (was returning hardcoded `issues: []` + detected-clients-only). `--report` = a redacted paste-for-bug-reports snapshot: OS/arch, mcpm+node versions, per-client server **counts**, runtime availability, confine + secret-store backend, issue **counts** — deliberately **no server names/args** (issue messages embed names → reduced to counts; the tested security invariant). New `.github/ISSUE_TEMPLATE/bug.yml` requires a pasted report (telemetry-free friction channel). D7 is the FIRST of the four structured-output mappers (D3/D7/E2/E6) to land: the shared "one model" is the `schemaVersion` + JSON convention, NOT a monolithic type — doctor issues stayed a doctor-specific typed list, deliberately NOT forced into the audit `Finding` shape (different domain). doctor `--json`/`--report` shapes UNSTABLE (added to CONTRACTS). On `main`, ships next tag. |

---
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ Without an external scanner installed, the maximum possible score is 80/100. The
| `mcpm export` | Export installed servers as an mcpm.yaml stack file |
| `mcpm lock` | Resolve versions and create mcpm-lock.yaml with trust snapshots |
| `mcpm up` | Install all servers from mcpm.yaml with trust verification |
| `mcpm verify` | Repo-only CI gate: verify lockfile integrity vs npm's published record (`--json`) |
| `mcpm diff` | Compare installed servers against mcpm.yaml and lock file |
| `mcpm completions <shell>` | Generate shell completion scripts (bash, zsh, fish) |
| `mcpm why <name>` | Explain a server's trust score (breakdown of all components) |
Expand All @@ -209,6 +210,31 @@ Without an external scanner installed, the maximum possible score is 80/100. The

Run `mcpm <command> --help` for options and flags.

## CI: verify your lockfile

`mcpm verify` is a repo-only, **client-free** gate: it checks your committed
`mcpm-lock.yaml` against npm's **published** `dist.integrity` record and exits
non-zero on integrity drift, an unverifiable record, a format mismatch, or a
suspicious missing baseline. Because it needs no AI clients installed, it runs on a
hosted CI runner (where `mcpm up` cannot).

```yaml
# .github/workflows/mcpm.yml
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: getmcpm/cli/.github/actions/mcpm-verify@v0.19.0 # or: run: npx @getmcpm/cli verify
```

The Action writes a job step summary from `--json`; the same verb works as a
pre-commit hook. See [`.github/actions/mcpm-verify`](.github/actions/mcpm-verify).

> Honesty boundary: a failure means npm's *published record* diverged from your
> lock — not that mcpm caught malicious bytes; npx/uvx fetch the artifact
> independently at server launch.

## Runtime defense (mcpm-guard)

Install-time trust scoring catches most poisoned servers before they ship. But what about **rug-pulls** — a server that changes its tool definitions after you've already approved them? Or **prompt-injection in tool responses** — adversarial text embedded in a Slack message, web page, or calendar invite that the agent reads through your trusted MCP server?
Expand Down
3 changes: 2 additions & 1 deletion docs/CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ do-not-proceed.
| `mcpm --version` | always | — | prints `X.Y.Z` |
| `mcpm up` | applied cleanly | `1` | blocks the run when any server is **blocked** (trust floor, integrity, policy) or **failed**, regardless of `--ci` |
| `mcpm up --frozen` | lockfile verified, applied | `1` | fail-closed pre-install verify: blocks on integrity drift, an unverifiable record, a format mismatch, or a missing stack/lock |
| `mcpm verify` | lockfile integrity verified | `1` | repo-only, **client-free** CI gate: the same fail-closed integrity pass as `up --frozen` (drift / unverifiable / format mismatch / suspicious missing baseline), plus `1` when no lock file is found. `--json` emits the verify model |
| `mcpm up --ci` | applied, no prompts | `1` | non-interactive; also non-zero on shadow collisions when combined with `--check-shadowing` |
| `mcpm sync --check` | all clients in sync | **`2`** on drift/conflict; `1` on error | **`2` is the drift signal** — the value CI consumes. `--json` emits the drift model |
| `mcpm audit` | scan complete | `1` when overall trust level is **risky** | advisory findings (e.g. a delisted/deprecated server) lower the score but do not by themselves flip the exit |
Expand All @@ -34,7 +35,7 @@ new failure modes, but the meanings above will not be repurposed within `0.x`.
## `--json` output (mostly UNSTABLE for now)

`--json` is available on `search`, `install`, `list`, `info`, `audit`, `update`,
`outdated`, `diff`, `sync`, `why`, `doctor`, `guard list-signatures`, and
`outdated`, `diff`, `sync`, `why`, `doctor`, `verify`, `guard list-signatures`, and
`guard doctor-confine`. **Treat these shapes as unstable in `0.x`** — fields may be
added or renamed — with one exception:

Expand Down
12 changes: 11 additions & 1 deletion docs/ROADMAP-ADOPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,17 @@ The completeness critic's verdict: the candidate set over-indexed enterprise for
backend — no server names/args). `.github/ISSUE_TEMPLATE/bug.yml` requires a pasted
report — the telemetry-free friction channel (flutter/brew/gh norm). **Cut:**
PATH-origin classification (that's F9-PR2; don't smuggle it in).
- **D2 · `mcpm verify` + GitHub Action.** The critique killed the "thin wrapper"
- **D2 · `mcpm verify` + GitHub Action. ✅ SHIPPED** (on `main`, ships next tag; see
CHANGELOG `[Unreleased]`). Confirmed the critique: `classifyIntegrity` + a new pure
`frozenVerdict` were extracted from `up.ts` into `src/stack/frozen-verify.ts`, and
`mcpm verify [--json]` runs that pass **client-free** (no client detection, no
`~/.mcpm`, no writes) with the same BLOCK semantics + exit codes as `up --frozen`
(exit 1 on drift / unverifiable / format mismatch / mixed missing baseline; benign
refuse on a lock-wide no-baseline; pypi/oci/url reported as unenforceable). Composite
Action at `.github/actions/mcpm-verify` (step summary from `--json`, static badge,
pre-commit snippet). `up`'s block matrix is byte-identical (13 frozen tests
unchanged). **v1 scope = npm `dist.integrity`**; stack-vs-lock staleness deferred.
Original plan: The critique killed the "thin wrapper"
framing: none of the existing commands run on a hosted runner (`up` hard-fails on
zero detected clients; `sync --check`/`audit` are vacuously green there). First a
CLI PR: **`mcpm verify [--json]`** — repo-only, client-free; loads
Expand Down
Loading
Loading