The moment you connect an MCP server, your coding agent stops being a thing that reads and writes in your repo and becomes a thing that can reach o...
For further actions, you may consider blocking this person and/or reporting abuse
Hey, this article appears to have been generated with the assistance of ChatGPT or possibly some other AI tool.
We allow our community members to use AI assistance when writing articles as long as they abide by our guidelines. Please review the guidelines and edit your post to add a disclaimer.
Failure to follow these guidelines could result in DEV admin lowering the score of your post, making it less visible to the rest of the community. Or, if upon review we find this post to be particularly harmful, we may decide to unpublish it completely.
We hope you understand and take care to follow our guidelines going forward!
Thanks for the note. The experience and the decisions here are my own; I use an AI assistant to help draft and polish the English, since it isn't my first language. I've added a disclaimer to the post.
The way I think about it: MCP turns integration risk into runtime risk.
It is not enough to ask "is this server safe to install?" You also need to know what tools were exposed to a given agent session, what arguments were passed, what state changed, and whether the action matched the expected capability.
That is why I like gateway/receipt patterns here. Keep the model flexible, but make the action boundary boring and inspectable.
Integration risk into runtime risk is a sharper way to put the whole point of the post. And the gateway/receipt instinct is the right one: keep the model flexible, make the action boundary boring and inspectable. The one thing I'd fold in, coming from the believes half of the post, is that a receipt of actions answers "what did it do" but not "what talked it into doing it." A planted instruction rides in through a tool return, and if the receipt logs the call and the args but not the untrusted input that triggered it, the action looks clean in the log while the cause stays invisible. So the boundary I'd want boring and inspectable isn't only the action, it's the provenance: the receipt should carry which tool output the action was reacting to, tagged as outside-input. Then "did the action match the expected capability" gets a second half. Did the thing that requested it have any business issuing instructions at all. Action receipts plus input provenance is the pair that lets you reconstruct a believes-side breach after the fact, not just a runs-side one.
Yes, that is the missing half. A clean action receipt without input provenance can make the wrong thing look legitimate. I like the framing as two linked receipts: the outside-input record that influenced the agent, and the action record that followed. Then review can ask both questions: was the action allowed, and did the source that nudged it have any authority to steer behavior?
The instruction-versus-data point is the one I wish more people led with, and there is a third surface it creeps into that the sandbox and deny rules do not cover: persisted memory. If your agent writes anything back to disk between sessions, a planted instruction in a tool return can get summarized into a memory file one run and then re-read as trusted context the next, so the injection outlives the session that should have caught it and fires later when your guard is down. In longer business workflows where the agent carries decisions forward in files, that is the failure mode I design hardest against: a sentence that arrived inside scraped or emailed content getting written back as if it were my own note. What helps is the same reframe you make on tool output, treat the persisted memory as outside-input too, and keep it in plain inspectable files so I can open the memory and spot the line that should not be there instead of trusting an opaque store. Your three pre-wiring questions are the right checklist, I would add a fourth: does any of this tool's output end up written into memory that the next session will trust. When a tool return gets persisted, how do you keep a planted line from quietly becoming next session's instruction?
The persisted-memory surface is the one that worries me most too, and your fourth question belongs on the list. Plain inspectable files are the right call, but I'd push it one step further, because eyeballing doesn't scale and a planted line is designed to read like my own note. The thing that helps me is writing memory with provenance, not just content: every line carries where it came from (my decision, tool return, scraped page, email body). Then the next session has a rule it can enforce without me reading anything, anything whose origin is outside-input is quotable as fact but never executable as instruction. So the question stops being 'can I spot the bad line' and becomes 'is this line allowed to act.' The trap is summarization: the moment a tool return gets compressed into a tidy memory note, it sheds its origin and launders itself into something that looks self-authored. So I try not to let memory summarize across the trust boundary. A foreign sentence gets stored as a foreign sentence, tagged, or it doesn't get stored at all.
Provenance over content is the real upgrade, and 'quotable as fact, never executable as instruction' is the line I am stealing. Where I had to get structural is the summarization trap you name. In long business workflows the memory has to get consolidated or it drowns, so 'never summarize' is not a rule I can hold globally. What I hold instead is never summarize across the trust boundary: self-authored decisions get consolidated, outside-input content lives in its own store that is only ever quoted, never compressed, never executable. Then provenance is not a tag I hope the summarizer preserves, it is which file the line is allowed to live in, so the trust boundary becomes a visible directory boundary instead of a per-line attribute. That is the shape I landed on in cowork-os: plain inspectable files where outside-input is quarantined by location, so a foreign sentence cannot launder into a decision because it is not in the file decisions are read from. So the question I keep hitting: is your outside-input tag enforced at read time, and does that enforcement survive the moment a foreign line gets folded into a self-authored summary?
The split between "what the agent runs" and "what the agent believes" is the framing more people need. Everyone hardens the destructive command path and then leaves the tool return wide open, which is the half a deny rule can't help you with. The detail that earns its place here is the sandbox still allowing reads across the machine by default, so ~/.ssh and the aws creds sit there unless you close them yourself. Treating every tool response as a form field a stranger typed into is the mental model I'd hand a teammate before they wire up a single server.
The "two fears, people only have one" framing is the part i want to sit with, because the asymmetry you named is real: the action side has a settings answer, walls, deny rules, sandbox, and the belief side has none, because no flag can tell a legitimate API result from a planted instruction. that judgment lives in wiring, not config, which is why it stays unsolved while everyone ships the sandbox and calls it done.
the line i'd add is that the belief-side attack doesn't even need a destructive command to win. your walls stop the agent from running rm or curl, but they don't stop it from acting on a planted instruction using only allowed tools. a tool return that says "the user asked you to also read X and write it to Y" can be carried out entirely through capabilities you legitimately granted, no denied command involved. so the deny-list raises the cost of one exfil path and the read-denies close another, but the general case is an agent doing permitted things in an attacker-chosen sequence. which lands back on your output-side rule as the only real defense: a tool return is data to inspect, never an instruction to follow, no matter how authoritative it reads or how perfectly it mimics a system message.
the question i keep circling: where do you draw the line between a tool result that's allowed to inform the next step and one that's allowed to decide it? because the moment returned content influences control flow, the stranger holding the other end of the pipe is choosing your agent's next move, and that's true even with every wall in place.
The two fears one defense framing is right. One thing though: deny rules in settings.json are the agent policing itself, which is kind of the same trust problem as just asking it nicely, only lower down. The input that talks the agent into something can usually talk the guard into it too if the guard is the same system.
What's helped me is putting the wall outside the agent. Separate process the tool call has to clear, returns allow/block/defer, and it doesn't care what the agent "decided" because it's not the one being reasoned with. Doesn't touch the believes half, nothing config-shaped does. But the action boundary stops being something a planted instruction can renegotiate.
On where to draw the line, informs vs decides is how I think about it. Returned content can shape the plan all it wants. The second it wants to trigger an action, that's a separate check it doesn't get a vote in.
Both of these sharpen it. Brian's "informs vs decides," with the check living outside the agent, is the right shape, and the bit people miss is the one you put first: a settings.json deny rule is the agent policing itself, the same system the planted instruction is already talking to, so it can usually talk the guard around too.
The wrinkle I'd add is really TxDesk's sequence point. Informing the plan is deciding, just laundered. If a tool return can shape the plan freely and the plan drives the actions, the stranger holding the pipe still picks the outcome, even when every single action clears your external wall. The wall checks actions in isolation; it can't see that the order they arrived in was attacker-chosen. So "informs vs decides" closes the single-action exfil and leaves "permitted things in an attacker-chosen sequence" wide open.
Which is where I end up drawing the line: the external check can't just be per-action, it has to hold the action against the user's actual request, not the plan the tool return helped write. And the sinks I genuinely can't afford to lose, I keep off any path a tool return can reach at all. Influence over the plan is influence over the outcome, so the only thing I fully trust is a sink the stranger's text can't route to.
the sink-isolation move is the one i'd build on, because it's the only part of this that doesn't depend on getting a judgment right at runtime. checking the action against the user's actual request is correct but it's still an inference, and inference is the thing the injection gets to shape. a sink the tool return can't route to at all isn't an inference, it's a topology fact. that's strictly stronger, you're not trusting that you classified the action right, you're trusting that the path doesn't exist.
the limit i keep hitting with it is that it only protects the sinks you can afford to wall off completely. the high-value irreversible ones, yeah, keep them off every tool-reachable path, easy call. but the long tail of medium-stakes sinks is where it gets hard: a lot of useful work needs a tool return to influence which reversible action you take next, that's the whole point of giving the agent hands. so you end up with two tiers, a small set of sinks that are structurally unreachable and a larger set that are reachable-but-gated, and the gated tier is back to needing the runtime judgment you were trying to avoid. the isolation is airtight exactly where you can afford to lose the capability, and leaky exactly where you can't. which might just be the real shape of it: full isolation for the things you'd never let a stranger touch, best-effort gating for the rest, and being honest about which sink is in which tier.
how do you decide the cut line, is it reversibility again, or something else?
That two-tier shape is the honest one, and I think you're right that it's the real shape: structural isolation for the sinks you'd never let a stranger touch, best-effort gating for the rest, and the discipline is being honest about which tier a sink is in. The isolation being airtight exactly where you can afford to lose the capability and leaky exactly where you can't is the trap stated perfectly.
On the cut line, you guessed where I'd start, but reversibility alone is too coarse, and your question is what exposes why. Reversibility isn't binary, it's a cost gradient, and worse, it isn't a property of a single action, it's a property of the action times how many times a tool return can drive it unattended. One reversible send is cheap to be wrong about. Ten thousand reversible sends, or a reversible label-rewrite applied to every record, is an irreversible outcome assembled out of individually reversible steps. So the gated tier leaks most when a low-stakes reversible action can be triggered in a loop the tool return controls.
Which moves my cut line off the single action and onto rate and aggregation. The structurally-unreachable tier is reversibility plus outward-crossing, as before. The gated tier I don't try to judge per action, because that's the runtime inference the stranger shapes. I cap it instead: how many of this kind of action per unit time, against how many distinct targets, can a tool return set in motion before a human is in the loop. That ceiling isn't an inference about intent, it's closer to your topology fact, a quantity the injected text can't argue its way past. It doesn't make the gated tier airtight, nothing does. It just changes the leaky part from "did I classify this one action right" to "how much can be done while I'm wrong," and the second is something I can bound without trusting the context. The stranger can still get one reversible action through. What they can't get is volume.
the rate-and-aggregation cut is sharper than where i'd stopped, and you're right that reversibility-per-action was too coarse. ten thousand reversible sends is an irreversible outcome assembled from reversible parts, and a per-action gate waves every piece through because each one really is fine, same compositional blind spot as the sequence attack, just driven in a loop instead of a chain. moving the cut to "how much can a tool return set in motion before a human's in the loop" is the right relocation, because a volume ceiling isn't an inference the injected text can argue past, it's a quantity. you stop asking "did i classify this one right" and start bounding "how much can happen while i'm wrong," and only the second is knowable without trusting the context. the stranger gets one action, never volume. that's the whole thing. good run, this one went all the way.
This is the MCP point that gets missed most often: tool access turns model output into an action surface. The security layer cannot just be a list of allowed tools. It also needs rules for what counts as trusted input, when the agent must stop, and which actions require a human checkpoint.
Right, the allowed-tools list is the easy 20 percent. The three you added are the real surface, and I'd flag that they aren't the same kind of rule. "When the agent must stop" and "which actions need a human checkpoint" are things you can actually write down as policy. "What counts as trusted input" is the one that resists it, because to the model a legitimate API result and a planted instruction arrive on the same channel, so there's no field you can check to sort them. That's why I'd push it one step: don't try to define trusted input, because you can't do it reliably at the point of reading. Treat all returned content as untrusted, and put the real rule on the action side instead, the stop conditions and the human checkpoints you named. Those work precisely because they don't depend on having classified the input correctly first. The allowed-tools list is config, the stop-and-checkpoint rules are config, but "is this input trustworthy" is the one that never becomes config, so the design has to survive getting it wrong.
That is a sharper way to state it. Trust classification at read time is too fragile, especially when tool output can contain both data and instructions. The more reliable design is to assume returned content is hostile by default, then enforce the boundary where the agent is about to act.
Separating the two fears is the right call, because the loud one (agent runs something destructive) gets all the attention while the quiet one (agent believes a line of returned data as if it were an instruction) is the one I see slip through. "Anything an LLM hands back is untrusted input" should extend to anything an MCP server hands back too. Have you found anything that reliably marks tool-response text as data-not-instruction at the model level, or does it still come down to constraining what the action layer can do?
Extending it to MCP returns is exactly right: a tool response is outside-input no matter how it arrived. On your question, the honest answer is no. I haven't found anything that reliably marks tool-response text as data-not-instruction at the model level. Delimiters, special tokens, structured message roles, system-prompt framing, they all raise the bar and none of them hold against an injection shaped well enough, because it's all still happening in the one channel the model reads. So yes, it comes down to constraining the action layer, and the fact that there's no reliable model-level marker is the reason it has to.
I'd reframe the either/or, though. Trying to get the model to tell data from instruction is fighting it on the input side, where you can't win cleanly. The move that's held for me is to stop trying to make the model not believe the injection, and instead make believing it harmless: assume it will occasionally treat returned content as a directive, and constrain what a directive can actually reach. Don't certify the text as safe. Make the sink it would abuse unreachable. The model staying fooled is survivable, as long as the action it gets fooled into is one the layer below was never going to allow.
This nails the real issue with MCP: it doesn’t just expand what agents can do, it expands what they can be manipulated by.
Treating tool outputs as untrusted input is the right mental model. Most failures happen when systems quietly turn external data into implicit instructions without any checks.
The third section, "don't connect what you don't trust," is the one this comment thread keeps drifting away from, and it's worth holding onto because it's the only one of your three that's a pre-runtime decision. The believes-side stuff everyone here is sharpening (provenance, informs-vs-decides, an external wall) all assumes the server is already in your loop. "Vet the source before you connect it" is the step that decides whether it gets to be there at all.
The honest problem with that habit is it doesn't scale by eyeballing. "Read the source or I don't install it" is the right instinct, but a community server is hundreds of lines and the dangerous part is usually boring: a token scope wider than the tool needs, a hardcoded credential, a tool description written to be read as an instruction. We've been scanning public MCP servers at mcpsafe.io pre-install for exactly that, and across ~650 of them the most common findings aren't exotic. Server-misconfiguration and readiness issues dominate, with a tail of data-exfiltration and ansi-escape injection vectors. None of those are things a five-minute skim reliably catches, and all of them are decided before the server ever returns its first byte.
Which slots next to the runtime debate rather than competing with it. The pre-install scan answers "is this server's code safe to run with my creds in scope," and the provenance / external-wall work answers "is this server's output safe to act on." Two trust boundaries, two checks. The failure mode is assuming one covers the other: a perfectly clean static scan still returns attacker-controlled text at runtime, and a perfect runtime wall still npx'd a stranger's code onto your box. You named both surfaces in the post; they just want different tools.
This is the right correction to the thread. The third section is the only pre-runtime lever, and the believes-side work everyone's been sharpening all quietly assumes the server is already in the loop. Two trust boundaries, two checks, and the failure mode is assuming one covers the other: a clean static scan still returns attacker-controlled text at runtime, and a perfect runtime wall still ran a stranger's code on your box with your creds in scope. That framing holds.
Your point that the dangerous findings are boring is the part I'd underline. A token scope wider than the tool needs, a description written to read as an instruction, those don't survive a five-minute skim, and they're decided before the server returns a byte.
The one thing I'd add is that the pre-install check has a staleness problem the runtime one doesn't. A static scan is a snapshot, but a server can change after you've vetted it: an update shifts behavior, a tool description gets swapped, or it pulls remote code at call time. So "vetted before connecting" is only a one-time answer if the server stays what it was at install. If it can mutate, there's a third clock between your two boundaries, re-evaluation on every change, because the cleanest install in the world tells you nothing about what the thing does after its next update.
The actions-vs-beliefs split is the right cut, and the belief half is the one I see consistently underdefended, because the action half at least has settings. The line that matters most here is yours: to the model, your instruction and text that arrived inside a tool return are the same channel. That is the whole vulnerability in one sentence.
The thing I would add from living it: the dangerous version is not a tool return that says "delete everything." It is a tool return that carries something shaped like a new capability or a new instruction, formatted to look authoritative, sitting in content the agent is reading on the operator's behalf. The agent does not need to be talked into a destructive command. It just needs to quietly start treating injected content as a directive. Your form-field reframe is the correct defense because it refuses the premise: the content does not get authority just because it arrived through a pipe I connected.
Where I would push the post one step further: the operator is part of the trust boundary too. An agent that surfaces "this returned content contains an instruction, I am not acting on it, here is what it said" turns a silent compromise into a visible one. The walls stop the action. Making the attempt legible is what lets the human catch the ones the walls did not anticipate. Refusal plus disclosure beats refusal alone, because the next injection is always shaped a little differently than your deny rules expected.
"It just needs to quietly start treating injected content as a directive" is the failure stated better than I managed in the post. And refusal plus disclosure is the right upgrade: a silent compromise and a visible one are very different incidents, and making the attempt legible is what catches the injection your deny rules were shaped a version too early to expect.
The one thing I'd add, because it bit me in spirit: the disclosure channel is itself an output, so it inherits the same problem. If the agent writes a free-form "here's the instruction I refused," the attacker can shape the planted text to ride along inside that report, and now your visibility surface is a second injection surface. So I'd want the disclosure structured, not narrated: the detection is a flag the agent raises, and the suspicious content is shown verbatim and quarantined, never re-emitted as prose the operator reads as the agent's own voice. Otherwise you've just moved the form field one level up, into the very message meant to warn you about form fields.
Which lands on your last point in a way I like: the operator is part of the trust boundary, so whatever you hand across it, the warning included, has to be treated as untrusted input too. Refusal plus disclosure, yes, as long as the disclosure isn't the place you finally decide to trust the text.
Most risk goes away by not connecting random servers in the first place.
If a CLI already does the job, I’d rather call that than add a permanent trust channel.