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.

π― 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:
- π Authenticate - Get permission from the server
- πΎ Store tokens - Keep credentials secure
- π Refresh tokens - Keep sessions alive
- π§ 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:
π Quick Start (5 minutes)
Installation
Using uv (recommended):
uv add chuk-mcp-client-oauth
Or using pip:
pip install chuk-mcp-client-oauth
30-Second Minimal Example
import asyncio
from chuk_mcp_client_oauth import OAuthHandler
async def main():
handler = OAuthHandler()
await handler.ensure_authenticated_mcp(
server_name="notion",
server_url="https://mcp.notion.com/mcp",
scopes=["read", "write"],
)
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 for full JSON-RPC + SSE example.
Your First OAuth Flow (Complete Example)
import asyncio
from chuk_mcp_client_oauth import OAuthHandler
async def main():
handler = OAuthHandler()
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]}...")
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):
import asyncio
from chuk_mcp_client_oauth import OAuthHandler, TokenManager, TokenStoreBackend
async def main():
token_manager = TokenManager(backend=TokenStoreBackend.KEYCHAIN)
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 in macOS Keychain")
print(f"π Access Token: {tokens.access_token[:20]}...")
asyncio.run(main())
Using the Tokens - Complete MCP Example:
Now let's use those tokens to actually interact with Notion MCP - listing available tools:
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"
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]}...")
session_id = str(uuid.uuid4())
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
)
session_id = init_response.headers.get('mcp-session-id', session_id)
print(f" β
Session initialized: {session_id[:16]}...")
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
)
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
},
timeout=30.0
)
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()
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]:
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:
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:
- β
Retrieved your cached tokens (no re-authentication needed)
- β
Added
Authorization: Bearer <token> header to every request
- β
Parsed SSE responses automatically
- β
Would have refreshed the token if server returned 401
Using a Custom Service Name (for your application):
import asyncio
from chuk_mcp_client_oauth import OAuthHandler, TokenManager, TokenStoreBackend
async def main():
token_manager = TokenManager(
backend=TokenStoreBackend.KEYCHAIN,
service_name="my-awesome-app"
)
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")
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
uvx chuk-mcp-client-oauth clear notion-mcp
security delete-generic-password -s "chuk-oauth" -a "notion-mcp"
rm ~/.chuk_oauth/tokens/notion-mcp.enc
rm ~/.chuk_oauth/tokens/notion-mcp_client.json
π§ 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:
import asyncio
from chuk_mcp_client_oauth import OAuthHandler
async def main():
handler = OAuthHandler()
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}")
)
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:
GET /mcp/api/resources HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
Server-Sent Events (SSE):
GET /mcp/events HTTP/1.1
Host: mcp.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Accept: text/event-stream
Connection: keep-alive
WebSocket:
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)
GET <mcp_server>/.well-known/oauth-protected-resource
Example PRM Response:
{
"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
GET <authorization_server_url>
Example AS Metadata Response:
{
"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):
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/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:
- Parse the
resource_metadata URL from the header
- Fetch the PRM document from that URL
- 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:
GET <server_url>/.well-known/oauth-authorization-server
Discovery priority:
- β
First: Try PRM at
/.well-known/oauth-protected-resource (MCP-compliant)
- β
Second: Check
WWW-Authenticate header on 401/403 responses
- β
Third: Fall back to direct AS discovery (legacy compatibility)
How This Library Uses Discovery
When you call:
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):
- PRM Discovery: Fetches
https://mcp.notion.com/.well-known/oauth-protected-resource
- Extract Resource: Saves the
resource identifier for token binding (RFC 8707)
- AS Discovery: Fetches AS metadata from
authorization_servers[0] URL
- Parse: Extracts
authorization_endpoint, token_endpoint, etc.
- Validate: Checks that PKCE is supported
- Cache: Saves the configuration for future use
- Token Requests: Includes
resource= parameter in all token requests
- 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:
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"
)
metadata = await client.discover_authorization_server()
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}")
asyncio.run(discover_endpoints())
Testing Discovery with curl
You can test if a server supports MCP-compliant OAuth discovery:
curl https://mcp.notion.com/.well-known/oauth-protected-resource
curl https://mcp.notion.com/.well-known/oauth-authorization-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:
curl -i https://mcp.example.com/mcp
Discovery Specification
MCP OAuth discovery follows:
- RFC 9728 - Protected Resource Metadata (PRM) - Primary discovery method
- RFC 8414 - OAuth 2.0 Authorization Server Metadata - Secondary discovery from PRM
- RFC 8707 - Resource Indicators - Token binding with
resource= parameter
PRM (/.well-known/oauth-protected-resource) must have:
resource - Resource identifier (used in token requests)
authorization_servers - Array of AS metadata URLs
AS Metadata (/.well-known/oauth-authorization-server) must have:
issuer - Server identifier
authorization_endpoint - Where to send users
token_endpoint - Where to get tokens
Should have (for MCP):
registration_endpoint - Dynamic client registration (RFC 7591)
code_challenge_methods_supported: ["S256"] - PKCE support
revocation_endpoint - Token revocation (RFC 7009)
Example of checking if a server supports MCP OAuth:
import asyncio
import httpx
async def check_mcp_oauth_support(server_url: str) -> bool:
"""Check if a server supports MCP-compliant OAuth."""
prm_url = f"{server_url}/.well-known/oauth-protected-resource"
try:
async with httpx.AsyncClient() as client:
prm_response = await client.get(prm_url)
if prm_response.status_code != 200:
print(f"β οΈ No PRM support (falling back to legacy discovery)")
as_url = f"{server_url}/.well-known/oauth-authorization-server"
as_response = await client.get(as_url)
if as_response.status_code != 200:
print(f"β No OAuth support at all")
return False
print("β
Server supports legacy OAuth (not MCP-compliant)")
return True
prm = prm_response.json()
if "resource" not in prm or "authorization_servers" not in prm:
print("β Invalid PRM document")
return False
as_url = prm["authorization_servers"][0]
as_response = await client.get(as_url)
if as_response.status_code != 200:
print(f"β AS metadata not available")
return False
as_config = as_response.json()
required = ["authorization_endpoint", "token_endpoint"]
if not all(field in as_config for field in required):
print("β Missing required OAuth endpoints")
return False
if "S256" not in as_config.get("code_challenge_methods_supported", []):
print("β οΈ PKCE not supported (less secure)")
if "registration_endpoint" not in as_config:
print("β οΈ No dynamic registration (manual setup required)")
print("β
Server supports MCP-compliant OAuth")
print(f" Resource: {prm['resource']}")
print(f" Auth: {as_config['authorization_endpoint']}")
print(f" Token: {as_config['token_endpoint']}")
return True
except Exception as e:
print(f"β Discovery failed: {e}")
return False
asyncio.run(check_mcp_oauth_support("https://mcp.notion.com/mcp"))
π¦ Installation Options
uv add chuk-mcp-client-oauth
uv add chuk-mcp-client-oauth --extra linux
uv add chuk-mcp-client-oauth --extra vault
uv add chuk-mcp-client-oauth --extra all
git clone https://github.com/chrishayuk/chuk-mcp-client-oauth.git
cd chuk-mcp-client-oauth
uv sync --all-extras
Platform-specific dependencies:
- macOS/Windows:
keyring is installed automatically (no action needed)
- Linux: Add
[linux] extra for Secret Service support, otherwise uses encrypted files
- Enterprise: Add
[vault] extra for HashiCorp Vault integration
What gets installed on your platform:
| macOS | keyring>=24.0.0 | macOS Keychain (no password) |
| Windows | keyring>=24.0.0 | Credential Manager (no password) |
| Linux | None (encrypted files) | Encrypted files (password prompt) |
| Linux + [linux] | keyring>=24.0.0, secretstorage>=3.3.0 | Secret Service (no password) |
π‘ Usage Examples
Example 1: CLI Tool with Token Management
import asyncio
from chuk_mcp_client_oauth import OAuthHandler
async def connect_to_server(server_name: str, server_url: str):
"""Connect to an MCP server with OAuth."""
handler = OAuthHandler()
tokens = await handler.ensure_authenticated_mcp(
server_name=server_name,
server_url=server_url,
scopes=["read", "write"]
)
if tokens.is_expired():
print("β οΈ Token expired, refreshing...")
return tokens
tokens = asyncio.run(connect_to_server("notion-mcp", "https://mcp.notion.com/mcp"))
print(f"Connected! Token expires in {tokens.expires_in} seconds")
Example 2: Web App with Multiple Servers
from chuk_mcp_client_oauth import OAuthHandler
class MCPClient:
def __init__(self):
self.handler = OAuthHandler()
self.servers = {}
async def add_server(self, name: str, url: str):
"""Add and authenticate with a server."""
tokens = await self.handler.ensure_authenticated_mcp(
server_name=name,
server_url=url,
scopes=["read", "write"]
)
self.servers[name] = url
return tokens
async def call_server(self, name: str, endpoint: str):
"""Make authenticated API call."""
import httpx
headers = await self.handler.prepare_headers_for_mcp_server(
server_name=name,
server_url=self.servers[name]
)
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.servers[name]}{endpoint}",
headers=headers
)
return response.json()
mcp = MCPClient()
await mcp.add_server("notion", "https://mcp.notion.com/mcp")
await mcp.add_server("github", "https://mcp.github.com/mcp")
data = await mcp.call_server("notion", "/api/pages")
Example 3: Lower-Level Control
import asyncio
from chuk_mcp_client_oauth import MCPOAuthClient
async def manual_oauth_flow():
"""Full control over the OAuth process."""
client = MCPOAuthClient(
server_url="https://mcp.example.com",
redirect_uri="http://localhost:8080/callback"
)
metadata = await client.discover_authorization_server()
print(f"π Auth URL: {metadata.authorization_endpoint}")
print(f"π Token URL: {metadata.token_endpoint}")
client_info = await client.register_client(
client_name="My Awesome App",
redirect_uris=["http://localhost:8080/callback"]
)
print(f"π Client ID: {client_info['client_id']}")
tokens = await client.authorize(scopes=["read", "write"])
print(f"ποΈ Access Token: {tokens.access_token[:20]}...")
headers = {"Authorization": tokens.get_authorization_header()}
if tokens.is_expired():
new_tokens = await client.refresh_token(tokens.refresh_token)
print(f"π Refreshed: {new_tokens.access_token[:20]}...")
return tokens
asyncio.run(manual_oauth_flow())
ποΈ Token Storage (Secure & Automatic)
How Storage Works
The library automatically stores tokens in the most secure location for your platform:
| macOS | Keychain | β
Yes | Uses the macOS Keychain (same as Safari, Chrome) - No password needed |
| Windows | Credential Manager | β
Yes | Uses Windows Credential Manager - No password needed |
| Linux | Secret Service | [linux] extra | Uses GNOME Keyring or KDE Wallet - No password needed |
| Vault | HashiCorp Vault | [vault] extra | For enterprise deployments |
| Fallback | Encrypted Files | β
Always | AES-256 encrypted files (requires password) |
Storage Directory
By default, tokens are stored in:
~/.chuk_oauth/tokens/
For encrypted file storage:
- Each server gets its own encrypted file:
<server_name>.enc
- Files are encrypted with AES-256
- Client registration stored as:
<server_name>_client.json
- Encryption salt stored as:
.salt
- You can set a custom password or let it auto-generate
Example directory structure:
$ ls -la ~/.chuk_oauth/tokens/
total 24
drwx------ 5 user staff 160 Nov 1 12:38 .
drwxr-xr-x 4 user staff 128 Nov 1 12:38 ..
-rw------- 1 user staff 16 Nov 1 12:38 .salt
-rw------- 1 user staff 132 Nov 1 12:38 notion-mcp_client.json
-rw------- 1 user staff 504 Nov 1 12:38 notion-mcp.enc
Inspecting and Clearing Tokens
Check what tokens are stored:
uvx chuk-mcp-client-oauth list
uvx chuk-mcp-client-oauth get notion-mcp
Clear tokens to re-run demos:
uvx chuk-mcp-client-oauth clear notion-mcp
uvx chuk-mcp-client-oauth logout notion-mcp --url https://mcp.notion.com/mcp
rm ~/.chuk_oauth/tokens/notion-mcp.enc
rm ~/.chuk_oauth/tokens/notion-mcp_client.json
rm -rf ~/.chuk_oauth/
For macOS Keychain storage:
Using Keychain Access app (GUI):
1. Open "Keychain Access" app (in Applications > Utilities)
2. Make sure "login" keychain is selected (left sidebar)
3. Search for "chuk-oauth" in the search box (top right)
4. You'll see entries like "notion-mcp" under the service "chuk-oauth"
5. Right-click on the entry β "Delete"
6. Confirm deletion
Using command line:
security delete-generic-password -s "chuk-oauth" -a "notion-mcp"
security find-generic-password -s "chuk-oauth"
security find-generic-password -s "chuk-oauth" -g
security dump-keychain | grep -A 5 "chuk-oauth"
Example: Delete notion-mcp token from Keychain
security delete-generic-password -s "chuk-oauth" -a "notion-mcp"
uvx chuk-mcp-client-oauth clear notion-mcp
security find-generic-password -s "chuk-oauth" -a "notion-mcp"
Troubleshooting macOS Keychain:
If you can't find tokens in Keychain Access:
security find-generic-password -s "chuk-oauth"
ls -la ~/.chuk_oauth/tokens/
Common issues:
- Can't find in Keychain Access app: Make sure you're searching in the "login" keychain, not "System"
- "Could not be found" error: Token might already be deleted, or using file storage instead
- Permission denied: You may need to allow terminal/app to access Keychain in System Preferences > Privacy & Security
Storage Examples
Auto-Detection (Recommended)
from chuk_mcp_client_oauth import TokenManager
manager = TokenManager()
manager.save_tokens("my-server", tokens)
tokens = manager.load_tokens("my-server")
if manager.has_valid_tokens("my-server"):
print("β
Tokens are valid")
manager.delete_tokens("my-server")
Explicit Backend Selection
from chuk_mcp_client_oauth import TokenManager, TokenStoreBackend
manager = TokenManager(backend=TokenStoreBackend.KEYCHAIN)
manager = TokenManager(
backend=TokenStoreBackend.ENCRYPTED_FILE,
password="my-super-secret-password-123"
)
manager = TokenManager(
backend=TokenStoreBackend.VAULT,
vault_url="https://vault.company.com",
vault_token="s.xyz123...",
vault_mount_point="secret",
vault_path_prefix="mcp-oauth"
)
Custom Storage Directory
from pathlib import Path
manager = TokenManager(
backend=TokenStoreBackend.ENCRYPTED_FILE,
token_dir=Path("/secure/custom/path/tokens"),
password="my-password"
)
Storage Security Features
-
Platform-Native Security
- macOS Keychain: Protected by system keychain access controls
- Windows: Protected by Windows account credentials
- Linux: Protected by Secret Service daemon
-
Encryption
- Encrypted file storage uses AES-256-GCM
- Keys derived from password using PBKDF2
- Each token file has unique salt and IV
-
Access Control
- Files created with mode 0600 (owner read/write only)
- Token directory created with mode 0700 (owner access only)
-
Token Metadata
- Creation timestamp
- Expiration tracking
- Scope information
- Automatic cleanup of expired tokens
Checking Available Backends
from chuk_mcp_client_oauth import TokenStoreFactory
available = TokenStoreFactory.get_available_backends()
print("Available backends:", available)
detected = TokenStoreFactory._detect_backend()
print(f"Auto-detected backend: {detected}")
Storage Best Practices
Development:
manager = TokenManager()
Production (Single User):
manager = TokenManager(backend=TokenStoreBackend.AUTO)
Production (Multi-User Server):
manager = TokenManager(
backend=TokenStoreBackend.VAULT,
vault_url=os.environ["VAULT_URL"],
vault_token=os.environ["VAULT_TOKEN"]
)
Testing:
import tempfile
manager = TokenManager(
backend=TokenStoreBackend.ENCRYPTED_FILE,
token_dir=Path(tempfile.mkdtemp()),
password="test-password"
)
π οΈ CLI Tool (Quick Testing)
The library includes a CLI tool for testing OAuth flows. You can run it with uvx (no installation required) or install it locally:
Using uvx (Recommended - No Installation)
uvx chuk-mcp-client-oauth auth notion-mcp https://mcp.notion.com/mcp
uvx chuk-mcp-client-oauth list
uvx chuk-mcp-client-oauth get notion-mcp
uvx chuk-mcp-client-oauth test notion-mcp
uvx chuk-mcp-client-oauth logout notion-mcp --url https://mcp.notion.com/mcp
uvx chuk-mcp-client-oauth clear notion-mcp
Using installed CLI
uv add chuk-mcp-client-oauth
chuk-mcp-client-oauth auth notion-mcp https://mcp.notion.com/mcp
chuk-mcp-client-oauth list
chuk-mcp-client-oauth get notion-mcp
chuk-mcp-client-oauth test notion-mcp
chuk-mcp-client-oauth logout notion-mcp --url https://mcp.notion.com/mcp
chuk-mcp-client-oauth clear notion-mcp
Using examples directory
uv run examples/oauth_cli.py auth notion-mcp https://mcp.notion.com/mcp
Example output:
============================================================
Authenticating with notion-mcp
============================================================
Server URL: https://mcp.notion.com/mcp
Scopes: read, write (default)
π Starting OAuth flow...
This will open your browser for authorization.
β
Authentication successful!
Access Token: 282c6a79-d66f-402e-a...********************...w7q85t
Token Type: Bearer
Expires In: 3600 seconds
πΎ Tokens saved to secure storage
Storage Backend: KeychainTokenStore
π» CLI Tool
The library includes a command-line interface for managing OAuth tokens and interacting with MCP servers:
Quick Start
uvx chuk-mcp-client-oauth --help
uvx chuk-mcp-client-oauth auth notion-mcp https://mcp.notion.com/mcp
uvx chuk-mcp-client-oauth tools notion-mcp https://mcp.notion.com/mcp
uvx chuk-mcp-client-oauth list
uvx chuk-mcp-client-oauth get notion-mcp
uvx chuk-mcp-client-oauth test notion-mcp
uvx chuk-mcp-client-oauth logout notion-mcp --url https://mcp.notion.com/mcp
uvx chuk-mcp-client-oauth clear notion-mcp
CLI Commands
auth | Authenticate with MCP server | uvx chuk-mcp-client-oauth auth notion-mcp https://mcp.notion.com/mcp |
tools | List available MCP tools | uvx chuk-mcp-client-oauth tools notion-mcp https://mcp.notion.com/mcp |
list | List all stored tokens | uvx chuk-mcp-client-oauth list |
get | View token for a server | uvx chuk-mcp-client-oauth get notion-mcp |
test | Test connection with token | uvx chuk-mcp-client-oauth test notion-mcp |
logout | Revoke and delete tokens | uvx chuk-mcp-client-oauth logout notion-mcp --url https://mcp.notion.com/mcp |
clear | Delete tokens locally | uvx chuk-mcp-client-oauth clear notion-mcp |
List MCP Tools
The tools command makes it easy to discover what an MCP server offers:
uvx chuk-mcp-client-oauth tools notion-mcp https://mcp.notion.com/mcp
Output:
============================================================
Listing Tools for notion-mcp
============================================================
π Authenticating...
β
Authenticated
π Initializing MCP session...
β
Session initialized: 7b3c8d2f...
π¨ Sending initialized notification...
β
Notification sent
π§ Listing available tools...
π¦ Found 15 tools:
β’ create_page
Create a new page in Notion
β’ search
Search across your Notion workspace
β’ get_page
Retrieve a specific page by ID
... and 12 more
This command:
- Authenticates with the MCP server (uses cached tokens if available)
- Initializes a proper MCP session following the protocol
- Sends the required
initialized notification
- Lists all available tools with descriptions
- Perfect for discovering what an MCP server can do without writing code
π Working Examples
The library includes complete, working examples:
1. Authenticated Requests (authenticated_requests.py) β
NEW!
What it shows: Complete authenticated requests with SSE support
uv run examples/authenticated_requests.py
Demonstrates:
- β
Working httpbin.org example - REST API authentication
- β
Complete Notion MCP example - Full MCP session with SSE support
- Automatic token refresh on 401
- SSE (Server-Sent Events) response parsing
- MCP session initialization and tool listing
- Custom headers with authentication
- Manual 401 handling
- Error scenarios
- Token lifecycle explanation
Interactive examples:
- httpbin.org REST API (working demo)
- Complete Notion MCP session (15 tools listed)
- Custom headers with JSON-RPC
- Manual 401 handling
- Error handling scenarios
- Token lifecycle explanation
2. Basic MCP OAuth (basic_mcp_oauth.py)
What it shows: Complete OAuth flow from scratch
uv run examples/basic_mcp_oauth.py
uv run examples/basic_mcp_oauth.py https://your-mcp-server.com/mcp
3. OAuth Handler (oauth_handler_example.py)
What it shows: High-level API with token caching
uv run examples/oauth_handler_example.py
Demonstrates:
- MCP OAuth with Notion
- Token caching and reuse
- Token validation
- Header preparation
4. Token Storage (token_storage_example.py)
What it shows: Different storage backends
uv run examples/token_storage_example.py
Demonstrates:
- Auto-detection
- Encrypted file storage
- Keychain integration
- Vault integration
5. CLI Tool (oauth_cli.py)
What it shows: Complete token management tool
uv run examples/oauth_cli.py --help
All examples are fully functional and tested with real MCP servers (Notion MCP).
π§ API Reference
Quick Reference
OAuthHandler | High-level "just work" client | ensure_authenticated_mcp(), prepare_headers_for_mcp_server(), authenticated_request(), logout() |
MCPOAuthClient | Low-level OAuth controls | discover_authorization_server(), register_client(), authorize(), refresh_token(), revoke_token() |
TokenManager | Secure token storage | save_tokens(), load_tokens(), has_valid_tokens(), delete_tokens() |
TokenStoreBackend | Storage backend enum | AUTO, KEYCHAIN, ENCRYPTED_FILE, VAULT, LINUX_SECRET_SERVICE |
parse_sse_json() | SSE response parser | Converts text/event-stream responses to JSON |
OAuthHandler (High-Level API)
Recommended for most use cases.
from chuk_mcp_client_oauth import OAuthHandler
handler = OAuthHandler(token_manager=None)
Methods:
-
ensure_authenticated_mcp(server_name, server_url, scopes=None)
Authenticate with MCP server (uses cached tokens if available)
tokens = await handler.ensure_authenticated_mcp(
server_name="my-server",
server_url="https://mcp.example.com/mcp",
scopes=["read", "write"]
)
-
prepare_headers_for_mcp_server(server_name, server_url, scopes=None)
Get ready-to-use HTTP headers with authorization
headers = await handler.prepare_headers_for_mcp_server(
server_name="my-server",
server_url="https://mcp.example.com/mcp"
)
-
get_authorization_header(server_name)
Get just the Authorization header value
auth = handler.get_authorization_header("my-server")
-
clear_tokens(server_name)
Remove tokens from cache and storage (local only)
handler.clear_tokens("my-server")
-
logout(server_name, server_url=None)
Logout and revoke tokens with server (RFC 7009)
await handler.logout(
server_name="my-server",
server_url="https://mcp.example.com/mcp"
)
await handler.logout("my-server")
Note: When server_url is provided, the library will:
- Attempt to revoke the refresh and access tokens with the server
- Clear tokens from memory cache
- Delete tokens from secure storage
- Remove client registration
If revocation fails (network error, server doesn't support it), tokens are still cleared locally.
MCPOAuthClient (Low-Level API)
For advanced control over the OAuth flow.
from chuk_mcp_client_oauth import MCPOAuthClient
client = MCPOAuthClient(
server_url="https://mcp.example.com/mcp",
redirect_uri="http://localhost:8080/callback"
)
Methods:
discover_authorization_server() - RFC 8414 discovery
register_client(client_name, redirect_uris) - RFC 7591 registration
authorize(scopes) - Full authorization flow with PKCE
refresh_token(refresh_token) - Get new access token
revoke_token(token, token_type_hint=None) - Revoke token with server (RFC 7009)
TokenManager
Manages secure token storage.
from chuk_mcp_client_oauth import TokenManager, TokenStoreBackend
manager = TokenManager(
backend=TokenStoreBackend.AUTO,
token_dir=None,
password=None,
)
Methods:
save_tokens(server_name, tokens) - Store tokens securely
load_tokens(server_name) - Retrieve stored tokens (returns None if not found)
has_valid_tokens(server_name) - Check if valid tokens exist
delete_tokens(server_name) - Remove tokens
OAuthTokens (Token Object)
Represents OAuth tokens.
tokens = OAuthTokens(
access_token="...",
token_type="Bearer",
expires_in=3600,
refresh_token="...",
scope="read write"
)
Methods:
get_authorization_header() - Returns "Bearer <token>"
is_expired() - Check if token has expired
to_dict() - Convert to dictionary
π Security Features
Built-in Security Guardrails
-
Loopback-Only Redirect URIs (RFC 8252)
- Default redirect URI:
http://127.0.0.1:<random-port>/callback
- Uses
127.0.0.1 (not localhost) to prevent DNS rebinding attacks
- Random port selection prevents port hijacking
- Custom hosts rejected unless explicitly allowed (advanced use only)
-
TLS Enforcement
- Public APIs do not expose a
verify=False escape hatch
- All OAuth endpoints must use HTTPS (except loopback for callbacks)
- For development with custom CAs, pass a custom
httpx.AsyncClient with trusted CA bundle
-
Refresh Token Binding
- Refresh tokens only sent to the discovered
token_endpoint for the same issuer + resource
- Binds to PRM
resource identifier (RFC 8707)
- Prevents token reuse across different MCP servers
-
PKCE Enforcement (RFC 7636)
- PKCE with S256 (SHA-256) always used for authorization code flow
- Code verifier never written to disk (memory-only during flow)
- State parameter (256 bits entropy) validates callback authenticity
- Prevents authorization code interception attacks
-
Token Storage Encryption
- Platform-native secure storage (macOS Keychain, Windows Credential Manager, Linux Secret Service)
- Fallback: AES-256-GCM encryption with PBKDF2-HMAC-SHA256 (600,000 iterations)
- Files created with mode 0600 (owner read/write only)
- Unique salt and IV per token file
-
Automatic Expiration Tracking
- Tracks token expiration timestamps
- Validates tokens before use
- Automatic refresh when tokens expire
- No plaintext storage - all tokens encrypted or in secure OS storage
-
Scope Validation
- Ensures requested scopes match granted scopes
- Prevents scope escalation attacks
π Support Matrix
OAuth Flows & Features
| Authorization Code + PKCE | β
Full | Primary flow (RFC 6749 + RFC 7636) |
| Refresh Tokens | β
Full | Automatic token refresh |
| Dynamic Client Registration | β
Full | RFC 7591 |
| OAuth Discovery | β
Full | RFC 8414 |
| Device Code Flow | π§ Planned | For headless/CI environments |
| Client Credentials | β Out of scope | Server-to-server only |
Platforms & Storage
| macOS | 3.10+ | Keychain | β
| Encrypted File |
| Linux | 3.10+ | Secret Service (GNOME Keyring/KWallet) | β
| Encrypted File |
| Windows | 3.10+ | Credential Manager | β
| Encrypted File |
| Docker/CI | 3.10+ | Encrypted File | β
| N/A |
| Vault | 3.10+ | HashiCorp Vault | Manual | Encrypted File |
MCP Integration
| Bearer Token Injection | β
| Authorization: Bearer <token> header |
| HTTP Requests | β
| Standard HTTP headers with JSON/JSON-RPC |
| SSE (Server-Sent Events) | β
NEW! | Auth header in initial connection + SSE response parsing |
| WebSocket | β
| Auth header in handshake |
| Automatic 401 Retry | β
NEW! | Token refresh and request retry on unauthorized |
| MCP Session Management | β
NEW! | Session initialization, notifications, and session IDs |
| Timeout Support | β
NEW! | Configurable timeouts for slow MCP operations |
How tokens are attached to MCP requests:
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Mcp-Session-Id": "<session-id>"
}
Making Authenticated Requests with Auto-Refresh
The library provides authenticated_request() which handles the complete request lifecycle, including automatic token refresh on 401 responses and SSE (Server-Sent Events) response parsing:
import asyncio
from chuk_mcp_client_oauth import OAuthHandler
async def main():
handler = OAuthHandler()
response = await handler.authenticated_request(
server_name="notion-mcp",
server_url="https://mcp.notion.com/mcp",
url="https://mcp.notion.com/mcp",
method="POST",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {}
},
headers={
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
"Mcp-Session-Id": "<session-id>"
},
timeout=30.0
)
print(f"Status: {response.status_code}")
if 'text/event-stream' in response.headers.get('content-type', ''):
data = parse_sse_response(response.text)
else:
data = response.json()
print(f"Response: {data}")
asyncio.run(main())
Complete MCP Session Example:
import asyncio
import uuid
from chuk_mcp_client_oauth import OAuthHandler
async def mcp_session_example():
handler = OAuthHandler()
server_name = "notion-mcp"
server_url = "https://mcp.notion.com/mcp"
session_id = str(uuid.uuid4())
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": "my-app", "version": "1.0.0"}
}
},
headers={
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json"
},
timeout=60.0
)
session_id = init_response.headers.get('mcp-session-id', session_id)
print(f"Session initialized: {session_id}")
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
)
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
},
timeout=30.0
)
print(f"Tools: {tools_response.json()}")
asyncio.run(mcp_session_example())
SSE (Server-Sent Events) Support:
Many MCP servers return responses in SSE format instead of plain JSON. The library works with both:
def parse_sse_response(response_text: str) -> dict:
"""
Parse Server-Sent Events (SSE) response format.
SSE format example:
event: message
data: {"jsonrpc":"2.0","result":{...}}
Returns the JSON data from the SSE message.
"""
import json
lines = response_text.strip().split('\n')
data_lines = []
for line in lines:
if line.startswith('data: '):
data_lines.append(line[6:])
if data_lines:
json_str = ''.join(data_lines)
return json.loads(json_str)
raise ValueError("No data found in SSE response")
response = await handler.authenticated_request(...)
content_type = response.headers.get('content-type', '')
if 'text/event-stream' in content_type:
data = parse_sse_response(response.text)
else:
data = response.json()
POST requests with JSON:
response = await handler.authenticated_request(
server_name="notion-mcp",
server_url="https://mcp.notion.com/mcp",
url="https://mcp.notion.com/mcp",
method="POST",
json={"jsonrpc": "2.0", "id": 1, "method": "resources/create", "params": {...}}
)
Custom headers:
response = await handler.authenticated_request(
server_name="notion-mcp",
server_url="https://mcp.notion.com/mcp",
url="https://mcp.notion.com/mcp",
method="POST",
json={...},
headers={
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
"Mcp-Session-Id": session_id,
"X-Custom-Header": "value"
}
)
Disable automatic retry:
try:
response = await handler.authenticated_request(
server_name="notion-mcp",
server_url="https://mcp.notion.com/mcp",
url="https://mcp.notion.com/mcp",
method="POST",
json={...},
retry_on_401=False
)
except httpx.HTTPStatusError as e:
if e.response.status_code == 401:
print("Unauthorized - handle manually")
How it works:
- β
Ensures you have valid tokens (gets/refreshes if needed)
- β
Makes the HTTP request with
Authorization: Bearer <token> header
- β
Supports both JSON and SSE (Server-Sent Events) response formats
- β
If server returns
401 Unauthorized, automatically refreshes the token
- β
Retries the request once with the new token
- β
Returns the final response or raises
httpx.HTTPStatusError if still unauthorized
- β
Supports custom timeouts for slow operations (e.g., MCP initialization)
π Security Model & Threat Considerations
PKCE Flow Security
What is PKCE?
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Here's how this library implements it:
-
Code Verifier Generation
- Random 128-character string generated for each flow
- Stored in memory only (never written to disk)
- Destroyed after token exchange
-
Code Challenge
- SHA-256 hash of verifier sent to authorization endpoint
- Server validates the verifier matches during token exchange
- Prevents stolen auth codes from being used
code_verifier = secrets.token_urlsafe(96)
code_challenge = base64url(sha256(code_verifier))
Token Storage Security
Encryption at Rest:
- Algorithm: AES-256-GCM (authenticated encryption)
- Key Derivation: PBKDF2-HMAC-SHA256 (600,000 iterations)
- Salt: 32 bytes random per file
- IV: 16 bytes random per encryption
- Tag: 16 bytes authentication tag
Access Control:
- Unix: Files created with mode
0600 (owner read/write only)
- Windows: Protected by Windows account credentials
- Keychain: Uses system keychain access controls (requires user authentication)
Token Lifecycle:
1. Access Token Generated β Stored encrypted
2. Access Token Used β Retrieved, decrypted in memory
3. Access Token Expires β Automatic refresh
4. Refresh Token Used β New tokens stored, old deleted
5. User Logout β All tokens deleted from storage
Redirect URI Strategy
Default Configuration:
redirect_uri = "http://127.0.0.1:<random_port>/callback"
CSRF Protection:
state = secrets.token_urlsafe(32)
Custom Redirect URI (Advanced):
client = MCPOAuthClient(
server_url="https://mcp.example.com/mcp",
redirect_uri="myapp://oauth/callback"
)
Security Checklist
When deploying this library:
What's NOT Stored
For security, these are never written to disk:
- β PKCE code verifier (memory only during flow)
- β CSRF state parameter (memory only during flow)
- β User passwords (never handled by this library)
- β Plaintext tokens (always encrypted in file storage)
β οΈ Error Handling & Recovery
Error Taxonomy
The library uses specific exceptions for different failure modes:
from chuk_mcp_client_oauth.exceptions import (
OAuthError,
DiscoveryError,
RegistrationError,
AuthorizationError,
TokenExchangeError,
TokenRefreshError,
TokenStorageError,
)
Common Errors & Solutions
Discovery Failures
Error: DiscoveryError: Failed to fetch discovery document
Causes:
- Server doesn't support OAuth discovery
- Network connectivity issues
- Invalid server URL
Recovery:
try:
await handler.ensure_authenticated_mcp(
server_name="my-server",
server_url="https://mcp.example.com/mcp"
)
except DiscoveryError as e:
print(f"β Discovery failed: {e}")
from chuk_mcp_client_oauth import MCPOAuthClient
client = MCPOAuthClient(
server_url="https://mcp.example.com/mcp",
authorization_url="https://mcp.example.com/oauth/authorize",
token_url="https://mcp.example.com/oauth/token",
redirect_uri="http://127.0.0.1:8080/callback"
)
Authorization Failures
Error: AuthorizationError: User denied consent
Causes:
- User clicked "Deny" in browser
- User closed browser window
- Timeout waiting for callback
Recovery:
try:
tokens = await client.authorize(scopes=["read", "write"])
except AuthorizationError as e:
if "denied" in str(e).lower():
print("β User denied access")
print("βΉοΈ Please approve the application to continue")
elif "timeout" in str(e).lower():
print("β Authorization timeout")
print("βΉοΈ Please complete the flow within 5 minutes")
Token Refresh Failures
Error: TokenRefreshError: Refresh token expired
Causes:
- Refresh token expired (server-configured TTL)
- Refresh token revoked by server
- Network error during refresh
Recovery:
try:
new_tokens = await client.refresh_token(old_tokens.refresh_token)
except TokenRefreshError as e:
print(f"β Refresh failed: {e}")
handler.clear_tokens("my-server")
tokens = await handler.ensure_authenticated_mcp(
server_name="my-server",
server_url=server_url
)
Storage Failures
Error: TokenStorageError: Failed to store token
Causes:
- Permission denied on storage directory
- Keychain locked (macOS)
- Disk full
- Encryption password wrong
Recovery:
from chuk_mcp_client_oauth import TokenManager, TokenStoreBackend
from pathlib import Path
try:
manager = TokenManager(backend=TokenStoreBackend.AUTO)
manager.save_tokens("server", tokens)
except TokenStorageError as e:
print(f"β Storage failed: {e}")
import tempfile
fallback_manager = TokenManager(
backend=TokenStoreBackend.ENCRYPTED_FILE,
token_dir=Path(tempfile.mkdtemp()),
password="explicit-password-123"
)
fallback_manager.save_tokens("server", tokens)
Retry Strategies
Automatic Retry (Built-in):
tokens = await handler.ensure_authenticated_mcp(...)
Manual Retry (Your Code):
import asyncio
from tenacity import retry, stop_after_attempt, wait_exponential
from chuk_mcp_client_oauth import OAuthHandler
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10)
)
async def connect_with_retry(server_name: str, server_url: str):
"""Connect with automatic retries on network errors."""
handler = OAuthHandler()
return await handler.ensure_authenticated_mcp(
server_name=server_name,
server_url=server_url
)
async def main():
"""Main function to run the retry example."""
try:
tokens = await connect_with_retry("my-server", "https://mcp.example.com")
print(f"β
Connected successfully!")
except Exception as e:
print(f"β Failed after 3 retries: {e}")
asyncio.run(main())
Debugging
Enable Debug Logging:
import logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger("chuk_mcp_client_oauth")
logger.setLevel(logging.DEBUG)
π§ͺ Testing
uv run pytest
uv run pytest --cov=chuk_mcp_client_oauth --cov-report=html
uv run pytest tests/auth/test_oauth_handler.py -v
uv run pytest -m "not slow"
Test Coverage: 99% (467 tests passing)
Test Coverage Matrix
| PRM Happy Path | PRM β AS β Auth Code + PKCE β Token (with resource=) |
| Legacy AS Discovery | Direct .well-known/oauth-authorization-server fallback |
| WWW-Authenticate Bootstrap | 401 header β PRM URL β discovery flow |
| Refresh Rotation | 401 β refresh token β retry β succeed |
| SSE JSON-RPC | text/event-stream parsing into JSON |
| Storage Backends | Keychain/Credential Manager/Secret Service/Encrypted File |
| Vault Integration | Read/write/rotate secrets in HashiCorp Vault |
| Resource Indicators | resource= parameter in token/refresh requests |
| Token Revocation | RFC 7009 revoke_token() implementation |
| PKCE S256 | Code challenge generation and verification |
| Dynamic Registration | RFC 7591 client registration flow |
| Token Expiration | Automatic expiration tracking and refresh |
ποΈ Development
git clone https://github.com/chrishayuk/chuk-mcp-client-oauth.git
cd chuk-mcp-client-oauth
uv sync --all-extras
make check
make format
make lint
make typecheck
make security
make test
make test-cov
π€ Contributing
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature)
- Make your changes
- Run tests (
make check)
- Commit (
git commit -m 'Add amazing feature')
- Push (
git push origin feature/amazing-feature)
- Open a Pull Request
π License
MIT License - see LICENSE file for details.
π Troubleshooting
"No module named 'keyring'"
uv add keyring
"OAuth flow failed"
- Check server URL is correct and reachable
- Verify server supports MCP OAuth (has
.well-known/oauth-authorization-server)
- Ensure scopes are valid for the server
"Token expired"
if tokens.is_expired():
new_tokens = await client.refresh_token(tokens.refresh_token)
"Permission denied" on token storage
ls -la ~/.chuk_oauth/
chmod 700 ~/.chuk_oauth/
chmod 600 ~/.chuk_oauth/tokens/*.enc
π Links
Made with β€οΈ by the chuk-ai team