chuk-mcp-client-oauth 0.4
A reusable OAuth 2.0 client library for MCP (Model Context Protocol) servers
License: Apache-2.0
Requires Python: >=3.10
Keywords: authentication, mcp, model-context-protocol, oauth, oauth2

# chuk-mcp-client-oauth

A simple, secure OAuth 2.0 client library for connecting to MCP (Model Context Protocol) servers.

**Perfect for developers who want to add OAuth authentication to their MCP applications without wrestling with OAuth complexity.**

[![Tests](https://github.com/chrishayuk/chuk-mcp-client-oauth/workflows/CI/badge.svg)](https://github.com/chrishayuk/chuk-mcp-client-oauth/actions)
[![Coverage](https://img.shields.io/badge/coverage-99%25-brightgreen)](https://github.com/chrishayuk/chuk-mcp-client-oauth)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
[![Code Quality](https://img.shields.io/badge/code%20quality-A+-success)](https://github.com/chrishayuk/chuk-mcp-client-oauth)

---

## 🎯 What is This?

This library makes it **dead simple** to authenticate with OAuth-enabled MCP servers. Whether you're building a CLI tool, web app, or service that needs to connect to MCP servers, this library handles all the OAuth complexity for you.

### What's MCP OAuth?

MCP (Model Context Protocol) servers can use OAuth 2.0 to control who can access them. Think of it like logging into GitHub or Google - but for AI/LLM services.

**As a client developer, you need:**
1. 🔐 **Authenticate** - Get permission from the server
2. 💾 **Store tokens** - Keep credentials secure
3. 🔄 **Refresh tokens** - Keep sessions alive
4. 🔧 **Use tokens** - Include them in API requests

This library does all of that for you.

### OAuth 2.1 & MCP Compliance

This library implements:
- ✅ **OAuth 2.1 Best Practices** - Authorization Code + PKCE, no legacy grants
- ✅ **MCP Authorization Spec** - Protected Resource Metadata discovery (RFC 9728)
- ✅ **Resource Indicators** - Token binding to prevent reuse (RFC 8707)
- ✅ **WWW-Authenticate Fallback** - Discovery from 401/403 responses
- ✅ **Secure Token Storage** - OS keychain, encrypted files, HashiCorp Vault
- ✅ **Automatic Token Refresh** - Handles expiration transparently
- 🔄 **Device Code Flow** - Coming in v0.2.0 for headless environments

**Standards Compliance:**
- [OAuth 2.1 Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-10) - Modern OAuth best practices
- [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) - Protected Resource Metadata
- [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) - Resource Indicators
- [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) - Authorization Server Metadata Discovery
- [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) - Dynamic Client Registration
- [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) - PKCE

---

## 🚀 Quick Start (5 minutes)

### Installation

Using `uv` (recommended):
```bash
uv add chuk-mcp-client-oauth
```

Or using pip:
```bash
pip install chuk-mcp-client-oauth
```

### 30-Second Minimal Example

```python
import asyncio
from chuk_mcp_client_oauth import OAuthHandler

async def main():
    handler = OAuthHandler()  # Auto keychain/credential manager or encrypted file

    # Authenticate (opens browser once, then caches tokens)
    await handler.ensure_authenticated_mcp(
        server_name="notion",
        server_url="https://mcp.notion.com/mcp",
        scopes=["read", "write"],
    )

    # Get ready-to-use headers for any HTTP/SSE/WebSocket call
    headers = await handler.prepare_headers_for_mcp_server(
        "notion",
        "https://mcp.notion.com/mcp"
    )
    print(headers["Authorization"][:30], "...")

asyncio.run(main())
```

**That's it!** Subsequent runs use cached tokens—no browser needed. See [Complete MCP Session](#using-the-tokens---complete-mcp-example) for full JSON-RPC + SSE example.

---

### Your First OAuth Flow (Complete Example)

```python
import asyncio
from chuk_mcp_client_oauth import OAuthHandler

async def main():
    # Create handler - it auto-configures secure storage
    handler = OAuthHandler()

    # Authenticate with a server (opens browser once)
    tokens = await handler.ensure_authenticated_mcp(
        server_name="notion-mcp",
        server_url="https://mcp.notion.com/mcp",
        scopes=["read", "write"]
    )

    print(f"✅ Authenticated! Token: {tokens.access_token[:20]}...")

    # Next time you run this, it uses cached tokens (no browser)
    # Headers are ready to use in your HTTP requests
    headers = await handler.prepare_headers_for_mcp_server(
        server_name="notion-mcp",
        server_url="https://mcp.notion.com/mcp"
    )

    print(f"🔑 Authorization header: {headers['Authorization'][:30]}...")

asyncio.run(main())
```

**Using macOS Keychain (Explicit):**

```python
import asyncio
from chuk_mcp_client_oauth import OAuthHandler, TokenManager, TokenStoreBackend

async def main():
    # Explicitly use macOS Keychain for token storage
    # NOTE: 'keyring' library is automatically installed on macOS/Windows
    # No password needed - uses macOS Keychain Access
    token_manager = TokenManager(backend=TokenStoreBackend.KEYCHAIN)
    handler = OAuthHandler(token_manager=token_manager)

    # Authenticate with a server (tokens stored in macOS Keychain)
    tokens = await handler.ensure_authenticated_mcp(
        server_name="notion-mcp",
        server_url="https://mcp.notion.com/mcp",
        scopes=["read", "write"]
    )

    print(f"✅ Authenticated! Token stored in macOS Keychain")
    print(f"🔑 Access Token: {tokens.access_token[:20]}...")

    # You can verify this in Keychain Access app:
    # 1. Open Keychain Access
    # 2. Search for "chuk-oauth"
    # 3. You'll see "notion-mcp" entry under the "chuk-oauth" service

asyncio.run(main())
```

**Using the Tokens - Complete MCP Example:**

Now let's use those tokens to actually interact with Notion MCP - listing available tools:

```python
import asyncio
import uuid
from chuk_mcp_client_oauth import OAuthHandler, parse_sse_json

async def list_notion_tools():
    """Complete example: Authenticate and list Notion MCP tools."""
    handler = OAuthHandler()
    server_name = "notion-mcp"
    server_url = "https://mcp.notion.com/mcp"

    # Authenticate (uses cached tokens if available)
    print("🔐 Authenticating with Notion MCP...")
    tokens = await handler.ensure_authenticated_mcp(
        server_name=server_name,
        server_url=server_url,
        scopes=["read", "write"]
    )
    print(f"✅ Authenticated! Token: {tokens.access_token[:20]}...")

    # Now use the tokens to make authenticated requests
    session_id = str(uuid.uuid4())

    # Step 1: Initialize MCP session
    print("\n📋 Initializing MCP session...")
    init_response = await handler.authenticated_request(
        server_name=server_name,
        server_url=server_url,
        url=server_url,
        method="POST",
        json={
            "jsonrpc": "2.0",
            "id": 1,
            "method": "initialize",
            "params": {
                "protocolVersion": "2024-11-05",
                "capabilities": {"roots": {"listChanged": True}},
                "clientInfo": {"name": "quickstart-example", "version": "1.0.0"}
            }
        },
        headers={
            "Accept": "application/json, text/event-stream",
            "Content-Type": "application/json"
        },
        timeout=60.0  # MCP initialization can be slow
    )

    # Extract session ID from response header
    session_id = init_response.headers.get('mcp-session-id', session_id)
    print(f"   ✅ Session initialized: {session_id[:16]}...")

    # Step 2: Send initialized notification
    await handler.authenticated_request(
        server_name=server_name,
        server_url=server_url,
        url=server_url,
        method="POST",
        json={"jsonrpc": "2.0", "method": "notifications/initialized"},
        headers={
            "Accept": "application/json, text/event-stream",
            "Content-Type": "application/json",
            "Mcp-Session-Id": session_id
        },
        timeout=30.0
    )

    # Step 3: List tools (this is where we use the Bearer token!)
    print("\n🔧 Listing available tools...")
    tools_response = await handler.authenticated_request(
        server_name=server_name,
        server_url=server_url,
        url=server_url,
        method="POST",
        json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
        headers={
            "Accept": "application/json, text/event-stream",
            "Content-Type": "application/json",
            "Mcp-Session-Id": session_id
            # Note: Authorization: Bearer <token> is automatically added!
        },
        timeout=30.0
    )

    # Parse SSE response (MCP servers often return text/event-stream)
    content_type = tools_response.headers.get('content-type', '')
    if 'text/event-stream' in content_type:
        data = parse_sse_json(tools_response.text.strip().splitlines())
    else:
        data = tools_response.json()

    # Display the tools
    if "result" in data and "tools" in data["result"]:
        tools = data["result"]["tools"]
        print(f"\n📦 Found {len(tools)} Notion tools:")
        for tool in tools[:5]:  # Show first 5
            print(f"   • {tool.get('name', 'Unknown')}")
            if 'description' in tool:
                desc = tool['description']
                print(f"     {desc[:80]}{'...' if len(desc) > 80 else ''}")
        if len(tools) > 5:
            print(f"   ... and {len(tools) - 5} more")

    print("\n✅ Complete! Your Bearer token was automatically used in all requests.")
    print(f"   The library added: Authorization: Bearer {tokens.access_token[:20]}...")
    print("   to every HTTP request above.")

asyncio.run(list_notion_tools())
```

**Output:**
```
🔐 Authenticating with Notion MCP...
✅ Authenticated! Token: 282c6a79-d66f-402e-a...

📋 Initializing MCP session...
   ✅ Session initialized: d6b130b8684f5ee9...

🔧 Listing available tools...

📦 Found 15 Notion tools:
   • notion-search
     Perform a search over: - "internal": Semantic search over Notion workspace and c...
   • notion-fetch
     Retrieves details about a Notion entity (page or database) by URL or ID.
Provide...
   • notion-create-pages
     ## Overview
Creates one or more Notion pages, with the specified properties and ...
   • notion-update-page
     ## Overview
Update a Notion page's properties or content.
## Properties
Notion p...
   • notion-move-pages
     Move one or more Notion pages or databases to a new parent.
   ... and 10 more

✅ Complete! Your Bearer token was automatically used in all requests.
   The library added: Authorization: Bearer 282c6a79-d66f-402e-a...
   to every HTTP request above.
```

**What happened behind the scenes:**

Every HTTP request included your Bearer token:
```http
POST /mcp HTTP/1.1
Host: mcp.notion.com
Authorization: Bearer 282c6a79-d66f-402e-a8f4-27b1c5d3e6f7...
Accept: application/json, text/event-stream
Content-Type: application/json
Mcp-Session-Id: d6b130b8684f5ee9...

{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
```

The `authenticated_request()` method:
1. ✅ Retrieved your cached tokens (no re-authentication needed)
2. ✅ Added `Authorization: Bearer <token>` header to every request
3. ✅ Parsed SSE responses automatically
4. ✅ Would have refreshed the token if server returned 401

**Using a Custom Service Name (for your application):**

```python
import asyncio
from chuk_mcp_client_oauth import OAuthHandler, TokenManager, TokenStoreBackend

async def main():
    # Use your own application name for keychain entries
    token_manager = TokenManager(
        backend=TokenStoreBackend.KEYCHAIN,
        service_name="my-awesome-app"  # Custom service name
    )
    handler = OAuthHandler(token_manager=token_manager)

    tokens = await handler.ensure_authenticated_mcp(
        server_name="notion-mcp",
        server_url="https://mcp.notion.com/mcp",
        scopes=["read", "write"]
    )

    print(f"✅ Authenticated! Token stored under 'my-awesome-app' service")

    # In Keychain Access, search for "my-awesome-app" instead of "chuk-oauth"
    # This helps organize tokens for your specific application

asyncio.run(main())
```

**Platform-Specific Token Storage:**
- **macOS**: `keyring` is automatically installed → Uses macOS Keychain (no password needed)
- **Windows**: `keyring` is automatically installed → Uses Windows Credential Manager (no password needed)
- **Linux**: Install with `pip install chuk-mcp-client-oauth[linux]` → Uses Secret Service (GNOME/KDE)
- **All platforms**: Falls back to encrypted file storage if platform backend unavailable

**That's it!** The library handles:
- ✅ OAuth server discovery
- ✅ Dynamic client registration
- ✅ Opening browser for user consent
- ✅ Receiving the callback
- ✅ Exchanging codes for tokens
- ✅ Storing tokens securely
- ✅ Reusing tokens on subsequent runs
- ✅ Refreshing expired tokens

**What happens on each run:**
- **First run**: Opens browser for authentication → Saves tokens to storage
- **Second run**: Loads cached tokens → No browser needed
- **Re-running after clearing tokens**: Opens browser again (like first run)

**Quick Reference: Clearing tokens to re-run quickstart**
```bash
# Method 1: Using CLI (works for all storage backends)
uvx chuk-mcp-client-oauth clear notion-mcp

# Method 2: macOS Keychain (if using Keychain storage)
security delete-generic-password -s "chuk-oauth" -a "notion-mcp"

# Method 3: Delete encrypted file (if using file storage)
rm ~/.chuk_oauth/tokens/notion-mcp.enc
rm ~/.chuk_oauth/tokens/notion-mcp_client.json

# After clearing, run the quickstart again - browser will open
```

---

## 🧠 Understanding MCP OAuth (The Client Perspective)

### The OAuth Flow (What Actually Happens)

When you authenticate with an MCP server, here's what happens behind the scenes:

```
1. 🔍 DISCOVERY
   Your app asks: "Server, how do I authenticate with you?"
   Server responds: "Here are my OAuth endpoints and capabilities"

2. 📝 REGISTRATION
   Your app: "I'd like to register as a client"
   Server: "OK, here's your client_id"

3. 🌐 AUTHORIZATION
   Your app opens browser: "User, please approve this app"
   User clicks "Allow"
   Browser redirects back with a code

4. 🎟️ TOKEN EXCHANGE
   Your app: "Here's the code, give me tokens"
   Server: "Here's your access_token and refresh_token"

5. 💾 STORAGE
   Your app saves tokens to secure storage (Keychain/etc)

6. ✅ AUTHENTICATED
   Your app can now make API requests with the token
```

This library automates **all of these steps**.

### Key Concepts

**Access Token** - Like a temporary password that proves you're authorized
- Used in every API request
- Expires after a time (e.g., 1 hour)
- Format: `Bearer <long-random-string>`

**Refresh Token** - Like a "get a new password" token
- Used to get new access tokens when they expire
- Long-lived (days/weeks)
- Stored securely

**Scopes** - What permissions you're requesting
- Examples: `["read", "write"]`, `["notion:read"]`
- Server decides what to grant

**PKCE** - Security enhancement that prevents token theft
- Automatically handled by this library
- You don't need to think about it

**Discovery** - How the client finds OAuth configuration
- **MCP-Compliant (RFC 9728)**: Protected Resource Metadata at `/.well-known/oauth-protected-resource`
  - Points to Authorization Server metadata
  - Includes resource identifier for token binding
- **Fallback (Legacy)**: Direct AS discovery at `/.well-known/oauth-authorization-server`
- **WWW-Authenticate Fallback**: PRM URL from 401/403 response headers
- Automatically discovered by this library with fallback support

**Resource Indicators (RFC 8707)** - Token binding to specific resources
- Tokens are bound to the specific MCP server resource
- Prevents token reuse across different resources
- Automatically included in token requests

---

## 📊 Flow Diagrams

### Auth Code + PKCE (Desktop/CLI with Browser)

This is the **primary flow** used by this library for interactive applications:

```
┌──────────────────┐        ┌──────────────┐         ┌──────────────────────┐        ┌───────────────┐
│  MCP Client      │        │  User        │         │  OAuth 2.1 Server    │        │  MCP Server    │
│  (CLI / Agent)   │        │  Browser     │         │  (Auth + Token)      │        │               │
└──┬───────────────┘        └──────┬───────┘         └──────────┬───────────┘        └───────┬───────┘
   │ 1) GET /.well-known/oauth-protected-resource (RFC 9728)   │                             │
   ├────────────────────────────────────────────────────────────────────────────────────────▶│
   │                                                           │ 2) PRM: resource ID,        │
   │◀────────────────────────────────────────────────────────────────────────────────────────┤    AS URLs
   │                                                           │                             │
   │ 3) GET AS metadata from PRM.authorization_servers[0]     │                             │
   ├──────────────────────────────────────────────────────────▶│                             │
   │                                                           │ 4) AS metadata: endpoints   │
   │◀───────────────────────────────────────────────────────────┤                            │
   │                                                           │                             │
   │ 5) Build Auth URL (PKCE: code_challenge)                  │                             │
   │ 6) Open browser ----------------------------------------▶ │                             │
   │                                                           │ 7) User login + consent     │
   │                                                           │◀────────────────────────────┤
   │                                                           │ 8) Redirect with ?code=...  │
   │◀───────────────────────────────────────────────────────────┤  to http://127.0.0.1:PORT   │
   │ 9) Local redirect handler captures code + state           │                             │
   │ 10) POST /token (code + code_verifier + resource=MCP_URL) │                             │
   ├──────────────────────────────────────────────────────────▶│                             │
   │                                                           │ 11) access_token + refresh  │
   │◀───────────────────────────────────────────────────────────┤     (bound to resource)    │
   │ 12) Store tokens securely (keyring / pluggable)           │                             │
   │                                                           │                             │
   │ 13) Connect to MCP with Authorization: Bearer <token>     │                             │
   ├────────────────────────────────────────────────────────────────────────────────────────▶│
   │                                                           │                             │ 14) Session OK
   │◀────────────────────────────────────────────────────────────────────────────────────────┤
   │                                                           │                             │
   │ 15) (When expired) POST /token (refresh_token + resource=MCP_URL)                       │
   ├──────────────────────────────────────────────────────────▶│                             │
   │                                                           │ 16) New access/refresh      │
   │◀───────────────────────────────────────────────────────────┤     -> update secure store │
   │                                                           │                             │
```

**Legend:**
- **PKCE**: `code_challenge = SHA256(code_verifier)` (sent at authorize), `code_verifier` (sent at token)
- **PRM**: Protected Resource Metadata (RFC 9728) - MCP-compliant discovery
- **Resource Indicators**: `resource=` parameter binds tokens to specific MCP server (RFC 8707)
- Tokens are stored in OS keychain (or pluggable secure backend)
- MCP requests carry `Authorization: Bearer <access_token>`

### MCP-Compliant Discovery Flow (RFC 9728)

The library implements the **MCP-specified discovery flow** with automatic fallback:

```
🔍 Discovery Attempt 1: Protected Resource Metadata (MCP-Compliant)
   ┌─────────────────────────────────────────────────────────────┐
   │ GET /.well-known/oauth-protected-resource                   │
   │ → Returns: {                                                │
   │     "resource": "https://mcp.notion.com/mcp",               │
   │     "authorization_servers": [                              │
   │       "https://auth.notion.com/.well-known/oauth-as"        │
   │     ]                                                       │
   │   }                                                         │
   └─────────────────────────────────────────────────────────────┘
                              ↓
   ┌─────────────────────────────────────────────────────────────┐
   │ GET https://auth.notion.com/.well-known/oauth-as            │
   │ → Returns AS metadata (authorization_endpoint, etc.)        │
   └─────────────────────────────────────────────────────────────┘

❌ If PRM fails (404/500):

🔍 Discovery Attempt 2: Direct AS Discovery (Fallback)
   ┌─────────────────────────────────────────────────────────────┐
   │ GET /.well-known/oauth-authorization-server                 │
   │ → Returns AS metadata directly                              │
   └─────────────────────────────────────────────────────────────┘

❌ If both fail, check WWW-Authenticate header:

🔍 Discovery Attempt 3: WWW-Authenticate Fallback
   ┌─────────────────────────────────────────────────────────────┐
   │ On 401/403 response:                                        │
   │ WWW-Authenticate: Bearer                                    │
   │   resource_metadata="https://mcp.example.com/.well-known/..." │
   │ → Extract PRM URL and try again                             │
   └─────────────────────────────────────────────────────────────┘
```

**Why this matters:**
- ✅ **MCP Spec Compliant**: Follows Model Context Protocol authorization specification
- ✅ **Token Binding**: Resource indicators prevent token reuse across servers
- ✅ **Backward Compatible**: Falls back to legacy discovery for older servers
- ✅ **Automatic**: Library handles all discovery methods transparently

### Device Code Flow (Headless TTY / SSH Agents)

**Coming in v0.2.0** - Perfect for SSH-only boxes, CI runners, and background agents.

**Planned API:**

```python
import asyncio
from chuk_mcp_client_oauth import OAuthHandler

async def main():
    handler = OAuthHandler()

    # Device code flow for headless environments
    await handler.ensure_authenticated_mcp_device(
        server_name="notion",
        server_url="https://mcp.notion.com/mcp",
        scopes=["read", "write"],
        prompt=lambda code, url: print(f"🔐 Go to {url} and enter code: {code}")
    )

    # Rest is identical to auth code flow
    headers = await handler.prepare_headers_for_mcp_server(
        "notion",
        "https://mcp.notion.com/mcp"
    )

asyncio.run(main())
```

**Use cases:**
- SSH-only servers
- CI/CD pipelines
- Background agents
- Shared/headless environments

**Flow diagram:**

```
┌──────────────────┐                          ┌──────────────────────┐                          ┌───────────────┐
│  MCP Client      │                          │  OAuth 2.1 Server    │                          │  MCP Server    │
│  (Headless)      │                          │  (Device + Token)    │                          │               │
└──┬───────────────┘                          └──────────┬───────────┘                          └───────┬───────┘
   │ 1) POST /device_authorization (client_id, scope)            │                                         │
   ├────────────────────────────────────────────────────────────▶│                                         │
   │                                                            │ 2) device_code, user_code, verify_uri   │
   │◀────────────────────────────────────────────────────────────┤    expires_in, interval                 │
   │ 3) Show: "Go to VERIFY_URI and enter USER_CODE"            │                                         │
   │                                                            │                                         │
   │                (User on any device)                         │                                         │
   │                         ┌──────────────┐                    │                                         │
   │                         │  User        │ 4) Visit verify URI│                                         │
   │                         │  Browser     │ ◀──────────────────▶│                                         │
   │                         └──────┬───────┘ 5) Enter user code │                                         │
   │                                                            │ 6) Consent + login done                  │
   │                                                            │                                         │
   │ 7) Poll POST /token (device_code, grant_type=device_code)  │                                         │
   ├────────────────────────────────────────────────────────────▶│                                         │
   │ (repeat every `interval` seconds until authorized)         │                                         │
   │◀────────────────────────────────────────────────────────────┤ 8) access_token + refresh               │
   │ 9) Store tokens securely                                   │                                         │
   │ 10) Connect MCP: Authorization: Bearer <token>             │                                         │
   ├─────────────────────────────────────────────────────────────────────────────────────────────────────▶│
   │                                                                                                     │ 11) Session OK
   │◀─────────────────────────────────────────────────────────────────────────────────────────────────────┤
   │ 12) Refresh on expiry → POST /token (refresh_token)         │                                        │
   ├────────────────────────────────────────────────────────────▶│                                        │
   │◀────────────────────────────────────────────────────────────┤ New tokens → update store              │
```

**When to use Device Code Flow:**
- **SSH-only environments** - No browser available on the target machine
- **CI/CD pipelines** - Automated builds need OAuth without interactive login
- **Background agents** - Services running without user interaction
- **Shared/headless servers** - Multiple users, no desktop environment

### How Tokens Attach to MCP Requests

> **Whiteboard view:** The client does discovery, performs OAuth (Auth Code + PKCE or Device Code), stores tokens safely, and automatically attaches `Authorization: Bearer <token>` to **every MCP handshake and request**, refreshing silently when needed.

**HTTP Requests:**
```http
GET /mcp/api/resources HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
```

**Server-Sent Events (SSE):**
```http
GET /mcp/events HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: text/event-stream
Connection: keep-alive
```

**WebSocket:**
```http
GET /mcp/ws HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Upgrade: websocket
Connection: Upgrade
```

---

## 🔍 OAuth Discovery (How Your App Finds OAuth Endpoints)

### What is OAuth Discovery?

MCP servers publish their OAuth configuration at a **well-known URL**. This is like a menu that tells your app:
- "Here's where you get authorization"
- "Here's where you exchange codes for tokens"
- "Here's what I support (PKCE, refresh tokens, etc.)"

### MCP-Compliant Discovery (Do This First)

**Per the MCP specification**, clients must discover OAuth endpoints via Protected Resource Metadata (RFC 9728):

**Step 1: Discover Protected Resource Metadata (PRM)**
```bash
# MCP-compliant discovery starts here
GET <mcp_server>/.well-known/oauth-protected-resource
```

**Example PRM Response:**
```json
{
  "resource": "https://mcp.notion.com/mcp",
  "authorization_servers": [
    "https://mcp.notion.com/.well-known/oauth-authorization-server"
  ],
  "scopes_supported": ["read", "write"],
  "bearer_methods_supported": ["header"]
}
```

**Key PRM fields:**
- `resource` - The resource identifier (use this in `resource=` parameter for token requests)
- `authorization_servers` - Array of AS metadata URLs to fetch next

**Step 2: Fetch Authorization Server Metadata**
```bash
# Follow the URL from PRM's authorization_servers[0]
GET <authorization_server_url>
```

**Example AS Metadata Response:**
```json
{
  "issuer": "https://mcp.notion.com",
  "authorization_endpoint": "https://mcp.notion.com/authorize",
  "token_endpoint": "https://mcp.notion.com/token",
  "registration_endpoint": "https://mcp.notion.com/register",
  "revocation_endpoint": "https://mcp.notion.com/token",
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["plain", "S256"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"]
}
```

**Key AS Metadata fields:**
- `authorization_endpoint` - Where users approve your app
- `token_endpoint` - Where you exchange codes for tokens
- `registration_endpoint` - Where you register as a client
- `code_challenge_methods_supported` - PKCE support (S256 = SHA-256)

**Step 3: Include Resource Indicator in Token Requests**

When requesting tokens, include the `resource` parameter from PRM (RFC 8707):

```http
POST /token HTTP/1.1
Host: mcp.notion.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=http://localhost:8080/callback
&client_id=CLIENT_ID
&code_verifier=CODE_VERIFIER
&resource=https://mcp.notion.com/mcp
```

This binds the token to the specific MCP resource, preventing token reuse across different servers.

### WWW-Authenticate Fallback

**If PRM discovery fails**, MCP servers **SHOULD** (per MCP spec convention) include the PRM URL in 401/403 responses via the `WWW-Authenticate` header:

> **Note**: The `resource_metadata` parameter is an **MCP-specific convention**, not part of core RFC 6750 (Bearer Token Usage). It extends the standard Bearer authentication scheme to enable OAuth discovery from error responses, as specified in the Model Context Protocol authorization specification.

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="mcp",
  resource_metadata="https://mcp.notion.com/.well-known/oauth-protected-resource"
```

**Example header formats:**
```
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"

WWW-Authenticate: Bearer realm="mcp", error="invalid_token",
  resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
```

The client should:
1. Parse the `resource_metadata` URL from the header
2. Fetch the PRM document from that URL
3. Continue with normal discovery flow (Step 2 above)

### Legacy Fallback (Non-MCP Servers)

For **backward compatibility** with servers that don't implement PRM discovery, the library falls back to direct AS discovery:

```bash
# Legacy OAuth servers (pre-MCP)
GET <server_url>/.well-known/oauth-authorization-server
```

**Discovery priority:**
1. ✅ **First**: Try PRM at `/.well-known/oauth-protected-resource` (MCP-compliant)
2. ✅ **Second**: Check `WWW-Authenticate` header on 401/403 responses
3. ✅ **Third**: Fall back to direct AS discovery (legacy compatibility)

### How This Library Uses Discovery

When you call:
```python
tokens = await handler.ensure_authenticated_mcp(
    server_name="notion-mcp",
    server_url="https://mcp.notion.com/mcp",
    scopes=["read", "write"]
)
```

Behind the scenes (MCP-compliant flow):
1. **PRM Discovery**: Fetches `https://mcp.notion.com/.well-known/oauth-protected-resource`
2. **Extract Resource**: Saves the `resource` identifier for token binding (RFC 8707)
3. **AS Discovery**: Fetches AS metadata from `authorization_servers[0]` URL
4. **Parse**: Extracts `authorization_endpoint`, `token_endpoint`, etc.
5. **Validate**: Checks that PKCE is supported
6. **Cache**: Saves the configuration for future use
7. **Token Requests**: Includes `resource=` parameter in all token requests
8. **Proceed**: Uses the discovered endpoints for OAuth flow

**Fallback**: If PRM discovery fails, falls back to direct AS discovery for legacy server compatibility.

### Manual Discovery (Advanced)

You can also discover endpoints manually:

```python
import asyncio
from chuk_mcp_client_oauth import MCPOAuthClient

async def discover_endpoints():
    client = MCPOAuthClient(
        server_url="https://mcp.notion.com/mcp",
        redirect_uri="http://localhost:8080/callback"
    )

    # Discover OAuth configuration
    metadata = await client.discover_authorization_server()

    # Now you can inspect the discovered endpoints
    print(f"Authorization URL: {metadata.authorization_endpoint}")
    print(f"Token URL: {metadata.token_endpoint}")
    print(f"Registration URL: {metadata.registration_endpoint}")
    print(f"Supported scopes: {metadata.scopes_supported}")
    print(f"PKCE methods: {metadata.code_challenge_methods_supported}")

# Run the async function
asyncio.run(discover_endpoints())
```

### Testing Discovery with curl

You can test if a server supports MCP-compliant OAuth discovery:

```bash
# Step 1: Test PRM discovery (MCP-compliant)
curl https://mcp.notion.com/.well-known/oauth-protected-resource

# Expected response:
# {
#   "resource": "https://mcp.notion.com/mcp",
#   "authorization_servers": ["https://mcp.notion.com/.well-known/oauth-authorization-server"],
#   "scopes_supported": ["read", "write"]
# }

# Step 2: Test AS discovery (from PRM's authorization_servers[0])
curl https://mcp.notion.com/.well-known/oauth-authorization-server

# Expected response: AS metadata with endpoints

# Test your own MCP server
curl https://your-server.com/.well-known/oauth-protected-resource
```

**Expected responses:**
- **PRM**: JSON with `resource`, `authorization_servers`, `scopes_supported`
- **AS Metadata**: JSON with `authorization_endpoint`, `token_endpoint`, etc.

**Common errors:**
- `404 Not Found` on PRM - Server may not be MCP-compliant (library will fall back to direct AS discovery)
- `404 Not Found` on both - Server doesn't support OAuth discovery at all
- `Connection refused` - Server URL is incorrect
- `Invalid JSON` - Server has misconfigured OAuth
- `{"error":"invalid_token"}` - Discovery endpoint is incorrectly protected (should be public)

**Testing WWW-Authenticate fallback:**
```bash
# Make an unauthenticated req