Skip to content

feat(mcp): shared OAuth token foundation - challenge, store seam, expiry-aware cache, single-flight refresh - #31275

Merged
tin-berri merged 11 commits into
litellm_internal_stagingfrom
litellm_mcp_v2_oauth_token_foundation
Jun 27, 2026
Merged

feat(mcp): shared OAuth token foundation - challenge, store seam, expiry-aware cache, single-flight refresh#31275
tin-berri merged 11 commits into
litellm_internal_stagingfrom
litellm_mcp_v2_oauth_token_foundation

Conversation

@tin-berri

@tin-berri tin-berri commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Part of the MCP v2 outbound-credential migration. The shared foundation for the OAuth2 / per-user modes of the v2 resolver: the 401-challenge mechanism, the OAuth token model + store seam, the expiry-aware cache, and proactive single-flight refresh. Stacked PRs build each oauth2 mode (authorization_code, client_credentials, token_exchange) on top of this, so the cache/refresh machinery lives once rather than being copied per mode

Scope: this PR vs #31474 (cross-replica)

Both this PR and #31474 say "single-flight refresh" — they are the two halves of the same machinery, and it is worth being explicit about the seam:

In one line: #31275 defines the slots and fills them for a single process; #31474 fills the same slots for a multi-replica fleet. (#31473 sits between them, wiring authorization_code onto these seams using the in-process defaults.)

Linear ticket

N/A (groundwork for MCP V2)

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review and received a Confidence Score of at least 4/5

Type

🆕 New Feature

Changes

Shared infrastructure for the oauth2 resolver modes, so each mode PR plugs in only a token source, a refresh action, and its arm, never re-touching the cache or the cross-replica wiring:

  • CredError.of_unauthorized carries a 401 challenge (the Unauthorized frozen dataclass with an optional WWW-Authenticate header and a structured body), emitted by raise_public
  • OAuthToken (access_token / expires_at / refresh_token), the OAuthTokenStore Protocol seam, and TokenStoreUnavailable (an outage is propagated, never cached as "not authorized")
  • CachedOAuthTokenStore: an expiry-aware cache that serves a token only while it is unexpired (minus a skew) and never caches a "not authorized" miss, so a token written after the OAuth flow is visible on the very next call (matching v1); storage sits behind the injectable TokenCacheBackend seam, whose default InMemoryTokenCacheBackend is the bounded per-process dict
  • TokenRefresher (mode-supplied seam over user_id / server_id / token, so the refresher has the server config to run the grant and the (user_id, server_id) key to persist under) plus RefreshingTokenStore: when the stored token is near expiry, mint a fresh one serialized per (user, server) by the injectable RefreshCoordinator so concurrent callers share one refresh; an unrefreshable expired token surfaces as None so the arm challenges, never a stale bearer
  • RefreshCoordinator seam with the default InProcessRefreshCoordinator (asyncio single-flight, self-cleaning so the in-flight map stays bounded by concurrent refreshes); a cross-replica deployment injects a Redis SET NX coordinator without touching the resolver, and the coordinator threads a reread callback so a distributed loser re-reads the persisted token (the in-process default ignores it)

Not in this PR: the per-mode arms, sources, mappings, and wiring (each a stacked PR). The concrete cross-replica backend and coordinator (DualCache / Redis SET NX) land in #31474; this PR only carves out their injection points, with the in-process defaults preserving today's behavior exactly. Reactive-401 refresh is later hardening and lives in the egress transport, which sees the upstream's 401

Screenshots / Proof of Fix

Foundation only; no runtime behavior change. Exercised by the unit tests (the challenge mapping; cache TTL / expiry / miss-never-cached / invalidate / outage / backend delegation; refresh plus single-flight under concurrency; coordinator delegation; repr masking) and by the stacked mode PRs


Note

Medium Risk
Touches authentication credential handling and token refresh concurrency, but ships as isolated foundation with in-process defaults and no mode wiring until stacked PRs land.

