I read a post this week where someone connected three MCP servers to one agent and watched it casually request the same access it'd need to hit production. The comment thread was full of "yeah, that's the whole problem with MCP" takes, and I almost scrolled past it — I don't run three servers, I run one. Then I actually opened server.py
to check, and realized my one server has the exact same shape of problem, just folded into a single file instead of spread across three.
server.py
is a FastMCP server with 8 tools split across two unrelated jobs: GitHub profile/repo reads, and DEV.to article reads and writes. Both credentials get loaded the same way, at import time, into the same process environment:
def load_env(path=".env"):
try:
with open(path) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k, v)
except FileNotFoundError:
pass
load_env()
and two helper functions read them back out:
def _gh(path, method="GET", data=None):
req = urllib.request.Request(f"https://api.github.com{path}", method=method)
req.add_header("Authorization", f"token {os.environ['GITHUB_TOKEN']}")
...
def _dev(path, method="GET", data=None):
req = urllib.request.Request(f"https://dev.to/api{path}", method=method)
req.add_header("api-key", os.environ["DEV_TO_API"])
...
Nothing here is a bug in the sense of "wrong output for some input." Every tool does exactly what it says: get_github_profile
reads GitHub, create_article
writes to DEV.to. The problem is one level up, in what the process boundary actually protects. I'd been thinking of GITHUB_TOKEN
and DEV_TO_API
as belonging to different tools, scoped by which function reads them. They don't. They belong to the process. Every one of those 8 tools runs with both credentials sitting in its environment, whether the tool needs one, the other, or neither. generate_commit_message
doesn't touch either API — it shells out to claude -p
on a git diff — but it runs in a process that could just as easily reach os.environ["DEV_TO_API"]
if a future edit to that function, or a bug in it, ever needed a string from somewhere and grabbed the wrong one.
That's the same failure mode as three separate MCP servers wired into one agent — the agent's session becomes the shared trust boundary, and every tool call inherits the union of everything reachable from it — just compressed into a single file where it's easier to miss because there's no server-to-server wire to point at. I read the code for months as "8 tools," never as "1 process holding 2 sets of write-capable credentials."
The reason it matters in practice, not just in theory, is that this server's tool inputs aren't all trusted. update_article(article_id, title=None, body_markdown=None, published=None)
takes an arbitrary integer and arbitrary text, and the text sometimes originates from an LLM call summarizing something I fed it — a draft, a trending-topic scrape, eventually maybe a comment thread. If that pipeline ever grows a step where article content is derived from external, untrusted text (someone else's dev.to comment, a scraped blog post) before being handed to update_article
, the only thing standing between "update my own draft" and "do something I didn't intend" is that nobody has yet written a code path connecting those two things. There's no process-level wall enforcing it. The credential for one integration isn't scoped away from the tool surface of the other; it's just that no one's called them together yet.
The fix isn't clever — it's the boring one nobody wants to do because it means running two processes instead of one. Split the server along credential boundaries, not along "feels like one project" boundaries:
load_env()
mcp = FastMCP("github-tools")
@mcp.tool()
def get_github_profile() -> dict:
...
load_env()
mcp = FastMCP("devto-tools")
@mcp.tool()
def create_article(title: str, body_markdown: str, tags: list[str] = None, published: bool = False) -> dict:
...
Two .env
files, two processes, two entries in the MCP client config instead of one. It's more annoying to run locally, and I haven't actually cut mine over yet — this article is me writing down the argument before I let myself talk out of it. But the property you get back is the one that actually matters: if devto-tools
gets fed something malicious through a tool argument, the worst it can do is misuse DEV_TO_API
. It cannot touch GITHUB_TOKEN
, because that string was never in its environment to begin with. That's a guarantee the current single-process version can't make, no matter how carefully I review each tool's implementation, because the guarantee I actually need lives at the OS process boundary, not in the Python.
The "vet each MCP server before installing it" advice — the checklist I wrote about a while back — still holds, but it answers a different question than the one this raised. Vetting tells you a single server isn't doing something malicious on its own. It says nothing about what happens once two of them, or two credential domains inside one of them, end up reachable from the same agent session. That composition risk doesn't show up in any one server's source code. It only shows up when you ask "what's actually in this process's environment right now, and which of my 8 tools could theoretically reach all of it" — a question I hadn't asked about my own code until someone else's three-server post made me go check.