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 thi...
For further actions, you may consider blocking this person and/or reporting abuse
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?
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 recordtools/listfrom servers started in Docker, so I lean towards measuring both.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.