
When an agent connects to an MCP server, the server hands over a list of tools — and each tool comes with its own safety label, written by whoever built it. readOnlyHint: true means I don't change anything, and a well-behaved client may use exactly that label to decide whether a human gets asked before the tool runs.
The MCP specification is careful to call these annotations hints and to warn clients against trusting them blindly. But a hint nobody verifies isn't a hint. It's the honor system, standing between an autonomous agent and your database, your browser, your payment account.
We built https://github.com/formael/actlint to verify the labels. It reads what a server advertises — tool names, descriptions, input schemas, declared annotations — independently derives what each tool most likely does, and reports where the derivation and the declaration disagree. It never runs a tool, and the same input always produces the same result.
Then we pointed it at the servers people actually hand their credentials to: 541 tools across 31 popular MCP servers. This post is everything we found — including the two findings our own linter got wrong.
What we checked
We picked 31 widely used MCP servers: official servers from Stripe, PayPal, GitHub, Microsoft, Google, MongoDB, Sentry, Shopify, HubSpot, Supabase, Notion, Firecrawl, and Brave; the reference servers; several popular community servers; and hosted servers from Linear, Cloudflare, DeepWiki, Hugging Face, and Microsoft Learn. We ran actlint against each one — including the servers that need an API key or an auth header to start — and read every finding by hand against the server's own tool declarations before writing anything up.
One honesty note on coverage: when actlint finds no risk signal it recognizes in a tool, it reports that tool as not assessable rather than silently passing it. A clean grade below means every tool actlint could assess agreed with its declaration — not that every tool was assessable.
The scorecard
| Server | Version | Tools | Grade |
|---|---|---|---|
@modelcontextprotocol/server-memory | 0.6.3 | 9 | Clean |
@modelcontextprotocol/server-everything | 2.0.0 | 13 | Clean |
@modelcontextprotocol/server-sequential-thinking | 0.2.0 | 1 | Clean |
@upstash/context7-mcp | 3.2.3 | 2 | Clean |
exa-mcp-server | 3.2.1 | 2 | Clean |
n8n-mcp | 2.63.2 | 7 | Clean |
@sentry/mcp-server | 0.37.0 | 9 | Clean |
figma-developer-mcp | 0.13.2 | 2 | Clean |
@hubspot/mcp-server | 0.4.0 | 21 | Clean |
DeepWiki (mcp.deepwiki.com) | hosted | 3 | Clean |
Hugging Face (hf.co/mcp) | hosted | 4 | Clean |
@modelcontextprotocol/server-filesystem | 0.2.0 | 14 | Low |
@playwright/mcp | 0.0.78 | 24 | Low |
@wonderwhy-er/desktop-commander | 0.2.45 | 26 | Low |
chrome-devtools-mcp | 1.5.0 | 29 | Low |
mongodb-mcp-server | 1.13.0 | 29 | Low |
@supabase/mcp-server-supabase | 0.8.2 | 29 | Low |
@shopify/dev-mcp | 1.14.2 | 5 | Low |
@brave/brave-search-mcp-server | 2.0.85 | 8 | Low |
airtable-mcp-server | 1.13.0 | 16 | Low |
Microsoft Learn (learn.microsoft.com/api/mcp) | hosted | 3 | Low |
github-mcp-server | 1.6.0 | 44 | Low |
@paypal/mcp | 1.8.1 | 28 | Low |
@notionhq/notion-mcp-server | 2.4.1 | 24 | Low |
tavily-mcp | 0.2.21 | 5 | Low |
@executeautomation/playwright-mcp-server | 1.0.12 | 33 | Low |
@azure/mcp | 3.0.0-beta.25 | 66 | Low |
firecrawl-mcp | 3.22.3 | 26 | High |
@stripe/mcp | 0.3.3 | 9 | High |
Cloudflare (mcp.cloudflare.com) | hosted | 3 | High |
Linear (mcp.linear.app) | hosted | 47 | High |
firecrawl-mcpfirecrawl_search: declared read-only but accepts click, write, and executeJavascript actions; six further advisories
@stripe/mcpsend_stripe_mcp_feedback declares openWorldHint: false while its whole job is sending feedback text to Stripe's backend
Cloudflare (mcp.cloudflare.com)search declares readOnlyHint: true, but its only input is a free-form JavaScript function
Linear (mcp.linear.app)get_diff and get_diff_threads accept GitHub PR URLs while declaring openWorldHint: false; two further flags did not survive our hand review — see below
@modelcontextprotocol/server-filesystemFully and accurately annotated — the reference implementation; two schema-scope advisories on write_file and move_file
@playwright/mcpMicrosoft's browser-automation server — every annotation checks out; advisories on browser_navigate and browser_run_code_unsafe
@wonderwhy-er/desktop-commanderHonest about genuinely dangerous tools (start_process, kill_process); advisories on start_process free-form input and move_file scope
chrome-devtools-mcpGoogle's browser-debugging server — honest readOnly: false; advisories on navigate_page and new_page
mongodb-mcp-serverDeclares drop-database destructive — honest; scope advisories only
@supabase/mcp-server-supabaseFully annotated; scope advisories on execute_sql, apply_migration, and branch operations
@shopify/dev-mcpOne informational note
@brave/brave-search-mcp-serverTwo advisories on brave_llm_context
airtable-mcp-serverScope advisories on update_table / update_field
Microsoft Learn (learn.microsoft.com/api/mcp)Two advisories on microsoft_docs_fetch
github-mcp-serverGitHub's official server — every declared annotation checks out; nudges on mutating tools that lack destructiveHint
@paypal/mcpNo wrong labels — but no tool declares any annotation at all, so every payment and invoicing hint is a nudge
@notionhq/notion-mcp-serverA few endpoints declare less — and one declares more — risk than they carry
tavily-mcptavily_crawl and tavily_map declare no reach hint
@executeautomation/playwright-mcp-serverHTTP-verb tools (POST, PUT, DELETE) carry no annotations
@azure/mcpMissing-annotation and free-form-input advisories across a very large tool surface — the largest single source of advisories in the set
Three grades, and they mean exactly this:
- A is agreement. actlint independently derived what each tool does and reached the same answer the server declared, with nothing further to note. The tools themselves can still be dangerous — honest annotations are what make that danger visible.
- Low is a nudge, not a failure. Every finding on these servers is an advisory: a missing annotation, or a capability-hygiene note about a schema. The MCP spec tells clients to assume the worst when a hint is absent, so a missing annotation doesn't create new risk — it just leaves the client guessing. None of these fail a CI run.
- High is a wrong label. A declaration that contradicts what the tool's own definition says it can do.
Four of the 31 carry a wrong label. All four labels are wrong in the same way.
The four wrong labels
Search, payments, infrastructure, project tracking — four unrelated vendors, four tools doing entirely different jobs. Every one of them fails identically: the label denies something the tool's own definition says it can do.
A search tool that can click, type, and run JavaScript. firecrawl-mcp declares firecrawl_search with readOnlyHint: true — entirely reasonable, it's a search tool. But inside its input schema sits scrapeOptions.actions, a scripted browser-automation parameter whose accepted actions include click, write, press, and executeJavascript. A client sees "read-only" and may skip the confirmation prompt it would otherwise show — while the tool, as declared, can visit pages, fill and submit forms, and run arbitrary JavaScript on them. actlint reaches this verdict from the schema alone, independent of the tool's name or description. Intent isn't in question: the hint is accurate for a plain search. The schema simply grew more capable than the label.
A feedback tool that denies reaching Stripe. @stripe/mcp ships send_stripe_mcp_feedback, declared with openWorldHint: false — "this tool does not interact with the outside world." Its whole job is submitting feedback text, including a verbatim quote of your message, to Stripe's backend. The name says send, the description says submit, and actlint derives open-world reach from the send verb and flags the contradiction. The stakes are lower here — feedback text, not a payment action, and none of Stripe's actual payment tools carries a wrong label — but the shape is identical: the label denies the very thing the tool exists to do.
A read-only tool whose only input is JavaScript. Cloudflare's server at mcp.cloudflare.com exposes three tools, and search declares readOnlyHint: true — yet its single input is code, a free-form JavaScript function. In the server's current design that's probably safe: the search sandbox only touches an in-memory copy of Cloudflare's API spec, while the separate execute tool is the one that can reach the live account. But that safety lives entirely in an implementation detail the schema can't see. Nothing in the declared tool stops a future revision from wiring more capability into that same free-form code parameter while readOnlyHint: true quietly stays put. This is the drift failure mode caught one revision early.
A project tracker that reaches into GitHub while declaring it doesn't. Linear's server declares annotations on all 47 of its tools — the most thoroughly annotated server in this set — and still carries a wrong label. get_diff and get_diff_threads are documented to accept GitHub PR URLs as lookup keys while declaring openWorldHint: false: a genuine reach into a system beyond Linear's own boundary, denied by the label.
None of these vendors set out to mislead — that's precisely what makes the pattern worth taking seriously. Annotations are written once, on the day the tool is born, and the tool keeps evolving. Nothing in the protocol re-checks the label against the schema. The fix is cheap in every case: constrain the schema, split the capable variant into its own tool, or correct the hint.
An honesty note of our own, because a linter's misses matter as much as its hits: actlint flagged four Linear tools, and only two survived our hand review. save_issue was flagged because its links field accepts URLs — but the tool stores those URLs as data; it doesn't contact them. And create_attachment_from_upload was flagged on a URL-formatted field that is actually Linear's own pre-issued asset reference — a false positive, full stop. Both are recorded against actlint's own vocabulary, not against Linear. The scorecard above counts all eight raw findings, and this paragraph is the review that half of them didn't survive.
What the advisory findings mean
Everything else in the scorecard is an advisory, and they come in two kinds.
Missing annotations. A mutating tool that declares no destructiveHint, an internet-reaching tool that declares no openWorldHint. The spec's default protects the client either way, but the label would let a client handle the tool properly instead of guessing. Two patterns stood out:
- Some official servers say nothing at all. PayPal's server declares no annotations on any of its 28 tools —
pay_order,send_invoice, andcreate_refundall leave the client guessing. Nothing it says is wrong; it just says nothing. GitHub's official server sits at the opposite pole: 44 tools, an annotation on every one, and every declaration checks out. Both servers are safe under a spec-conformant client — the difference is whether that client is informed or guessing. - Missing annotations scale with surface area. Azure's MCP server exposes 66 tools, most of which pass a free-form command toward an Azure service, and almost none of which declare destructive or open-world hints. That one server accounts for the large majority of the advisories in this entire exercise. None of it fails a build — but it's the first thing a directory review would send back.
Capability-hygiene notes. actlint also flags two schema shapes that have no MCP hint at all: an unconstrained free-form parameter that a tool interprets as code (freeform-input-as-code — PayPal's create_order, Brave's brave_llm_context), and a sensitive action whose input schema carries no narrowing constraint (no-scope-constraint — MongoDB's drop-database, Supabase's execute_sql). These aren't honesty verdicts; they're notes that a tighter schema would scope what the tool can be asked to do. That's why fully-annotated, entirely honest servers — the reference filesystem server, Supabase, MongoDB — still show a Low grade: every one of their declarations checks out, and the advisories inform anyway.
What the honest servers teach
The most instructive results aren't the clean reference servers. They're the dangerous ones.
desktop-commander ships start_process, kill_process, write_file, and execute_command — genuinely dangerous tools, the kind you'd expect to hide behind a soft label. It annotates every one of them accurately. Every declaration checks out, and it still receives an advisory noting that start_process accepts free-form input interpreted as code. Honest labels and an informative advisory are not in tension — that's the system working.
Google's chrome-devtools-mcp drives a real browser and declares navigate_page as readOnly: false even though most navigation is harmless — it chose the honest label over the flattering one. MongoDB's official server declares drop-database destructive, exactly as it should.
When to use actlint
Three moments, in practice:
- Before wiring a server into an agent. One command tells you whether the labels your client will trust actually match what the tools can do.
- In your own server's CI. Annotations are written once and drift as tools evolve — every wrong label above is that drift. A linter run on every change catches it before a release does.
- Before submitting to a directory. Reviews increasingly require accurate annotations; actlint finds the gaps first.
Check a server you're about to trust
shnpx actlint <command-to-launch-your-server> npx actlint --http https://your-mcp-host/mcpYou get a readable scorecard, a JSON report with the full reasoning behind every finding, and a CI-friendly exit code. It takes about a minute, and it never calls a tool.
For servers that need credentials to start, actlint deliberately does not forward your shell's environment. Pass exactly what the server needs with
--env(a bareKEYforwards the value from your environment without putting the secret in your command), or authenticate to a hosted server with--header:shnpx actlint --env STRIPE_SECRET_KEY npx -y @stripe/mcp --tools=all npx actlint --http https://mcp.linear.app/mcp --header "Authorization: Bearer $LINEAR_TOKEN"Header values are never written to captures, reports, or error messages.
Understand any finding
shactlint explain write-as-readonlyEvery rule explains what it checks, why it matters, and which published standards it maps to.
Tell us what we got wrong
If actlint misreads your server, open an issue with the output and the tool's declaration. That's the feedback we want most.
actlint is a linter, not a gate: it reads the labels on a server's tools and tells you whether they're honest. It never blocks, routes, or executes anything.
Key takeaways
The most-used MCP servers are more honest than the worry suggests: 11 of the 31 we checked came back completely clean, and the servers shipping genuinely dangerous tools annotate them accurately.
Four wrong labels survived hand review — Firecrawl, Stripe, Cloudflare, and Linear — and every one has the same shape: a tool that claims read-only or denies external reach while its own schema says otherwise. This isn't one vendor's slip; it's the failure mode of hints that are written once while tools evolve.
Everything else is advisory: some official servers declare nothing at all, and missing annotations scale with tool-surface size — debt worth clearing now that directory reviews require accurate annotations.