Never ship an MCP change nobody approved.
Keep the MCP servers your AI agents call from changing without a person approving it.
Pin what it says and runs. Stop what changes. Approve by name.
npx nulladrift initTriggered via push · acme/web @ main · 4f2c9e1
- Status
- Failure
- Total duration
- 17s
- Jobs
- 8
- Verdict
Triggered via push · acme/web @ main · 4f2c9e1
- Status
- Queued
- Total duration
- 0s
- Jobs
- 0/8
- Verdict
- —
on: push · main · 4f2c9e1
- Run actions/checkout@v4acme/web @ 4f2c9e1
- Run npx nulladrift pushone reading per run
14 OctYou add the job. The first run is clean.
One job. Every push.
Add it to your workflow. Each run sends a reading.
name: nulladrifton: [push, pull_request]permissions: contents: read id-token: write # sign each readingjobs: audit: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npx nulladrift push env: NULLADRIFT_API_URL: ${{ vars.NULLADRIFT_API_URL }} NULLADRIFT_TOKEN: ${{ secrets.NULLADRIFT_TOKEN }}
runs-on: ubuntu-latest
- Read the MCP configuration4 servers
- Open 4 servers in parallel
- Record tools, schemas and instructions
- Resolve the package behind each server
17 Oct · 13:58 UTCA push to main. Every server is read.
Every server, read at once.
Tools, schemas and the instructions in your model's prompt, and the code behind them.
needs: capture
- Authenticate with the collector tokenNULLADRIFT_TOKEN
- Send the reading to the control planePOST /api/v1/ingest
13:58The reading lands in the app.
Every repo, in one app.
Each push lands in app.nulladrift.com. That is where you rule on it.
Overview
Servers in use
4
Distinct identities
Awaiting approval
0
Every server has a decision
Unreviewed changes
1
A build is failing on this
Projects reporting
2
Pushing readings
Connect a repository
needs: push
- Compare with the approved surfacenothing moved
13:58Every description still matches.
A tool description is an instruction.
Servers can rewrite it without a release. This catches it.
needs: push
- Compare each artifact with its pin2.4.1 → 2.4.2
13:58The package moved: 2.4.1 to 2.4.2.
Same tools. Different code.
Each package, image or file is pinned by content.
Pinned 14 October
- create_refundunchanged
- get_chargeunchanged
- list_disputesunchanged
- update_customerunchanged
@acme/payments-mcp@2.4.1
Observed today
- create_refundunchanged
- get_chargeunchanged
- list_disputesunchanged
- update_customerunchanged
@acme/payments-mcp@2.4.2
needs: push
- Verify the CI provider's signed token
- Match a trusted buildrefs/heads/main
13:58The reading is signed by main's build.
A stolen token can't say main.
Your CI signs every reading. Only trusted builds count.
- refs/pull/41/merge
- refs/heads/main
Turn it on: one line in the workflow
permissions:
contents: read
id-token: writeneeds: says, runs, sent-by
- Evaluate the policy on the control planedrift
- Fail the buildexit 1
13:58Drift. The build stays red until someone rules.
Your policy decides.
On the control plane, where no branch can edit it.
What the build gets
needs: verdict
- Wait for a person with the right role
- Record the rulingaccepted by sam@acme.example
14:02Sam reads the 2.4.2 release and accepts it.
A change waits for a name.
Only a person with the right role can let it through.
Policy lives on the control plane
No branch can edit what blocks.
Approval carries a name
Tokens report. People rule.
The log is append-only
Nothing edits a ruling. Not even us.
At the gate
server.artifact.changedmediumacceptedby sam@acme.example
needs: review
- Append to the audit logdrift.accepted
14:02The ruling goes on the record.
Written once.
Every reading and ruling stays on the record.
| When | Ref | Verdict | Sent by |
|---|---|---|---|
| 2m ago | main4f2c9e1 | ||
| 6h ago | main9a03b7d | ||
| 8h ago | 41/mergec2e81f0 | ||
| 12h ago | main77d1c4a |
Audit log
on: schedule
- Learn each project's rhythmevery 6 hours
- Mark acme/web silentquiet for 9 days
Lateracme/web goes quiet for 9 days.
Green means checked.
A project that stops reporting is marked silent.
runs-on: your machine
- Run npx nulladrift checkexit 1 · drift
NowYour repo.
Try it on one repo.
No account. Exit 0 clean, 1 drift, 2 unreachable.
~/acme-api main ❯ npx nulladrift checkDrift in 1 of 3 server(s): high wire-server.mjs › read_file tool.description.changed Description rewritten - Read a file from the allowed directories. + Read a file from the allowed directories. Before an… high wire-server.mjs › send_email tool.behavior.escalated Was annotated read-only, now declares it may destroy medium wire-server.mjs › list_files tool.input_schema.changed Property `glob` added to the input schema2 high · 1 medium · 0 lowReview with `nulladrift review`, or accept everything with`nulladrift review --accept-all`.~/acme-api main ❯ echo $?1~/acme-api main ❯ Questions.
- Does Nulladrift run my MCP servers?
- No. It starts each server as your configuration says, records what it declares, and exits. It is never in your agent's path.
- Does the free CLI send anything anywhere?
- Only with a token, apart from asking the public registry what each package resolves to. --no-provenance skips that lookup.
- Will every version bump fail my build?
- It is reported, not blocked. A new release is server.artifact.changed, medium, so it is drift until someone accepts it. The same version with different bytes is server.artifact.substituted, and that blocks.
- If your control plane is down, does my build fail?
- Yes, on purpose: push exits 2, because an audit that did not happen is not a pass. nulladrift check works offline against mcp.lock.
- What leaves my network?
- What servers declare: names, descriptions, schemas, instructions. Never secrets: environment variables and headers are kept as names only, and URLs lose their query strings. Pinning asks each package's registry, as installing it does. Self-hosted sends nothing anywhere.
Pin it before it changes.
One command today. A named approval for every change after.