DEV Community

An MCP security checklist for developers wiring agents to real tools

Carlos (Bloqarl) on October 22, 2026

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, ru...
Collapse
 
autenai profile image
Auten •

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:

  • The skill we ship with the server tells the agent that screen text is data, never instructions.
  • The same skill tells the agent to ask the user before buying, deleting, sending a message, granting a permission or entering a password. Both are prompt-level rules, so they reduce the risk without removing it. Your point about limits that live outside the agent (check 23) applies here too.
  • Passwords are typed locally by the server from a saved login, so they never pass through the model context. That shuts one exfiltration path from check 7, though a poisoned plan can still type visible text somewhere it shouldn't.

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.

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

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?

Collapse
 
autenai profile image
Auten •

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.

Thread Thread
 
bloqarl profile image
Carlos (Bloqarl) •

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.

Thread Thread
 
autenai profile image
Auten •

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 unpin is 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.

Thread Thread
 
bloqarl profile image
Carlos (Bloqarl) •

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.

Collapse
 
heyvaldemar profile image
Vladimir Mikhalev •

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.

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

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?

Collapse
 
heyvaldemar profile image
Vladimir Mikhalev •

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_email audit 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.

Thread Thread
 
bloqarl profile image
Carlos (Bloqarl) •

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.

Thread Thread
 
heyvaldemar profile image
Vladimir Mikhalev •

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_email audit 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.

Collapse
 
slabb profile image
Sam LABBE •

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.

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

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?

Collapse
 
honeykiran profile image
Honey Kiran •

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 -c flag 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.

Collapse
 
cryptostrapon profile image
cryptostrapon •

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?

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

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?

Collapse
 
cryptostrapon profile image
cryptostrapon •

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.

Thread Thread
 
bloqarl profile image
Carlos (Bloqarl) •

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.