Upstream MCP OAuth lifecycle¶
The external MCP client uses services/upstream_mcp_oauth.py. This is separate
from Preloop's inbound MCP authorization server. Apply the additive
20261110_upstream_mcp_oauth migration before running this application version.
Drain old application workers before accepting new OAuth setup/refresh requests:
they cannot read the new encrypted references. Downgrading removes the new
connection records and their references; grants created by this lifecycle then
need reconnecting. Existing bearer/API-key connections are unchanged; existing OAuth grants remain
usable and are moved into encrypted storage on their next refresh or reconnect.
Discovery and authorization¶
An unauthenticated GET reads the MCP endpoint's WWW-Authenticate
resource_metadata challenge. Without an advertised location, discovery tries
the path-aware and origin /.well-known/oauth-protected-resource locations.
The metadata's resource must match the configured URL. Its selected
authorization_servers issuer is resolved through RFC 8414 or OIDC metadata,
whose issuer must match exactly. Several advertised issuers require an explicit
auth_config.issuer; explicit metadata never falls back to contradictory
legacy metadata. Legacy servers may advertise authorization-server metadata
at their MCP path or origin when protected-resource metadata is absent.
Setup accepts a pre-registered client_id, optional write-only client_secret,
token_endpoint_auth_method (none, client_secret_post, client_secret_basic),
and scope or scopes. Otherwise it attempts advertised RFC 7591 public client
registration. Authorization uses S256 PKCE, a state nonce, the exact callback URI
and resource indicators on both authorization and token requests. Configure
PRELOOP_URL as the external console origin to obtain the correct callback URI.
The ten-minute transaction stores hashed state/browser nonces, account and user
identity, the user's authentication generation, server configuration version,
issuer, redirect URI, and encrypted PKCE/context. An HttpOnly, SameSite=Lax cookie
binds the callback to the initiating browser (Secure on HTTPS). CRUD locks the
server and consumes the transaction once, before token exchange, on any worker.
Expired transactions are removed on subsequent setup/completion; the indexed
CRUD cleanup operation can also be invoked by maintenance. Expiration is
always enforced even before physical cleanup. Deletion, disconnect, or a
configuration replacement invalidates obsolete state. Credential completion
compares the version again so an in-flight exchange cannot undo replacement.
Storage and refresh¶
upstream_mcp_oauth_connection owns a tenant-scoped SecretReference using the
existing local encrypted backend. The secret contains the grant and registration
credentials. mcp_server.auth_config retains public configuration.
GET /api/v1/mcp-servers returns that public OAuth configuration without
write-only credential markers and batches connection-state reads without
decrypting credentials. Before editing, clients must fetch
GET /api/v1/mcp-servers/{id} to obtain the redacted credential markers; the
console does this when opening the edit form. Submit those markers with the
edited configuration to preserve stored secrets. A write-only edit merges the
markers under the server lock, so a stale browser response cannot restore
tokens superseded by a refresh. Policy snapshots do not copy new OAuth grants.
Before every MCP session, the pool reads the current connection version. Tokens expiring within 60 seconds require refresh; absent expiry is treated as unknown. CRUD claims a bounded lease in a short transaction, releases the DB before the token request, then commits the complete rotated pair and expiry atomically. A missing replacement refresh token retains the previous value; missing expiry clears the previous expiry. Other workers wait for the winning version. A stale completion cannot overwrite a new version or disconnect. Configured introspection checks the resolved token again before a session.
A refresh POST has a 15-second deadline and is never retried automatically. Invalid grants, malformed responses, timeouts, and expired abandoned leases require reconnecting because the token might already have been consumed. An explicit rate limit releases the lease with a cooldown. At most one recovery refresh is allowed for an explicit authentication rejection during session setup, before dispatch. A dispatched tool call is never replayed; connection loss or timeout returns an unknown outcome, instructing the caller to check the remote result before deciding to invoke the tool again.
Network and privacy boundaries¶
Discovery has a 45-second total budget, 15 seconds per request, at most two
transient retries, and three redirects with loop detection. Retry-After must
fit the remaining budget. Every outbound destination and redirect is validated.
DNS results are checked and the connection is pinned to a checked IP while
preserving HTTP Host and TLS SNI/certificate validation. Ambient proxy settings
and credentials are disabled. Token/registration POST redirects are refused.
HTTPS public addresses are the default. Explicit local development can allow
private or loopback CIDRs through MCP_OAUTH_ALLOWED_CIDRS; HTTP requires this
operator opt-in. Link-local, unspecified, multicast, and reserved addresses stay
blocked. This setting is independent of model-gateway network exceptions.
Metadata and registration never receive the MCP bearer. Errors omit provider bodies. Application access logs redact OAuth callback/authorize queries and error telemetry excludes these routes. Configure any external reverse proxy to omit query strings for these routes too.
Console and disconnect¶
The Lit tools setup shows disconnected, connecting, connected, and reconnect required. Connect/reconnect opens provider consent; cancel/disconnect clears local access/refresh grants and pending transactions and invalidates pooled credentials. Encrypted client registration remains available for reconnect. Disconnect intentionally makes no upstream revocation request; revocation is not part of this local operation. Disabling an OAuth server drops its grant the same way, so it reads as disconnected and needs a reconnect after re-enabling.
Every write goes through crud_mcp_server, including policy apply: OAuth secrets
never reach the auth_config JSONB column, and switching a server away from
OAuth keeps only the public fields of its old configuration. A snapshot
round-trip re-applies the exported public configuration without touching the
stored grant.
All automated coverage uses synthetic providers and a local PostgreSQL database. Real-provider authenticated compatibility remains a separately authorized release gate. This lifecycle continues to use the MCP SDK 1.x transport API.