This document describes the OAuth 2.1 + PKCE authentication implementation for the Pi MCP Adapter using the official MCP SDK.
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
- ✅ 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
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
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"
}
}
}
}url- The MCP server URL (required)auth- Set to"oauth"to force OAuth,falseto disable, or omit to auto-detectoauth.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 ashttp://localhost:3118/callback(optional)oauth.clientName- Client display name used for dynamic registration (optional, defaults toPi 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.
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.
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:
- Start the callback server lazily on an OS-assigned local port, or on the exact
oauth.redirectUriport for pre-registered callbacks - Discover OAuth endpoints automatically
- Register a dynamic client (if no clientId provided)
- Open your browser for authentication
- Wait for the automatic callback
- Complete the OAuth flow
- Store tokens securely
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
┌─────────┐ ┌──────────────┐ ┌─────────────────┐
│ 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 │
└─────────┘ └──────────────┘ └─────────────────┘
The SDK attempts to discover OAuth endpoints using:
- RFC 9728 Metadata - Fetches
/.well-known/oauth-protected-resource - WWW-Authenticate Header - Parses
resource_metadatafrom 401 responses
If no clientId is provided, the SDK:
- Discovers the registration endpoint from OAuth metadata
- Registers a new client with:
client_name: configuredoauth.clientNameor "Pi Coding Agent"client_uri: configuredoauth.clientUrior the adapter repository URLredirect_uris:["http://localhost:<active-callback-port>/callback"], or the configuredoauth.redirectUrigrant_types:["authorization_code", "refresh_token"]
- 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.
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) withoutoauth.redirectUrirequire the exact configured callback port fromMCP_OAUTH_CALLBACK_PORTor the default19876onlocalhost -
oauth.redirectUribinds the exact loopback host, port, and path from that URI and advertises the same URI to the provider -
Handles
code,state, anderrorparameters -
Displays success/error HTML pages
-
Validates state parameter for CSRF protection
-
Has a 5-minute timeout for pending authorizations
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.
All OAuth flows use PKCE with the S256 method, preventing authorization code interception attacks.
A cryptographically secure random state parameter is generated for each flow and validated on callback.
Token files (tokens.json) are created with 0o600 permissions and stored in hashed per-server directories with 0o700 permissions (readable only by owner).
Credentials are tied to a specific server URL. If the URL changes, the credentials are invalidated and re-authentication is required.
Run /mcp-auth <server> to authenticate.
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"
}
}
}
}Some servers require pre-registered clients. Obtain a client ID from your OAuth provider and add it to the config.
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.
If the browser fails to open (e.g., in SSH sessions), the authorization URL will be displayed. Copy it manually to your browser.
The OAuth implementation uses the following modules:
mcp-auth.ts- Auth storage and retrieval (hashed per-servertokens.jsonfiles)mcp-oauth-provider.ts- SDK OAuthClientProvider implementationmcp-callback-server.ts- Node.js HTTP callback servermcp-auth-flow.ts- High-level auth flow using SDK transport
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),
})