Skip to content
API Blog

MCP Proxy

Workers never hold real credentials. The gateway resolves them at request time and proxies every outbound MCP call.

Every MCP URL a worker receives points back to the gateway with an X-Mcp-Id header identifying the upstream server.

  1. Worker sends a JSON-RPC request to the gateway proxy.
  2. Gateway authenticates the worker JWT, extracts agentId / userId.
  3. Looks up credentials for that user — auto-refreshes expired tokens.
  4. Injects the Authorization: Bearer <token> header, forwards to the upstream MCP.
  5. Response flows back to the worker.

Workers call tools/list and tools/call — credential handling is invisible.

MCP servers come from two sources, merged per agent:

  1. Agent settings + local skills — MCP servers come from per-agent settings and local SKILL.md files.
  2. Per-agent settings — MCPs added through the settings page or agent-driven install.

Global MCPs take precedence when IDs collide.

There are three ways an MCP server can authenticate:

MethodConfig fieldUse case
Static headersheadersAPI keys, service tokens — no per-user auth needed
Device-code OAuthoauth on the MCP serverPer-user OAuth — each user authenticates in their browser
Owletto-managedN/A (Owletto handles internally)Third-party APIs (GitHub, Google, Linear, etc.)

For MCP servers that use a shared API key or service token. The header value supports ${env:VAR_NAME} substitution so secrets stay in environment variables.

{
"id": "my-mcp",
"mcpServers": [{
"id": "my-mcp",
"url": "https://mcp.example.com",
"type": "sse",
"headers": {
"Authorization": "Bearer ${env:MY_MCP_TOKEN}"
}
}]
}

No user interaction needed. The gateway injects the header on every request.

For MCP servers that implement the OAuth 2.0 Device Authorization Grant. Each user authenticates individually by clicking a link and logging in via their browser.

User (chat) Worker Gateway MCP Server (OAuth)
| | | |
| "use tool X" | | |
|------------------>| | |
| | tools/call X | |
| |------------------>| |
| | | tools/call X |
| | |---------------------->|
| | | 401 Unauthorized |
| | |<---------------------|
| | | |
| | | POST /oauth/register |
| | |---------------------->|
| | | { client_id } |
| | |<---------------------|
| | | |
| | | POST /oauth/device_authorization
| | |---------------------->|
| | | { device_code, |
| | | user_code, |
| | | verification_uri } |
| | |<---------------------|
| | | |
| | login_required | |
| | + link + code | |
| |<------------------| |
| "Click this link | | |
| and enter code | | |
| ABCD-1234" | | |
|<------------------| | |
| | | |
| (user clicks link, logs in via browser) |
| | | |
| "done, try again"| | |
|------------------>| | |
| | tools/call X | |
| |------------------>| |
| | | poll device_code |
| | |---------------------->|
| | | { access_token, |
| | | refresh_token } |
| | |<---------------------|
| | | |
| | | (store credential) |
| | | |
| | | tools/call X + token |
| | |---------------------->|
| | | { result } |
| | |<---------------------|
| | { result } | |
| |<------------------| |
| "Here's the | | |
| result..." | | |
|<------------------| | |
  1. Tool call fails with auth error — Worker calls a tool, gateway proxies it, upstream MCP returns 401/403.

  2. Gateway auto-starts device-code flow — Detects the auth error and:

    • Registers as an OAuth client at {mcp-server-origin}/oauth/register (cached per MCP server)
    • Requests a device code from {mcp-server-origin}/oauth/device_authorization
    • Gets back a user_code, verification_uri, and device_code
  3. User gets a link in chat — The gateway returns a login_required response to the worker, which shows the user:

    Authentication required. Visit https://mcp.example.com/oauth/device and enter code ABCD-1234

  4. User authenticates in browser — Clicks the link, enters the code, and authorizes the application.

  5. Gateway polls for completion — On the next tool call from the worker, the gateway polls the token endpoint with the device_code. If the user has completed auth, it receives access_token + refresh_token.

  6. Credentials stored — Encrypted in Postgres, keyed by (agentId, userId, mcpId), with 90-day TTL.

  7. Future calls are transparent — Gateway injects Authorization: Bearer <token> on every proxied request. No more user interaction needed.

  • Storage: Encrypted at rest in Postgres with 90-day TTL
  • Auto-refresh: When a token is within 5 minutes of expiry, the gateway refreshes it using the refresh_token before proxying the request
  • Refresh locking: A per-process mutex prevents concurrent refresh races within a single gateway instance
  • Expiry fallback: If refresh fails (no refresh token, revoked, etc.), the next tool call triggers a new device-code flow

