# 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

```bash
bun add mcp-oauth-provider @modelcontextprotocol/sdk
```

## Examples

Check out the [examples](./examples) directory for complete working examples:

### **[Notion MCP](./examples/notion)** - 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](./examples/notion/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](./examples/notion/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.

## Quick Start

### Basic OAuth Flow

```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();
}
```

### Using with MCP Client

```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();
```

### Initial Tokens Configuration

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.

### Automatic Token Refresh

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_in`
- When retrieved, `expires_in` is calculated from `expires_at` and current time
- This ensures `expires_in` is always accurate, even hours after tokens were saved
- Tokens auto-refresh when `expires_in < 300 seconds` (5-minute buffer)

## Storage Options

### Memory Storage (Default)

```typescript
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

```typescript
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:

```typescript
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

```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();
```

### Custom Templates

```typescript
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

```typescript
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 URL
- `clientId` - OAuth client ID (optional for dynamic registration)
- `clientSecret` - OAuth client secret (optional for public clients)
- `scope` - OAuth scope to request
- `sessionId` - 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 registration
- `tokenRefresh` - Token refresh configuration
  - `maxRetries` - 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<OAuthTokens | undefined>`

**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()`)
- Stored tokens must include a `refresh_token`

**Returns:** `Promise<OAuthTokens>`

**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<OAuthTokens | undefined>`

**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 refresh
- `authorization_endpoint` - URL for authorization
- `issuer` - 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"
```

### 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 port
- `hostname` - Server hostname (default: 'localhost')
- `successHtml` - Custom success page HTML
- `errorHtml` - Custom error page HTML
- `signal` - AbortSignal for cancellation
- `onRequest` - 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:

```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
```

## Error Handling

The library handles several OAuth-specific errors:

- **Invalid state/verifier:** Throws `Error` with descriptive message
- **Missing authorization code:** Throws `Error`
- **Network errors:** Propagated from fetch calls
- **Token refresh failures:** Throws `Error` with 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

```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');
}
```

### Manual Operations

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);
}
```

## 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

```bash
# Run all tests
bun test

# Run tests in watch mode
bun test --watch

# Run tests with coverage
bun test --coverage
```

### Test 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 functionality
```

### Writing Tests

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 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 --watch
```

### Preview OAuth Templates

You can preview the OAuth success and error page templates locally:

```bash
# 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: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)

## License

MIT

## Contributing

Contributions welcome! Please open an issue or PR.

## Documentation

- [Automatic Token Refresh Guide](./docs/AUTOMATIC_TOKEN_REFRESH.md) - Deep dive into automatic token refresh behavior
- [Token Storage Changelog](./CHANGELOG_TOKEN_STORAGE.md) - Details on expires_at storage implementation

## Links

- [Model Context Protocol](https://modelcontextprotocol.io/)
- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- [OAuth 2.0 RFC](https://tools.ietf.org/html/rfc6749)
- [PKCE RFC](https://tools.ietf.org/html/rfc7636)
