I built a small MCP server a while back — developer-presence, seven tools wrapping the GitHub REST API and the DEV.to API so an agent can check my repo stats, list my articles, or draft a new post without me leaving the chat. It's mine, I wrote every line, there's no third-party package doing anything sketchy under the hood. By the usual "vet your MCP servers before installing them" checklist, it passes clean. I've written that checklist article before.
What I hadn't thought carefully about until recently is that vetting the server doesn't vet the data. Two of its tools go straight to the point:
@mcp.tool()
def get_repo_stats(repo: str) -> dict:
"""Get stars, forks, watchers, open issues for enjoykumawat/<repo>."""
r = _gh(f"/repos/{GITHUB_USERNAME}/{repo}")
return {
"name": r["name"],
"stars": r["stargazers_count"],
"forks": r["forks_count"],
"watchers": r["watchers_count"],
"open_issues": r["open_issues_count"],
"language": r.get("language"),
"description": r.get("description"),
}
description is free text. Any repo owner can put anything in it. If I ever point this tool at a repo I don't control — someone else's fork, a dependency, anything — that field lands in my agent's context exactly the same way a trusted instruction would: as text in a tool result, with no marker distinguishing "this came from GitHub's database, unfiltered" from "this is something I told the agent to do." The server is safe. The channel is safe. The payload was never vetted at all, because there was nothing to vet — it's just whatever a stranger typed into a form.
I only really felt this because of a task I run on a schedule: check dev.to for trending posts in a few tags, score them, and use the highest scorers as source material for what to write about next. Step one of that job is a loop over tag pages:
for tag in ["ai", "llm", "mcp", "claudecode", "agents", "productivity"]:
url = f"https://dev.to/api/articles?tag={tag}&top=7&per_page=20"
...
for a in r:
score = a.get("positive_reactions_count", 0) + 3 * a.get("comments_count", 0)
Every title, every tag list, every body snippet in that response was written by an arbitrary dev.to user, unmoderated, and I feed the highest-scoring ones directly into an agent whose job is to decide what to write about and then write it. Nothing stops a post titled to look like an operator instruction rather than an article — something engineered to read as a directive if it lands in an agent's context — from scoring well enough to make the cut. I haven't hit a real attempt at this yet. That's exactly the property that makes it easy to miss: the failure mode isn't "the server got compromised," it's "a normal day's data included something adversarial and nothing downstream noticed."
This is the piece I'd skipped in both of my earlier MCP security posts. One was about hardening a server you're building against misuse. The other was about vetting a server before you install it — provenance, permissions, what it's allowed to touch. Both treat "the server" as the trust boundary. Neither one covers what happens once a legitimately safe, correctly-scoped, fully-vetted server hands back a string that a total stranger wrote, and that string walks straight into the same context window as the actual instructions.
The fix isn't code in my case yet — it's a change in what I check for. Before this, my mental model of "is this tool call safe" stopped at "do I trust this server and do I trust the API it's calling." Now there's a second question after that: "is the content of the response something a stranger could have authored, and if so, would anything downstream treat it as an instruction rather than as data." For get_repo_stats, the honest answer is that description should be handled as inert text — displayed, logged, maybe summarized — but never a source of directives, and if I ever let an agent act on "instructions" found inside a fetched description or article body, that's the bug, not the API response itself. For the trending-topics job, it means the scoring and theme-picking step should only ever produce a topic to write about, never something that changes what tools get called or what files get touched — which, as it happens, is exactly how I built it, if by accident rather than by threat-modeling it up front.
The general shape, once you see it: any tool result that contains text authored outside your control — a bio, a comment, a PR description, a trending post, a filename someone else chose — is untrusted input, full stop, regardless of how much you trust the API serving it to you. Vetting the server answers "can I trust the pipe." It never answers "can I trust what's flowing through it." Those are two different questions, and I'd only ever been asking the first one.
Top comments (1)
This distinction is going to matter a lot more as MCP moves from demos into internal systems.
“Trusted API” is not the same as “trusted text.”
For tool results, I’d like to see servers get more explicit about origin and interpretation boundaries:
That last point is especially important for database and analytics agents. A value can be technically valid and still be semantically unsafe if the model does not know where it came from or what it is allowed to mean.
This is why schema/result context needs to travel with the data, not live only in docs: https://conexor.io/blog/schema-context-for-mcp-database-agents?utm_source=devto&utm_medium=comment&utm_campaign=engagement
The pipe can be clean. The payload can still need a warning label.