Skip to content

Latest commit

 

History

History
334 lines (247 loc) · 12.6 KB

File metadata and controls

334 lines (247 loc) · 12.6 KB

OAuth 2.1 Authentication for MCP

This document describes the OAuth 2.1 + PKCE authentication implementation for the Pi MCP Adapter using the official MCP SDK.

Overview

The Pi MCP Adapter uses the official MCP SDK's built-in OAuth implementation, which provides:

  • Automatic OAuth endpoint discovery (RFC 9728) - No manual configuration needed
  • Dynamic client registration (RFC 7591) - No clientId needed for most servers
  • Automatic callback handling - Built-in HTTP server handles callbacks automatically
  • Automatic token refresh - SDK handles token refresh transparently

Features

  • PKCE (S256) - Mandatory code challenge method for OAuth 2.1
  • Automatic Callback Server - No URL copying needed, browser redirects automatically
  • Dynamic Client Registration - Automatically registers with OAuth servers
  • Auto-Discovery - Discovers OAuth endpoints from server metadata
  • Automatic Token Refresh - SDK handles expired tokens automatically
  • State Parameter Validation - CSRF protection
  • Secure Token Storage - Stored in ~/.pi/agent/mcp-oauth/sha256-<server-hash>/tokens.json

Configuration

Minimal Configuration (Recommended)

For most MCP servers, you only need the URL:

{
  "mcpServers": {
    "my-oauth-server": {
      "url": "https://api.example.com/mcp"
    }
  }
}

OAuth is automatically enabled for HTTP servers. The SDK will:

  • Auto-detect if the server requires OAuth
  • Discover OAuth endpoints from the server
  • Register a dynamic client (if supported by the server)
  • Handle the entire OAuth flow including callback

Optional Configuration

You can optionally provide a pre-registered client:

{
  "mcpServers": {
    "my-oauth-server": {
      "url": "https://api.example.com/mcp",
      "auth": "oauth",
      "oauth": {
        "clientId": "your-client-id",
        "clientSecret": "your-client-secret",
        "scope": "read write",
        "redirectUri": "http://localhost:3118/callback"
      }
    }
  }
}

Configuration Options

  • url - The MCP server URL (required)
  • auth - Set to "oauth" to force OAuth, false to disable, or omit to auto-detect
  • oauth.grantType - "authorization_code" (default, browser flow) or "client_credentials" (non-interactive)
  • oauth.clientId - Pre-registered client ID (optional, SDK tries dynamic registration if not provided)
  • oauth.clientSecret - Client secret for confidential clients (optional)
  • oauth.scope - Requested OAuth scopes (optional)
  • oauth.redirectUri - Exact browser callback URI to advertise and bind, such as http://localhost:3118/callback (optional)
  • oauth.clientName - Client display name used for dynamic registration (optional, defaults to Pi Coding Agent)
  • oauth.clientUri - Client homepage URI used for dynamic registration (optional)

Dynamic clients normally omit oauth.redirectUri; the adapter starts the callback server lazily on the default loopback host (localhost) and asks the OS for an available local port when auth begins. Use oauth.redirectUri when the provider requires a pre-registered callback, such as Slack MCP's Claude-compatible http://localhost:3118/callback. The URI must use http:// with localhost, 127.0.0.1, or [::1], include an explicit port, and its host/path become the bound callback endpoint.

Non-Interactive client_credentials

For machine-to-machine OAuth, configure grantType: "client_credentials".

{
  "mcpServers": {
    "my-service": {
      "url": "https://api.example.com/mcp",
      "auth": "oauth",
      "oauth": {
        "grantType": "client_credentials",
        "clientId": "service-client-id",
        "clientSecret": "service-client-secret",
        "scope": "read write"
      }
    }
  }
}

This flow does not open a browser or use callback handling. oauth.redirectUri is ignored for client_credentials; oauth.clientName and oauth.clientUri still apply to dynamic client registration metadata.

Usage

Step 1: Authenticate

Run the /mcp-auth command with the server name:

/mcp-auth my-oauth-server

Manual /mcp-auth is the default flow. If you set settings.autoAuth: true, proxy/direct tool execution will trigger OAuth automatically when a server returns needs-auth, then retry the original operation once.

This will:

  1. Start the callback server lazily on an OS-assigned local port, or on the exact oauth.redirectUri port for pre-registered callbacks
  2. Discover OAuth endpoints automatically
  3. Register a dynamic client (if no clientId provided)
  4. Open your browser for authentication
  5. Wait for the automatic callback
  6. Complete the OAuth flow
  7. Store tokens securely

Step 2: Use the Server

Once authenticated, use the server normally:

mcp({ server: "my-oauth-server" })
mcp({ tool: "my-tool", args: '{"key": "value"}' })

The SDK automatically:

  • Adds the access token to requests
  • Refreshes expired tokens automatically
  • Re-authenticates if tokens are invalid

To clear stored OAuth credentials and force a fresh authorization:

/mcp logout my-oauth-server

How It Works

Authentication Flow

┌─────────┐     ┌──────────────┐     ┌─────────────────┐
│   Pi    │────▶│  MCP Server  │────▶│  OAuth Server   │
│         │     │              │     │                 │
│ 1. Init │     │ 2. Discovery │     │ 3. Register     │
│         │     │              │     │                 │
│         │◀────│              │◀────│ 4. Auth URL     │
│         │     │              │     │                 │
│         │────▶│  Callback    │◀────│ 5. Browser      │
│         │     │  Server      │     │    Redirect     │
│         │     │              │     │                 │
│         │◀────│              │◀────│ 6. Code         │
│         │     │              │     │                 │
│         │────▶│              │────▶│ 7. Exchange     │
│         │     │              │     │                 │
│         │◀────│              │◀────│ 8. Tokens       │
└─────────┘     └──────────────┘     └─────────────────┘

