Back to Articles

Building Secure MCP Servers: How Arcade-MCP Solves the OAuth Token Leakage Problem

[ View on GitHub ]

Building Secure MCP Servers: How Arcade-MCP Solves the OAuth Token Leakage Problem

Hook

Every time an LLM agent calls an authenticated API, you're potentially exposing OAuth tokens to the model itself, your logs, and your network traffic. Arcade-MCP treats this as a first-class architectural problem.

Context

The Model Context Protocol is Anthropic's attempt to standardize how AI agents interact with external tools and data sources. It's essentially JSON-RPC with conventions for exposing tools, resources, and prompts that LLMs can discover and invoke. The protocol has gained rapid adoption—Claude Desktop uses it, dozens of community servers have emerged, and the ecosystem is growing fast.

But MCP's specification has a glaring security gap: it doesn't define how credentials should flow through the system. When your agent needs to call the GitHub API or query Slack, where do those OAuth tokens live? Do they get passed from the client? Hardcoded in server environment variables? Exposed to the LLM's context window? Most early MCP implementations punted on this problem, assuming developers would figure it out. Arcade-MCP takes the opposite approach—it makes authenticated tool calling the central architectural concern, treating credential isolation as non-negotiable.

Technical Insight

Arcade-MCP's core innovation is the Context object that gets injected into every tool invocation. When you define a tool using the @mcp.tool() decorator, you can request a ctx: Context parameter that provides runtime access to user credentials without ever exposing them to the client or LLM.

Here's what a simple authenticated tool looks like:

from arcademcp import MCPApp
from arcademcp.context import Context

mcp = MCPApp()

@mcp.tool()
async def list_github_repos(
    ctx: Context,
    username: str
) -> dict:
    """List all repositories for a GitHub user."""
    token = await ctx.get_secret("github_token")
    
    async with httpx.AsyncClient() as client:
        response = await client.get(
            f"https://api.github.com/users/{username}/repos",
            headers={"Authorization": f"Bearer {token}"}
        )
        return response.json()

The magic happens in ctx.get_secret(). When this tool runs, Arcade's authorization layer intercepts the call and injects the user's actual GitHub token—but that token never appears in the MCP protocol messages, never gets logged to the LLM's conversation history, and never touches the client application. The client only sees the tool's return value.

For OAuth flows, Arcade-MCP ships with provider-specific helpers that handle the full authentication dance. The framework includes 22 pre-configured providers (GitHub, Google, Slack, Linear, Notion, etc.) that know each service's OAuth endpoints, scopes, and quirks:

from arcademcp.oauth import GitHubOAuth

mcp = MCPApp(
    oauth_providers=[
        GitHubOAuth(
            client_id="your_client_id",
            client_secret="your_client_secret",
            scopes=["repo", "read:user"]
        )
    ]
)

@mcp.tool(requires_auth="github")
async def create_issue(
    ctx: Context,
    repo: str,
    title: str,
    body: str
) -> dict:
    """Create a GitHub issue."""
    # Token automatically refreshed if expired
    token = await ctx.get_oauth_token("github")
    # Issue creation logic...

The requires_auth parameter triggers automatic OAuth flow initialization when the tool is first invoked. For desktop clients using stdio transport, this generates a URL the user visits to authorize. For HTTP+SSE deployments (like Arcade Cloud), the framework redirects users through a proper OAuth2 authorization code flow with PKCE.

The framework's decorator API extends beyond tools to cover the full MCP specification. Resources (retrievable data sources), prompts (reusable LLM instructions), and even sampling (letting servers request LLM completions) all use the same pattern:

@mcp.resource(uri="config://app/settings")
def get_settings(ctx: Context) -> str:
    """Return current app configuration."""
    return json.dumps({"theme": "dark", "notifications": True})

@mcp.prompt()
def code_review_prompt(
    ctx: Context,
    language: str,
    code: str
) -> list[Message]:
    """Generate a code review prompt."""
    return [
        Message(
            role="user",
            content=f"Review this {language} code:\n\n{code}"
        )
    ]

Under the hood, Arcade-MCP uses a dual-transport architecture. For local development and Claude Desktop integration, it runs in stdio mode where JSON-RPC messages flow over standard input/output. For production deployments, it can run as an HTTP server using Server-Sent Events for server-to-client notifications. The MCPApp class abstracts these differences—the same tool code works in both modes.

One underappreciated feature is arcade evals, a built-in testing framework that validates tool definitions against real LLM behavior. It's not enough for your tool schema to be technically correct—the LLM has to actually understand how to invoke it. The evals system sends your tool definitions to Claude or GPT-4, asks it to call them with various prompts, and scores whether the generated tool calls match expected parameters:

arcade evals run --tool list_github_repos \
  --model claude-3-5-sonnet \
  --prompt "Show me all repos for user octocat"

This catches issues like ambiguous parameter descriptions, missing validation constraints, or examples that mislead the LLM into incorrect invocations. It's the kind of pragmatic testing that only makes sense once you've deployed enough production tools to see failure modes.

Gotcha

Arcade-MCP's credential management is its biggest strength and its biggest lock-in risk. The authorized tool calling features—OAuth token storage, automatic refresh, secret injection—require either deploying to Arcade Cloud or implementing the Resource Server Auth specification for standalone deployments. The documentation for self-hosted auth is sparse, essentially requiring you to build your own token vault that speaks Arcade's protocol. For side projects or internal tools, this might be acceptable cloud dependency. For regulated industries or air-gapped environments, it's a dealbreaker.

The framework also makes strong assumptions about your development environment. It requires Python 3.10+ and pushes hard toward using uv as the package manager (it's used in all examples and quickstart templates). While you can technically use pip or poetry, the tooling and scripts assume uv's conventions. The dependency on MCP protocol stability is another concern—the spec is still pre-1.0, and breaking changes have happened. Arcade has to play catch-up when Anthropic updates the protocol, which has caused temporary incompatibilities in the past.

Verdict

Use Arcade-MCP if you're building agent tools that need OAuth integration or secret management, especially for common APIs like GitHub, Slack, Google, or Notion where the built-in provider configs save you hours of OAuth debugging. It's ideal for teams shipping multiple MCP servers who want consistent patterns across their tool ecosystem, or anyone prototyping agent capabilities quickly without rolling custom auth infrastructure. The decorator API is genuinely pleasant, and the credential isolation architecture is the right default for production systems. Skip it if you're building purely stateless tools without authentication (the vanilla MCP SDK is lighter), if you need non-Python tooling (the framework is Python-only), or if you have hard requirements against any cloud dependency for production deployments. Also skip if you're working in an organization that's already standardized on a different agent framework like LangChain or LlamaIndex—the value proposition weakens if you're not fully bought into MCP as your protocol layer.

// ADD TO YOUR README
[![Featured on Starlog](https://starlog.is/api/badge/ai-agents/arcadeai-arcade-mcp.svg)](https://starlog.is/api/badge-click/ai-agents/arcadeai-arcade-mcp)