cd /news/developer-tools/my-mcp-server-holds-two-api-keys-eve… · home topics developer-tools article
[ARTICLE · art-76944] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

My MCP Server Holds Two API Keys. Every Tool Call Runs in the Same Process as Both.

A developer discovered that their single FastMCP server holding both GitHub and DEV.to API keys exposes both credentials to every tool call, creating a security risk where untrusted inputs could access write-capable credentials. The server's 8 tools run in one process with both keys in the environment, meaning any tool—even one that doesn't need a credential—could potentially misuse it. The fix is to split the server into separate processes along credential boundaries.

read4 min views3 publishedJul 28, 2026

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @github 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/my-mcp-server-holds-…] indexed:0 read:4min 2026-07-28 ·