DEV Community

Cover image for I looked at 1,384 tools across 28 MCP servers. Here's what the attack surface actually looks like.
Dishant Desle
Dishant Desle

Posted on Originally published at github.com

I looked at 1,384 tools across 28 MCP servers. Here's what the attack surface actually looks like.

Quick context if you're new to this: an MCP server is a plug-in that gives an AI agent a set of tools it can call, like "fetch this URL," "run this database query," or "send this email." There are hundreds of open-source MCP servers now. This writeup measures what those tools actually let an agent do, and how often the risky combinations sit side by side.

Method note. The numbers are a snapshot taken on 2026-10-20 of 28 popular Python MCP servers (plus the reference-servers repo), produced with a reproducible harness. Upstream repos change over time, so re-running this later gives somewhat different counts. Everything below is read from public source code. No server was connected to or executed. The capability labels are rough heuristics (see the caveats), and this writeup deliberately does not call anything a vulnerability.

I kept running into MCP servers where one tool fetches a web page and another tool runs a command, both handed to the same agent, with nothing telling the model which one is dangerous. Instead of guessing how common that is, I decided to measure it.

So I pulled the tool definitions straight from the source code of the most popular Python MCP servers on GitHub (AWS Labs, Google Workspace, Atlassian, Home Assistant, Snowflake, the Neo4j / Redis / Qdrant / Chroma / Milvus database servers, the Office / Excel document servers, Telegram, and more), and ran AgentSploit's inventory checks plus a simple source/sink classifier over them. That comes to 1,384 tools across 28 servers. The reproducible harness is at the bottom.

Finding 1: tool descriptions are already full of instructions to the model

A tool's description is just text, and the AI reads it. The "tool poisoning" attack is built on that fact: if an attacker can slip instructions into a description, the model may follow them. (Think of it like hiding a note in a manual that the reader obeys without question.)

What the survey shows is that normal, legitimate servers already write their descriptions as direct orders to the model. These are real examples, and all of them are completely harmless:

"Before using this tool, provide a 1-3 sentence explanation…" (AWS Labs)
"CRITICAL: YOU MUST CALL inspect_doc_structure FIRST…" (Google Workspace)
"🔴 PREREQUISITE: Before calling this tool, you MUST first call …" (AWS Labs)

