mcp-oauth-provider
v1.0.0
Published
MCP OAuth client provider for streamable HTTP clients
Maintainers
Readme
MCP OAuth Provider
OAuth client provider implementation for the Model Context Protocol (MCP) HTTP stream transport.
Features
✅ Full MCP SDK Integration - Implements OAuthClientProvider from @modelcontextprotocol/sdk
✅ Automatic Token Refresh - Tokens refresh automatically when expired or about to expire
✅ Smart Token Storage - Stores expires_at (absolute time) for accurate expiry tracking
✅ PKCE Support - Automatic PKCE (Proof Key for Code Exchange) via MCP SDK
✅ Retry Logic - Configurable retry attempts with exponential backoff for token refresh
✅ Multiple Storage Backends - Memory and file-based storage with simple interface
✅ Session Management - Support for multiple concurrent OAuth sessions
✅ Callback Server - Bun-native HTTP server for handling OAuth callbacks
✅ Error Handling - Automatic credential invalidation on auth failures
✅ TypeScript - Full type safety with types from MCP SDK
✅ Bun-Only - Optimized for Bun runtime
Installation
bun add mcp-oauth-provider @modelcontextprotocol/sdkExamples
Check out the examples directory for complete working examples:
Notion MCP - Connect to Notion's MCP server
Two complete examples demonstrating OAuth integration with Notion's MCP server at https://mcp.notion.com/mcp:
1. Basic OAuth Flow (index.ts)
cd examples/notion
NOTION_CLIENT_ID=your_id NOTION_CLIENT_SECRET=your_secret bun index.tsDemonstrates:
- OAuth 2.0 authentication flow
- Token retrieval and display
- Authorization server metadata
- Error handling
2. Advanced MCP Integration (advanced.ts)
cd examples/notion
NOTION_CLIENT_ID=your_id NOTION_CLIENT_SECRET=your_secret bun advanced.tsDemonstrates:
- Full MCP client integration with
StreamableHTTPClientTransport - Automatic token refresh during MCP operations
- Listing and using MCP tools, resources, and prompts
- OAuth provider integration with MCP SDK
Each example includes detailed setup instructions and demonstrates different features of the library.
Quick Start
Basic OAuth Flow
import { createOAuthProvider } from 'mcp-oauth-provider';
import { auth } from '@modelcontextprotocol/sdk/client/auth.js';
import { createCallbackServer } from 'mcp-oauth-provider/server';
// Create OAuth provider
const provider = createOAuthProvider({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
redirectUri: 'http://localhost:8080/callback',
scope: 'openid profile email',
});
// Start callback server
const server = await createCallbackServer({
port: 8080,
hostname: 'localhost',
});
const serverUrl = 'https://mcp.notion.com/mcp'; // or your MCP server URL
try {
// Execute OAuth flow (PKCE handled automatically by SDK)
const result = await auth(provider, {
serverUrl,
});
if (result === 'REDIRECT') {
console.log('Browser opened for authorization...');
// Wait for callback
const callbackResult = await server.waitForCallback('/callback', 120000);
// Exchange code for tokens
await auth(provider, {
serverUrl,
authorizationCode: callbackResult.code,
});
}
console.log('Authorization successful!');
// Get tokens (automatically refreshed if expired)
const tokens = await provider.tokens();
console.log('Access token:', tokens?.access_token);
} finally {
await server.stop();
}Using with MCP Client
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { createOAuthProvider } from 'mcp-oauth-provider';
// Create provider and authenticate (see above)
const provider = createOAuthProvider({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
redirectUri: 'http://localhost:8080/callback',
});
// Perform OAuth flow...
await auth(provider, { serverUrl: 'https://mcp.notion.com/mcp' });
// Create MCP client with OAuth provider
const transport = new StreamableHTTPClientTransport(
new URL('https://mcp.notion.com/mcp'),
{
authProvider: provider, // Provider handles automatic token refresh!
}
);
const client = new Client(
{ name: 'my-app', version: '1.0.0' },
{ capabilities: {} }
);
await client.connect(transport);
// Use MCP features
const tools = await client.listTools();
const resources = await client.listResources();Initial Tokens Configuration
You can provide initial tokens in the config. These will be stored on first use and can be updated:
import { createOAuthProvider } from 'mcp-oauth-provider';
// Provider with initial tokens
const provider = createOAuthProvider({
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
redirectUri: 'http://localhost:8080/callback',
tokens: {
access_token: 'existing-access-token',
refresh_token: 'existing-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
},
});
// First call returns initial tokens (and stores them)
const tokens1 = await provider.tokens();
console.log(tokens1.access_token); // 'existing-access-token'
// Tokens can be updated
await provider.saveTokens({
access_token: 'new-access-token',
refresh_token: 'new-refresh-token',
token_type: 'Bearer',
expires_in: 3600,
});
// Subsequent calls return updated tokens from storage
const tokens2 = await provider.tokens();
console.log(tokens2.access_token); // 'new-access-token'Note: Unlike clientId/clientSecret (which always take precedence from config), tokens from config are initial values only. Once stored, the storage takes over. This is because tokens change over time (via refresh), while client credentials are permanent.
Automatic Token Refresh
The provider automatically refreshes expired tokens when you call tokens():
// Tokens are automatically refreshed if expired or expiring soon (< 5 minutes)
const tokens = await provider.tokens(); // May refresh behind the scenes!
// Requirements for automatic refresh:
// 1. authorizationServerMetadata must be set (done by auth() function)
// 2. A refresh_token must be available
// 3. Token must be expired or expiring within 5 minutes
// For manual refresh (after auth() sets metadata):
const newTokens = await provider.refreshTokens();How it works:
- Tokens are stored with
expires_at(absolute timestamp) instead ofexpires_in - When retrieved,
expires_inis calculated fromexpires_atand current time - This ensures
expires_inis always accurate, even hours after tokens were saved - Tokens auto-refresh when
expires_in < 300 seconds(5-minute buffer)
Storage Options
Memory Storage (Default)
import { createOAuthProvider, MemoryStorage } from 'mcp-oauth-provider';
const provider = createOAuthProvider({
redirectUri: 'http://localhost:8080/callback',
storage: new MemoryStorage(), // Data lost when process exits
});File Storage
import { createOAuthProvider, FileStorage } from 'mcp-oauth-provider';
const provider = createOAuthProvider({
redirectUri: 'http://localhost:8080/callback',
storage: new FileStorage('./oauth-data'), // Persists to filesystem
});Custom Storage
Implement the simple StorageAdapter interface:
import type { StorageAdapter } from 'mcp-oauth-provider';
class RedisStorage implements StorageAdapter {
constructor(private redis: RedisClient) {}
async get(key: string): Promise<string | undefined> {
return await this.redis.get(key);
}
async set(key: string, value: string): Promise<void> {
await this.redis.set(key, value);
}
async delete(key: string): Promise<void> {
await this.redis.del(key);
}
}
const provider = createOAuthProvider({
redirectUri: 'http://localhost:8080/callback',
storage: new RedisStorage(redisClient),
});Callback Server
Basic Usage
import { createCallbackServer } from 'mcp-oauth-provider/server';
const server = await createCallbackServer({
port: 8080,
hostname: 'localhost',
});
// Wait for OAuth callback
const result = await server.waitForCallback('/callback', 30000);
console.log('Authorization code:', result.code);
console.log('State:', result.state);
await server.stop();Custom Templates
const server = await createCallbackServer({
port: 8080,
successHtml: '<html><body><h1>Success!</h1></body></html>',
errorHtml: '<html><body><h1>Error: {{error}}</h1></body></html>',
});One-Shot Callback
import { waitForOAuthCallback } from 'mcp-oauth-provider/server';
// Server automatically starts and stops
const result = await waitForOAuthCallback('/callback', {
port: 8080,
timeout: 30000,
});API Reference
Client Provider
createOAuthProvider(config: OAuthConfig)
Creates an OAuth client provider instance.
Config Options:
redirectUri(required) - OAuth callback URLclientId- OAuth client ID (optional for dynamic registration)clientSecret- OAuth client secret (optional for public clients)scope- OAuth scope to requestsessionId- Session identifier (generated automatically if not provided)storage- Storage adapter (defaults to MemoryStorage)tokens- Static OAuth tokens (takes precedence over storage)clientMetadata- OAuth client metadata for registrationtokenRefresh- Token refresh configurationmaxRetries- Maximum retry attempts (default: 3)retryDelay- Delay between retries in ms (default: 1000)
server- Callback server configuration
provider.tokens()
Get OAuth tokens with automatic refresh.
Behavior:
- Returns stored tokens if valid (> 5 minutes until expiry)
- Automatically refreshes tokens if expired or expiring soon (< 5 minutes)
- Requires
authorizationServerMetadatato be set for automatic refresh - Falls back to current tokens if refresh fails
Returns: Promise<OAuthTokens | undefined>
Example:
// Tokens are automatically refreshed if needed
const tokens = await provider.tokens();provider.refreshTokens()
Manually refresh OAuth tokens using the refresh token.
Requirements:
authorizationServerMetadata.token_endpointmust be set (done byauth())- Stored tokens must include a
refresh_token
Returns: Promise<OAuthTokens>
Throws: Error if no authorization server metadata or refresh token available
Example:
// Manual refresh (after calling auth())
const newTokens = await provider.refreshTokens();provider.getStoredTokens()
Get stored tokens without triggering automatic refresh.
Use Case: Check token state without side effects
Returns: Promise<OAuthTokens | undefined>
Example:
// Get tokens without auto-refresh
const tokens = await provider.getStoredTokens();
if (tokens && tokens.expires_in < 300) {
console.log('Tokens expiring soon!');
}provider.authorizationServerMetadata
OAuth server metadata set during the auth flow. Contains endpoints for token operations.
Type: AuthorizationServerMetadata | undefined
Properties:
token_endpoint- URL for token refreshauthorization_endpoint- URL for authorizationissuer- OAuth server identifier- And more...
Example:
// Metadata is set automatically by auth()
await auth(provider, { serverUrl: 'https://mcp.notion.com/mcp' });
console.log(provider.authorizationServerMetadata?.token_endpoint);
// Output: "https://auth.notion.com/token"Storage
MemoryStorage
In-memory storage (data lost on process exit).
FileStorage
File-based storage using Bun's file API.
OAuthStorage
Helper class that wraps a storage adapter with OAuth-specific methods.
Server
createCallbackServer(options)
Create and start an OAuth callback server.
Options:
port(required) - Server porthostname- Server hostname (default: 'localhost')successHtml- Custom success page HTMLerrorHtml- Custom error page HTMLsignal- AbortSignal for cancellationonRequest- Request handler callback
waitForOAuthCallback(path, options)
Convenience function that starts server, waits for callback, and stops server automatically.
Session Management
The provider supports multiple concurrent OAuth sessions:
// Create session-specific providers
const session1 = createOAuthProvider({
redirectUri: 'http://localhost:8080/callback',
sessionId: 'user-123',
});
const session2 = createOAuthProvider({
redirectUri: 'http://localhost:8080/callback',
sessionId: 'user-456',
});
// Each session has isolated tokens and credentialsError Handling
The library handles several OAuth-specific errors:
- Invalid state/verifier: Throws
Errorwith descriptive message - Missing authorization code: Throws
Error - Network errors: Propagated from fetch calls
- Token refresh failures: Throws
Errorwith details - Automatic refresh failures: Logged as warning, returns existing tokens
Automatic Refresh Error Behavior
When tokens() attempts automatic refresh and fails:
- Logs a warning to console
- Returns existing (expired) tokens instead of throwing
- Allows application to continue and handle expiry
// Automatic refresh handles errors gracefully
const tokens = await provider.tokens();
// If refresh failed, you'll get expired tokens
// Check expires_in to detect this scenario
if (tokens && tokens.expires_in < 0) {
console.log('Tokens expired and refresh failed');
}Manual Operations
Always wrap manual OAuth operations in try-catch:
import { executeOAuthFlow } from 'mcp-oauth-provider';
try {
await executeOAuthFlow(provider, {
serverUrl: 'https://your-mcp-server.com',
});
} catch (error) {
if (error.message.includes('access_denied')) {
console.log('User denied authorization');
} else if (error.message.includes('invalid_client')) {
console.log('Invalid client credentials');
// Credentials automatically invalidated by library
}
}
try {
await provider.refreshTokens();
} catch (error) {
console.error('Manual refresh failed:', error.message);
}Security Considerations
- Never log tokens or secrets - The library avoids logging sensitive data
- PKCE is automatic - Code challenge/verifier handled by MCP SDK
- State parameter - CSRF protection with random state generation
- Token expiry - Automatic refresh before expiration
- Credential invalidation - Automatic cleanup on auth errors
Testing
This package includes comprehensive unit and integration tests using bun:test.
Running Tests
# Run all tests
bun test
# Run tests in watch mode
bun test --watch
# Run tests with coverage
bun test --coverageTest Coverage
The test suite covers:
- ✅ OAuth Flow Utilities - Token expiry detection, token refresh with retry logic, exponential backoff
- ✅ Storage Adapters - MemoryStorage, FileStorage, OAuthStorage with session isolation, time-based expiry
- ✅ Configuration - Session ID generation, state generation, default metadata
- ✅ OAuth Client Provider - Token management, automatic refresh, client information handling, storage isolation
- ✅ Callback Server - Server lifecycle, callback handling, timeout management, custom templates
71 tests pass with high coverage of critical OAuth functionality including automatic token refresh.
Test Structure
src/__tests__/
├── config.test.ts # Configuration utilities
├── storage.test.ts # Storage adapters
├── oauth-flow.test.ts # OAuth flow helpers
├── integration.test.ts # MCPOAuthClientProvider integration
└── server.test.ts # Callback server functionalityWriting Tests
When contributing, please:
- Add tests for new features
- Ensure existing tests pass
- Use descriptive test names
- Test error conditions and edge cases
- Mock external dependencies appropriately
Example test pattern:
import { describe, expect, test, beforeEach } from 'bun:test';
describe('MyFeature', () => {
beforeEach(() => {
// Setup
});
test('should do something', () => {
// Test implementation
expect(result).toBe(expected);
});
});Development
# Install dependencies
bun install
# Build
bun run build
# Type check
bun run check-types
# Lint
bun run lint
# Format
bun run format
# Run tests
bun test
# Run tests in watch mode
bun test --watchPreview OAuth Templates
You can preview the OAuth success and error page templates locally:
# Preview success page (runs on http://localhost:3000)
bun run server:success
# Preview error page (runs on http://localhost:3001)
bun run server:error
# Customize error page with environment variables
ERROR="invalid_client" ERROR_DESCRIPTION="Custom error message" bun run server:errorAvailable environment variables for server:error:
ERROR- The error code (default:access_denied)ERROR_DESCRIPTION- Detailed error message (default: "The user denied the authorization request.")ERROR_URI- Optional URL for more informationPORT- Server port (default: 3001)
For server:success:
PORT- Server port (default: 3000)
License
MIT
Contributing
Contributions welcome! Please open an issue or PR.
Documentation
- Automatic Token Refresh Guide - Deep dive into automatic token refresh behavior
- Token Storage Changelog - Details on expires_at storage implementation
