Client OAuth¶
OAuth obtains MCP access tokens through discovery, Authorization Code with
PKCE, and refresh. It works with compatible authorization servers without
GitHub, Google, or other provider adapters. Server-side upstream integration
remains the responsibility of OAuthProvider.
An OAuth object belongs to one MCP endpoint and one application account. It
can serve several successive Client sessions. A request can refresh an
existing token, but it never opens a browser. If a login is necessary, it raises
AuthorizationRequired.
Local browser login¶
Use LoopbackCallback for a CLI or desktop application. It starts an aiohttp
server on 127.0.0.1. A fixed port is useful for a pre-registered callback;
the default port is allocated by the operating system.
Register the exact redirect URI with the authorization server before using a
static client ID. The following example uses http://127.0.0.1:8765/callback.
from aiohttp_tiny_mcp import AdapterSet, Client, LoopbackCallback, OAuth
async def list_private_tools(endpoint: str, issuer: str, client_id: str):
adapter = AdapterSet.default().by_version["2026-07-28"]
async with LoopbackCallback(port=8765) as callback:
async with OAuth(
endpoint,
issuer=issuer,
client_id=client_id,
redirect_uri=callback.redirect_uri,
) as auth:
await callback.login(auth)
async with Client(endpoint, adapter, auth=auth) as client:
await client.initialize()
return await client.list_tools()
A runnable version is available as examples/client_oauth.py:
uv run python examples/client_oauth.py https://mcp.example.com/mcp \
--issuer https://auth.example.com/tenant --client-id example-client \
--port 8765 --success-html success.html
Omit --port to let the operating system select a free port. The helper binds
to port 0 and includes the assigned port in callback.redirect_uri.
callback.login(auth) opens the browser, receives the authorization response,
exchanges the code, and saves the credentials before reporting success. Pass
an async open_browser(url) callback to control how the browser is opened.
It must return after opening the URL, or after the browser request completes.
The OAuth object owns a separate HTTP session for discovery, registration, and
token requests. MCP credentials and cookies are not sent on those requests.
Client(session=...) retains its existing session ownership rules. Do not
combine auth=OAuth(...) with Authorization headers or default authentication
on that MCP session.
Custom success HTML¶
Pass trusted application HTML through success_html. Both callback helpers
serve it unchanged after the token exchange and the save() callback succeed.
No callback parameters or tokens are inserted into the HTML. Failure returns a
generic error page. The application receives the original exception through
login() or wait().
from aiohttp_tiny_mcp import LoopbackCallback
callback = LoopbackCallback(
success_html="""<!doctype html>
<html lang="en"><meta charset="utf-8"><title>Connected</title>
<h1>Connected to Example</h1><p>Return to the application.</p></html>""",
)
Result pages disable caching and referrers. Their Content Security Policy allows inline styles and HTTPS/data images; it blocks scripts and embedding in a frame.
Remote callback in an aiohttp application¶
The redirect URI can be an external HTTPS URL. OAuthCallback mounts on an
existing aiohttp.web.Application; it does not open a port or a browser.
LoopbackCallback is the local convenience wrapper around this same handler.
The following factory serves one account context. Application authentication
middleware must set request["account_id"] before the start route can run.
For multiple users, resolve a separate OAuth/callback context for each user;
do not share one object’s credentials between accounts.
from aiohttp import web
from aiohttp_tiny_mcp import OAuth, OAuthCallback
def make_app(auth: OAuth, account_id: str) -> web.Application:
callback = OAuthCallback(
auth,
success_html="<!doctype html><h1>Connected</h1><p>Return to Example.</p>",
)
app = web.Application()
async def oauth_lifetime(app):
async with auth:
yield
callback.cancel()
async def start(request):
if request.get("account_id") != account_id:
raise web.HTTPUnauthorized()
attempt = await callback.begin()
raise web.HTTPFound(location=attempt.url)
app.cleanup_ctx.append(oauth_lifetime)
app.router.add_post("/connect", start)
callback.setup(app)
return app
auth = OAuth(
"https://mcp.example.com/mcp",
issuer="https://auth.example.com/tenant",
client_id="example-web-client",
redirect_uri="https://app.example.com/oauth/callback",
)
app = make_app(auth, account_id="account-123")
callback.setup(app) adds GET /oauth/callback, using the path in
auth.redirect_uri. callback.routes() returns the route for manual mounting.
The handler uses the configured public URI, so it also works behind a reverse
proxy. It does not derive the public URI from Host or Forwarded headers.
Protect the start route with the application’s normal session and CSRF checks.
Disable or redact callback query strings in the application’s access logs.
Pending login attempts stay in memory. Route the callback to the process that
started the attempt. Starting a new attempt replaces the previous one for that
context. Use callback.wait() when an application task needs the result of
the token exchange; it is optional for ordinary HTTP redirect flows.
Application-managed interaction¶
For another UI or callback mechanism, supply an async function to
auth.login(authorize=...). The function receives the authorization URL and
returns the full callback URL. OAuth validates the URI, state, issuer, and
response before sending the code to the token endpoint.
from aiohttp_tiny_mcp import OAuth
from aiohttp_tiny_mcp.client.oauth import Authorize
async def sign_in(auth: OAuth, open_and_wait: Authorize) -> None:
await auth.login(authorize=open_and_wait)
await auth.begin() and await auth.complete(attempt, callback_url) expose the
same flow in two steps. Use auth.cancel(attempt) to discard an attempt.
Attempts are single-use and expire after login_timeout seconds, default 300.
Registration and discovery¶
The client follows Protected Resource Metadata from WWW-Authenticate, with
path and root well-known fallbacks. It supports both OAuth authorization-server
metadata and OIDC discovery. OIDC metadata support does not require OIDC login
or ID-token validation.
Configuration |
Behavior |
|---|---|
|
Use a client registered with that exact issuer. |
|
Use a Client ID Metadata Document when the server supports it. The application hosts this document. |
|
Allow Dynamic Client Registration when the server exposes a registration endpoint. |
The selection order is static registration, configured CIMD, then explicitly
allowed DCR. DCR is retained for compatibility. Its default metadata requests
Authorization Code and refresh grants, uses public-client authentication, and
chooses native for an HTTP loopback callback or web for HTTPS. Set
registration={"application_type": "native"} for an HTTPS native-app callback.
Registration failures raise an exception rather than changing the callback URI.
Static credentials and stored DCR credentials are bound to their issuer. If
metadata advertises a different issuer, the client stops instead of forwarding
those credentials. If several authorization servers are listed, select one
with issuer=. Registration state is saved with tokens when storage callbacks
are configured.
For a confidential static client, supply client_secret and
token_endpoint_auth_method="client_secret_basic" or "client_secret_post".
Public clients use "none". Private-key JWT authentication is not implemented.
The client requires advertised PKCE S256 support and sends the MCP resource
on authorization and token requests. An advertised response issuer must be
present and match exactly. An unadvertised iss is checked if present.
Scopes from a server challenge are combined with explicitly requested scopes;
without either, the client uses the resource metadata’s scope list.
These rules follow the MCP authorization profile, including its registration order. The flow operates independently of the JSON-RPC adapter. An older wire revision can use it when its server supplies this authorization profile. Historical OAuth discovery profiles and STDIO authorization are not implemented here.
Storage callbacks¶
By default, credentials stay in the OAuth object’s memory. To retain them between process runs, supply both callbacks:
from collections.abc import Awaitable, Callable
from typing import Any
Credentials = dict[str, Any]
LoadCredentials = Callable[[], Awaitable[Credentials | None]]
SaveCredentials = Callable[[Credentials], Awaitable[None]]
load() returns the full stored record, or None if no record exists.
save(record) replaces the full record and returns after storage succeeds.
Exceptions propagate; a read failure is not treated as an empty store.
The library defines and validates the record format: version, issuer, resource,
client ID, registration source and data, tokens, requested scopes, and expiry.
Preserve the complete record instead of reconstructing it from the access token.
Bind callbacks to an application account and connection. For example, this in-memory adapter demonstrates the callback boundary; replace its operations with the application’s storage calls:
from copy import deepcopy
def credentials_callbacks(records, account_id, connection_id):
key = (account_id, connection_id)
async def load():
return deepcopy(records.get(key))
async def save(credentials):
records[key] = deepcopy(credentials)
return load, save
records = {}
load, save = credentials_callbacks(records, "alice", "reports")
assert await load() is None
await save({"version": 1})
assert await load() == {"version": 1}
Pass these callbacks to OAuth(..., load=load, save=save). A later OAuth object
with the same configuration restores credentials on first use. It can refresh
and make MCP requests without calling login() again. Storage contains secrets;
the application controls access and encryption.
The library serializes token refresh within one OAuth instance. If multiple instances or processes share credentials, the application must coordinate the complete load, refresh, and save cycle. Atomic database writes alone do not prevent concurrent refresh requests. For example, two processes can read the same refresh token from SQLite before either saves a replacement.
Use a single refresh owner, or coordinate access outside the library and read
current credentials after obtaining exclusive access. An existing OAuth object
caches credentials; locking save() or reusing a stale object does not reload
them. The simple callbacks do not provide distributed transactions or locks.
Refresh and errors¶
Refresh is automatic when a token expires or a request receives HTTP 401. Concurrent callers of the same OAuth object wait for one refresh operation. An MCP request gets at most one retry with a refreshed token. HTTP timeouts and ambiguous network failures do not cause automatic replay of MCP operations.
Before a refresh request, the client saves a record without usable tokens. This prevents automatic reuse of a refresh token if the server rotates it but the response is lost. After success, the client saves the replacement before using it. If the server omits a new refresh token, the old one is retained. A storage or network failure between these steps can require another login. There is no atomic transaction spanning application storage and the external OAuth server.
AuthorizationRequired.scopes identifies the scopes needed for explicit login.
For 403 insufficient_scope, it includes previously requested scopes and the
new challenge. A token without a refresh token requires login when it expires.
The client does not infer expiry by decoding access tokens as JWTs.
RegistrationRequired means the application must provide or renew client
registration. Other protocol failures raise OAuthError with a sanitized
message. Storage callback exceptions retain their original type. Do not put
credentials into application exception messages or logs.
HTTPS is required for MCP and OAuth endpoints. For local development,
allow_http=True permits HTTP only on loopback hosts. HTTP loopback callback
URIs are accepted independently of this setting. Redirects on credentialed
requests are rejected.
For applications that accept untrusted server URLs, use the async
check_url(url) callback and network egress controls to enforce permitted
destinations. HTTPS validation alone does not prevent access to private
networks. The library permits corporate HTTPS authorization servers on private
networks and does not impose a public-address-only policy.