Overview
Adds shared MCP v2 outbound OAuth plumbing: per-user OAuthToken model, OAuthTokenStore / TokenRefresher seams, expiry-aware CachedOAuthTokenStore (positive hits only; never cache “not authorized” or store outages), and RefreshingTokenStore with injectable RefreshCoordinator / TokenCacheBackend plus in-process defaults (bounded in-memory cache, asyncio single-flight refresh with a re-read-before-refresh guard against duplicate IdP refreshes).

401 challenges are richer: CredError.of_unauthorized now carries an Unauthorized payload (optional structured body and WWW-Authenticate), and raise_public maps that onto HTTPException headers/detail instead of a plain string.

Broad unit coverage for cache TTL/skew, miss behavior, invalidate, concurrency, coordinator/backend injection, and challenge HTTP mapping. Per-mode resolver wiring and Redis cross-replica fillers are explicitly out of scope here.

Reviewed by Cursor Bugbot for commit 66a33c9. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds shared OAuth token infrastructure for MCP outbound credentials. The main changes are:

  • Adds structured 401 challenges with optional WWW-Authenticate headers and response bodies.
  • Introduces the OAuthTokenStore seam, OAuthToken model, and store-unavailable error path.
  • Adds an expiry-aware positive-token cache with injectable cache backends.
  • Adds proactive token refresh with an injectable single-flight coordinator.
  • Adds focused tests for challenge mapping, cache behavior, refresh behavior, and the latest stale-read race fix.

Confidence Score: 4/5

Medium risk: the changes touch authentication error shaping and token caching/refresh semantics, but the foundation is isolated and covered by focused tests.

The implementation is not production-wired yet, which limits immediate blast radius, and tests cover challenge mapping, cache expiry, invalidation, outage behavior, refresh coordination, and concurrency paths.

No files require follow-up from the completed review.

T-Rex T-Rex Logs

What T-Rex did

  • Verified two runs of the outbound_credentials.types scenario and exercised the HTTPException mapping with the unauthorized raise_public contract to isolate runtime dependencies.
  • Validated the oauth_token_store check across two environments, confirming exit code 0 in both, with pre-run showing module absence and post-run showing ALL_HEAD_ASSERTIONS_PASSED.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (6): Last reviewed commit: "fix: reread oauth token before refresh" | Re-trigger Greptile

Comment thread litellm/proxy/_experimental/mcp_server/outbound_credentials/oauth_token_store.py Outdated
Comment thread litellm/proxy/_experimental/mcp_server/outbound_credentials/oauth_token_store.py Outdated
@codecov

codecov Bot commented Jun 25, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tin-berri
tin-berri force-pushed the litellm_mcp_v2_oauth_token_foundation branch from 57bbf5a to 8cceb45 Compare June 25, 2026 04:36
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@tin-berri tin-berri changed the title feat(mcp): OAuth token foundation - challenge, store seam, expiry-aware cache, single-flight refresh feat(mcp): shared OAuth token foundation - challenge, store seam, expiry-aware cache, single-flight refresh Jun 25, 2026
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_oauth_token_foundation branch 2 times, most recently from 6b2a061 to c7cfd84 Compare June 25, 2026 16:40
@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai re-review — the full self._cache.clear() flagged earlier was replaced with single-oldest-entry FIFO eviction, and the cache is now positive-only (never caches the not-authorized None). Please re-review the current tip.

The unauthorized case becomes a structured Unauthorized (detail + optional WWW-Authenticate
header + optional structured body) instead of a bare string, and raise_public emits the header
and body when present. This lets a mode reproduce a rich 401 challenge (e.g. BYOK's
provisioning prompt) through the generic resolver edge. of_unauthorized's new params are
keyword-only and default to None, so existing callers and the summary string are unchanged.
…t flat

CredError's unauthorized payload was a pydantic BaseModel, whose base resolves as unknown in
this repo's basedpyright (every model in the file trips reportUntypedBaseClass plus an unknown
model_config), so the tagged-union case read as unknown and the public edge's challenge access
added reportUnknownMemberType errors over the per-rule ceiling. A frozen dataclass is fully
typed here, so error.unauthorized resolves directly with no cast or accessor and the per-rule
basedpyright counts match base.
…ion_code

