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.

Diagram

Zero-config OAuth

🔗 MCP URL

🔎 Discover + Register

🔐 Authorize + Token

✅ Connected

Status

CapabilityStatus
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

  1. 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_id or scopes to configure.

  2. Step
    python
    agent.start("Close pull request 42")

    Cached tokens are re-used, and expired ones are refreshed transparently.

  3. Or pre-seed from the CLI
    bash
    # One-time interactive login; tokens cached at ~/.praisonai/mcp-auth.json
    praisonai mcp auth github

    Run this once on a workstation, then copy ~/.praisonai/mcp-auth.json to 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.

Diagram
BrowserAuth ServerMCP ServerAgentUserBrowserAuth ServerMCP ServerAgentUserrequestcall401 + WWW-Authenticate: Bearer resource_metadata=”…”GET /.well-known/oauth-protected-resourceGET /.well-known/oauth-authorization-serverPOST /register (RFC 7591, if no client stored)open authorize URL with PKCEsign in and approvecode (loopback callback)POST /token (code + verifier)persist tokens (0600)retry with Bearer token200
StepWhat happens
DiscoveryParses the WWW-Authenticate challenge, fetches protected-resource + authorization-server metadata (RFC 8414, OpenID fallback).
RegistrationRegisters a public client via RFC 7591 if none is stored.
AuthorizeOpens the browser with PKCE and a loopback callback.
TokenExchanges the code for tokens and persists them (0600).
RefreshRenews expired tokens automatically on the next request.

Interactive vs Headless

Interactive sessions open a browser; headless environments raise a single actionable exception.

Interactive (default)

python
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

Headless / CI

python
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.

yaml
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:

yaml
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
          - write

Remote Server with API Key

yaml
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.

python
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
ParameterTypeDefaultDescription
mcp_namestr—Stable identifier used as the storage key.
server_urlstr—The MCP server URL.
storageAnyNoneOptional pre-built MCPAuthStorage (created lazily otherwise).
open_browserboolTrueSet False for headless/CI to raise InteractiveAuthRequired.
MethodSignatureReturns
get_valid_tokenget_valid_token()`str \None` — a non-expired token, refreshing if possible.
ensure_authenticatedensure_authenticated(www_authenticate=None, timeout=10.0)str — a valid bearer token, running the full flow if needed.

Discovery and Registration Helpers

python
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}
FunctionSignatureReturns
discover_oauth_metadatadiscover_oauth_metadata(server_url, www_authenticate=None, timeout=10.0)`dict \None`
parse_www_authenticateparse_www_authenticate(header)dict — resource_metadata, scope, error, etc.
register_clientregister_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.

python
from praisonaiagents.mcp import InteractiveAuthRequired

Auth Storage

python
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

python
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/callback

OAuth Callback Handler

python
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

bash
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.

bash
praisonai mcp auth github

The 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_secret from ~/.praisonai/config.toml for servers without dynamic client registration, seeding them into storage before the flow runs.
  • Honors --timeout (default 300s).

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.

Diagram
~/.praisonai/mcp-auth.jsonAuth ServerBrowserpraisonai mcp authUser~/.praisonai/mcp-auth.jsonAuth ServerBrowserpraisonai mcp authUserpraisonai mcp auth githubpurge legacy placeholder if presentdiscover metadata (RFC 8414 / OpenID)register client if needed (RFC 7591)open authorize URL (PKCE + state)sign in and approvecode (loopback callback)POST /token (code + verifier)access_token, refresh_token, expires_atpersist (0600)✅ Successfully authenticated with github

Logout

bash
praisonai mcp logout <server-name>

Removes stored OAuth credentials for a server. Use --yes to skip the confirmation prompt.

List Servers

bash
praisonai mcp list

Shows 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).

json
{
  "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, ::1 still 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 as token_endpoint or registration_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/308 can’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_endpoint over HTTPS (defence in depth).
  • Public client — dynamic registration uses token_endpoint_auth_method: "none" and requests grant_types: ["authorization_code", "refresh_token"].
  • PKCE — all flows use PKCE (S256) plus a random state for CSRF protection.
  • File permissions — token storage uses 0600 (owner read/write only).

Troubleshooting

IssueCause / 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 registrationNo registration_endpoint in auth-server metadata. Register a client manually and supply oauth.client_id.
InteractiveAuthRequiredRunning 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.

Related