By default, the gateway auto-derives OAuth endpoints from the MCP server’s URL origin:

  • Registration: {origin}/oauth/register
  • Device authorization: {origin}/oauth/device_authorization
  • Token: {origin}/oauth/token
  • Verification (for user): {origin}/oauth/device

If the MCP server’s OAuth endpoints live at non-standard paths, or if you have a pre-registered client, you can override any of these via the oauth config. All fields are optional — omitted fields fall back to auto-derivation:

{
"oauth": {
"clientId": "my-pre-registered-client",
"clientSecret": "secret",
"tokenUrl": "https://auth.example.com/oauth/token",
"deviceAuthorizationUrl": "https://auth.example.com/oauth/device_authorization",
"registrationUrl": "https://auth.example.com/oauth/register",
"authUrl": "https://auth.example.com/oauth/device",
"scopes": ["read", "write"],
"resource": "https://api.example.com"
}
}

When clientId is provided, dynamic client registration is skipped entirely — useful when you’ve pre-registered an OAuth application with the MCP server.

Third-party API integrations (GitHub, Google, Linear, Notion, etc.) are handled by Owletto MCP servers. Owletto manages OAuth flows, token storage, and API proxying internally. The gateway acts as a thin proxy — it doesn’t know or care about the integration’s auth.

Workers access these APIs through Owletto tools (e.g., owletto_github_read_repo). If Owletto needs the user to authenticate, it returns instructions for the user to call owletto_login.

Workers receive MCP status at session startup that includes auth state:

interface McpStatus {
id: string;
name: string;
requiresAuth: boolean; // MCP config has oauth
requiresInput: boolean; // MCP needs manual config inputs
authenticated: boolean; // User has valid stored credential
configured: boolean; // Manual inputs have been provided
}

Based on this status, the worker’s system prompt includes setup instructions for any MCPs that need authentication. This lets the agent proactively guide users through login rather than waiting for a tool call to fail.

The proxy resolves upstream URLs and blocks requests to reserved/internal IP ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, link-local, IPv6 loopback/ULA). This prevents workers from using MCP configs to reach internal services.

MCP sessions (via Mcp-Session-Id header) are tracked in Postgres (mcp_proxy_sessions) with 30-minute TTL. If an upstream returns “Server not initialized” (stale session), the gateway automatically re-initializes with the MCP handshake (initialize + notifications/initialized) before retrying.

MCP tools can declare annotations indicating whether they are destructive or have side effects. The gateway checks these annotations and may require explicit user approval before executing a tool call. Grants are stored per agent and checked on each call.

Adding an MCP via local skills or agent settings

Section titled “Adding an MCP via local skills or agent settings”

Skills-registry entries wrap one or more MCP server definitions:

{
"id": "my-mcp",
"name": "My MCP Server",
"description": "What this MCP does",
"mcpServers": [ /* one of the server configs below */ ]
}

The inner mcpServers[] entry varies by auth mode:

No auth

{ "id": "my-mcp", "name": "My MCP", "url": "https://mcp.example.com", "type": "sse" }

Static auth headers (${env:VAR} substitution)

{
"id": "my-mcp", "name": "My MCP", "url": "https://mcp.example.com", "type": "sse",
"headers": { "Authorization": "Bearer ${env:MY_MCP_TOKEN}" }
}

Per-user OAuth, auto-derived endpoints

{
"id": "my-mcp", "name": "My MCP", "url": "https://mcp.example.com", "type": "sse",
"oauth": {}
}

Per-user OAuth, pre-registered client or custom endpoints

{
"id": "my-mcp", "name": "My MCP", "url": "https://mcp.example.com", "type": "sse",
"oauth": {
"clientId": "my-pre-registered-client",
"tokenUrl": "https://auth.example.com/oauth/token",
"deviceAuthorizationUrl": "https://auth.example.com/oauth/device_authorization",
"scopes": ["read", "write"]
}
}