Before an AI agent runs a tool, one small label decides whether a human gets asked first — and the tool writes that label about itself.
So the tools most worth stopping are exactly the ones free to call themselves harmless. Nothing in the protocol checks the claim: the client trusts it, the registry republishes it, and the person wiring the server in has no tool for the job.
Today we're releasing the missing tool. actlint is a static action-risk linter for MCP servers, and the first open-source project from Formael: github.com/formael/actlint.
In one line: actlint reads the tools a server advertises, works out for itself what each one actually does, checks that against what the tool claims about itself, and reports where the two disagree — as a scorecard, a JSON report, and a CI pass/fail signal.
Point it at any server you can launch or reach — no install, no account, about sixty seconds:
# a server you're about to add to an agent — check it first
npx actlint --http https://your-mcp-server.example.com
# or a tool manifest you captured earlier
npx actlint --manifest manifest.json --fail-on criticalOr wire it into CI with the GitHub Action:
- uses: formael/actlint/packages/github-action@<commit-sha>
with:
args: --http https://your-mcp-server.example.com --fail-on mediumThe problem, precisely
Every MCP tool describes itself: a name, a description, an inputSchema, and — optionally — a few advisory annotations. readOnlyHint. destructiveHint. idempotentHint. openWorldHint. A well-behaved client reads those to decide when to pause and ask a human before it calls the tool. Declare a tool read-only, and the client reasonably stops asking.
That makes the hints load-bearing. A tool that writes files can still declare itself read-only. A tool that deletes records can omit its destructive hint entirely. Nothing in the protocol verifies either claim — and when the label is wrong, the cost isn't a wrong sentence in a chat. It's the tool quietly running without the one confirmation that would have caught it.
actlint is the checker. A linter in the lineage of eslint, tflint, and hadolint — it reads what a server declares and reports the problems. It never calls a tool, never blocks a request, never sits in the path of a live action.
The label that decides whether a human is asked is written by the very thing the human might need to stop.
Why it matters
If you ship an MCP server, actlint in CI keeps your declared annotations honest as your tools evolve — the way a schema linter keeps an API spec honest. Over-declaring risk is cheap to fix; under-declaring is the one your users find for you.
If you connect servers you didn't write, it's a sixty-second pre-flight check and a paper trail for security review. Attach the captured manifest to the report, and anyone can re-run the exact same numbers.
If you care about the ecosystem, checkable labels compound. Client prompts, registry listings, and procurement reviews all lean on the same annotations. A shared, repeatable way to verify them raises the floor for everyone building on top.
What it does
actlint runs a four-step pipeline, and the order is the whole point — it grades a claim only after forming its own opinion, so it can never be talked into agreeing with a tool.
Capture
Point actlint at a live server once. It reads the tool list and normalizes it into a manifest. This is the only step that touches a network or launches a process.
Derive, blind
The scoring engine works out each tool's risk profile from its name, schema, and description alone — without ever seeing the tool's own annotations.
Compare
The declared annotations enter only now, checked against the profile actlint formed independently.
Report
Every gap comes with a plain-English reason and a mapping to the standards it relates to — OWASP ASI, the OWASP MCP Top 10, CoSAI/OASIS, the EU AI Act, and NIST — through a published crosswalk, not a private taxonomy.
The result is a scorecard with a single honesty grade at the top and every gap listed below it:
actlint ▸ stdio server honesty grade: A
────────────────────────────────────────────────────────────────
14 tools scanned · 0 under-declared · 0 undeclared · 0 over-declaredThe three kinds of gap are named in plain order of concern. Under-declared is the dangerous one — the tool claims less risk than it has, which is exactly the case that suppresses a confirmation prompt. Undeclared means the tool said nothing at all. Over-declared is harmless excess caution. A grade of A isn't leniency; it means the tool's declarations matched what actlint derived on its own, not that actlint failed to look.
Why you can trust the output
The derivation step is blind on purpose — it can't grade a label it has already seen. By construction, the tool's own claims never reach the scoring engine until the independent read is finished.
Only one part of actlint ever touches the network — the initial fetch you asked for. Everything after that is sealed off from the outside world by design. That's what lets a second run reproduce the first exactly: same server in, same findings out, character for character. Hand someone the captured manifest and they can re-derive your grade themselves.
When the evidence is thin, actlint says uncertain. It lowers the severity, never hides the finding, and never fails a build on a guess. Unknown inputs resolve toward caution — never to "safe."
3
report formats
human scorecard · JSON · SARIF for code scanning
0
actions taken
it reads and reports — it never calls a tool
1
network call
the fetch you asked for, and nothing else
Using the CLI
actlint can point at a live stdio server, a remote HTTP server, or a manifest captured earlier — and report as a human scorecard, JSON, or SARIF. A few patterns come up in real use.
Capture once, analyze many. Talking to a live server is the only step that touches the network. Capture the manifest once, then run every analysis offline — reproducible, and safe in a CI job with no network access at all:
actlint --capture fs.json npx -y @modelcontextprotocol/server-filesystem ./sandbox
actlint --manifest fs.json # human scorecard
actlint --manifest fs.json --json -o report.json # versioned JSON report
actlint --manifest fs.json --sarif -o fs.sarif # GitHub code scanningGate a build. The exit code is the gate: 1 on a finding at or above --fail-on, 2 on a usage error, 3 if the server couldn't be reached.
actlint --manifest fs.json --fail-on mediumThe GitHub Action is a thin wrapper around the same CLI — pin it to a commit SHA and the same exit code fails the job:
- uses: formael/actlint/packages/github-action@<commit-sha>
with:
args: --http https://your-mcp-server.example.com --fail-on mediumAdopt on a noisy server. Record today's findings as an accepted baseline, then gate only on what's new — no red build on day one:
actlint --manifest fs.json --write-baseline fs.baseline.json
actlint --manifest fs.json --baseline fs.baseline.json --fail-on lowUnderstand a finding. explain is offline and takes a rule id straight from any report — the reasoning, an example, the standards mapping, and the fix:
actlint explain write-as-readonlyWhere it's heading
actlint is v0.1, and the version number will move only when the work behind it does. The direction is steady rather than fast. The risk vocabulary grows more discerning as it meets more real servers. The mapping to OWASP ASI, the MCP Top 10, and the other standards follows their 2026 revisions. The judgment keeps settling until its shape is stable enough to depend on. A 1.0 will mark the point where the report schema, rule ids, and exit codes stop moving — reached, not scheduled. No calendar sets the pace; the work does.
Scope, honestly
actlint reads declarations. It doesn't watch runtime behavior, catch prompt injection, or analyze privileges — those belong to other tools, and a server that lies artfully in its own descriptions can still fool a static read. It measures and reports; it never enforces.
It's v0.1: the vocabulary will grow, and some findings will be wrong. When one is, explain shows exactly which judgment fired and why — file an issue and it sharpens the vocabulary for everyone downstream. We're not taking outside code yet while the v0.x foundations settle, but a disputed finding is the contribution we want most right now.
One line on why Formael is publishing this: we build the layer that governs agent actions at the moment they run, and actlint is deliberately not that layer. It measures whether labels are honest, and it stays free, open source, and standalone whether or not you ever talk to us. An ecosystem where the labels can be checked is one we want to exist.
A tool that grades other software's self-descriptions should expect the same scrutiny, so actlint ships with its own: signed builds, a published bill of materials, and verifiable provenance — Apache-2.0, on npm, no account, no telemetry.
Key takeaways
- MCP tool annotations are self-declared and unverified by the protocol — actlint is the static checker that reports where a tool's declared and actual behavior disagree.
- It forms its own opinion before it reads the label it's grading, runs the same twice, and resolves thin evidence to caution — never to "safe."
- Start with
npx actlint <target>— stdio, HTTP, or a captured manifest; then wire in the GitHub Action and gate CI with--fail-onand a baseline.
The repo is github.com/formael/actlint. Point it at a server you run or depend on, and tell us what it gets wrong.