MCP OAuth client provider for streamable HTTP clients
npm install mcp-oauth-providerOAuth client provider implementation for the Model Context Protocol (MCP) HTTP stream transport.
✅ 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
``bash`
bun add mcp-oauth-provider @modelcontextprotocol/sdk
Check out the examples directory for complete working examples:
Two complete examples demonstrating OAuth integration with Notion's MCP server at https://mcp.notion.com/mcp:
#### 1. Basic OAuth Flow (index.ts)
`bash`
cd examples/notion
NOTION_CLIENT_ID=your_id NOTION_CLIENT_SECRET=your_secret bun index.ts
Demonstrates:
- OAuth 2.0 authentication flow
- Token retrieval and display
- Authorization server metadata
- Error handling
#### 2. Advanced MCP Integration (advanced.ts)
`bash`
cd examples/notion
NOTION_CLIENT_ID=your_id NOTION_CLIENT_SECRET=your_secret bun advanced.ts
Demonstrates:
- 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.
`typescript
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();
}
`
`typescript
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();
`
You can provide initial tokens in the config. These will be stored on first use and can be updated:
`typescript
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.
The provider automatically refreshes expired tokens when you call tokens():
`typescript
// 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 of expires_inexpires_in
- When retrieved, is calculated from expires_at and current timeexpires_in
- This ensures is always accurate, even hours after tokens were savedexpires_in < 300 seconds
- Tokens auto-refresh when (5-minute buffer)
`typescript
import { createOAuthProvider, MemoryStorage } from 'mcp-oauth-provider';
const provider = createOAuthProvider({
redirectUri: 'http://localhost:8080/callback',
storage: new MemoryStorage(), // Data lost when process exits
});
`
`typescript
import { createOAuthProvider, FileStorage } from 'mcp-oauth-provider';
const provider = createOAuthProvider({
redirectUri: 'http://localhost:8080/callback',
storage: new FileStorage('./oauth-data'), // Persists to filesystem
});
`
Implement the simple StorageAdapter interface:
`typescript
import type { StorageAdapter } from 'mcp-oauth-provider';
class RedisStorage implements StorageAdapter {
constructor(private redis: RedisClient) {}
async get(key: string): Promise
return await this.redis.get(key);
}
async set(key: string, value: string): Promise
await this.redis.set(key, value);
}
async delete(key: string): Promise
await this.redis.del(key);
}
}
const provider = createOAuthProvider({
redirectUri: 'http://localhost:8080/callback',
storage: new RedisStorage(redisClient),
});
`
`typescript
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();
`
`typescript`
const server = await createCallbackServer({
port: 8080,
successHtml: 'Success!
',
errorHtml: 'Error: {{error}}
',
});
`typescript
import { waitForOAuthCallback } from 'mcp-oauth-provider/server';
// Server automatically starts and stops
const result = await waitForOAuthCallback('/callback', {
port: 8080,
timeout: 30000,
});
`
#### 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 authorizationServerMetadata to be set for automatic refresh
- Falls back to current tokens if refresh fails
Returns: Promise
Example:
`typescript`
// Tokens are automatically refreshed if needed
const tokens = await provider.tokens();
#### provider.refreshTokens()
Manually refresh OAuth tokens using the refresh token.
Requirements:
- authorizationServerMetadata.token_endpoint must be set (done by auth())refresh_token
- Stored tokens must include a
Returns: Promise
Throws: Error if no authorization server metadata or refresh token available
Example:
`typescript`
// 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
Example:
`typescript`
// 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:
`typescript
// 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"
`
#### 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.
#### 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.
The provider supports multiple concurrent OAuth sessions:
`typescript
// 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 credentials
`
The library handles several OAuth-specific errors:
- Invalid state/verifier: Throws Error with descriptive messageError
- Missing authorization code: Throws Error
- Network errors: Propagated from fetch calls
- Token refresh failures: Throws with details
- Automatic refresh failures: Logged as warning, returns existing tokens
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
`typescript`
// 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');
}
Always wrap manual OAuth operations in try-catch:
`typescript
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);
}
`
- 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
This package includes comprehensive unit and integration tests using bun:test.
`bashRun all tests
bun test
$3
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.
$3
`
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 functionality
`$3
When contributing, please:
1. Add tests for new features
2. Ensure existing tests pass
3. Use descriptive test names
4. Test error conditions and edge cases
5. Mock external dependencies appropriately
Example test pattern:
`typescript
import { describe, expect, test, beforeEach } from 'bun:test';describe('MyFeature', () => {
beforeEach(() => {
// Setup
});
test('should do something', () => {
// Test implementation
expect(result).toBe(expected);
});
});
`Development
`bash
Install dependencies
bun installBuild
bun run buildType check
bun run check-typesLint
bun run lintFormat
bun run formatRun tests
bun testRun tests in watch mode
bun test --watch
`$3
You can preview the OAuth success and error page templates locally:
`bash
Preview success page (runs on http://localhost:3000)
bun run server:successPreview error page (runs on http://localhost:3001)
bun run server:errorCustomize error page with environment variables
ERROR="invalid_client" ERROR_DESCRIPTION="Custom error message" bun run server:error
`Available 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 information
- PORT - Server port (default: 3001)For
server:success:-
PORT` - Server port (default: 3000)MIT
Contributions welcome! Please open an issue or PR.
- Automatic Token Refresh Guide - Deep dive into automatic token refresh behavior
- Token Storage Changelog - Details on expires_at storage implementation
- Model Context Protocol
- MCP TypeScript SDK
- OAuth 2.0 RFC
- PKCE RFC