Auto-Discovery

The SDK attempts to discover OAuth endpoints using:

  1. RFC 9728 Metadata - Fetches /.well-known/oauth-protected-resource
  2. WWW-Authenticate Header - Parses resource_metadata from 401 responses

Dynamic Client Registration

If no clientId is provided, the SDK:

  1. Discovers the registration endpoint from OAuth metadata
  2. Registers a new client with:
    • client_name: configured oauth.clientName or "Pi Coding Agent"
    • client_uri: configured oauth.clientUri or the adapter repository URL
    • redirect_uris: ["http://localhost:<active-callback-port>/callback"], or the configured oauth.redirectUri
    • grant_types: ["authorization_code", "refresh_token"]
  3. Stores the registered client credentials and the redirect URIs returned by the authorization server

When a fresh browser auth starts, cached dynamic client info with tokens is re-registered if its stored redirect URIs are missing or do not include the current redirect URI. Token refresh does not perform this redirect check, so existing refresh-token grants keep working even after a callback setting changes.

Callback Server

A Node.js HTTP server runs on a loopback callback endpoint and handles the active callback path:

  • Dynamic registration starts the callback server only when auth begins, binds the default host localhost, and asks the OS for an available local port

  • Pre-registered clients (oauth.clientId) without oauth.redirectUri require the exact configured callback port from MCP_OAUTH_CALLBACK_PORT or the default 19876 on localhost

  • oauth.redirectUri binds the exact loopback host, port, and path from that URI and advertises the same URI to the provider

  • Handles code, state, and error parameters

  • Displays success/error HTML pages

  • Validates state parameter for CSRF protection

  • Has a 5-minute timeout for pending authorizations

Token Storage

Tokens are stored per-server in ~/.pi/agent/mcp-oauth/sha256-<server-hash>/tokens.json. The hash is derived from the configured MCP server name, so any valid config key can be used without becoming a filesystem path component:

{
  "tokens": {
    "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
    "expiresAt": 1709769600,
    "scope": "read write"
  },
  "clientInfo": {
    "clientId": "auto-registered-client-id",
    "clientSecret": "auto-generated-secret",
    "redirectUris": ["http://localhost:49152/callback"]
  },
  "serverUrl": "https://api.example.com/mcp"
}

Example directory structure:

~/.pi/agent/mcp-oauth/
├── sha256-<linear-server-name-hash>/
│   └── tokens.json
├── sha256-<github-server-name-hash>/
│   └── tokens.json
└── ...

The serverUrl field ensures credentials are invalidated if the server URL changes.

Security Considerations

PKCE

All OAuth flows use PKCE with the S256 method, preventing authorization code interception attacks.

State Parameter

A cryptographically secure random state parameter is generated for each flow and validated on callback.

File Permissions

Token files (tokens.json) are created with 0o600 permissions and stored in hashed per-server directories with 0o700 permissions (readable only by owner).

URL Validation

Credentials are tied to a specific server URL. If the URL changes, the credentials are invalidated and re-authentication is required.

Troubleshooting

"No OAuth tokens found"

Run /mcp-auth <server> to authenticate.

"Failed to discover OAuth endpoints"

The SDK automatically discovers OAuth endpoints from the MCP server. If discovery fails, the server may require a pre-registered client ID:

{
  "mcpServers": {
    "server": {
      "url": "https://api.example.com/mcp",
      "auth": "oauth",
      "oauth": {
        "clientId": "your-client-id",
        "scope": "read"
      }
    }
  }
}

"Dynamic client registration not supported"

Some servers require pre-registered clients. Obtain a client ID from your OAuth provider and add it to the config.

Callback server already in use

Dynamic browser OAuth uses a lazy OS-assigned port on the default loopback host (localhost), so the configured default port being busy should not block dynamic registration.

For pre-registered OAuth clients (oauth.clientId), the callback redirect URI must match exactly. Set oauth.redirectUri to the full registered callback, such as Slack MCP's Claude-compatible http://localhost:3118/callback, or free/set MCP_OAUTH_CALLBACK_PORT when you rely on the default /callback path without an explicit redirect URI.

Browser doesn't open

If the browser fails to open (e.g., in SSH sessions), the authorization URL will be displayed. Copy it manually to your browser.

Architecture

The OAuth implementation uses the following modules:

  • mcp-auth.ts - Auth storage and retrieval (hashed per-server tokens.json files)
  • mcp-oauth-provider.ts - SDK OAuthClientProvider implementation
  • mcp-callback-server.ts - Node.js HTTP callback server
  • mcp-auth-flow.ts - High-level auth flow using SDK transport

SDK Integration

The implementation uses these SDK exports:

import {
  auth,
  UnauthorizedError,
  OAuthClientProvider,
} from "@modelcontextprotocol/sdk/client/auth.js"

import {
  StreamableHTTPClientTransport,
} from "@modelcontextprotocol/sdk/client/streamableHttp.js"

The McpOAuthProvider class implements OAuthClientProvider and is passed to StreamableHTTPClientTransport:

const transport = new StreamableHTTPClientTransport(url, {
  authProvider: new McpOAuthProvider(serverName, serverUrl, config, callbacks),
})

References