-
-
Notifications
You must be signed in to change notification settings - Fork 11.6k
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 all 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
74 changes: 74 additions & 0 deletions
74
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,74 @@ | ||
| """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 dataclasses import KW_ONLY, dataclass | ||
| from typing import Protocol | ||
|
|
||
| from litellm._logging import verbose_logger | ||
| 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: ... | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class DualCacheTokenCacheBackend: | ||
| """Every method degrades a cache or codec failure to its safe value - ``get`` to a miss | ||
| (``None``), ``set``/``delete`` to a no-op - so a Redis outage or an undecryptable entry reads as a | ||
| cache miss rather than a request error, matching v1 and this layer's "boundary failure = miss" | ||
| contract. The guarantee holds here regardless of whether the injected cache/codec also swallow. | ||
| """ | ||
|
|
||
| cache: AsyncCache | ||
| codec: OAuthTokenCacheCodec | ||
| _: KW_ONLY | ||
| key_prefix: str = "mcp:per_user_token:" | ||
|
|
||
| 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: | ||
| try: | ||
| blob = await self.cache.async_get_cache(self._key(user_id, server_id)) | ||
| return self.codec.decode(blob) if isinstance(blob, str) else None | ||
| except Exception as exc: # noqa: BLE001 | ||
| verbose_logger.debug("MCP per-user token cache get failed (miss): %s", exc) | ||
| return None | ||
|
|
||
| async def set(self, user_id: str, server_id: str, token: OAuthToken, ttl_seconds: float) -> None: | ||
| if ttl_seconds <= 0: | ||
| return | ||
| try: | ||
| await self.cache.async_set_cache( | ||
| self._key(user_id, server_id), | ||
| self.codec.encode(token), | ||
| ttl=ttl_seconds, | ||
| ) | ||
| except Exception as exc: # noqa: BLE001 | ||
| verbose_logger.debug("MCP per-user token cache set failed (ignored): %s", exc) | ||
|
|
||
| async def delete(self, user_id: str, server_id: str) -> None: | ||
| try: | ||
| await self.cache.async_delete_cache(self._key(user_id, server_id)) | ||
| except Exception as exc: # noqa: BLE001 | ||
| verbose_logger.debug("MCP per-user token cache delete failed (ignored): %s", exc) | ||
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
94 changes: 94 additions & 0 deletions
94
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,94 @@ | ||
| """Concrete ``DistributedLock`` over a Redis client: ``SET NX PX`` / owner-only renew / delete. | ||
|
|
||
| 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). ``extend`` renews the lease only when the token still matches, and | ||
| ``release`` deletes the key only when it still holds this caller's token, 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 lock | ||
| keys carry the same namespace as cache keys and cannot collide with another deployment sharing Redis. | ||
|
|
||
| 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 dataclasses import KW_ONLY, dataclass | ||
| 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" | ||
| _EXTEND_IF_OWNER = ( | ||
| "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) 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: ... | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class RedisDistributedLock: | ||
| client: RedisCommands | ||
| _: KW_ONLY | ||
| namespace_key: Callable[[str], str] = lambda key: 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.
|
||
|
|
||
| async def extend(self, key: str, token: str, ttl_seconds: float) -> bool: | ||
| try: | ||
| result = await self.client.eval( | ||
| _EXTEND_IF_OWNER, | ||
| 1, | ||
| self.namespace_key(key), | ||
| token, | ||
| str(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.extend failed: %s", exc) | ||
| return False | ||
| return result == 1 | ||
|
|
||
| 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 | ||
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.