Lay the foundation for the authorization_code resolver arm: OAuthToken (access_token,
expires_at, refresh_token), the OAuthTokenStore Protocol seam, TokenStoreUnavailable for
outages, and CachedOAuthTokenStore, an expiry-aware cache that serves a token only while
unexpired, caches the "not authorized" None for a default TTL, and propagates a store outage
without caching it. Mirrors the BYOK store/cache pattern, adapted for tokens. Refresh and
distributed single-flight are deferred to the hardening step.
Add TokenRefresher (a mode-supplied seam: mint a fresh token from an expired one and persist it)
and RefreshingTokenStore: when the stored token is near expiry, the first caller refreshes while
concurrent callers await the same in-flight task and share its result, so the IdP is not
stampeded. The task self-cleans (a done-callback drops its entry), so the map is bounded by
in-flight refreshes rather than by distinct users/servers, and is detached from the caller so a
cancelled caller does not abort the refresh. An expired token the refresher cannot renew surfaces
as None so the arm challenges, never a stale bearer; it composes under CachedOAuthTokenStore.
OAuthToken's repr masks the access/refresh tokens so a stray log cannot leak them. Cross-replica
single-flight (Redis) and reactive-401 refresh are the later distributed hardening.
…aching)

CachedOAuthTokenStore no longer caches the "not authorized" None result; every miss re-reads the
inner store. v1's per-user token cache never caches misses, so a token written by the OAuth flow
is visible on the next request without an invalidation hook, and uniformly across replicas since
the in-process cache holds no stale None to clear. invalidate() now only covers rotation or
revocation of a cached token. Negative caching (with distributed invalidation) can return later
if a slow DB-backed v2-native source makes per-miss reads expensive.
The proactive token-refresh / cache-expiry buffer defaulted to 30s, which is
an outlier among OAuth clients. Spring Security uses 60s as both its JWT
clock-skew tolerance and its refresh buffer, and 60s sits inside RFC 7519's
"a few minutes" leeway while preserving nearly all of a typical token's life;
30s was untested, so pin the default with two boundary-probe regression tests.
@tin-berri
tin-berri force-pushed the litellm_mcp_v2_oauth_token_foundation branch from 81c7402 to 54414ff Compare June 26, 2026 00:23
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptile-apps

The refresh seam took only the OAuthToken, but a refresher needs the server's
config (token endpoint, client credentials, scopes) to run the grant and the
(user_id, server_id) key to persist the minted token, neither of which is
derivable from the token. Widen TokenRefresher.refresh to (user_id, server_id,
token) and pass them through from RefreshingTokenStore so each stacked mode PR
plugs into the final seam rather than forcing a later signature change across
the stack.
…replica token caching)

Make CachedOAuthTokenStore's storage and RefreshingTokenStore's single-flight injectable so a
cross-replica deployment can back them with Redis without touching the resolver. The defaults preserve
today's behavior exactly: InMemoryTokenCacheBackend (the bounded per-process dict) and
InProcessRefreshCoordinator (the asyncio single-flight). A distributed deployment injects a shared
DualCache-backed backend and a SET NX PX coordinator. invalidate() is now async (the backend may be).
The cache stores via the backend with a TTL derived from the token's expiry; the coordinator threads a
reread callback for the cross-replica case (losers re-read the persisted token) that the in-process
default ignores.
@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Stale token duplicate refresh race
    • RefreshingTokenStore now re-reads the persisted token immediately before leading a refresh and skips the IdP call when another caller already stored a fresh token.

You can send follow-ups to the cloud agent here.

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ tin-berri
❌ cursoragent
You have signed the CLA already but the status is still pending? Let us recheck it.

@mateo-berri

Copy link
Copy Markdown
Contributor

bugbot run

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 66a33c9. Configure here.

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you could verify whether

  1. the autofix did things incorrectly and we should force push it back, or
  2. it's fine we should bundle it in

then I'm happy to approve. Otherwise LGTM!

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM; thanks!

@tin-berri
tin-berri merged commit 2e69708 into litellm_internal_staging Jun 27, 2026
124 checks passed
@tin-berri
tin-berri deleted the litellm_mcp_v2_oauth_token_foundation branch June 27, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants