Repository navigation
feat(mcp): cross-replica single-flight refresh for the v2 per-user OAuth store [2/2] #31493
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
tin-berri
merged 20 commits into
litellm_internal_staging
from
litellm_mcp_v2_authz_code_xrepl_2
Jun 27, 2026
Merged
Changes from 10 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
5308aa0
feat(mcp): encrypt+serialize codec for caching OAuth tokens in Redis …
tin-berri 95386fb
feat(mcp): DualCache-backed token cache backend (step 1b §1.5)
tin-berri 7528b11
feat(mcp): Redis SET NX PX refresh coordinator (step 1b §1.5)
tin-berri bd1db7d
feat(mcp): Redis SET NX PX distributed lock (step 1b §1.5)
tin-berri 8ca2edf
feat(mcp): wire the cross-replica cache + coordinator into the per-us…
tin-berri fb11eb8
fix(mcp): refresh on lock-backend error instead of serving a stale token
tin-berri 698b3d8
style(mcp): wrap redis lock signatures at line-length 88 for CI ruff …
tin-berri 880c7dc
fix(mcp): a refresh loser surfaces None, not a stale token, when the …
tin-berri 49781f6
fix(mcp): log per-user token decrypt failures at debug, matching v1
tin-berri df4396f
fix(mcp): namespace the refresh lock key and fence its release with a…
tin-berri 6f17ea9
Merge remote-tracking branch 'origin/litellm_internal_staging' into l…
tin-berri aa2aa3c
fix(mcp): fail open when the per-user token cache delete errors
tin-berri acdcb33
style(mcp): reformat outbound-credentials files to line-length 120
tin-berri 8853089
fix: harden mcp oauth redis refresh coordination
cursoragent 0c72d84
fix(mcp): make the per-user token cache backend airtight on boundary …
tin-berri 47ffe5f
test(mcp): pin per-user cache get() to a miss when decrypt raises
tin-berri f1f9925
refactor(mcp): use frozen dataclasses for the trivial DI constructors
tin-berri a0eefcf
fix: serialize lazy per-user oauth store rebuild
cursoragent e16bc75
fix(mcp): stop losers challenging mid-refresh by decoupling wait from…
tin-berri 9eea30f
fix: allow concurrent lazy OAuth fetches without Redis
cursoragent File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
65 changes: 65 additions & 0 deletions
65
litellm/proxy/_experimental/mcp_server/outbound_credentials/dual_cache_token_backend.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| """Cross-replica ``TokenCacheBackend``: stores the token in LiteLLM's shared ``DualCache``. | ||
|
|
||
| Plugs into the foundation's ``CachedOAuthTokenStore`` via the ``TokenCacheBackend`` seam. The token is | ||
| encrypted + serialized by the injected codec and written under a per-``(user, server)`` key with the | ||
| given TTL, so every worker reads one refresh rather than each re-reading and re-refreshing - matching | ||
| v1's ``MCPPerUserTokenCache`` (same NaCl encryption and key, so a token cached by either is readable by | ||
| the other across the cutover). A missing or undecryptable entry reads as a miss. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Protocol | ||
|
|
||
| from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import ( | ||
| OAuthToken, | ||
| ) | ||
| from litellm.proxy._experimental.mcp_server.outbound_credentials.token_cache_codec import ( | ||
| OAuthTokenCacheCodec, | ||
| ) | ||
|
|
||
|
|
||
| class AsyncCache(Protocol): | ||
| """The slice of LiteLLM's ``DualCache`` this backend needs (Redis-backed, shared across workers).""" | ||
|
|
||
| async def async_get_cache(self, key: str) -> object | None: ... | ||
|
|
||
| async def async_set_cache( | ||
| self, key: str, value: str, ttl: float | None = None | ||
| ) -> None: ... | ||
|
|
||
| async def async_delete_cache(self, key: str) -> None: ... | ||
|
|
||
|
|
||
| class DualCacheTokenCacheBackend: | ||
| def __init__( | ||
| self, | ||
| cache: AsyncCache, | ||
| codec: OAuthTokenCacheCodec, | ||
| *, | ||
| key_prefix: str = "mcp:per_user_token:", | ||
| ) -> None: | ||
| self._cache = cache | ||
| self._codec = codec | ||
| self._key_prefix = key_prefix | ||
|
|
||
| def _key(self, user_id: str, server_id: str) -> str: | ||
| return f"{self._key_prefix}{user_id}:{server_id}" | ||
|
|
||
| async def get(self, user_id: str, server_id: str) -> OAuthToken | None: | ||
| blob = await self._cache.async_get_cache(self._key(user_id, server_id)) | ||
|
tin-berri marked this conversation as resolved.
Resolved
|
||
| return self._codec.decode(blob) if isinstance(blob, str) else None | ||
|
|
||
| async def set( | ||
| self, user_id: str, server_id: str, token: OAuthToken, ttl_seconds: float | ||
| ) -> None: | ||
| if ttl_seconds <= 0: | ||
| return | ||
| await self._cache.async_set_cache( | ||
| self._key(user_id, server_id), | ||
| self._codec.encode(token), | ||
| ttl=ttl_seconds, | ||
|
tin-berri marked this conversation as resolved.
Resolved
|
||
| ) | ||
|
|
||
| async def delete(self, user_id: str, server_id: str) -> None: | ||
| await self._cache.async_delete_cache(self._key(user_id, server_id)) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
89 changes: 89 additions & 0 deletions
89
litellm/proxy/_experimental/mcp_server/outbound_credentials/redis_distributed_lock.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| """Concrete ``DistributedLock`` over a Redis client: ``SET NX PX`` / compare-and-delete / ``EXISTS``. | ||
|
|
||
| The cross-replica lock the ``RedisRefreshCoordinator`` elects refreshers with. ``acquire`` is an | ||
| atomic ``SET key token NX PX ttl`` (only the first caller wins; the entry self-expires so a crashed | ||
| holder can't wedge refresh). ``release`` deletes the key only when it still holds this caller's token | ||
| (a compare-and-delete Lua script), so a holder whose lock already PX-expired and was re-acquired by | ||
| another worker cannot delete the new holder's lock. ``is_held`` is ``EXISTS``. Every key is run | ||
| through the injected ``namespace_key`` before it reaches Redis, so a deployment's lock keys carry the | ||
| same namespace its cache keys do and cannot collide with another deployment sharing the Redis backend. | ||
|
|
||
| The Redis client is injected (in production the async client from LiteLLM's ``RedisCache``), so the | ||
| lock is unit-testable with a fake. A transport error on ``acquire`` returns ``LockAcquisition.ERROR`` - | ||
| distinct from ``HELD`` - so the coordinator refreshes anyway instead of mistaking a dead backend for a | ||
| busy holder; a Redis blip degrades to an extra refresh, never a stale bearer. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from collections.abc import Callable | ||
| from typing import Protocol | ||
|
|
||
| from litellm._logging import verbose_logger | ||
| from litellm.proxy._experimental.mcp_server.outbound_credentials.redis_refresh_coordinator import ( | ||
| LockAcquisition, | ||
| ) | ||
|
|
||
| # Delete the key only if it still holds this caller's token, so a holder whose lock already expired | ||
| # (PX) and was re-acquired by another worker cannot delete the new holder's lock. | ||
| _RELEASE_IF_OWNER = ( | ||
| "if redis.call('get', KEYS[1]) == ARGV[1] " | ||
| "then return redis.call('del', KEYS[1]) else return 0 end" | ||
| ) | ||
|
|
||
|
|
||
| class RedisCommands(Protocol): | ||
| """The slice of the async Redis client this lock needs.""" | ||
|
|
||
| async def set( | ||
| self, name: str, value: str, *, nx: bool = False, px: int | None = None | ||
| ) -> object | None: ... | ||
|
|
||
| async def eval(self, script: str, numkeys: int, *keys_and_args: str) -> object: ... | ||
|
|
||
| async def exists(self, *names: str) -> int: ... | ||
|
|
||
|
|
||
| class RedisDistributedLock: | ||
| def __init__( | ||
| self, | ||
| client: RedisCommands, | ||
| *, | ||
| namespace_key: Callable[[str], str] = lambda key: key, | ||
| ) -> None: | ||
| self._client = client | ||
| self._namespace_key = namespace_key | ||
|
|
||
| async def acquire( | ||
| self, key: str, token: str, ttl_seconds: float | ||
| ) -> LockAcquisition: | ||
| try: | ||
| result = await self._client.set( | ||
| self._namespace_key(key), token, nx=True, px=int(ttl_seconds * 1000) | ||
| ) | ||
| # Degrade on any Redis client error: redis.exceptions narrows only via an import that | ||
| # is Unknown under basedpyright, and the lock must never crash the resolve path. | ||
| except Exception as exc: # noqa: BLE001 | ||
| verbose_logger.warning("RedisDistributedLock.acquire failed: %s", exc) | ||
| return LockAcquisition.ERROR | ||
| return LockAcquisition.ACQUIRED if result is not None else LockAcquisition.HELD | ||
|
tin-berri marked this conversation as resolved.
Resolved
|
||
|
|
||
| async def release(self, key: str, token: str) -> None: | ||
| try: | ||
| await self._client.eval( | ||
| _RELEASE_IF_OWNER, 1, self._namespace_key(key), token | ||
| ) | ||
| # Degrade on any Redis client error: redis.exceptions narrows only via an import that | ||
| # is Unknown under basedpyright, and the lock must never crash the resolve path. | ||
| except Exception as exc: # noqa: BLE001 | ||
| verbose_logger.warning("RedisDistributedLock.release failed: %s", exc) | ||
|
|
||
| async def is_held(self, key: str) -> bool: | ||
| try: | ||
| return await self._client.exists(self._namespace_key(key)) > 0 | ||
| # Degrade on any Redis client error: redis.exceptions narrows only via an import that | ||
| # is Unknown under basedpyright, and the lock must never crash the resolve path. | ||
| except Exception as exc: # noqa: BLE001 | ||
| # On error, report "not held" so a waiter stops waiting and re-reads rather than blocking. | ||
| verbose_logger.warning("RedisDistributedLock.is_held failed: %s", exc) | ||
| return False | ||
109 changes: 109 additions & 0 deletions
109
litellm/proxy/_experimental/mcp_server/outbound_credentials/redis_refresh_coordinator.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| """Cross-replica ``RefreshCoordinator``: one refresh per ``(user, server)`` across all workers. | ||
|
|
||
| Plugs into the foundation's ``RefreshingTokenStore`` via the ``RefreshCoordinator`` seam. A ``SET NX | ||
| PX`` lock elects one worker to run the refresh while the rest wait for it and re-read the token it | ||
| persisted - so a rotating refresh_token is used once across the fleet, not once per worker. The lock | ||
| auto-expires (``PX``), so a crashed holder can't wedge refresh; a loser that times out (or whose | ||
| holder crashed mid-refresh) falls back to a re-read, and the surrounding store re-checks expiry on the | ||
| next fetch, so a crash self-heals rather than serving stale forever. Reading needs no lock, so losers | ||
| don't serialize behind each other. The lock is injected (a thin Redis ``SET NX``/``DEL``/``EXISTS`` | ||
| wrapper in production, a fake in tests). | ||
|
|
||
| The lock is a single-flight optimization, not a correctness mutex, so it fails open: when the lock | ||
| backend is unreachable, ``acquire`` reports ``ERROR`` (distinct from ``HELD``) and this coordinator | ||
| refreshes anyway rather than wait on a holder that may not exist and then serve a still-expired token. | ||
| That degrades a Redis outage to the no-coordinator behavior (each worker may refresh), never a stale | ||
| bearer the upstream would 401. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
| import time | ||
| import uuid | ||
| from collections.abc import Awaitable, Callable | ||
| from enum import Enum | ||
| from typing import Protocol | ||
|
|
||
| from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import ( | ||
| OAuthToken, | ||
| ) | ||
|
|
||
|
|
||
| class LockAcquisition(Enum): | ||
| """Outcome of a best-effort ``acquire``. ``ERROR`` is kept distinct from ``HELD`` so a caller can | ||
| tell "someone else is refreshing" (wait and re-read) from "the lock backend is down" (no election | ||
| happened, so refresh anyway) instead of conflating both into a single ``False``.""" | ||
|
|
||
| ACQUIRED = "acquired" # won the election; this worker refreshes | ||
| HELD = "held" # another worker holds it; wait then re-read | ||
| ERROR = "error" # lock backend unreachable; holder unknown, so refresh anyway | ||
|
|
||
|
|
||
| class DistributedLock(Protocol): | ||
| """A best-effort cross-replica lock. ``acquire`` is ``SET key token NX PX ttl`` reported as a | ||
| ``LockAcquisition`` (won / held by another / backend error); ``release`` deletes the key only if | ||
| it still holds this caller's ``token`` (so it cannot delete a lock another worker re-acquired | ||
| after PX-expiry); ``is_held`` is ``EXISTS`` (so a waiter can poll without taking the lock).""" | ||
|
|
||
| async def acquire( | ||
| self, key: str, token: str, ttl_seconds: float | ||
| ) -> LockAcquisition: ... | ||
|
|
||
| async def release(self, key: str, token: str) -> None: ... | ||
|
|
||
| async def is_held(self, key: str) -> bool: ... | ||
|
|
||
|
|
||
| class RedisRefreshCoordinator: | ||
| def __init__( | ||
| self, | ||
| lock: DistributedLock, | ||
| *, | ||
| key_prefix: str = "mcp:refresh_lock:", | ||
| lock_ttl_seconds: float = 10.0, | ||
| wait_timeout_seconds: float = 10.0, | ||
| poll_interval_seconds: float = 0.05, | ||
| sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, | ||
| clock: Callable[[], float] = time.monotonic, | ||
| new_token: Callable[[], str] = lambda: uuid.uuid4().hex, | ||
| ) -> None: | ||
| self._lock = lock | ||
| self._key_prefix = key_prefix | ||
| self._lock_ttl_seconds = lock_ttl_seconds | ||
| self._wait_timeout_seconds = wait_timeout_seconds | ||
| self._poll_interval_seconds = poll_interval_seconds | ||
| self._sleep = sleep | ||
| self._clock = clock | ||
| self._new_token = new_token | ||
|
tin-berri marked this conversation as resolved.
Resolved
|
||
|
|
||
| def _key(self, user_id: str, server_id: str) -> str: | ||
| return f"{self._key_prefix}{user_id}:{server_id}" | ||
|
|
||
| async def run( | ||
| self, | ||
| user_id: str, | ||
| server_id: str, | ||
| refresh: Callable[[], Awaitable[OAuthToken | None]], | ||
| reread: Callable[[], Awaitable[OAuthToken | None]], | ||
| ) -> OAuthToken | None: | ||
| key = self._key(user_id, server_id) | ||
| token = self._new_token() | ||
| match await self._lock.acquire(key, token, self._lock_ttl_seconds): | ||
| case LockAcquisition.ACQUIRED: | ||
| try: | ||
| return await refresh() | ||
| finally: | ||
| await self._lock.release(key, token) | ||
|
tin-berri marked this conversation as resolved.
Resolved
|
||
| case LockAcquisition.ERROR: | ||
| # No election happened (lock backend down), so waiting would just re-read the | ||
| # still-expired token. Refresh anyway; worst case is an extra refresh, not a stale bearer. | ||
| return await refresh() | ||
| case LockAcquisition.HELD: | ||
| # Another worker holds the lock; wait for it to finish (release or PX-expiry), then read | ||
| # the token it persisted - the winner wrote the fresh token to the store, so a plain | ||
| # re-read sees it without us refreshing again. | ||
| deadline = self._clock() + self._wait_timeout_seconds | ||
| while self._clock() < deadline and await self._lock.is_held(key): | ||
| await self._sleep(self._poll_interval_seconds) | ||
| return await reread() | ||
|
tin-berri marked this conversation as resolved.
Resolved
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.