MCP OAuth Authentication
Zero-config OAuth 2.1 for remote MCP servers — no client_id required
OAuth-protected remote MCP servers now Just Work — point at the URL, approve once in your browser, and tokens are cached and refreshed automatically.
Status
| Capability | Status |
|---|---|
| OAuth utilities (PKCE, state, callback) | ✅ |
Token storage (~/.praisonai/mcp-auth.json, 0600 perms) | ✅ |
| Metadata discovery (RFC 8414 + OpenID fallback) | ✅ |
| Dynamic Client Registration (RFC 7591) | ✅ |
| PKCE authorize + token exchange | ✅ |
| Transparent token refresh | ✅ |
Headless / CI signal (InteractiveAuthRequired) | ✅ |
praisonai mcp auth CLI flow | ✅ |
Quick Start
- Step
python from praisonaiagents import Agent, MCP agent = Agent( instructions="You help with GitHub tasks", tools=MCP("https://api.githubcopilot.com/mcp/") ) agent.start("List my open pull requests")The first call opens your browser once so you can approve access. Tokens are cached for future runs — no
client_idor scopes to configure. - Step
python agent.start("Close pull request 42")Cached tokens are re-used, and expired ones are refreshed transparently.
- Or pre-seed from the CLI
bash # One-time interactive login; tokens cached at ~/.praisonai/mcp-auth.json praisonai mcp auth githubRun this once on a workstation, then copy
~/.praisonai/mcp-auth.jsonto a CI runner for headless environments.
How Zero-config OAuth Works
A 401 with a WWW-Authenticate challenge triggers discovery, registration, and the PKCE flow — all automatic.
| Step | What happens |
|---|---|
| Discovery | Parses the WWW-Authenticate challenge, fetches protected-resource + authorization-server metadata (RFC 8414, OpenID fallback). |
| Registration | Registers a public client via RFC 7591 if none is stored. |
| Authorize | Opens the browser with PKCE and a loopback callback. |
| Token | Exchanges the code for tokens and persists them (0600). |
| Refresh | Renews expired tokens automatically on the next request. |
Interactive vs Headless
Interactive sessions open a browser; headless environments raise a single actionable exception.
Interactive (default)
from praisonaiagents.mcp import MCPOAuthProvider
provider = MCPOAuthProvider(
mcp_name="github",
server_url="https://api.githubcopilot.com/mcp/",
)
token = provider.ensure_authenticated() # opens a browser the first timeHeadless / CI
from praisonaiagents.mcp import MCPOAuthProvider, InteractiveAuthRequired
provider = MCPOAuthProvider(
mcp_name="github",
server_url="https://api.githubcopilot.com/mcp/",
open_browser=False,
)
try:
token = provider.ensure_authenticated()
except InteractiveAuthRequired:
print("Seed ~/.praisonai/mcp-auth.json interactively first.")Configuration Schema
The oauth: block is now optional. For servers that advertise standard metadata, just supply type: remote and url.
mcp:
servers:
github:
type: remote
url: https://api.githubcopilot.com/mcp/Keep the explicit block only as an escape hatch for servers without RFC 8414 metadata or dynamic registration:
mcp:
servers:
my-server:
type: remote
url: https://mcp.example.com/mcp
enabled: true
timeout: 30000 # milliseconds
oauth:
client_id: your_client_id
client_secret: your_client_secret # optional
scopes:
- read
- writeRemote Server with API Key
mcp:
servers:
tavily:
type: remote
url: https://mcp.tavily.com/mcp
headers:
Authorization: Bearer ${TAVILY_API_KEY}Python SDK
All OAuth helpers are lazy-loaded from praisonaiagents.mcp.
MCPOAuthProvider
Orchestrates discovery, registration, the PKCE flow, and refresh for one server.
from praisonaiagents.mcp import MCPOAuthProvider
provider = MCPOAuthProvider(
mcp_name="github",
server_url="https://api.githubcopilot.com/mcp/",
)
token = provider.ensure_authenticated() # opens a browser the first time| Parameter | Type | Default | Description |
|---|---|---|---|
mcp_name | str | — | Stable identifier used as the storage key. |
server_url | str | — | The MCP server URL. |
storage | Any | None | Optional pre-built MCPAuthStorage (created lazily otherwise). |
open_browser | bool | True | Set False for headless/CI to raise InteractiveAuthRequired. |
| Method | Signature | Returns | |
|---|---|---|---|
get_valid_token | get_valid_token() | `str \ | None` — a non-expired token, refreshing if possible. |
ensure_authenticated | ensure_authenticated(www_authenticate=None, timeout=10.0) | str — a valid bearer token, running the full flow if needed. |
Discovery and Registration Helpers
from praisonaiagents.mcp import (
discover_oauth_metadata,
parse_www_authenticate,
register_client,
)
challenge = parse_www_authenticate(
'Bearer resource_metadata="https://auth.example.com/.well-known/oauth-protected-resource"'
)
metadata = discover_oauth_metadata("https://api.githubcopilot.com/mcp/")
# {authorization_endpoint, token_endpoint, registration_endpoint, scopes_supported, resource}| Function | Signature | Returns | |
|---|---|---|---|
discover_oauth_metadata | discover_oauth_metadata(server_url, www_authenticate=None, timeout=10.0) | `dict \ | None` |
parse_www_authenticate | parse_www_authenticate(header) | dict — resource_metadata, scope, error, etc. | |
register_client | register_client(registration_endpoint, redirect_uris, client_name="PraisonAI Agent", scope=None, timeout=10.0) | dict — at least {"client_id": …} (RFC 7591). |
InteractiveAuthRequired
Raised by ensure_authenticated() when interactive auth is required but open_browser=False.
from praisonaiagents.mcp import InteractiveAuthRequiredAuth Storage
from praisonaiagents.mcp import MCPAuthStorage
storage = MCPAuthStorage()
entry = storage.get("github")
if entry and entry.get("tokens"):
print("Authenticated!")
if storage.is_token_expired("github"):
print("Token expired, need to re-authenticate")PKCE Utilities
from praisonaiagents.mcp import (
generate_state,
generate_code_verifier,
generate_code_challenge,
get_redirect_url,
)
state = generate_state()
verifier = generate_code_verifier()
challenge = generate_code_challenge(verifier)
redirect_url = get_redirect_url()
print(f"Redirect URL: {redirect_url}")
# Output: http://127.0.0.1:19876/mcp/oauth/callbackOAuth Callback Handler
from praisonaiagents.mcp import OAuthCallbackHandler, generate_state
import webbrowser
handler = OAuthCallbackHandler()
state = generate_state()
auth_url = f"https://auth.example.com/authorize?state={state}&..."
webbrowser.open(auth_url)
try:
code = handler.wait_for_callback(state, timeout=300)
print(f"Received authorization code: {code[:20]}...")
except TimeoutError:
print("OAuth flow timed out")CLI Commands
Authenticate
praisonai mcp auth <server-name>Runs the same OAuth 2.1 authorization-code flow (with PKCE) as the Python client. Use it to seed tokens ahead of time — for example, on a workstation before running your agent in CI.
praisonai mcp auth githubThe command:
- Opens your browser and waits for the loopback callback.
- Exchanges the code at the discovered
token_endpoint. - Persists tokens (with
expires_at) to~/.praisonai/mcp-auth.json(0600 permissions). - Clears any legacy placeholder entry (
oauth_<...>...) from a previous CLI run before starting, so a prior failed login recovers automatically — no manual cleanup. - Reads
server.oauth.client_id/client_secretfrom~/.praisonai/config.tomlfor servers without dynamic client registration, seeding them into storage before the flow runs. - Honors
--timeout(default300s).
On success, tokens are written with access_token, optional refresh_token, and expires_at. The Python client picks them up automatically on the next Agent.start(...). On failure, the command exits 1.
Logout
praisonai mcp logout <server-name>Removes stored OAuth credentials for a server. Use --yes to skip the confirmation prompt.
List Servers
praisonai mcp listShows all configured servers with their type (local/remote) and status.
Token Storage
OAuth tokens are stored in ~/.praisonai/mcp-auth.json with secure file permissions (0600).
{
"github": {
"server_url": "https://api.githubcopilot.com/mcp/",
"tokens": {
"access_token": "gho_xxx...",
"refresh_token": "ghr_xxx...",
"expires_at": 1234567890,
"token_endpoint": "https://auth.example.com/token",
"scope": "repo user"
},
"client_info": {
"client_id": "xxx"
}
}
}Security
- HTTPS-only — every metadata, authorize, token, and registration URL must be HTTPS (loopback
http://127.0.0.1,localhost,::1still allowed for testing). - Internal-host guard — on non-loopback hosts, every OAuth URL also passes the SDK’s SSRF check (
is_safe_http_url). An HTTPS endpoint on a private, link-local or cloud-metadata address (e.g.https://169.254.169.254/,https://10.0.0.5/,https://[::ffff:169.254.169.254]/,https://0177.0.0.1/) is refused before any request is sent — even when a compromised MCP server advertises it astoken_endpointorregistration_endpoint. - Pre-request redirect validation — discovery follows redirects by hand (max 3 hops) and validates every hop before fetching it, so a redirect to an internal or plaintext endpoint never reaches the network.
- No redirects on credential POSTs — code-exchange, refresh-token and registration POSTs never follow redirects, so a
307/308can’t re-send a refresh token or client secret to another host. A redirect is treated as a failure and saved tokens are preserved. - Refresh re-check — refresh-token grants re-validate the stored
token_endpointover HTTPS (defence in depth). - Public client — dynamic registration uses
token_endpoint_auth_method: "none"and requestsgrant_types: ["authorization_code", "refresh_token"]. - PKCE — all flows use PKCE (
S256) plus a randomstatefor CSRF protection. - File permissions — token storage uses 0600 (owner read/write only).
Troubleshooting
| Issue | Cause / fix |
|---|---|
RuntimeError: Could not discover OAuth metadata for … | Server doesn’t advertise RFC 8414 / OpenID metadata. Supply oauth.client_id and oauth.scopes in config as a fallback. |
RuntimeError: … does not support dynamic client registration | No registration_endpoint in auth-server metadata. Register a client manually and supply oauth.client_id. |
InteractiveAuthRequired | Running headless / in CI. Seed ~/.praisonai/mcp-auth.json from an interactive session first. |
ValueError: Refusing OAuth request over insecure transport: … | Auth server advertises http:// endpoints. Refused by design — fix the server or use an HTTPS reverse proxy. |
ValueError: Refusing OAuth request to a private/internal address: … | Auth server advertises an HTTPS endpoint on a private / RFC1918 / link-local host (e.g. cloud metadata 169.254.169.254). Refused by design — fix the server. |
ValueError: Too many redirects fetching OAuth metadata: … | Discovery hopped more than 3 times. Fix the server’s redirect chain. |
ValueError: Dynamic client registration response missing 'client_id' | Malformed response from the auth server’s registration_endpoint. |