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 (8)

Collapse
 
max_quimby profile image
Max Quimby

The "one file just compresses the three-server problem" observation is the part most people miss — collapsing servers doesn't shrink the trust boundary, it just hides it. The process env is the union of every credential, and every tool inherits all of it regardless of what it declares.

The thing that made this concrete for us was realizing the blast radius isn't just "a buggy tool grabs the wrong key" — it's that a prompt-injected instruction reaching any tool in that process can pivot to every credential in the env, because there's no boundary between them. Splitting _gh and _dev into separate processes (or at minimum separate credential scopes fetched per-call from a broker, not read from os.environ at import) is what actually enforces least privilege. Once keys live in the environment they're ambient authority for the whole process.

Curious whether you landed on process-per-credential, or something like a short-lived token minted per tool invocation? The latter also buys you an audit trail of which tool used which credential when — which the shared-env model can't give you even in principle.

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.

Collapse
 
rizzdev profile image
Andrew R

The process split stops one server from seeing both tokens. That part holds. Underneath it the mcp client still registers both servers tools in one list for the agent so a single conversation can pull from one server and push to the other inside the same context window and the protocol carries no server origin on a tool call so the process boundary never sees that handoff

Collapse
 
zira125 profile image
Zira

The process split is a useful minimum, but the stronger contract is the one you name at the end: capability injection rather than ambient authority. I would make the boundary testable with a fixture that attempts to read sibling environments and inherited descriptors, reach the other provider host, and request an unauthorized audience from the broker. For write tools, record tool identity, destination, credential audience, approval decision, and content hash per invocation. That catches composition risk and gives you a revocation/audit story that process separation alone cannot provide.

Collapse
 
sandrog profile image
Sandro Garcia

"I'd been thinking of GITHUB_TOKEN and DEV_TO_API as belonging to different tools, scoped by which function reads them."

When I read this part, my first reaction was "bad design" — but then you land exactly where you should: splitting into two MCPs along credential boundaries.

Each MCP server should be a toolset for one specific job. And the ecosystem is converging on the same principle at every layer: Anthropic's Tool Search and programmatic tool calling keep tool definitions out of the agent's context until they're needed, and the new MCP spec makes the protocol core stateless. Context, session, credentials — everything scoped to the minimum surface.

I'd take it one step further: the agent shouldn't hold credentials at all — only the execution layer should. The open-source IRC-A project explores exactly this for stateless multi-agent systems: short-lived, per-invocation delegated access instead of standing credentials sitting in process memory, so untrusted input like your update_article scenario is contained by reach, not by code review.

"…the agent's session becomes the shared trust boundary, and every tool call inherits the union of everything reachable from it…"

This part makes me think we're aligned: the problem with agents today isn't the tools or the agents themselves — it's the architecture, how we compose them. One process holding two write-capable credentials is the same mistake as three servers wired into one agent session, just at a different layer.

The fix isn't better tools; it's better boundaries.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.