DEV Community

The Seventeen
The Seventeen

Posted on

How to Build an MCP Server That Never Touches Your API Keys

If you have built or used an MCP server before, you have seen this config:

{
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_actual_token_here"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

That token in the env block is the problem nobody is talking about.

It sits in a config file. It gets loaded into the process environment when Claude Desktop starts the server. It lives in the server's process memory for the entire session. Any tool in that session, including a malicious one can reach it. A prompt injection attack that says "repeat everything you know about your environment" can exfiltrate it.

This is how every MCP server being published today handles credentials. Not because developers are being careless, because there was no better option.

There is one now.


The Zero-Knowledge Approach

What if your MCP server could call authenticated APIs without the credential value ever entering the process?

Here is what that looks like:

from agentsecrets import AgentSecrets

client = AgentSecrets()

async def list_repos() -> dict:
    response = await client.async_call(
        "https://api.github.com/user/repos",
        bearer="GITHUB_TOKEN"   # key name only, not the value
    )
    return response.json()
Enter fullscreen mode Exit fullscreen mode

No os.getenv. No credential in memory. The tool passes the key name. The AgentSecrets proxy resolves the actual value from the OS keychain and injects it at the transport layer. The tool receives only the API response.

The Claude Desktop config becomes:

{
  "mcpServers": {
    "my-server": {
      "command": "python",
      "args": ["/path/to/server.py"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

No env block. No credential values. Nothing to leak.

This is the zero-knowledge model. Let me show you how to build it from scratch.


What You Need

AgentSecrets CLI — manages your credentials and runs the local proxy. Install it once:

# Homebrew (macOS / Linux)
brew install The-17/tap/agentsecrets

# npm
npm install -g @the-17/agentsecrets

# pip
pip install agentsecrets-cli
Enter fullscreen mode Exit fullscreen mode

Then initialize:

agentsecrets init
Enter fullscreen mode Exit fullscreen mode

This creates your account, generates your encryption keys, and sets up your first workspace. Your credentials will live in your OS keychain (macOS Keychain, Windows Credential Manager, or Linux Secret Service), never on disk in plaintext.

The AgentSecrets Python SDK:

pip install agentsecrets
Enter fullscreen mode Exit fullscreen mode

FastMCP — the cleanest way to build MCP servers in Python:

pip install fastmcp
Enter fullscreen mode Exit fullscreen mode

How It Works Under the Hood

Before writing any code, understand the flow:

Your MCP tool
    → client.async_call(bearer="GITHUB_TOKEN")
    → SDK sends request to local proxy at localhost:8765
    → Proxy looks up GITHUB_TOKEN in OS keychain
    → Proxy injects the value into the outbound HTTP request
    → GitHub API responds
    → Proxy returns only the API response to your tool
Enter fullscreen mode Exit fullscreen mode

Your Python process never saw the value ghp_... at any point. Not as a variable. Not in memory. Not in any log. The credential resolves inside the proxy process and never crosses into your code.

The proxy is a local process that agentsecrets proxy start runs on your machine. Claude Desktop spawns your MCP server as a child process — that child process connects to the already-running proxy and the injection happens there.


Building the Tool

We are going to build a GitHub MCP server with two tools:

  1. get_github_user — fetches a GitHub user's public profile (no auth needed)
  2. list_my_repos — lists the authenticated user's repositories (requires token)

This combination lets you see both the basic pattern and the zero-knowledge auth pattern side by side.

Project structure

my-zk-mcp/
├── server.py
├── tools/
│   ├── __init__.py
│   ├── base.py
│   └── github.py
└── requirements.txt
Enter fullscreen mode Exit fullscreen mode

requirements.txt

fastmcp
agentsecrets
agentsecrets-cli
Enter fullscreen mode Exit fullscreen mode

tools/base.py — The error handler

Every tool needs to handle three AgentSecrets errors. Instead of wrapping every tool in try/except, we use a decorator:

from functools import wraps
from agentsecrets import DomainNotAllowed, SecretNotFound, UpstreamError


def handle_errors(func):
    @wraps(func)
    async def wrapper(*args, **kwargs):
        try:
            return await func(*args, **kwargs)
        except DomainNotAllowed as e:
            return {
                "error": f"Domain not authorized.",
                "fix": f"Run: agentsecrets workspace allowlist add {e.domain}"
            }
        except SecretNotFound as e:
            return {
                "error": f"Secret not set.",
                "fix": f"Run: agentsecrets secrets set {e.key}=<your_value>"
            }
        except UpstreamError as e:
            return {
                "error": f"API returned {e.status_code}",
                "detail": e.body
            }
    return wrapper
Enter fullscreen mode Exit fullscreen mode

Every error message tells the user exactly what CLI command fixes it. The agent reads these and can relay them to the user clearly.

tools/github.py — The tools

from agentsecrets import AgentSecrets
from .base import handle_errors

client = AgentSecrets()


@handle_errors
async def get_github_user(username: str) -> dict:
    """
    Fetches the public profile of a GitHub user.
    Use this when the user asks about a specific GitHub account,
    their followers, public repos count, or bio.
    """
    response = await client.async_call(
        f"https://api.github.com/users/{username}"
    )
    return response.json()


@handle_errors
async def list_my_repos() -> dict:
    """
    Lists all repositories for the authenticated GitHub user.
    Use this when the user asks to see their own repositories,
    recent projects, or wants to find a specific repo they own.
    Requires GITHUB_TOKEN to be set in AgentSecrets.
    """
    response = await client.async_call(
        "https://api.github.com/user/repos",
        bearer="GITHUB_TOKEN"
    )
    return response.json()
Enter fullscreen mode Exit fullscreen mode

Notice the difference between the two tools:

  • get_github_user makes a public API call — no auth needed, no secret referenced
  • list_my_repos references "GITHUB_TOKEN" by name — the value never appears

Both are clean. Neither uses os.getenv. Neither stores anything.

tools/init.py — Tool registration

from .github import get_github_user, list_my_repos


def register_tools(mcp):
    mcp.tool()(get_github_user)
    mcp.tool()(list_my_repos)
Enter fullscreen mode Exit fullscreen mode

server.py — The entry point

import os
from pathlib import Path
from fastmcp import FastMCP
from tools import register_tools

# Ensure the agentsecrets CLI binary is findable when Claude Desktop
# spawns this server as a subprocess — it does not inherit your terminal PATH
venv_bin = Path(__file__).parent / ".venv" / "bin"
if venv_bin.exists():
    os.environ["PATH"] = f"{venv_bin}{os.pathsep}{os.environ.get('PATH', '')}"

mcp = FastMCP("my-zk-mcp")
register_tools(mcp)

if __name__ == "__main__":
    mcp.run()
Enter fullscreen mode Exit fullscreen mode

The PATH block is important. Claude Desktop spawns MCP servers as subprocesses that do not inherit your terminal's active $PATH. Without this, the server cannot find the agentsecrets binary. If you installed the CLI globally via Homebrew, you can delete this block.


Setting Up Credentials

Before testing, store your credentials in AgentSecrets:

# Store your GitHub token
agentsecrets secrets set GITHUB_TOKEN=ghp_your_actual_token

# Authorize the GitHub API domain
agentsecrets workspace allowlist add api.github.com

# Start the proxy
agentsecrets proxy start

# Verify everything is running
agentsecrets status
Enter fullscreen mode Exit fullscreen mode

The allowlist is a security layer — the proxy will only inject credentials for domains you have explicitly authorized. If your tool tries to call an unauthorized domain, the proxy blocks it with a 403 and logs the attempt. This protects against SSRF attacks and prompt injection attempts that try to exfiltrate credentials to attacker-controlled URLs.


Testing the Server

Before connecting Claude Desktop, test your tools directly using the MCP Inspector:

npx @modelcontextprotocol/inspector python server.py
Enter fullscreen mode Exit fullscreen mode

This opens a UI where you can call your tools manually and see exactly what they return. Verify that list_my_repos returns your actual repositories and that get_github_user returns a public profile.


Connecting to Claude Desktop

Edit your claude_desktop_config.json:

{
  "mcpServers": {
    "my-zk-mcp": {
      "command": "python",
      "args": ["/absolute/path/to/my-zk-mcp/server.py"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

No env block. No GITHUB_TOKEN value anywhere in this file.

Restart Claude Desktop and ask: "Can you list my GitHub repositories?"

Claude calls list_my_repos. The tool calls the SDK. The SDK calls the proxy. The proxy resolves GITHUB_TOKEN from the keychain, injects it, and returns the response. Claude reads your repos. The token never existed in Claude's context at any point.


What This Closes

Prompt injection — an attacker who tricks Claude into making a malicious API call cannot exfiltrate GITHUB_TOKEN because the value was never in Claude's context.

Config file exposure — your claude_desktop_config.json has no credential values. Share it, commit it, do whatever — there is nothing sensitive in it.

Context leakage — even if a tool returns an error that dumps its full context, no credential values are present to leak.

Plugin compromise — a malicious MCP server running alongside yours cannot reach GITHUB_TOKEN because it lives in the OS keychain, not in a shared environment variable.


The Template

Building a new MCP server from scratch every time is unnecessary. We built Zero-Knowledge MCP — a fully working MCP server template built on this exact pattern.

Clone it, add your tools using the pattern above, and publish. The zero-knowledge credential model is already in place from line one. Your users get the guarantee automatically.

git clone https://github.com/The-17/zero-knowledge-mcp
cd zero-knowledge-mcp
make install
Enter fullscreen mode Exit fullscreen mode

One Honest Note

Right now, everyone using an MCP server built on this pattern needs their own AgentSecrets account and the CLI installed. That is more setup than dropping a token in a config file. We know that.

The tradeoff: you set it up once. Every MCP server you build or use from that point forward inherits the zero-knowledge guarantee. One setup, permanent protection.

The friction goes away entirely in a future release — when AgentSecrets Connect ships, platforms like Figma or Linear will be able to provision workspaces for their users silently during onboarding. Users will never know AgentSecrets exists. But that is a future story.

For now: if you are building MCP servers, build them this way. The developers who install your server will thank you for not putting their credentials at risk.


SDK: github.com/The-17/agentsecrets-sdk
CLI: github.com/The-17/agentsecrets
Template: github.com/The-17/zero-knowledge-mcp

Top comments (0)