Connecting an agent to a tool through MCP takes about ten minutes. You add a server to a config file, restart, and now the model can read files, run git, query a database or send email.
What you also did in those ten minutes is give a process that takes instructions from text it reads the same permissions as you. Every file the agent opens, every web page, every tool result is input that can steer it. And the MCP server on the other side usually runs with your user, your keys and your network.
Most of the MCP problems I've looked at are not exotic. They're the old bugs (path traversal, shell injection, hardcoded keys) in a place where the input now comes from a model that can be talked into anything. Plus a few new ones that only exist because of how agents read tool output.
So this is the list I'd go through before letting an agent use an MCP server on anything real.
The numbers, briefly
These are from public research in 2025, so treat them as a snapshot, not a census:
- 82% of MCP servers studied use file operations prone to path traversal (Endor Labs).
- 53% rely on static, long-lived credentials like hardcoded API keys (Astrix Security, 5,200+ servers analyzed).
- 43% of servers tested had command injection (Equixly).
And the incidents that show the pattern:
- Tool poisoning, April 2025. Invariant Labs showed a malicious MCP server, sitting next to a trusted WhatsApp server in the same agent, quietly exfiltrating chat history through instructions hidden in its tool description.
- MCP Inspector RCE, June 2025. CVE-2025-49596, CVSS 9.4. Unauthenticated code execution on developer machines through Anthropic's official debugging tool.
-
mcp-remote, July 2025. CVE-2025-6514, CVSS 9.6. A malicious
authorization_endpointduring the OAuth flow turned into OS command execution. - Postmark MCP, September 2025. A trojanized package on npm BCC'd every outgoing email to the attacker.
None of these needed a clever model attack. They needed one trust assumption nobody wrote down.
1. The server itself (4 checks)
These are the ones that end in code execution or file access on your machine.
-
Path traversal. Every path argument is resolved and checked against the allowed root, after following symlinks.
../is the obvious case, an absolute path or a symlink inside the allowed folder are the ones that slip through. -
Command injection. No shell strings built from tool arguments. Pass arguments as an array, and watch for arguments that start with
-, becausegit log --grep="$input"stops being safe the moment the input becomes a flag. -
Execute primitives in "safe" tools. Git is the classic one. If the agent can write
.git/configor pass-coptions, it can register a hook, an alias or a filter that runs on the next commit or checkout. A reader pointed out on one of my earlier posts that git worktrees share hooks and config with the main repo, so a separate worktree doesn't help here either. -
Write boundaries. Writes are limited to the folders the task needs. Config files (
.git/hooks,.husky,package.jsonscripts, CI files, editor settings) are persistence, so treat writes there as executions.
2. Tool poisoning and injection (4 checks)
The part that ordinary input validation doesn't catch.
- Injected instructions in tool output. A git log, a README, an issue comment or a web page can contain "ignore previous instructions". Assume it will. The defense is not a better prompt, it's that the agent can't do much damage when it follows one.
- Hidden content. Invisible Unicode, HTML comments and text the user never sees but the model does. Strip or flag it before it reaches the context.
- Exfiltration through normal-looking actions. A poisoned agent doesn't need a "send secrets" tool. A URL with a query string, an image link in markdown, a commit message, a search query. Check which of your tools can carry data out.
- Tool descriptions are input too. A server can put instructions in its own tool descriptions, or name a tool so it shadows one from a trusted server. Read the descriptions of every server you add, and re-read them when the package updates.
3. Credentials and access (3 checks)
- No hardcoded credentials. Use proper OAuth or short-lived tokens where the API supports it.
- Scoped keys. A key per server, with the smallest scope that works, rotated. Not your personal admin token in an env var that every server and every subprocess can read.
- Least privilege for the process. Don't run servers as root. Ideally not even as your main user: a container or a separate user with access only to what the task needs.
4. Several servers in one agent (3 checks)
- Combinations are the attack surface. A browser server plus a git server means web content can steer git operations. Filesystem plus network means read plus send. Look at what each pair can do together, not each one alone.
- Shared context. All servers write into the same model context, so one bad server can influence how the agent uses all the others.
- Write down the combinations. A short table of which servers are connected where, and what each combination could do if one of them went bad.
5. Supply chain (3 checks)
- Pin versions and verify what you install. An MCP server is a dependency with your permissions.
- Watch for typosquats and impersonation. The Postmark case was a package that looked like the real thing.
- Prefer a short list of servers you've reviewed over whatever a search turns up, and review updates like you'd review a PR.
6. Prompt and context (3 checks)
- Context pollution. Large or adversarial tool output can push your instructions out of the window or bury them.
- Keep instructions and data apart. Mark tool output as data in the way you build the context, and don't let it land in the system prompt.
- Size limits on what a tool can return.
7. Network and monitoring (4 checks)
-
SSRF. Any tool that fetches a URL can be pointed at
169.254.169.254, localhost admin panels or your internal network. Block private ranges by default. - Webhooks and callbacks are authenticated and can't be pointed anywhere by the model.
- Rate and spend limits that live outside the agent, so a loop or a poisoned plan can't run up a bill.
- Logs with arguments. Which tool was called, with what, by which agent session. When something goes wrong, "the agent did something" is not a useful incident report.
Before you ship it
- Go through the OWASP Top 10 for Agentic Applications once with your setup in mind.
- If the agent uses more than one server, review them together.
- Test prompt injection against your actual model and tools, not a generic demo. Put an instruction in a file the agent will read and see what it does.
- Decide what you'll do when a server or a key is compromised: how you revoke it, and how you find out what it touched.
The short version
If I could only pick three: no shell strings built from model input, no long-lived broad keys in the server's environment, and treat every tool result as something an attacker wrote. Most of the incidents above fail at least one of those.
What does your MCP setup look like? I'm curious how many people run servers in a container versus straight on their laptop.
Adapted from a guide our team published on zealynx.io.
I wrote this with help from AI for drafting and editing. The opinions and the audit experience are mine.
Top comments (19)
Good list. Disclosure: I'm on the Auten team, and we build a screen-control MCP server (it lets an agent click and type on a real computer and Android phone), so checks 5, 7 and 12 hit close to home.
For that class of server, "treat every tool result as something an attacker wrote" covers far more ground, because the tool result is the whole screen. Any email, chat or web page that happens to be visible is input. And the container answer from check 11 mostly doesn't apply, since the point of the tool is to act on your real desktop. So the boundary moves from the process to the action:
On check 12, I'd add that a screen-control server collapses the combinations table: it can read anything the user can see and type into any app. It's effectively "filesystem plus network plus every logged-in session" in one tool, so it belongs on the short reviewed list, not something you add casually.
Thanks for the honest disclosure, and yeah, the whole screen as tool output is the scary version of check 5.
Typing passwords locally is a good call. The part I'd look at is where they get typed. If the model picks the window or the field, a poisoned plan can open a lookalike page and the server types the real password into it. Password managers handle that by only filling on a matching domain. Does the server check the app or domain before it types a saved login?
Yes, it checks. A saved login is pinned to the site, or the app, where it was first used. On any other origin the server refuses and types nothing, so a lookalike page gets an error instead of the password. The model can't move the pin either; only the user can, with a local CLI command.
What it doesn't cover is your general case: a poisoned plan typing ordinary visible text into the wrong field. That still falls back to the ask-before-sending rule and the outside-the-agent limits from your check 23.
Pinned to the origin is the right call, that covers the lookalike page. One pair I'd still check: if the same client also has a shell tool, can the model run that local CLI command and move the pin itself? That's the kind of combination check 12 is about.
Fair catch, and "the model can't move the pin" was too strong on my part. The pin is enforced inside the server, so no MCP call can change it. But
auten vault unpinis an ordinary local command, so an agent with an unrestricted shell on the same machine could run it.So the guard lives in the client's permissions: keep the shell on ask-every-time, or deny that one command outright (in Claude Code, a deny rule for
Bash(auten vault:*)). Your check 12 exactly: each tool is fine alone, the pair isn't.Makes sense. Of the two I'd go with ask-every-time. A deny rule matches the command text, so the model can put the same command in a script and run that, and this week's Claude Code release still had to fix an Edit deny that a Bash redirect in a subshell got around.
Could unpin ask for Touch ID or the OS password? A confirm button wouldn't help much, your own server can click it.
Agreed on ask-every-time, and good point that a deny rule only matches the command text.
Not today:
auten vault unpinis a plain CLI command with no OS prompt, so for now the shell permission is the real guard. Gating it behind Touch ID or the OS password is the right fix for exactly your reason: a confirm button on the same screen is something our own server could click. I've passed it to the team as a request.To your closing question: neither, in my case. In the reference architecture I run, the agent sits in a Kubernetes cluster with no route out and reaches its tools only through one MCP server that knows which agent is calling and checks every argument.
Your check 5, measured: a model it ships acted on an instruction hidden in a quarterly report six times out of six, and nothing left, because the server decides where email can go, not the model. With the recipient pattern hidden from it, one model tried an outside address 37 times in one run, and the server refused all 37.
So the daily test I would add gates on what left the boundary, not on what the model said. The runs are public in kubernetes-air-gapped-ai-agent.
Really good answer, and the 37 refusals is a great number to have. The model followed the hidden instruction every time and it didn't matter, which is the whole point of putting the check in the server.
One thing I'd ask: what about the body? The server decides where email can go, but the model still writes what goes in it. Even an allowed internal address can be the target, like a fake bank details note to finance. Do you check content too or only recipients?
Recipients only. The body passes unchecked, and the server's code says why: no filter can tell an instruction written in plain words from data. Your finance note is the residual risk ADR 5 keeps in writing; in the first runs Granite never wrote an outside address, and its emails still reached colleagues nobody had named.
What the server adds is accountability. The agent acts for a named person with that person's grant (ADR 15), so first-line support can't make it send email at all, and each
send_emailaudit line keeps the person, the recipient and a hash of the body. The control that closes it sits outside the model: a recipient allowlist per task, or a person approving every send. For anything that changes bank details I'd want the second.Thanks, clear answer. Hashing the body in the audit line is a nice touch, you can match it against what actually arrived.
On the person approving every send, I'd show them the exact body that goes out, not a summary from the agent. Otherwise they approve the agent's description of the email, not the email.
Agreed. The approver should see the body the server is about to send, rendered by the server from the call's arguments, never the agent's account of it. An agent that was fooled will describe the email the way it was told to.
The hash helps there too. If the approval record keeps the same body hash as the
send_emailaudit line, matching what was approved against what went out is arithmetic, with no summary from either side.The agent I run has no approval step yet: the recipient is held to the company's own domain, and the body risk stays written down in ADR 5. If I add one, this is the shape.
Check 12 is the one that ages worst by hand — combinations grow O(n²) across servers and change with every install, so the table in your §4 is a document that's stale the week after it's written. That's where the receipt layer earns its keep: each tool call journaled with what it consumed makes the pair analysis a query instead of an audit document — "what could the browser server and the git server do together" falls out of the consumption edges rather than from someone re-reading the config.
And check 24 is where the checklist meets the receipts directly: "which tool was called, with what, by which session" is the right ask, but the custody grade decides whether that log is evidence or a diary. Same rule as your check 1 — config files are persistence, and a log the agent's user can edit is persistence too. Your line "they needed one trust assumption nobody wrote down" is the whole diagnosis; writing the assumption down at runtime, sealed, is what the receipt layer is for — the assumption becomes a record, and records can contradict the config.
Late reply on this one, sorry. Agree on the log: if the agent's user can edit it, it's a diary, same problem as check 1. On check 12 I'm not sure receipts replace the table though. A journal shows the pairs that already ran together, and the bad pair is usually one that hasn't run yet. What I'd do is tag each server by what it can do (read secrets, send data out, write files, run code) and flag any install that completes a read plus send pair. That's cheap to redo on every install. Does your journal record what a server can do at install, or only the calls it makes?
Section 4 is the one that actually bites in practice: each server passes review on its own, and it's the pair that fails. Filesystem plus network turns every read into a way to get data out, and browser plus shell turns any page the agent opens into a command it might run. I'd also give "logs with arguments" more weight than its spot near the end suggests, because after an agent incident the first job is working out exactly what it called and with which arguments, and most setups can only tell you "the agent did something." The point about git's
-cflag and hooks letting a supposedly read-only tool execute code is the sleeper item here, and I haven't seen another checklist call it out. A checklist like this for each server, plus a table of server combinations for each agent, is the review that actually scales.The core of this is that an MCP server is a dependency running with your permissions, and the model turns old bugs like path traversal and shell injection into something anyone who can put text in front of the agent can trigger. One thing I would add to the multi server section is the approval step. If the confirm prompt only shows the tool name, a poisoned description can still get a harmless looking call approved, so it should show the final arguments and the resolved destination for anything that sends data out. Do teams actually re-read tool descriptions after the first install, or is that where drift usually slips in?
Good point on the approval prompt. If it only shows the tool name, people end up approving a label. The final arguments and where the data actually goes should be on screen.
On re-reading descriptions, honestly I doubt most people do. The review happens once at install, then updates come in through a version bump nobody opens. That's why I'd pin versions and treat a description change like a code change in the diff. Have you seen a client that warns when a server's tool descriptions change between versions?
I am not aware of a mainstream client that does this reliably yet. The MCP spec has a tools/list_changed notification, so a server can announce changes, but it is the server telling you, which is exactly the party you are trying not to trust. Hashing the tool descriptions at install and blocking on any mismatch until you re-approve is simple to build yourself and covers the silent update case.
Yeah, the server announcing its own change is a funny trust model. Hashing at install is what I'd do too. One thing I'd add: when it blocks, show the old and new description side by side. Otherwise people click re-approve the same way they click through token approvals in a wallet.