DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

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

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()
Enter fullscreen mode Exit fullscreen mode

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"])
    ...
Enter fullscreen mode Exit fullscreen mode

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:

# github_server.py — only GITHUB_TOKEN is ever loaded into this process
load_env()
mcp = FastMCP("github-tools")

@mcp.tool()
def get_github_profile() -> dict:
    ...

# devto_server.py — only DEV_TO_API is ever loaded into this process
load_env()
mcp = FastMCP("devto-tools")

@mcp.tool()
def create_article(title: str, body_markdown: str, tags: list[str] = None, published: bool = False) -> dict:
    ...
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Splitting by credential domain is the right first move. I’d be careful not to treat “two processes” as the final boundary, though: two processes under the same Unix user can often read each other’s environment via /proc, inspect inherited descriptors, access the same .env files, or call the same local secret socket.

The stronger contract is per-tool capability injection:

  • the long-lived MCP process holds no provider token;
  • a local broker authenticates the tool identity plus invocation;
  • it returns a short-lived, audience-bound credential or performs the outbound call itself;
  • egress policy allows only the provider host required by that tool;
  • the credential scope is narrower than the provider account wherever the API supports it;
  • write tools run in a separate sandbox from read-only tools.

Then test the boundary adversarially. Add a diagnostic fixture to each tool process that tries to read /proc/*/environ, enumerate inherited file descriptors, open the other .env, reach the other provider’s host, and invoke the secret broker for an unauthorized audience. CI should prove all of those fail.

I’d also avoid importing secrets into os.environ at all. Environment variables are copied into children, appear in crash/debug tooling, and tend to live for the entire process lifetime. Secret handles or brokered calls make rotation and revocation much cleaner.

Process separation reduces accidental reachability. Per-invocation capabilities plus OS/network enforcement make the least-privilege claim executable.