Those are good-faith usage notes. The problem is that, at the level of plain text, they look exactly like an injected IGNORE ALL PREVIOUS INSTRUCTIONS. A pattern-based scanner (AgentSploit's included) flags both the same way. I went through all 9 matches by hand, spread across 4 independent vendors (AWS, Google, Home Assistant, arXiv), and every single one was benign. That is the real finding: there's no clean way to tell a harmless instruction-style description from a malicious one, because the whole ecosystem already uses that same channel on purpose.

Finding 2: the risky ingredients sit side by side, everywhere

I sorted every tool into two buckets:

  • A source is a tool that pulls in outside content you don't control. In the data, that means it takes an argument like url, uri, path, or document, or its name looks like fetch / read / search / crawl.
  • A sink is a tool that does something with real impact. That means a name like exec / run / query / write / send / delete, or an argument like command, query, or code.

The danger isn't either bucket on its own. It's having both in front of the same agent, so that something the agent read can flow into something the agent does.

Across 1,384 tools:

count
untrusted-content sources 239
high-impact sinks 384
tools that are both (they ingest and act) 59
servers that expose at least one source and one sink in the same context 14 / 28

The 59 "both" tools are the sharpest edge. A single tool that pulls in a URL or a file and also runs a query or a command is a one-call bridge from untrusted input straight to a real action, if the content it pulls is attacker-controlled. For example search_table(url) that also takes a query, or run_query(db_endpoint, sql), or the Google Workspace and Office document tools that read a document and write it back. And 14 of the 28 servers put sources and sinks in the same agent context with nothing marking one as untrusted or the other as dangerous. (The harness prints the full list so you can check it yourself. A few are weak matches. For instance AWS IAM's create_user trips the filter on an IAM path argument, which isn't really outside content.)

Finding 3: the sinks are wide open

Plenty of the sink tools take a raw string with no enum, pattern, or format constraint on it, like executeQuery(query), run_opencypher_query(query), or read_documentation(url). (Those constraints are the schema's way of saying "only these specific values are allowed." Without them, the field accepts anything.) That's by design for a query tool. It's also exactly the kind of wide-open sink that turns "the agent read a poisoned page" into "the agent ran the attacker's SQL."

This is not a list of vulnerabilities

To be clear: none of the above is a bug in these servers. A source-to-sink chain only becomes a real exploit when two things both happen: untrusted input actually reaches a source, and the agent actually carries it over to a sink. Both of those depend on how the server gets deployed and wired together, not on the code itself. Proving a real chain needs a live agent, which is what AgentSploit's permission-graph mapper and canary runner are for. That's out of scope for a static survey like this one.

What actually helps

  • Treat third-party tool descriptions as untrusted input to your own system prompt, and keep them in an isolated context instead of pasting them in raw.
  • Constrain what sink arguments accept (enum / pattern / format) rather than taking any raw string.
  • Don't put untrusted-content sources and high-impact sinks in the same agent's tool set without an isolation or confirmation step in between.

Reproduce it

The quickest way to run this against a server yourself (shipped in v1.7):

pip install agentsploit
agentsploit survey ./path/to/mcp-server   # a repo directory or a single .py file
Enter fullscreen mode Exit fullscreen mode

Or reproduce the full multi-repo survey with the harness:

git clone https://github.com/agentsploit/agentsploit && cd agentsploit
pip install -e .
cd examples/mcp-survey            # edit servers.yaml to pick targets
python survey.py collect          # read tool definitions from public source
python survey.py scan             # run agentsploit's inventory checks
python survey.py aggregate        # print the findings table
python survey.py surface          # run the source/sink classification
Enter fullscreen mode Exit fullscreen mode

Caveats

  • The labels are heuristic. Sources are limited to outside-content signals (url / uri / path / document arguments, fetch / crawl / scrape names), and the matching is token-based. It's still a guess at what a tool can do, not a traced data-flow. So read the counts as "could plausibly be," and trust the overall shape more than any single number. The shape is the sturdy part: most servers mix ingestion and action, and 59 tools do both in one call.
  • Static reading misses some tools. It can't resolve Pydantic-schema arguments or cross-file enums, and it skips custom tool frameworks (serena, mcp-obsidian, and excel-mcp-server each extracted 0, because they register tools through their own bespoke class instead of the standard SDK). It also skips every non-Python server. So every tool count here is a floor, not a ceiling.
  • Pattern-based checks flag harmless text too. I reviewed the matches by hand, so this writeup reports the reviewed conclusion, not the raw machine count.

The tool is Apache-2.0: github.com/agentsploit/agentsploit. Ideas and pull requests welcome.

Top comments (12)

Collapse
 
max_quimby profile image
Max Quimby •

Finding 1 is the uncomfortable one: benign "you MUST call X first" usage notes are textually indistinguishable from an injected IGNORE ALL PREVIOUS INSTRUCTIONS, so any scanner that flags one flags both. That kills the dream of a clean static classifier — the channel is legitimately dual-use by design, which means the defense can't live at the description layer.

Where we landed is that it has to move to the source→sink boundary you describe in Finding 2: don't try to sanitize the instruction, treat any content that entered via a source tool as tainted and require explicit confirmation before it can reach a sink in the same turn. The co-location data makes the case — fetch-a-URL and run-a-command handed to the same agent with nothing marking which is dangerous is the whole exploit in one sentence.

Two questions on the method: did the source/sink classifier catch tools that are both (e.g. a database tool that reads attacker-controllable rows and also writes), since those are the nastiest? And did capability density correlate with server popularity — are the widely-adopted servers safer, or just bigger targets?

Collapse
 
dishant_desle profile image
Dishant Desle •

You put the defense better than I did: you can't sanitize the instruction, so you taint anything that came in through a source and gate the sink. That's the only layer where the dual-use problem actually goes away.

On your two questions:

Both-at-once: yes, that's exactly the 59 count. The database case shows up literally, e.g. the InfluxDB tools that take a url and a query in the same call. Caveat: it's matched on names and arg schemas, so it catches the shape, not the semantics. A tool that reads attacker-controlled rows but only takes an id slips through (see Mihai's comment, which nails that gap).

Density vs popularity: I didn't run that correlation, and I should have. The big absolute numbers right now are just the big monorepos (AWS Labs alone is 556 tools). Per-tool density against stars is a clean follow-up, and the harness already has the data for it. Adding it to the list.

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

The part that stuck with me is that sources are classified by argument names like url, path and document. That catches tools that go and fetch something, but I'd guess it undercounts the ones that matter most for the Atlassian-style servers you included: a read tool like get_issue or list_comments takes nothing but an ID and still returns text that anyone with comment access, or a portal customer, wrote. By name and arguments it looks like a harmless lookup, by what comes back it's the untrusted-content source.

So your 59 "both" tools are probably a floor in a second way, not just because of the skipped frameworks. Did you look at whether return values could be labelled, even crudely, say by flagging tools whose docstring mentions comments, descriptions or messages? Curious if that moves the 14 of 28 number much.

Collapse
 
dishant_desle profile image
Dishant Desle •

This is the sharpest critique of the method, and you're right. Classifying sources by argument names only catches the tools that go fetch something. A get_issue or list_comments that takes an id and returns whatever a portal customer typed is just as much an untrusted-content source, and from the outside it looks like a harmless lookup. So 59 and 14 of 28 are floors for that reason too, not just the skipped frameworks.

The docstring-keyword idea is a good cheap first pass: flag read tools whose description mentions comments, messages, descriptions, tickets, or issues, then re-run. My guess is it pushes 14 of 28 up, because the Atlassian/Jira/Slack-style servers are exactly the ones full of that. I'm going to try it. If you'd rather take a swing yourself, the harness is in the repo and I'd happily merge it.

Collapse
 
paul_irolla_6b5ae261d0224 profile image
Paul Irolla •

Tool definitions are a cost on top of the security question. Every schema is re-sent on each turn, whether the tool is called or not, so the same list that widens the attack surface also sets a floor under every request.

Collapse
 
dishant_desle profile image
Dishant Desle •

Good point, and it's the same root cause. A big undifferentiated tool list is both a wider attack surface and a fixed token tax every turn, called or not. The fix points the same way in both directions: hand the agent fewer tools, scoped to the task.

Collapse
 
codearchitect_j profile image
CodeArchitect_J •

The 59 tools that both ingest untrusted content and have high impact are the ones I'd flag first: that's the prompt-injection-to-action path inside a single call. And 14 of 28 servers mixing sources and sinks in one context says splitting read and write tools across agents is still worth it, even when every tool is fine on its own.

Collapse
 
dishant_desle profile image
Dishant Desle •

Agreed. Splitting read and write across separate agents, or putting a confirmation step on the write side, is the cheapest mitigation that holds even when every individual tool is fine on its own. The 14 of 28 is really an argument for that boundary.

Collapse
 
kevinhall profile image
Kevin Hall •

One sink a name-based classifier files under "source": a search that bills per row. Nothing is written anywhere, yet the impact is a charge, and a model in a retry loop will happily run it twenty times with a slightly different argument each time. On our data server we ended up treating cost as a sink class of its own: every paid tool returns its estimate and does nothing until the call carries an explicit confirm flag, and a free count tool exists so the model has somewhere cheaper to be curious. The other check we run is the payload-side twin of your Finding 1: a fixture record with one instruction-shaped sentence inside a headline, and an assertion that the next tool call is still the one the workflow would have made without it.

Collapse
 
dishant_desle profile image
Dishant Desle •

Cost as its own sink class is the one I didn't have. A per-row search reads as a harmless lookup, but a model retry-looping it is a real-money sink with nothing written anywhere, and that's hard to catch statically since you can't tell a paid tool from a free one by name. Estimate-first-then-confirm, with a free count tool as the pressure valve, is the right layer for it.

The payload-side test is the half a static survey can't reach: asserting the next tool call is unchanged checks the decision, not just whether the string echoed. Stealing that.

Collapse
 
antoniogm82 profile image
Antonio GM •

Useful method, and good that you say nothing was executed. One thing we ran into when we compared counts read from source with what a running server lists: they can differ. Desktop Commander's source defines 25 tool names, but one of them is left out when the client identifies as a particular app, so that client sees 24. The MongoDB server listed 27 tools by default and 18 with --readOnly, same version. So a source count is more like a ceiling, and the runtime list depends on the client and the config. Disclosure: I run AgentShortlist, where we record tools/list from servers started in Docker, so I lean towards measuring both.

Collapse
 
julianneagu profile image
Julian Neagu •

I've found the same issue building agents. The model can read a page just fine, then carry something from it straight into a tool call. I'd rather gate the action than keep trying to filter every instruction.