feat(mcp): clear and reauthorize a server's stored MCP OAuth tokens

Add a disconnect endpoint and UI controls that clear every stored OAuth
token for an MCP server so an admin can reauthorize with a narrower
grant, instead of deleting and recreating the server. A machine-to-machine
server also loses its client_credentials grant on disconnect so it cannot
mint a replacement token until an admin re-enters the client secret.

The revoke is broadcast on the auth cache invalidation channel so every
worker drops its locally minted tokens and refreshes the server config
right away, rather than waiting out the in-memory TTL.
This commit is contained in:
Yassin Kortam 2026-08-25 13:07:09 -07:00
parent 27ca05a707
commit d45c4f9d25
25 changed files with 1774 additions and 43 deletions

View file

@ -115,6 +115,10 @@ MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL: Final = int(os.getenv("MCP_OAUTH2_TOKEN_CACH
MCP_NPM_CACHE_DIR: Final = os.getenv("MCP_NPM_CACHE_DIR", "/tmp/.npm_mcp_cache")
MCP_OAUTH2_TOKEN_CACHE_MIN_TTL: Final = int(os.getenv("MCP_OAUTH2_TOKEN_CACHE_MIN_TTL", "10"))
# Broadcast-only (never stored) key prefix telling every worker to drop its process-local minted
# client_credentials tokens for a server.
MCP_OAUTH2_MINT_INVALIDATION_KEY_PREFIX: Final = "mcp:oauth2_mint_invalidation"
# Per-user OAuth token Redis cache (for server-side token storage)
MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX: Final = "mcp:per_user_token"
MCP_PER_USER_TOKEN_DEFAULT_TTL: Final = int(

View file

@ -3,12 +3,14 @@ import binascii
import hashlib
import json
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict, TypeVar, cast
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.constants import MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy._experimental.mcp_server.oauth_utils import build_upstream_oauth2_token_request
from litellm.proxy._types import (
@ -40,7 +42,7 @@ from litellm.repositories.verification_token_repository import (
VerificationTokenRepository,
)
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.mcp import MCPCredentials
from litellm.types.mcp import MCPAuth, MCPCredentials
if TYPE_CHECKING:
from prisma import models as prisma_db_models
@ -133,6 +135,10 @@ _CLIENT_FORWARDED_AUTH_TYPES: Final["frozenset[str]"] = frozenset({"true_passthr
# Minted token material that must never survive a client rotation on a persisted row.
_MINTED_TOKEN_CREDENTIAL_FIELDS: Final["frozenset[str]"] = frozenset({"access_token", "refresh_token", "expires_in"})
# For an M2M server the grant IS the standing authorization: the gateway mints on demand, so a
# disconnect that kept it would just force a remint on the next call instead of ending access.
_CLIENT_CREDENTIALS_GRANT_FIELDS: Final["frozenset[str]"] = frozenset({"client_id", "client_secret"})
class _OAuthCredentialAccessToken(TypedDict):
access_token: str
@ -334,7 +340,6 @@ def _prepare_mcp_server_data(
Returns:
Dict with properly serialized JSON fields
"""
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
# Convert model to dict.
# - Partial update (exclude_unset): only caller-provided keys are emitted, so
@ -986,7 +991,6 @@ async def update_mcp_server(
"""
Update a new mcp server record in the db
"""
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
# Use helper to prepare data with proper JSON serialization.
# exclude_unset=True makes this a true partial update: fields the caller did
@ -1102,6 +1106,92 @@ async def update_mcp_server(
return updated_mcp_server
@dataclass(frozen=True, slots=True)
class ClearedMCPServerOAuthToken:
"""Outcome of clearing a server's stored OAuth authorization: the row as persisted, whether
minted token material was present to remove, and whether an M2M client_credentials grant was
dropped with it (so the caller can report a no-op, and tell the admin what to re-enter)."""
server: LiteLLM_MCPServerTable
had_token: bool
cleared_client_credentials: bool
@property
def changed_row(self) -> bool:
return self.had_token or self.cleared_client_credentials
def _credential_str(credentials: Mapping[str, object], key: str) -> str | None:
value: Final = credentials.get(key)
return value if isinstance(value, str) else None
def _oauth_fields_to_clear(
row: "prisma_db_models.LiteLLM_MCPServerTable", credentials: Mapping[str, object]
) -> "frozenset[str]":
"""Which credential keys a disconnect must remove. An interactive (authorization_code) server
keeps its declared client, because its authorization lives in the per-user rows the caller purges
separately. A client_credentials server loses the grant too, since keeping it would let the very
next request mint a replacement token and continue upstream access.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # avoids circular import
MCPServerManager,
)
flow: Final = MCPServerManager.resolve_oauth2_flow_from_fields(
auth_type=MCPAuth.oauth2 if row.auth_type == MCPAuth.oauth2 else None,
oauth2_flow=row.oauth2_flow,
token_url=row.token_url or _credential_str(credentials, "token_url"),
authorization_url=row.authorization_url,
client_id=_credential_str(credentials, "client_id"),
client_secret=_credential_str(credentials, "client_secret"),
)
if flow != "client_credentials":
return _MINTED_TOKEN_CREDENTIAL_FIELDS
return _MINTED_TOKEN_CREDENTIAL_FIELDS | _CLIENT_CREDENTIALS_GRANT_FIELDS
async def clear_mcp_server_oauth_token(
prisma_client: PrismaClient, server_id: str, touched_by: str
) -> ClearedMCPServerOAuthToken | None:
"""Drop a server's stored OAuth authorization: the gateway-minted token material
(``access_token``/``refresh_token``/``expires_in``) always, plus the ``client_id``/``client_secret``
grant when the row is a client_credentials (M2M) server, whose grant would otherwise mint again on
the next request. Everything else the admin declared (url, auth_type, issuer, authorization/token
urls, token-exchange columns) stays, so reauthorizing an interactive server needs no reconfiguration.
Returns ``None`` when the server row does not exist.
"""
existing: Final = await _db_find_mcp_server_row(prisma_client, server_id)
if existing is None:
return None
existing_creds: Final = _credentials_blob_to_mutable_dict(existing.credentials) if existing.credentials else {}
fields_to_clear: Final = _oauth_fields_to_clear(existing, existing_creds)
cleared_fields: Final = fields_to_clear & existing_creds.keys()
if not cleared_fields:
_decrypt_env_vars_on_returned_row(existing)
return ClearedMCPServerOAuthToken(server=existing, had_token=False, cleared_client_credentials=False)
remaining: Final = { # mutable-ok: safe_dumps serializes a plain dict and str()s a mapping view
key: value for key, value in existing_creds.items() if key not in fields_to_clear
}
updated: Final = await _db_update_mcp_server_row(
prisma_client,
server_id,
{ # mutable-ok: prisma update-inputs must be plain dicts
"credentials": safe_dumps(remaining),
"updated_by": touched_by,
},
)
_decrypt_env_vars_on_returned_row(updated)
return ClearedMCPServerOAuthToken(
server=updated,
had_token=bool(_MINTED_TOKEN_CREDENTIAL_FIELDS & cleared_fields),
cleared_client_credentials=bool(_CLIENT_CREDENTIALS_GRANT_FIELDS & cleared_fields),
)
async def get_mcp_server_oauth_client_credentials(prisma_client: PrismaClient, server_id: str) -> object | None:
"""Read the persisted (encrypted) DCR OAuth client blob for a server from the
server-scoped store, or None. Config.yaml-declared servers have no
@ -1124,7 +1214,6 @@ async def upsert_mcp_server_oauth_client_credentials(
client_id/client_secret are encrypted at rest with the same salt key used for the
server row's credentials blob, so ``_apply_persisted_dcr_credentials`` decrypts them the
same way regardless of which store a server's client came from."""
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
encrypted: Final = encrypt_credentials(credentials=MCPCredentials(**credentials), encryption_key=_get_salt_key())
blob: Final = safe_dumps(encrypted)
@ -1146,7 +1235,6 @@ def _reencrypt_mcp_credentials_blob(
uniformly and cannot silently skip one."""
if not credentials:
return None
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps # noqa: PLC0415 # avoids circular import
creds_dict: Final = _credentials_blob_to_mutable_dict(credentials)
decrypted: Final = decrypt_credentials(credentials=cast(MCPCredentials, creds_dict))
@ -1155,7 +1243,6 @@ def _reencrypt_mcp_credentials_blob(
async def rotate_mcp_server_credentials_master_key(prisma_client: PrismaClient, touched_by: str, new_master_key: str):
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps # noqa: PLC0415 # avoids circular import
mcp_servers: Final = await _db_find_mcp_server_rows(prisma_client)
@ -1589,12 +1676,9 @@ async def purge_user_oauth_credentials_for_server(
invalidate_token_cache is injectable for tests; it defaults to the manager's shared
invalidate_user_oauth_token_cache, the single invalidation point for per-user tokens."""
rows: Final = await _db_find_user_credential_rows(prisma_client, {"server_id": server_id})
oauth_rows: Final = [row for row in rows if _decode_oauth_payload(row.credential_b64) is not None]
oauth_rows: Final = tuple(row for row in rows if _decode_oauth_payload(row.credential_b64) is not None)
if not oauth_rows:
return 0
deleted_count: Final = await _user_credential_actions(prisma_client).delete_many(
where={"server_id": server_id, "user_id": {"in": [row.user_id for row in oauth_rows]}}
)
if invalidate_token_cache is None:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
@ -1602,8 +1686,20 @@ async def purge_user_oauth_credentials_for_server(
invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache
for row in oauth_rows:
await invalidate_token_cache(row.user_id, server_id)
# Resolved above and dropped in a finally so a delete that succeeds is never followed by a skipped
# invalidation: the rows would be gone while their cached tokens stayed servable to the end of
# their TTL, and a retry would find nothing left to enumerate and so could never drop them. An
# invalidation for a row that survived the delete only costs that user's next resolve a DB read.
try:
deleted_count: Final = await _user_credential_actions(prisma_client).delete_many(
where={ # mutable-ok: prisma where-inputs must be plain dicts
"server_id": server_id,
"user_id": {"in": [row.user_id for row in oauth_rows]}, # mutable-ok: prisma `in` takes a plain list
}
)
finally:
for row in oauth_rows:
await invalidate_token_cache(row.user_id, server_id)
if deleted_count != len(oauth_rows):
verbose_proxy_logger.warning(
"MCP server %s: purge removed %d OAuth credential row(s) but %d were enumerated; "

View file

@ -13,7 +13,7 @@ import json
import os
import re
import time
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypedDict, cast
@ -70,8 +70,12 @@ from litellm.proxy._experimental.mcp_server.faults.list_outcomes import (
)
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
MCPPerUserTokenCache,
mcp_oauth2_mint_invalidation_key,
mcp_oauth2_token_cache,
mcp_per_user_token_cache,
per_user_token_cache_key,
resolve_mcp_auth,
server_id_from_mint_invalidation_key,
)
from litellm.proxy._experimental.mcp_server.oauth_utils import (
_redact_mcp_resource_url,
@ -143,6 +147,9 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.auth.ip_address_utils import IPAddressUtils
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import (
publish_auth_cache_invalidation,
)
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.proxy.common_utils.user_api_key_cache import get_management_object_ttl
from litellm.proxy.utils import PrismaClient, ProxyLogging, get_server_root_path
@ -1399,7 +1406,7 @@ class MCPServerManager:
return None
@staticmethod
def _resolve_oauth2_flow(
def resolve_oauth2_flow_from_fields(
*,
auth_type: MCPAuthType | None,
oauth2_flow: str | None,
@ -1410,8 +1417,9 @@ class MCPServerManager:
) -> Literal["client_credentials", "authorization_code"] | None:
"""Infer oauth2_flow from field shape when the value is omitted.
SECURITY-SENSITIVE: this is the shape-inference engine both request-time security
helpers delegate to, so it is what decides M2M-vs-interactive for an unstamped row.
SECURITY-SENSITIVE: this is the shape-inference engine the request-time security
helpers and the disconnect write path (which must know whether a row's stored client
can mint again) delegate to, so it is what decides M2M-vs-interactive for an unstamped row.
Always access it through ``effective_oauth2_flow`` (boolean/enum decisions) or
``resolve_oauth2_flow_for_request`` (the egress object backstop), which are the single
choke points for request-time resolution; do not call it directly from security sites
@ -1445,7 +1453,7 @@ class MCPServerManager:
resolution) goes through this one helper rather than reading the bare
``has_client_credentials`` column, which is unreliable for null rows.
"""
return MCPServerManager._resolve_oauth2_flow(
return MCPServerManager.resolve_oauth2_flow_from_fields(
auth_type=server.auth_type,
oauth2_flow=server.oauth2_flow,
token_url=server.token_url,
@ -1496,11 +1504,13 @@ class MCPServerManager:
cred_provider: UpstreamCredentialProvider | None = None,
per_user_oauth_token_store: InvalidatableOAuthTokenStore | None = None,
per_user_token_cache: MCPPerUserTokenCache | None = None,
publish_cache_invalidation: Callable[[str], Awaitable[None]] = publish_auth_cache_invalidation,
):
self._per_user_oauth_token_store = per_user_oauth_token_store or LazyPerUserOAuthTokenStore(
self.get_mcp_server_by_id
)
self._per_user_token_cache = per_user_token_cache or mcp_per_user_token_cache
self._publish_cache_invalidation = publish_cache_invalidation
self._cred_provider = cred_provider or UpstreamCredentialProvider(
oauth_token_store=self._per_user_oauth_token_store,
token_exchanger=build_token_exchanger(),
@ -5541,9 +5551,10 @@ class MCPServerManager:
(re-auth, revoke, config-change purge): the v2 chain's cache and the legacy per-user token
cache, so the next resolve reads the new row instead of serving the replaced token until its
cache TTL, whichever path resolves it. This is the single invalidation point for per-user
OAuth tokens; callers must not evict individual caches directly. Best-effort: a cache-drop
failure is logged, never raised, because the DB write already succeeded and the TTL remains
the backstop.
OAuth tokens; callers must not evict individual caches directly. Both caches are local-first
DualCaches, so the shared key is also broadcast on the auth cache invalidation channel to
evict the in-memory copy other workers hold. Best-effort: a cache-drop failure is logged,
never raised, because the DB write already succeeded and the TTL remains the backstop.
"""
try:
await self._per_user_oauth_token_store.invalidate(user_id, server_id)
@ -5557,6 +5568,46 @@ class MCPServerManager:
verbose_logger.warning(
"Failed to drop legacy cached MCP OAuth token for user=%s server=%s: %s", user_id, server_id, exc
)
await self._publish_cache_invalidation(per_user_token_cache_key(user_id, server_id))
async def invalidate_local_upstream_m2m_tokens(self, server_id: str) -> None:
"""Drop this worker's cached gateway-minted (``client_credentials``) tokens for a server.
Both mint caches are process-local, so this is per worker; ``revoke_upstream_m2m_tokens``
is what makes a revoke reach the others.
"""
mcp_oauth2_token_cache.invalidate(server_id)
await self._cred_provider.invalidate_server_m2m_credentials(server_id)
async def refresh_local_server_from_db(self, server_id: str) -> None:
"""Re-read one server's persisted config into this worker's registry.
Dropping the cached token is not enough on a worker that did not serve the revoke: its
registry entry still holds the pre-revoke ``client_id``/``client_secret``, and mints a
replacement token on the next request. Best-effort: the DB write has already committed.
"""
from litellm.proxy._experimental.mcp_server.db import get_mcp_server # noqa: PLC0415 # avoids circular import
try:
from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # avoids circular import
if prisma_client is None:
return
server: Final = await get_mcp_server(prisma_client, server_id)
if server is not None:
await self.update_server(server)
except Exception as exc: # noqa: BLE001 - registry refresh is best-effort; the periodic reload is the backstop
verbose_logger.warning("Failed to refresh MCP server %s from the database: %s", server_id, exc)
async def revoke_upstream_m2m_tokens(self, server_id: str) -> None:
"""Revoke a server's gateway-minted tokens on every worker, not just this one.
A worker that already minted keeps a usable upstream token in memory, so invalidating only
the worker that served the admin request left the revoked authorization in use until its
TTL. Best-effort broadcast: the DB write has already committed.
"""
await self.invalidate_local_upstream_m2m_tokens(server_id)
await self._publish_cache_invalidation(mcp_oauth2_mint_invalidation_key(server_id))
async def _resolve_oauth2_headers_for_tool_call(
self,
@ -6535,3 +6586,17 @@ class MCPServerManager:
global_mcp_server_manager: Final[MCPServerManager] = MCPServerManager()
async def apply_mcp_upstream_m2m_invalidation(cache_key: str) -> None:
"""Handle a broadcast mint-invalidation key on the worker that receives it.
The auth cache invalidation subscriber evicts the shared ``user_api_key_cache`` only, which
cannot reach the process-local mint caches holding a usable upstream token, so it routes
broadcast keys here. Any other key is ignored.
"""
server_id: Final = server_id_from_mint_invalidation_key(cache_key)
if server_id is None:
return
await global_mcp_server_manager.invalidate_local_upstream_m2m_tokens(server_id)
await global_mcp_server_manager.refresh_local_server_from_db(server_id)

View file

@ -14,6 +14,7 @@ import httpx
from litellm._logging import verbose_logger
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.constants import (
MCP_OAUTH2_MINT_INVALIDATION_KEY_PREFIX,
MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL,
MCP_OAUTH2_TOKEN_CACHE_MAX_SIZE,
MCP_OAUTH2_TOKEN_CACHE_MIN_TTL,
@ -199,6 +200,24 @@ class MCPOAuth2TokenCache(InMemoryCache):
mcp_oauth2_token_cache: Final = MCPOAuth2TokenCache()
def mcp_oauth2_mint_invalidation_key(server_id: str) -> str:
return f"{MCP_OAUTH2_MINT_INVALIDATION_KEY_PREFIX}:{server_id}"
def server_id_from_mint_invalidation_key(cache_key: str) -> str | None:
"""The server a broadcast invalidation key names, or ``None`` for any other key."""
prefix: Final = f"{MCP_OAUTH2_MINT_INVALIDATION_KEY_PREFIX}:"
if not cache_key.startswith(prefix):
return None
return cache_key.removeprefix(prefix) or None
def per_user_token_cache_key(user_id: str, server_id: str) -> str:
"""The ``user_api_key_cache`` key holding a user's token for a server. Shared by the legacy
per-user cache and the v2 store backend, so broadcasting this one key evicts both."""
return f"{MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX}:{user_id}:{server_id}"
def _compute_per_user_token_ttl(server: "MCPServer", expires_in: int | None) -> int:
"""Compute Redis TTL for a per-user token.
@ -229,7 +248,7 @@ class MCPPerUserTokenCache:
"""
def _cache_key(self, user_id: str, server_id: str) -> str:
return f"{MCP_PER_USER_TOKEN_REDIS_KEY_PREFIX}:{user_id}:{server_id}"
return per_user_token_cache_key(user_id, server_id)
async def get(self, user_id: str, server_id: str) -> str | None:
"""Return the plaintext access_token, or None on miss/error."""

View file

@ -179,6 +179,7 @@ class ClientCredentialsTokenSource:
self._max_locks = max_locks
self._clock = clock
self._locks: dict[str, asyncio.Lock] = {}
self._identities: dict[str, frozenset[str]] = {} # mutable-ok: process-lifetime index, like _locks
def _lock(self, server_id: str) -> asyncio.Lock:
"""Per-server single-flight lock, bounded so ephemeral server ids (e.g. the REST tools
@ -190,6 +191,20 @@ class ClientCredentialsTokenSource:
self._locks.pop(next(iter(self._locks)), None)
return self._locks.setdefault(server_id, asyncio.Lock())
def _remember_identity(self, server_id: str, identity_key: str) -> None:
"""Record the identities a server minted under, bounded like the locks. ``invalidate`` runs
after the stored client credentials were cleared, so the identity key is no longer
derivable from config and the cached token would be unreachable without this.
"""
if server_id not in self._identities and len(self._identities) >= self._max_locks:
self._identities.pop(next(iter(self._identities)), None)
self._identities[server_id] = self._identities.get(server_id, frozenset()) | {identity_key}
async def invalidate(self, server_id: str) -> None:
"""Drop every cached token for a server, whichever client identity minted it."""
for identity_key in self._identities.pop(server_id, frozenset()):
await self._backend.delete(identity_key, server_id)
async def get(self, server_id: str, config: ClientCredentialsConfig) -> Result[OAuthToken, CredError]:
match _prepare_grant(config):
case Error(err):
@ -262,6 +277,7 @@ class ClientCredentialsTokenSource:
)
if ttl > 0:
await self._backend.set(grant.identity_key, server_id, token, ttl)
self._remember_identity(server_id, grant.identity_key)
return Ok(token)

View file

@ -356,6 +356,14 @@ class UpstreamCredentialProvider:
subject.inbound_token.get_secret_value(), server, server.config, tenant_id=subject.tenant_id
)
async def invalidate_server_m2m_credentials(self, server_id: str) -> None:
"""Drop the gateway's cached `client_credentials` tokens for a server, for every caller.
The M2M token belongs to the gateway rather than a subject, so a revoke has to clear the
whole server, unlike the per-subject `invalidate_credentials`.
"""
await self._client_credentials_source.invalidate(server_id)
def _invalidate_id_jag(self, subject: Subject, server: ServerSpec) -> None:
"""Evict the bearer this `(subject, server)` last resolved, without depending on the store.

View file

@ -1579,6 +1579,19 @@ class MCPOAuthUserCredentialStatus(LiteLLMPydanticObjectBase):
connected_at: str | None = None # ISO-8601
class MCPServerOAuthTokenStatus(LiteLLMPydanticObjectBase):
"""Outcome of the admin-wide OAuth token clear for one MCP server.
Distinct from MCPOAuthUserCredentialStatus, which is scoped to the calling user alone.
"""
server_id: str
has_token: bool
cleared: bool = False
cleared_user_tokens: int = 0
cleared_client_credentials: bool = False
class MCPUserCredentialListItem(LiteLLMPydanticObjectBase):
"""One entry in the /user-credentials list."""

View file

@ -1,6 +1,6 @@
import asyncio
import json
from collections.abc import Sequence
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Final
@ -122,17 +122,25 @@ async def evict_and_broadcast(cache_keys: Sequence[str], user_api_key_cache: "Us
class AuthCacheInvalidationSubscriber:
__slots__ = ("_additional_in_memory_caches", "_redis_cache", "_task", "_user_api_key_cache")
__slots__ = (
"_additional_in_memory_caches",
"_local_invalidators",
"_redis_cache",
"_task",
"_user_api_key_cache",
)
def __init__(
self,
redis_cache: "RedisCache",
user_api_key_cache: "UserApiKeyCache",
additional_in_memory_caches: Sequence["InMemoryCache"] = (),
local_invalidators: Sequence[Callable[[str], Awaitable[None]]] = (),
) -> None:
self._redis_cache = redis_cache
self._user_api_key_cache = user_api_key_cache
self._additional_in_memory_caches = tuple(additional_in_memory_caches)
self._local_invalidators = tuple(local_invalidators)
self._task: asyncio.Task[None] | None = None
def start(self) -> None:
@ -185,9 +193,9 @@ class AuthCacheInvalidationSubscriber:
message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=_POLL_TIMEOUT_SECONDS)
if message is None:
continue
self._apply_message(message)
await self._apply_message(message)
def _apply_message(self, message: object) -> None:
async def _apply_message(self, message: object) -> None:
data: Final = message.get("data") if isinstance(message, dict) else None
parsed: Final = _message_from_data(data)
if parsed is None:
@ -201,6 +209,13 @@ class AuthCacheInvalidationSubscriber:
in_memory_cache.delete_cache(parsed.cache_key)
for additional_cache in self._additional_in_memory_caches:
additional_cache.delete_cache(parsed.cache_key)
for invalidate_local in self._local_invalidators:
try:
await invalidate_local(parsed.cache_key)
except Exception as e: # noqa: BLE001 # one bad invalidator must not stop the subscriber
verbose_proxy_logger.warning(
"auth cache invalidation local handler failed for %s: %s", parsed.cache_key, e
)
@staticmethod
async def _close_pubsub(pubsub: _ConfigSyncPubSub) -> None:

View file

@ -22,7 +22,7 @@ import os
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Final, Literal, Protocol
from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol
from fastapi import (
APIRouter,
@ -128,6 +128,7 @@ if MCP_AVAILABLE:
from litellm.proxy._experimental.mcp_server.db import (
approve_mcp_server,
clear_mcp_server_oauth_token,
create_draft_mcp_server,
create_mcp_server,
delete_mcp_server,
@ -175,6 +176,7 @@ if MCP_AVAILABLE:
MCPApprovalStatus,
MCPOAuthUserCredentialRequest,
MCPOAuthUserCredentialStatus,
MCPServerOAuthTokenStatus,
MCPSubmissionsSummary,
MCPTransport,
MCPUserCredentialListItem,
@ -2032,6 +2034,88 @@ if MCP_AVAILABLE:
return Response(status_code=status.HTTP_202_ACCEPTED)
@router.delete(
"/server/{server_id}/oauth-token",
description=(
"Clear every OAuth token stored for an MCP server (admin only), so it can be reauthorized "
"with a different set of upstream grants. An interactive (authorization_code) server keeps "
"its declared OAuth app (url, auth_type, issuer, client_id/secret) so users just reconnect; "
"a client_credentials server also loses the client_id/secret grant, which is the only way "
"to stop it minting a replacement token, so an admin must re-enter the client to resume."
),
response_model=MCPServerOAuthTokenStatus,
)
@management_endpoint_wrapper
async def clear_mcp_server_oauth_token_endpoint(
server_id: str,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
):
"""
Clear every stored OAuth token for a server so the next call reauthorizes from scratch.
A token minted by 'Authorize & Fetch Token' can be served from three places, and a clear that
misses any one of them leaves the old upstream grants live:
- the per-user credential rows the interactive (authorization_code) flow writes, plus the two
caches that hydrate from them,
- the in-memory client_credentials mint cache an M2M server serves from,
- the minted fields on the server row's own credential blob.
This clears all three. A user's own BYOK API key is never touched.
A client_credentials server mints on demand from its stored client, so the grant itself is the
standing authorization and gets dropped with the token; the rest of the declared app stays, and
an admin re-enters the client to authorize again. The mint cache is process-local, so the
server-level invalidation is broadcast on the auth cache invalidation channel and every worker
drops its copy. Without redis pub/sub there is nothing to broadcast over, and a worker that
already minted keeps its token until the TTL, but the DB no longer backs a fresh mint.
```
curl -X "DELETE" --location 'http://localhost:4000/v1/mcp/server/server_id/oauth-token' \
--header 'Authorization: Bearer your_api_key_here'
```
"""
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={ # mutable-ok: HTTPException details are plain dicts across every management route
"error": (
"Call not allowed to clear the MCP server OAuth token. User is not a proxy admin. "
"route=DELETE /v1/mcp/server/{server_id}/oauth-token"
)
},
)
cleared: Final = await clear_mcp_server_oauth_token(
prisma_client,
server_id,
touched_by=user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
)
if cleared is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": f"MCP Server not found, passed server_id={server_id}"}, # mutable-ok: plain dict
)
# The in-memory stores are reconciled even when the purge raises. The server row has already
# been written by then, so skipping them on the error path would leave the registry and the
# mint cache serving material the DB no longer backs, and the admin's retry would read as a
# no-op while the old grants stayed live. Both are idempotent, so running them twice is free.
try:
cleared_user_tokens: Final = await purge_user_oauth_credentials_for_server(prisma_client, server_id)
finally:
await global_mcp_server_manager.revoke_upstream_m2m_tokens(server_id)
if cleared.changed_row:
await global_mcp_server_manager.update_server(cleared.server)
return MCPServerOAuthTokenStatus(
server_id=server_id,
has_token=False,
cleared=cleared.changed_row or cleared_user_tokens > 0,
cleared_user_tokens=cleared_user_tokens,
cleared_client_credentials=cleared.cleared_client_credentials,
)
@router.post(
"/server/{server_id}/user-credential",
description="Store or update the calling user's API key for a BYOK MCP server",

View file

@ -16,7 +16,7 @@ import threading
import time
import traceback
import warnings
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Mapping, MutableMapping, Sequence
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Mapping, MutableMapping, Sequence
from datetime import datetime, timedelta, timezone
from types import MappingProxyType, UnionType
from typing import (
@ -4198,6 +4198,20 @@ def should_load_db_object(object_type: str | SupportedDBObjectType) -> bool:
return any(str(obj) == object_type_str for obj in supported_db_objects)
def _local_cache_invalidators() -> tuple[Callable[[str], Awaitable[None]], ...]:
"""Handlers for broadcast invalidation keys that name a process-local cache the shared
``user_api_key_cache`` eviction cannot reach, such as the MCP client_credentials mint caches."""
from litellm.proxy._experimental.mcp_server.utils import is_mcp_available
if not is_mcp_available():
return ()
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
apply_mcp_upstream_m2m_invalidation,
)
return (apply_mcp_upstream_m2m_invalidation,)
class ProxyConfig:
"""
Abstraction class on top of config loading/updating logic. Gives us one place to control all config updating logic.
@ -6805,6 +6819,7 @@ class ProxyConfig:
redis_cache=redis_cache,
user_api_key_cache=user_api_key_cache,
additional_in_memory_caches=(spend_counter_cache.in_memory_cache,),
local_invalidators=_local_cache_invalidators(),
)
self.auth_cache_invalidation_subscriber = subscriber
subscriber.start()

View file

@ -425,3 +425,50 @@ def test_bearer_auth_rejects_sync_clients():
with httpx.Client(transport=httpx.MockTransport(lambda request: httpx.Response(200)), auth=auth) as client:
with pytest.raises(RuntimeError):
client.get("https://upstream.example.com/mcp")
@pytest.mark.asyncio
async def test_invalidate_stops_serving_the_cached_token_for_a_server():
"""An admin revoke has to reach this source, not just the row: otherwise the worker that already
minted keeps serving the revoked authorization from cache until its TTL."""
poster = _FakePoster([_success(access_token="tok-before", expires_in=3600), _success(access_token="tok-after", expires_in=3600)])
source = ClientCredentialsTokenSource(poster)
first = await source.get("s", _config())
await source.invalidate("s")
second = await source.get("s", _config())
assert isinstance(first, Ok) and isinstance(second, Ok)
assert first.ok.access_token == "tok-before"
assert second.ok.access_token == "tok-after"
assert len(poster.calls) == 2
@pytest.mark.asyncio
async def test_invalidate_drops_tokens_minted_under_credentials_that_are_already_gone():
"""The revoke clears the stored client credentials first, so the identity key is no longer
derivable from config by the time invalidation runs."""
poster = _FakePoster([_success(access_token="tok-old", expires_in=3600), _success(access_token="tok-new", expires_in=3600)])
source = ClientCredentialsTokenSource(poster)
_ = await source.get("s", _config(client_secret=SecretStr("old-secret")))
await source.invalidate("s")
reused = await source.get("s", _config(client_secret=SecretStr("old-secret")))
assert isinstance(reused, Ok)
assert reused.ok.access_token == "tok-new"
@pytest.mark.asyncio
async def test_invalidate_leaves_other_servers_alone():
poster = _FakePoster([_success(access_token="tok-a", expires_in=3600), _success(access_token="tok-b", expires_in=3600)])
source = ClientCredentialsTokenSource(poster)
_ = await source.get("srv-a", _config())
_ = await source.get("srv-b", _config())
await source.invalidate("srv-a")
kept = await source.get("srv-b", _config())
assert isinstance(kept, Ok)
assert kept.ok.access_token == "tok-b"
assert len(poster.calls) == 2

View file

@ -12,7 +12,7 @@ import base64
import json
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -200,6 +200,28 @@ async def test_purge_user_oauth_credentials_for_server_invalidates_each_user():
assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")}
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_invalidates_even_when_the_delete_raises():
"""A delete that raises after touching rows must not skip the invalidation: the cached tokens would
stay servable to the end of their TTL, and a retry could not fix it because the enumeration it
depends on no longer finds those rows. Invalidating a row that survived only costs a DB read."""
from litellm.proxy._experimental.mcp_server.db import purge_user_oauth_credentials_for_server
prisma = MagicMock()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[_oauth_row("alice"), _oauth_row("bob")])
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(side_effect=RuntimeError("connection reset"))
invalidations = []
async def record_invalidation(user_id: str, server_id: str) -> None:
invalidations.append((user_id, server_id))
with pytest.raises(RuntimeError, match="connection reset"):
await purge_user_oauth_credentials_for_server(prisma, "srv-1", invalidate_token_cache=record_invalidation)
assert set(invalidations) == {("alice", "srv-1"), ("bob", "srv-1")}
@pytest.mark.asyncio
async def test_purge_user_oauth_credentials_for_server_spares_byok_rows():
"""Regression: the purge used to delete_many on server_id alone, wiping BYOK API keys that share
@ -1337,3 +1359,243 @@ def test_mcp_oauth_token_identity_changes_when_only_upstream_resource_is_edited(
assert mcp_oauth_token_identity(set_to_explicit) == mcp_oauth_token_identity(
_identity_server(credentials={**creds, "upstream_resource": "api://audience-one"})
)
def _server_row_with_credentials(credentials, **overrides):
"""A LiteLLM_MCPServerTable-shaped row as prisma hands it back, for the server-level
minted-token clear tests."""
base = {
"server_id": "srv-clear",
"url": "https://upstream.example.com/mcp",
"auth_type": MCPAuth.oauth2,
"issuer": "https://issuer.example.com",
"transport": MCPTransport.http,
"credentials": credentials,
"env_vars": None,
"oauth2_flow": None,
"token_url": None,
"authorization_url": None,
}
base.update(overrides)
return SimpleNamespace(**base)
def _prisma_with_server_row(row):
prisma = MagicMock()
prisma.db.litellm_mcpservertable.find_unique = AsyncMock(return_value=row)
prisma.db.litellm_mcpservertable.update = AsyncMock(
side_effect=lambda where, data: _server_row_with_credentials(
data.get("credentials"), server_id=where["server_id"]
)
)
return prisma
def _written_credentials(prisma) -> dict:
return json.loads(prisma.db.litellm_mcpservertable.update.call_args.kwargs["data"]["credentials"])
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_drops_only_minted_fields():
"""The server-level clear must remove exactly the minted token material and leave every
declared-app credential key behind, so an admin can reauthorize without reconfiguring the
OAuth app. A clear that dropped the whole blob would still pass a test that only checked the
access token is gone, so the surviving keys are asserted explicitly."""
from litellm.proxy._experimental.mcp_server.db import clear_mcp_server_oauth_token
stored = {
"access_token": "at-live",
"refresh_token": "rt-live",
"expires_in": 3600,
"client_id": "declared-client",
"client_secret": "declared-secret",
"scopes": ["read", "write"],
"token_endpoint_auth_method": "client_secret_post",
"upstream_resource": "api://audience-one",
}
prisma = _prisma_with_server_row(_server_row_with_credentials(json.dumps(stored)))
result = await clear_mcp_server_oauth_token(prisma, "srv-clear", touched_by="admin-1")
assert result is not None
assert result.had_token is True
written = _written_credentials(prisma)
assert "access_token" not in written
assert "refresh_token" not in written
assert "expires_in" not in written
assert written == {
"client_id": "declared-client",
"client_secret": "declared-secret",
"scopes": ["read", "write"],
"token_endpoint_auth_method": "client_secret_post",
"upstream_resource": "api://audience-one",
}
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_leaves_declared_app_columns_untouched():
"""Clearing the token must not write url, auth_type, issuer or any other declared column.
The pre-existing clear paths (client rotation, auth_type/url/issuer change) are destructive
side effects; this one has to be surgical or it re-creates the workaround it replaces."""
from litellm.proxy._experimental.mcp_server.db import clear_mcp_server_oauth_token
prisma = _prisma_with_server_row(
_server_row_with_credentials(json.dumps({"access_token": "at-live", "client_id": "declared-client"}))
)
await clear_mcp_server_oauth_token(prisma, "srv-clear", touched_by="admin-1")
written_data = prisma.db.litellm_mcpservertable.update.call_args.kwargs["data"]
assert set(written_data) == {"credentials", "updated_by"}
assert written_data["updated_by"] == "admin-1"
for untouched in ("url", "auth_type", "issuer", "client_id", "transport", "env_vars"):
assert untouched not in written_data
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_is_a_noop_without_a_minted_token():
"""A server that only carries a declared app must not be written at all, so a repeated
Disconnect does not churn updated_by or bump the row."""
from litellm.proxy._experimental.mcp_server.db import clear_mcp_server_oauth_token
prisma = _prisma_with_server_row(
_server_row_with_credentials(json.dumps({"client_id": "declared-client", "client_secret": "declared-secret"}))
)
result = await clear_mcp_server_oauth_token(prisma, "srv-clear", touched_by="admin-1")
assert result is not None
assert result.had_token is False
prisma.db.litellm_mcpservertable.update.assert_not_awaited()
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_handles_dict_shaped_credentials():
"""Prisma hands JSONB back as a dict on some read paths; the clear must work there too."""
from litellm.proxy._experimental.mcp_server.db import clear_mcp_server_oauth_token
prisma = _prisma_with_server_row(
_server_row_with_credentials({"access_token": "at-live", "client_id": "declared-client"})
)
result = await clear_mcp_server_oauth_token(prisma, "srv-clear", touched_by="admin-1")
assert result is not None and result.had_token is True
assert _written_credentials(prisma) == {"client_id": "declared-client"}
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_returns_none_for_missing_server():
from litellm.proxy._experimental.mcp_server.db import clear_mcp_server_oauth_token
prisma = _prisma_with_server_row(None)
assert await clear_mcp_server_oauth_token(prisma, "srv-missing", touched_by="admin-1") is None
prisma.db.litellm_mcpservertable.update.assert_not_awaited()
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_drops_the_client_credentials_grant_for_m2m():
"""An M2M server mints on demand from its stored client, so a clear that kept the grant left the
next request free to mint a replacement token and keep calling upstream."""
from litellm.proxy._experimental.mcp_server.db import clear_mcp_server_oauth_token
stored = {
"access_token": "at-live",
"client_id": "m2m-client",
"client_secret": "m2m-secret",
"scopes": ["read"],
}
prisma = _prisma_with_server_row(
_server_row_with_credentials(json.dumps(stored), oauth2_flow="client_credentials")
)
result = await clear_mcp_server_oauth_token(prisma, "srv-clear", touched_by="admin-1")
assert result is not None
assert result.had_token is True
assert result.cleared_client_credentials is True
assert _written_credentials(prisma) == {"scopes": ["read"]}
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_drops_the_grant_for_an_unstamped_m2m_shaped_row():
"""Rows written before oauth2_flow was stamped carry the M2M shape instead, and the same
inference the request path uses has to classify them here or they keep minting."""
from litellm.proxy._experimental.mcp_server.db import clear_mcp_server_oauth_token
prisma = _prisma_with_server_row(
_server_row_with_credentials(
json.dumps({"client_id": "m2m-client", "client_secret": "m2m-secret"}),
token_url="https://auth.example.com/token",
)
)
result = await clear_mcp_server_oauth_token(prisma, "srv-clear", touched_by="admin-1")
assert result is not None
assert result.had_token is False
assert result.cleared_client_credentials is True
assert result.changed_row is True
assert _written_credentials(prisma) == {}
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_keeps_the_client_for_an_interactive_server():
"""An authorization_code server's authorization lives in the per-user rows the caller purges, so
its OAuth app must survive for users to reconnect without an admin reconfiguring it."""
from litellm.proxy._experimental.mcp_server.db import clear_mcp_server_oauth_token
prisma = _prisma_with_server_row(
_server_row_with_credentials(
json.dumps({"access_token": "at-live", "client_id": "declared-client", "client_secret": "declared-secret"}),
oauth2_flow="authorization_code",
authorization_url="https://auth.example.com/authorize",
)
)
result = await clear_mcp_server_oauth_token(prisma, "srv-clear", touched_by="admin-1")
assert result is not None
assert result.cleared_client_credentials is False
assert _written_credentials(prisma) == {"client_id": "declared-client", "client_secret": "declared-secret"}
@pytest.mark.asyncio
async def test_cleared_m2m_row_can_no_longer_mint_a_replacement_token():
"""End state check: the row the clear persists must fail the mint path's precondition, so no
request can quietly re-authorize the server."""
from litellm.proxy._experimental.mcp_server.db import clear_mcp_server_oauth_token
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import MCPOAuth2TokenCache
from litellm.types.mcp_server.mcp_server_manager import MCPServer
prisma = _prisma_with_server_row(
_server_row_with_credentials(
json.dumps({"access_token": "at-live", "client_id": "m2m-client", "client_secret": "m2m-secret"}),
oauth2_flow="client_credentials",
)
)
await clear_mcp_server_oauth_token(prisma, "srv-clear", touched_by="admin-1")
remaining = _written_credentials(prisma)
server = MCPServer(
server_id="srv-clear",
name="srv-clear",
url="https://upstream.example.com/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="client_credentials",
token_url="https://auth.example.com/token",
client_id=remaining.get("client_id"),
client_secret=remaining.get("client_secret"),
)
token_endpoint = AsyncMock()
with patch(
"litellm.proxy._experimental.mcp_server.oauth2_token_cache.get_async_httpx_client",
return_value=token_endpoint,
):
assert await MCPOAuth2TokenCache().async_get_token(server) is None
token_endpoint.post.assert_not_awaited()

View file

@ -5170,6 +5170,138 @@ class TestMCPServerManager:
manager = MCPServerManager(per_user_oauth_token_store=_Store(), per_user_token_cache=_RaisingLegacyCache())
await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
@pytest.mark.asyncio
async def test_invalidate_user_oauth_token_cache_broadcasts_the_shared_key(self):
"""Both per-user caches are local-first, so evicting them here leaves every other worker
holding the revoked token until its TTL unless the shared key is broadcast."""
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
per_user_token_cache_key,
)
class _Store:
async def fetch(self, user_id: str, server_id: str):
return None
async def invalidate(self, user_id: str, server_id: str) -> None:
return None
class _LegacyCache:
async def delete(self, user_id: str, server_id: str) -> None:
return None
published: list[str] = []
async def _publish(cache_key: str) -> None:
published.append(cache_key)
manager = MCPServerManager(
per_user_oauth_token_store=_Store(),
per_user_token_cache=_LegacyCache(),
publish_cache_invalidation=_publish,
)
await manager.invalidate_user_oauth_token_cache("alice", "srv-1")
assert published == [per_user_token_cache_key("alice", "srv-1")]
@pytest.mark.asyncio
async def test_revoke_upstream_m2m_tokens_clears_both_mint_caches_and_broadcasts(self):
"""A revoke has to reach the v2 token source that actually serves M2M traffic, the legacy
mint cache, and every other worker, or the gateway keeps minting or replaying upstream
access the admin just revoked."""
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
mcp_oauth2_mint_invalidation_key,
mcp_oauth2_token_cache,
)
invalidated: list[str] = []
class _FakeProvider:
async def invalidate_server_m2m_credentials(self, server_id: str) -> None:
invalidated.append(server_id)
published: list[str] = []
async def _publish(cache_key: str) -> None:
published.append(cache_key)
mcp_oauth2_token_cache.set_cache("srv-1:identity", "tok-revoked")
mcp_oauth2_token_cache.set_cache("srv-2:identity", "tok-untouched")
manager = MCPServerManager(cred_provider=_FakeProvider(), publish_cache_invalidation=_publish)
await manager.revoke_upstream_m2m_tokens("srv-1")
assert invalidated == ["srv-1"]
assert published == [mcp_oauth2_mint_invalidation_key("srv-1")]
assert mcp_oauth2_token_cache.get_cache("srv-1:identity") is None
assert mcp_oauth2_token_cache.get_cache("srv-2:identity") == "tok-untouched"
@pytest.mark.asyncio
async def test_broadcast_mint_invalidation_only_fires_for_its_own_keys(self):
"""The channel carries every management-object eviction, so a receiving worker must not read
a per-user key as a server id and wipe an unrelated server's minted tokens."""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
apply_mcp_upstream_m2m_invalidation,
global_mcp_server_manager,
)
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
mcp_oauth2_mint_invalidation_key,
)
invalidated: list[str] = []
with (
patch.object(
global_mcp_server_manager,
"invalidate_local_upstream_m2m_tokens",
new=AsyncMock(side_effect=lambda server_id: invalidated.append(server_id)),
),
patch.object(global_mcp_server_manager, "refresh_local_server_from_db", new=AsyncMock()),
):
await apply_mcp_upstream_m2m_invalidation("mcp:per_user_token:alice:srv-1")
assert invalidated == []
await apply_mcp_upstream_m2m_invalidation(mcp_oauth2_mint_invalidation_key("srv-1"))
assert invalidated == ["srv-1"]
@pytest.mark.asyncio
async def test_broadcast_mint_invalidation_rereads_the_revoked_server_from_the_database(self):
"""Dropping the cached token is not enough: a worker that did not serve the revoke still holds
the pre-revoke client_id/client_secret in its registry and would mint a replacement token."""
from litellm.proxy._experimental.mcp_server import db as mcp_db
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
apply_mcp_upstream_m2m_invalidation,
global_mcp_server_manager,
)
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
mcp_oauth2_mint_invalidation_key,
)
revoked_row = LiteLLM_MCPServerTable(
server_id="srv-1",
server_name="srv-1",
url="http://upstream/mcp",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
oauth2_flow="client_credentials",
created_at=None,
updated_at=None,
)
applied: list[LiteLLM_MCPServerTable] = []
with (
patch.object(global_mcp_server_manager, "invalidate_local_upstream_m2m_tokens", new=AsyncMock()),
patch.object(
global_mcp_server_manager,
"update_server",
new=AsyncMock(side_effect=lambda server: applied.append(server)),
),
patch.object(mcp_db, "get_mcp_server", new=AsyncMock(return_value=revoked_row)),
patch("litellm.proxy.proxy_server.prisma_client", new=MagicMock()),
):
await apply_mcp_upstream_m2m_invalidation(mcp_oauth2_mint_invalidation_key("srv-1"))
assert applied == [revoked_row]
@pytest.mark.asyncio
async def test_resolve_oauth2_headers_no_user_id(self):
"""Skip lookup entirely when user_api_key_auth has no user_id."""

View file

@ -12,7 +12,10 @@ import pytest
from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
MCPOAuth2TokenCache,
mcp_oauth2_mint_invalidation_key,
mcp_oauth2_token_cache,
resolve_mcp_auth,
server_id_from_mint_invalidation_key,
)
from litellm.proxy._types import MCPTransport
from litellm.types.mcp import MCPAuth
@ -392,3 +395,14 @@ async def test_invalidate_clears_every_identity_for_a_server():
assert refetched == "tok-after-invalidate"
assert mock_client.post.call_count == 3
def test_mint_invalidation_key_round_trips_to_its_server_id():
assert server_id_from_mint_invalidation_key(mcp_oauth2_mint_invalidation_key("srv-1")) == "srv-1"
def test_unrelated_cache_keys_do_not_name_a_server():
"""The channel carries every management-object eviction, so a user key must not be read as a
server id and wipe an unrelated server's minted tokens."""
assert server_id_from_mint_invalidation_key("mcp:per_user_token:user-1:srv-1") is None
assert server_id_from_mint_invalidation_key("mcp:oauth2_mint_invalidation:") is None

View file

@ -185,9 +185,55 @@ async def test_subscriber_ignores_malformed_messages() -> None:
redis_cache=_FakeRedisCache(client=_ScriptedPubSubRedisClient(pubsubs=[_QueuePubSub()])),
user_api_key_cache=cache,
)
subscriber._apply_message({"type": "message", "data": b"not json"})
subscriber._apply_message({"type": "message", "data": json.dumps({"other": "x"}).encode()})
subscriber._apply_message("raw string")
subscriber._apply_message(None)
await subscriber._apply_message({"type": "message", "data": b"not json"})
await subscriber._apply_message({"type": "message", "data": json.dumps({"other": "x"}).encode()})
await subscriber._apply_message("raw string")
await subscriber._apply_message(None)
assert cache.in_memory_cache.get_cache("project_id:p-1") is not None
@pytest.mark.asyncio
async def test_subscriber_routes_broadcast_keys_to_injected_local_invalidators() -> None:
"""Some caches a broadcast has to reach are process-local rather than entries in
user_api_key_cache (the MCP client_credentials mint cache), so the receiving worker hands every
key to the injected handlers as well."""
cache = UserApiKeyCache()
cache.in_memory_cache.set_cache("project_id:p-1", {"models": []})
seen: List[str] = []
async def _record(cache_key: str) -> None:
seen.append(cache_key)
subscriber = AuthCacheInvalidationSubscriber(
redis_cache=_FakeRedisCache(client=_ScriptedPubSubRedisClient(pubsubs=[_QueuePubSub()])),
user_api_key_cache=cache,
local_invalidators=(_record,),
)
await subscriber._apply_message(_invalidation_message("project_id:p-1"))
assert seen == ["project_id:p-1"]
assert cache.in_memory_cache.get_cache("project_id:p-1") is None
@pytest.mark.asyncio
async def test_subscriber_survives_a_failing_local_invalidator() -> None:
"""One handler raising must not stop the others or kill the subscriber loop, which would leave
the worker serving stale authorization for every later broadcast."""
cache = UserApiKeyCache()
seen: List[str] = []
async def _boom(cache_key: str) -> None:
raise RuntimeError("handler exploded")
async def _record(cache_key: str) -> None:
seen.append(cache_key)
subscriber = AuthCacheInvalidationSubscriber(
redis_cache=_FakeRedisCache(client=_ScriptedPubSubRedisClient(pubsubs=[_QueuePubSub()])),
user_api_key_cache=cache,
local_invalidators=(_boom, _record),
)
await subscriber._apply_message(_invalidation_message("project_id:p-1"))
assert seen == ["project_id:p-1"]

View file

@ -1,8 +1,9 @@
import base64
import os
import sys
import types
import json
from contextlib import ExitStack
from contextlib import ExitStack, contextmanager
from datetime import datetime, timedelta
from types import SimpleNamespace
from typing import List, Optional
@ -6766,3 +6767,347 @@ class TestConnectedAppViewAnnotation:
assert all(server.connected_app_reachable is None for server in result)
reload_mock.assert_not_awaited()
def _make_admin_auth(user_id: str = "admin-abc") -> "UserAPIKeyAuth":
return UserAPIKeyAuth(
api_key="sk-test",
user_id=user_id,
user_role=LitellmUserRoles.PROXY_ADMIN,
)
@contextmanager
def _patched_clear_oauth_token_endpoint(clear_mock, purge_mock, manager, revoke_mock):
"""Patch every collaborator the clear endpoint reaches, so a test can assert which stores it
actually emptied rather than only what it returned."""
manager.revoke_upstream_m2m_tokens = revoke_mock
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=_make_prisma_client(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.clear_mcp_server_oauth_token",
new=clear_mock,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.purge_user_oauth_credentials_for_server",
new=purge_mock,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
manager,
),
):
yield
def _clear_oauth_token_manager():
manager = MagicMock()
manager.update_server = AsyncMock()
return manager
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_endpoint_empties_every_store_a_token_is_served_from():
"""A token authorized for a server can be served from the per-user credential rows, from the
in-memory client_credentials mint cache, or from the server row's own minted fields. The clear
has to empty all three: leaving any one of them behind keeps the old upstream grants live, which
is exactly the state the admin asked to get out of."""
from litellm.proxy._types import MCPServerOAuthTokenStatus
if not mgmt_endpoints.MCP_AVAILABLE:
pytest.skip("MCP module not installed")
from litellm.proxy._experimental.mcp_server.db import ClearedMCPServerOAuthToken
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
clear_mcp_server_oauth_token_endpoint,
)
server = generate_mock_mcp_server_db_record(server_id="srv-oauth")
clear_mock = AsyncMock(return_value=ClearedMCPServerOAuthToken(server=server, had_token=True, cleared_client_credentials=False))
purge_mock = AsyncMock(return_value=3)
manager = _clear_oauth_token_manager()
revoke_mock = AsyncMock()
with _patched_clear_oauth_token_endpoint(clear_mock, purge_mock, manager, revoke_mock):
result = await clear_mcp_server_oauth_token_endpoint(
server_id="srv-oauth",
user_api_key_dict=_make_admin_auth("admin-1"),
)
assert isinstance(result, MCPServerOAuthTokenStatus)
assert result.server_id == "srv-oauth"
assert result.has_token is False
assert result.cleared is True
assert result.cleared_user_tokens == 3
assert clear_mock.await_args.kwargs["touched_by"] == "admin-1"
purge_mock.assert_awaited_once()
assert purge_mock.await_args.args[1] == "srv-oauth"
revoke_mock.assert_awaited_once_with("srv-oauth")
manager.update_server.assert_awaited_once_with(server)
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_endpoint_clears_per_user_tokens_with_no_minted_server_row():
"""The interactive flow stores its token per user and writes nothing to the server row, so a clear
that keyed 'did anything happen' off the server row alone would report a no-op while revoking
three users. cleared must follow the per-user purge too."""
if not mgmt_endpoints.MCP_AVAILABLE:
pytest.skip("MCP module not installed")
from litellm.proxy._experimental.mcp_server.db import ClearedMCPServerOAuthToken
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
clear_mcp_server_oauth_token_endpoint,
)
server = generate_mock_mcp_server_db_record(server_id="srv-oauth")
purge_mock = AsyncMock(return_value=3)
manager = _clear_oauth_token_manager()
revoke_mock = AsyncMock()
with _patched_clear_oauth_token_endpoint(
AsyncMock(return_value=ClearedMCPServerOAuthToken(server=server, had_token=False, cleared_client_credentials=False)),
purge_mock,
manager,
revoke_mock,
):
result = await clear_mcp_server_oauth_token_endpoint(
server_id="srv-oauth",
user_api_key_dict=_make_admin_auth(),
)
assert result.cleared is True
assert result.cleared_user_tokens == 3
purge_mock.assert_awaited_once()
manager.update_server.assert_not_awaited()
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_endpoint_drives_a_real_purge_for_the_authorize_and_fetch_state():
"""End to end through the endpoint with the real purge, in the state 'Authorize & Fetch Token'
actually leaves behind: nothing minted on the server row, one stored per-user token.
The sibling tests either mock the purge (so they pin the wiring, not the delete) or call the purge
helper directly (so they never prove the endpoint reaches it). Between those two a gate reinstated
on the endpoint would pass both while no-opping on the ticket's own scenario, so this one drives
the endpoint against a prisma double and asserts the delete really issued."""
if not mgmt_endpoints.MCP_AVAILABLE:
pytest.skip("MCP module not installed")
from litellm.proxy._experimental.mcp_server.db import ClearedMCPServerOAuthToken
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
clear_mcp_server_oauth_token_endpoint,
)
stored_token = base64.urlsafe_b64encode(
json.dumps({"type": "oauth2", "access_token": "upstream-token-a"}).encode()
).decode()
prisma = _make_prisma_client()
prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(
return_value=[SimpleNamespace(user_id="alice", server_id="srv-oauth", credential_b64=stored_token)]
)
prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock(return_value=1)
server = generate_mock_mcp_server_db_record(server_id="srv-oauth")
manager = _clear_oauth_token_manager()
manager.invalidate_user_oauth_token_cache = AsyncMock()
manager.revoke_upstream_m2m_tokens = AsyncMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=prisma,
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.clear_mcp_server_oauth_token",
new=AsyncMock(return_value=ClearedMCPServerOAuthToken(server=server, had_token=False, cleared_client_credentials=False)),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
manager,
),
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
manager,
),
):
result = await clear_mcp_server_oauth_token_endpoint(
server_id="srv-oauth",
user_api_key_dict=_make_admin_auth(),
)
assert result.cleared_user_tokens == 1
assert result.cleared is True
prisma.db.litellm_mcpusercredentials.delete_many.assert_awaited_once_with(
where={"server_id": "srv-oauth", "user_id": {"in": ["alice"]}}
)
manager.invalidate_user_oauth_token_cache.assert_awaited_once_with("alice", "srv-oauth")
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_endpoint_reconciles_caches_when_the_purge_fails():
"""The server row is written before the per-user purge runs, so a purge that raises must not skip
the in-memory reconciliation: the registry and the mint cache would go on serving material the DB
no longer backs, and the admin's retry would look like a no-op while the old grants stayed live.
The error still surfaces, because the clear did not finish."""
if not mgmt_endpoints.MCP_AVAILABLE:
pytest.skip("MCP module not installed")
from litellm.proxy._experimental.mcp_server.db import ClearedMCPServerOAuthToken
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
clear_mcp_server_oauth_token_endpoint,
)
server = generate_mock_mcp_server_db_record(server_id="srv-oauth")
manager = _clear_oauth_token_manager()
revoke_mock = AsyncMock()
with _patched_clear_oauth_token_endpoint(
AsyncMock(return_value=ClearedMCPServerOAuthToken(server=server, had_token=True, cleared_client_credentials=False)),
AsyncMock(side_effect=RuntimeError("database went away mid-purge")),
manager,
revoke_mock,
):
with pytest.raises(RuntimeError, match="database went away mid-purge"):
await clear_mcp_server_oauth_token_endpoint(
server_id="srv-oauth",
user_api_key_dict=_make_admin_auth(),
)
revoke_mock.assert_awaited_once_with("srv-oauth")
manager.update_server.assert_awaited_once_with(server)
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_endpoint_reports_a_true_noop():
"""Nothing stored anywhere means nothing to clear: the response says so and the registry is left
alone. The mint cache is still flushed, because it is the one store the endpoint cannot count."""
if not mgmt_endpoints.MCP_AVAILABLE:
pytest.skip("MCP module not installed")
from litellm.proxy._experimental.mcp_server.db import ClearedMCPServerOAuthToken
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
clear_mcp_server_oauth_token_endpoint,
)
server = generate_mock_mcp_server_db_record(server_id="srv-oauth")
manager = _clear_oauth_token_manager()
revoke_mock = AsyncMock()
with _patched_clear_oauth_token_endpoint(
AsyncMock(return_value=ClearedMCPServerOAuthToken(server=server, had_token=False, cleared_client_credentials=False)),
AsyncMock(return_value=0),
manager,
revoke_mock,
):
result = await clear_mcp_server_oauth_token_endpoint(
server_id="srv-oauth",
user_api_key_dict=_make_admin_auth(),
)
assert result.cleared is False
assert result.has_token is False
assert result.cleared_user_tokens == 0
revoke_mock.assert_awaited_once_with("srv-oauth")
manager.update_server.assert_not_awaited()
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_endpoint_reports_a_dropped_m2m_grant():
"""An M2M server has no per-user rows and may have nothing minted on the row yet, so dropping its
client grant is the whole disconnect: the registry has to be reloaded and the caller told what to
re-enter, or the dashboard reports a no-op on the one change that revoked access."""
if not mgmt_endpoints.MCP_AVAILABLE:
pytest.skip("MCP module not installed")
from litellm.proxy._experimental.mcp_server.db import ClearedMCPServerOAuthToken
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
clear_mcp_server_oauth_token_endpoint,
)
server = generate_mock_mcp_server_db_record(server_id="srv-oauth")
manager = _clear_oauth_token_manager()
revoke_mock = AsyncMock()
with _patched_clear_oauth_token_endpoint(
AsyncMock(
return_value=ClearedMCPServerOAuthToken(
server=server, had_token=False, cleared_client_credentials=True
)
),
AsyncMock(return_value=0),
manager,
revoke_mock,
):
result = await clear_mcp_server_oauth_token_endpoint(
server_id="srv-oauth",
user_api_key_dict=_make_admin_auth(),
)
assert result.cleared is True
assert result.cleared_client_credentials is True
revoke_mock.assert_awaited_once_with("srv-oauth")
manager.update_server.assert_awaited_once_with(server)
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_endpoint_rejects_non_admin():
"""Clearing the shared token logs every user of the server out of the upstream, so it is
admin-only. A non-admin who wants to drop their own connection uses the per-user revoke."""
if not mgmt_endpoints.MCP_AVAILABLE:
pytest.skip("MCP module not installed")
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
clear_mcp_server_oauth_token_endpoint,
)
clear_mock = AsyncMock()
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=_make_prisma_client(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.clear_mcp_server_oauth_token",
new=clear_mock,
),
pytest.raises(HTTPException) as exc_info,
):
await clear_mcp_server_oauth_token_endpoint(
server_id="srv-oauth",
user_api_key_dict=_make_user_auth("bob"),
)
assert exc_info.value.status_code == 403
clear_mock.assert_not_awaited()
@pytest.mark.asyncio
async def test_clear_mcp_server_oauth_token_endpoint_404s_for_unknown_server():
if not mgmt_endpoints.MCP_AVAILABLE:
pytest.skip("MCP module not installed")
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
clear_mcp_server_oauth_token_endpoint,
)
with (
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=_make_prisma_client(),
),
patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.clear_mcp_server_oauth_token",
new=AsyncMock(return_value=None),
),
pytest.raises(HTTPException) as exc_info,
):
await clear_mcp_server_oauth_token_endpoint(
server_id="srv-missing",
user_api_key_dict=_make_admin_auth(),
)
assert exc_info.value.status_code == 404

View file

@ -0,0 +1,139 @@
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import MCPDisconnectDialog from "./MCPDisconnectDialog";
import { toast } from "@/lib/toast";
const deleteMCPServerOAuthToken = vi.fn();
const deleteMCPOAuthUserCredential = vi.fn();
vi.mock("@/components/networking", () => ({
deleteMCPServerOAuthToken: (...args: unknown[]) => deleteMCPServerOAuthToken(...args),
deleteMCPOAuthUserCredential: (...args: unknown[]) => deleteMCPOAuthUserCredential(...args),
}));
vi.mock("@/lib/toast", () => ({
toast: { success: vi.fn(), fromError: vi.fn(), error: vi.fn() },
}));
const SHARED_BLAST_RADIUS = /Clears every OAuth token LiteLLM holds for this server, for every user/i;
const OWN_BLAST_RADIUS = /Clears only the token stored against your user/i;
function renderDialog(overrides: Partial<React.ComponentProps<typeof MCPDisconnectDialog>> = {}) {
const onCleared = vi.fn();
const onOpenChange = vi.fn();
render(
<MCPDisconnectDialog
open
mode="disconnect"
serverId="srv-1"
serverName="demo_server"
accessToken="sk-test"
isProxyAdmin
onOpenChange={onOpenChange}
onCleared={onCleared}
{...overrides}
/>,
);
return { onCleared, onOpenChange };
}
describe("MCPDisconnectDialog blast radius", () => {
beforeEach(() => {
vi.clearAllMocks();
deleteMCPServerOAuthToken.mockResolvedValue({
server_id: "srv-1",
has_token: false,
cleared: true,
cleared_user_tokens: 2,
cleared_client_credentials: false,
});
deleteMCPOAuthUserCredential.mockResolvedValue({ server_id: "srv-1", has_credential: false, is_expired: false });
});
it("states both blast radii, distinguishing the all-users clear from the caller's own connection", () => {
renderDialog();
const shared = screen.getByText(SHARED_BLAST_RADIUS);
const own = screen.getByText(OWN_BLAST_RADIUS);
expect(shared).toBeInTheDocument();
expect(own).toBeInTheDocument();
expect(shared).not.toHaveTextContent(OWN_BLAST_RADIUS);
expect(own).not.toHaveTextContent(SHARED_BLAST_RADIUS);
expect(screen.getByText(/Every stored token for this server \(affects all users\)/i)).toBeInTheDocument();
expect(screen.getByText(/Only your own connection \(affects just you\)/i)).toBeInTheDocument();
});
it("warns that the all-users clear revokes everyone while the per-user clear leaves everyone else connected", () => {
renderDialog();
expect(screen.getByText(SHARED_BLAST_RADIUS)).toHaveTextContent(
/Anyone who authorized interactively loses upstream access until they authorize again/i,
);
expect(screen.getByText(SHARED_BLAST_RADIUS)).toHaveTextContent(
/machine-to-machine server also loses the stored client credentials .* re-enter the client secret/i,
);
expect(screen.getByText(SHARED_BLAST_RADIUS)).toHaveTextContent(/BYOK API keys are left alone/i);
expect(screen.getByText(OWN_BLAST_RADIUS)).toHaveTextContent(/Every other user keeps their connection/i);
});
it("defaults an admin to the server-level clear and calls the server-level endpoint", async () => {
const { onCleared } = renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Disconnect" }));
await waitFor(() => expect(deleteMCPServerOAuthToken).toHaveBeenCalledWith("sk-test", "srv-1"));
expect(deleteMCPOAuthUserCredential).not.toHaveBeenCalled();
expect(onCleared).toHaveBeenCalledWith("server");
});
it("calls the per-user revoke when the caller picks their own connection", async () => {
const { onCleared } = renderDialog();
await userEvent.click(screen.getByText(/Only your own connection \(affects just you\)/i));
await userEvent.click(screen.getByRole("button", { name: "Disconnect" }));
await waitFor(() => expect(deleteMCPOAuthUserCredential).toHaveBeenCalledWith("sk-test", "srv-1"));
expect(deleteMCPServerOAuthToken).not.toHaveBeenCalled();
expect(onCleared).toHaveBeenCalledWith("self");
});
it("defaults a non-admin to their own connection and blocks the shared clear", async () => {
const { onCleared } = renderDialog({ isProxyAdmin: false });
expect(screen.getByText(/Requires proxy admin/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Disconnect" }));
await waitFor(() => expect(onCleared).toHaveBeenCalledWith("self"));
expect(deleteMCPServerOAuthToken).not.toHaveBeenCalled();
});
it("tells the admin to re-enter the client when the M2M grant was dropped", async () => {
deleteMCPServerOAuthToken.mockResolvedValue({
server_id: "srv-1",
has_token: false,
cleared: true,
cleared_user_tokens: 0,
cleared_client_credentials: true,
});
renderDialog();
await userEvent.click(screen.getByRole("button", { name: "Disconnect" }));
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(expect.stringMatching(/Re-enter the client credentials/i)),
);
});
it("labels the reauthorize entry point and still shows both blast radii", () => {
renderDialog({ mode: "reauthorize" });
expect(screen.getByText("Reauthorize MCP Server?")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Clear & Reauthorize" })).toBeInTheDocument();
expect(screen.getByText(SHARED_BLAST_RADIUS)).toBeInTheDocument();
expect(screen.getByText(OWN_BLAST_RADIUS)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,150 @@
import { type FC, useState } from "react";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/cva.config";
import { toast } from "@/lib/toast";
import { deleteMCPOAuthUserCredential, deleteMCPServerOAuthToken } from "@/components/networking";
export type MCPDisconnectScope = "server" | "self";
export type MCPDisconnectMode = "disconnect" | "reauthorize";
export const MCP_DISCONNECT_SCOPE_COPY: Record<MCPDisconnectScope, { label: string; blastRadius: string }> = {
server: {
label: "Every stored token for this server (affects all users)",
blastRadius:
"Clears every OAuth token LiteLLM holds for this server, for every user. Anyone who authorized interactively loses upstream access until they authorize again, and their OAuth app stays configured. A machine-to-machine server also loses the stored client credentials it mints from, so an admin has to re-enter the client secret before it can call upstream again. Stored BYOK API keys are left alone.",
},
self: {
label: "Only your own connection (affects just you)",
blastRadius:
"Clears only the token stored against your user. Every other user keeps their connection and the server stays authorized for them.",
},
};
interface MCPDisconnectDialogProps {
open: boolean;
mode: MCPDisconnectMode;
serverId: string;
serverName?: string;
accessToken: string;
isProxyAdmin: boolean;
onOpenChange: (open: boolean) => void;
onCleared: (scope: MCPDisconnectScope) => void;
}
const MCPDisconnectDialog: FC<MCPDisconnectDialogProps> = ({
open,
mode,
serverId,
serverName,
accessToken,
isProxyAdmin,
onOpenChange,
onCleared,
}) => {
const [scope, setScope] = useState<MCPDisconnectScope>(isProxyAdmin ? "server" : "self");
const [isSubmitting, setIsSubmitting] = useState(false);
const confirmLabel = mode === "reauthorize" ? "Clear & Reauthorize" : "Disconnect";
const handleConfirm = async () => {
setIsSubmitting(true);
try {
if (scope === "server") {
const result = await deleteMCPServerOAuthToken(accessToken, serverId);
toast.success(
result.cleared
? `Cleared every stored OAuth token for this server (${result.cleared_user_tokens} user token${
result.cleared_user_tokens === 1 ? "" : "s"
})${
result.cleared_client_credentials
? ". Re-enter the client credentials to let this server mint tokens again"
: ""
}`
: "This server had no stored OAuth token",
);
} else {
await deleteMCPOAuthUserCredential(accessToken, serverId);
toast.success("Cleared your connection to this MCP server");
}
onOpenChange(false);
onCleared(scope);
} catch (error) {
toast.fromError(error);
} finally {
setIsSubmitting(false);
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{mode === "reauthorize" ? "Reauthorize MCP Server?" : "Disconnect MCP Server?"}
</AlertDialogTitle>
</AlertDialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
{mode === "reauthorize"
? "Clearing the stored token lets you run Authorize & Fetch Token again and grant a different set of upstream scopes. The server's URL, auth type and issuer are kept, so nothing has to be recreated."
: "Choose what to clear. The server's URL, auth type and issuer are kept, so the server can be authorized again without being recreated."}
</p>
<dl className="space-y-1 rounded-lg border border-border bg-muted p-4">
{serverName && (
<div className="flex gap-2">
<dt className="text-sm text-muted-foreground">Name</dt>
<dd className="text-sm font-semibold">{serverName}</dd>
</div>
)}
<div className="flex gap-2">
<dt className="text-sm text-muted-foreground">ID</dt>
<dd className="font-mono text-xs">{serverId}</dd>
</div>
</dl>
<RadioGroup value={scope} onValueChange={(value) => setScope(value as MCPDisconnectScope)}>
{(["server", "self"] as const).map((option) => {
const disabled = option === "server" && !isProxyAdmin;
return (
<label
key={option}
onClick={() => !disabled && setScope(option)}
className={cn(
"flex cursor-pointer items-start gap-3 rounded-lg border p-3",
scope === option ? "border-primary bg-accent/40" : "border-border",
disabled && "cursor-not-allowed opacity-60",
)}
>
<RadioGroupItem value={option} disabled={disabled} className="mt-1" />
<div className="space-y-1">
<Label className="cursor-pointer font-semibold">{MCP_DISCONNECT_SCOPE_COPY[option].label}</Label>
<p className="text-xs text-muted-foreground">{MCP_DISCONNECT_SCOPE_COPY[option].blastRadius}</p>
{disabled && <p className="text-xs text-destructive">Requires proxy admin.</p>}
</div>
</label>
);
})}
</RadioGroup>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}>Cancel</AlertDialogCancel>
<Button variant="destructive" disabled={isSubmitting} onClick={handleConfirm}>
{isSubmitting ? "Clearing..." : confirmLabel}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default MCPDisconnectDialog;

View file

@ -1,5 +1,6 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, afterEach } from "vitest";
import MCPServerCard from "./MCPServerCard";
import type { MCPServer } from "@/components/mcp_tools/types";
@ -67,3 +68,60 @@ describe("MCPServerCard logo", () => {
expect(screen.getByText("DE")).toBeInTheDocument();
});
});
describe("MCPServerCard OAuth token actions", () => {
async function openMenu(overrides: Partial<MCPServer>, handlers: Record<string, () => void>) {
render(
<MCPServerCard
server={{ ...baseServer, ...overrides } as MCPServer}
onClick={vi.fn()}
onRecheckHealth={vi.fn()}
onDelete={vi.fn()}
{...handlers}
/>,
);
await userEvent.click(screen.getByLabelText("Server actions"));
}
it("offers Disconnect and Reauthorize for an oauth2 server", async () => {
await openMenu({ auth_type: "oauth2" }, { onDisconnectOAuth: vi.fn(), onReauthorizeOAuth: vi.fn() });
expect(await screen.findByText("Disconnect / Clear token")).toBeInTheDocument();
expect(screen.getByText("Reauthorize")).toBeInTheDocument();
expect(screen.getByText("Test Connection")).toBeInTheDocument();
expect(screen.getByText("Delete")).toBeInTheDocument();
});
it("invokes the disconnect handler without triggering the card's own click", async () => {
const onDisconnectOAuth = vi.fn();
const onClick = vi.fn();
render(
<MCPServerCard
server={{ ...baseServer, auth_type: "oauth2" } as MCPServer}
onClick={onClick}
onDisconnectOAuth={onDisconnectOAuth}
/>,
);
await userEvent.click(screen.getByLabelText("Server actions"));
await userEvent.click(await screen.findByText("Disconnect / Clear token"));
expect(onDisconnectOAuth).toHaveBeenCalledTimes(1);
expect(onClick).not.toHaveBeenCalled();
});
it("hides both token actions for a non-oauth2 server", async () => {
await openMenu({ auth_type: "api_key" }, { onDisconnectOAuth: vi.fn(), onReauthorizeOAuth: vi.fn() });
expect(await screen.findByText("Test Connection")).toBeInTheDocument();
expect(screen.queryByText("Disconnect / Clear token")).not.toBeInTheDocument();
expect(screen.queryByText("Reauthorize")).not.toBeInTheDocument();
});
it("hides both token actions when the caller passes no handlers (non-admin)", async () => {
await openMenu({ auth_type: "oauth2" }, {});
expect(await screen.findByText("Test Connection")).toBeInTheDocument();
expect(screen.queryByText("Disconnect / Clear token")).not.toBeInTheDocument();
expect(screen.queryByText("Reauthorize")).not.toBeInTheDocument();
});
});

View file

@ -1,5 +1,5 @@
import { type FC, type KeyboardEvent, type MouseEvent } from "react";
import { Check, CircleAlert, Ellipsis, Trash2, Zap } from "lucide-react";
import { Check, CircleAlert, Ellipsis, Trash2, Unplug, RefreshCw, Zap } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@ -27,6 +27,8 @@ interface MCPServerCardProps {
onRecheckHealth?: () => void;
onByokConnect?: () => void;
onOpenFillFields?: () => void;
onDisconnectOAuth?: () => void;
onReauthorizeOAuth?: () => void;
onDelete?: () => void;
}
@ -48,6 +50,8 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
onRecheckHealth,
onByokConnect,
onOpenFillFields,
onDisconnectOAuth,
onReauthorizeOAuth,
onDelete,
}) => {
const alias = server.alias || server.server_name || "";
@ -106,7 +110,9 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
}
};
const hasMenu = !!onRecheckHealth || !!onDelete;
const isOAuthServer = server.auth_type === AUTH_TYPE.OAUTH2;
const showOAuthTokenActions = isOAuthServer && (!!onDisconnectOAuth || !!onReauthorizeOAuth);
const hasMenu = !!onRecheckHealth || !!onDelete || showOAuthTokenActions;
// Card uses role="button" + nested <button> children (Set, BYOK Connect, the
// recheck-health Badge), so a real <button> wrapper would produce invalid
@ -175,7 +181,30 @@ const MCPServerCard: FC<MCPServerCardProps> = ({
Test Connection
</DropdownMenuItem>
)}
{onRecheckHealth && onDelete && <DropdownMenuSeparator />}
{onRecheckHealth && showOAuthTokenActions && <DropdownMenuSeparator />}
{showOAuthTokenActions && onReauthorizeOAuth && (
<DropdownMenuItem
onClick={(e) => {
stop(e);
onReauthorizeOAuth();
}}
>
<RefreshCw />
Reauthorize
</DropdownMenuItem>
)}
{showOAuthTokenActions && onDisconnectOAuth && (
<DropdownMenuItem
onClick={(e) => {
stop(e);
onDisconnectOAuth();
}}
>
<Unplug />
Disconnect / Clear token
</DropdownMenuItem>
)}
{(onRecheckHealth || showOAuthTokenActions) && onDelete && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem
variant="destructive"

View file

@ -1017,7 +1017,13 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
</span>
}
name={["credentials", "auth_value"]}
rules={{ validate: { notWhitespace: notOnlyWhitespace("Authentication value cannot be empty") } }}
rules={{
validate: {
notWhitespace: notOnlyWhitespace(
"Authentication value cannot be empty. To remove a stored token, use Disconnect / Clear token on the server's Overview tab.",
),
},
}}
>
{(control) => (
<PasswordInput

View file

@ -1,16 +1,17 @@
import React, { useState } from "react";
import { ArrowLeft, Eye, EyeOff } from "lucide-react";
import { ArrowLeft, Eye, EyeOff, RefreshCw, Unplug } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types";
import { AUTH_TYPE, MCPServer, handleTransport, handleAuth } from "@/components/mcp_tools/types";
// TODO: Move Tools viewer from index file
import { MCPToolsViewer } from ".";
import MCPServerEdit, { EDIT_OAUTH_UI_STATE_KEY } from "./mcp_server_edit";
import { getSecureItem } from "@/utils/secureStorage";
import MCPServerCostDisplay from "./mcp_server_cost_display";
import MCPDisconnectDialog, { type MCPDisconnectMode } from "./MCPDisconnectDialog";
import { getMaskedAndFullUrl } from "./utils";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react";
@ -27,6 +28,8 @@ interface MCPServerViewProps {
initialTabIndex?: number;
}
export const MCP_TAB = { OVERVIEW: 0, TOOLS: 1, SETTINGS: 2 } as const;
// True when this render is the return from the edit-settings OAuth redirect for this
// server: the edit form wrote its UI-state snapshot before redirecting. Used to open
// the editing Settings tab on first render instead of defaulting to Overview.
@ -62,7 +65,20 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
const [editing, setEditing] = useState(isEditing || returningFromEditOAuth);
const [showFullUrl, setShowFullUrl] = useState(false);
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const [selectedTabIndex, setSelectedTabIndex] = useState(returningFromEditOAuth ? 2 : initialTabIndex);
const [selectedTabIndex, setSelectedTabIndex] = useState(returningFromEditOAuth ? MCP_TAB.SETTINGS : initialTabIndex);
const [disconnectMode, setDisconnectMode] = useState<MCPDisconnectMode | null>(null);
const isOAuthServer = mcpServer.auth_type === AUTH_TYPE.OAUTH2;
const handleDisconnectCleared = (scope: "server" | "self") => {
const wasReauthorize = disconnectMode === "reauthorize";
setDisconnectMode(null);
if (!wasReauthorize) {
return;
}
setEditing(scope === "server");
setSelectedTabIndex(scope === "server" ? MCP_TAB.SETTINGS : MCP_TAB.TOOLS);
};
const handleSuccess = (updated: MCPServer) => {
setEditing(false);
@ -129,6 +145,19 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
{mcpServer.description && <p className="mt-2 text-sm text-muted-foreground">{mcpServer.description}</p>}
</div>
{disconnectMode && accessToken && (
<MCPDisconnectDialog
open
mode={disconnectMode}
serverId={mcpServer.server_id}
serverName={mcpServer.server_name || mcpServer.alias || undefined}
accessToken={accessToken}
isProxyAdmin={isProxyAdmin}
onOpenChange={(open) => !open && setDisconnectMode(null)}
onCleared={handleDisconnectCleared}
/>
)}
<Tabs value={String(selectedTabIndex)} onValueChange={(v: unknown) => setSelectedTabIndex(Number(v))}>
<TabsList variant="line" className="mb-4 h-auto w-full justify-start rounded-none border-b p-0">
<TabsTrigger value="0" className="flex-none rounded-none px-4 py-2">
@ -183,6 +212,25 @@ export const MCPServerView: React.FC<MCPServerViewProps> = ({
</div>
</Card>
</div>
{isOAuthServer && accessToken && (
<Card className="mt-4 p-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">OAuth Connection</p>
<p className="mt-3 text-sm text-muted-foreground">
Clear the stored token to authorize this server again with a different set of upstream grants. The URL,
auth type, issuer and OAuth client are kept, so the server does not have to be recreated.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setDisconnectMode("reauthorize")}>
<RefreshCw />
Reauthorize
</Button>
<Button variant="outline" onClick={() => setDisconnectMode("disconnect")}>
<Unplug />
Disconnect / Clear token
</Button>
</div>
</Card>
)}
<Card className="mt-4 p-4">
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Cost Configuration</p>
<div className="mt-3">

View file

@ -17,6 +17,24 @@ vi.mock("@/components/networking", () => ({
updateConfigFieldSetting: vi.fn().mockResolvedValue(undefined),
deleteConfigFieldSetting: vi.fn().mockResolvedValue(undefined),
listMCPUserEnvVarStatus: vi.fn().mockResolvedValue([]),
deleteMCPServerOAuthToken: vi
.fn()
.mockResolvedValue({ server_id: "server-1", has_token: false, cleared: true, cleared_user_tokens: 1 }),
deleteMCPOAuthUserCredential: vi
.fn()
.mockResolvedValue({ server_id: "server-1", has_credential: false, is_expired: false }),
}));
// The detail view is stubbed so this file can assert which tab the list page hands it without
// mounting the edit form and tools viewer underneath. MCP_TAB stays real: the point of the
// assertion is the shared index, so a stubbed constant would let the two sides drift apart.
const mcpServerViewProps = vi.fn();
vi.mock("./mcp_server_view", async (importOriginal) => ({
MCP_TAB: ((await importOriginal()) as { MCP_TAB: unknown }).MCP_TAB,
MCPServerView: (props: Record<string, unknown>) => {
mcpServerViewProps(props);
return <div data-testid="mcp-server-view" />;
},
}));
const createQueryClient = () =>
@ -321,6 +339,48 @@ describe("MCPServers", () => {
expect(screen.queryByText("Team B Server")).not.toBeInTheDocument();
});
it("opens Settings after an all-users Reauthorize from a list card, where Authorize & Fetch Token lives", async () => {
// Regression: the list page hardcoded "Tools tab or Overview", so an all-users Reauthorize
// dropped the admin on Overview with no authorize button in sight, while the same action on the
// detail page correctly opened Settings. Reauthorize that lands nowhere useful is the whole bug.
const { MCP_TAB } = await import("./mcp_server_view");
vi.mocked(networking.fetchMCPServers).mockResolvedValue([
{
server_id: "server-1",
server_name: "OAuth Server",
alias: "oauth-server",
url: "https://example.com/mcp",
transport: "http",
auth_type: "oauth2",
created_at: "2024-01-01T00:00:00Z",
created_by: "user-1",
updated_at: "2024-01-01T00:00:00Z",
updated_by: "user-1",
teams: [],
mcp_access_groups: [],
},
]);
vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue([]);
render(
<QueryClientProvider client={createQueryClient()}>
<MCPServers {...defaultProps} />
</QueryClientProvider>,
);
expect(await screen.findByText("OAuth Server")).toBeInTheDocument();
await userEvent.click(screen.getByLabelText("Server actions"));
await userEvent.click(await screen.findByText("Reauthorize"));
await userEvent.click(await screen.findByRole("button", { name: "Clear & Reauthorize" }));
expect(await screen.findByTestId("mcp-server-view")).toBeInTheDocument();
expect(networking.deleteMCPServerOAuthToken).toHaveBeenCalledWith("123", "server-1");
expect(mcpServerViewProps).toHaveBeenLastCalledWith(
expect.objectContaining({ initialTabIndex: MCP_TAB.SETTINGS, isEditing: true }),
);
});
it("should not trigger an extra health check when the server list changes after deletion", async () => {
// Regression test: previously useMCPServerHealth received serverIds derived from the
// server list. Deleting a server changed serverIds, which changed the React Query key,

View file

@ -27,7 +27,8 @@ import { MCPToolsetsTab } from "./MCPToolsetsTab";
import CreateMCPServer from "./CreateMCPServer";
import MCPConnect from "./mcp_connect";
import MCPServerCard from "./MCPServerCard";
import { MCPServerView } from "./mcp_server_view";
import MCPDisconnectDialog, { type MCPDisconnectMode } from "./MCPDisconnectDialog";
import { MCP_TAB, MCPServerView } from "./mcp_server_view";
import type {
DiscoverableMCPServer,
MCPServer,
@ -152,6 +153,11 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
const [disconnectServer, setDisconnectServer] = useState<MCPServer | null>(null);
const [disconnectMode, setDisconnectMode] = useState<MCPDisconnectMode>("disconnect");
// Set when an all-users Reauthorize is confirmed from a list card, so the detail view opens on
// Settings where "Authorize & Fetch Token" lives rather than on Overview.
const [settingsTabServerId, setSettingsTabServerId] = useState<string | null>(null);
// Per-user env-var fill modal target + deep-link source captured once from the URL.
const [envVarsModalServer, setEnvVarsModalServer] = useState<MCPServer | null>(null);
const [deepLinkServerId, setDeepLinkServerId] = useState<string | null>(() =>
@ -338,6 +344,30 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
return [...matches].sort((a, b) => compareServers(a, b, sortKey));
}, [filteredServers, searchQuery, sortKey]);
const openDisconnectDialog = (server: MCPServer, mode: MCPDisconnectMode) => {
setDisconnectMode(mode);
setDisconnectServer(server);
};
const handleDisconnectCleared = (scope: "server" | "self") => {
const server = disconnectServer;
setDisconnectServer(null);
refetch();
if (!server || disconnectMode !== "reauthorize") {
return;
}
setToolsTabServerId(scope === "self" ? server.server_id : null);
setSettingsTabServerId(scope === "server" ? server.server_id : null);
setEditServer(scope === "server");
setSelectedServerId(server.server_id);
};
const initialTabIndexForSelectedServer = () => {
if (selectedServerId === toolsTabServerId) return MCP_TAB.TOOLS;
if (selectedServerId === settingsTabServerId) return MCP_TAB.SETTINGS;
return MCP_TAB.OVERVIEW;
};
function handleDelete(server_id: string) {
setServerToDelete(server_id);
setIsDeleteModalOpen(true);
@ -407,8 +437,9 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
const handleBack = React.useCallback(() => {
setEditServer(false);
setSelectedServerId(null);
// Drop the post-redirect one-shot so re-selecting that server opens Overview.
// Drop the post-redirect one-shots so re-selecting that server opens Overview.
setToolsTabServerId(null);
setSettingsTabServerId(null);
refetch();
}, [refetch]);
@ -458,6 +489,18 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{disconnectServer && (
<MCPDisconnectDialog
open
mode={disconnectMode}
serverId={disconnectServer.server_id}
serverName={disconnectServer.server_name || disconnectServer.alias || undefined}
accessToken={accessToken}
isProxyAdmin={isAdminRole(userRole)}
onOpenChange={(open) => !open && setDisconnectServer(null)}
onCleared={handleDisconnectCleared}
/>
)}
<CreateMCPServer
userRole={userRole}
userID={userID}
@ -555,7 +598,7 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
userID={userID}
userRole={userRole}
availableAccessGroups={uniqueMcpAccessGroups}
initialTabIndex={selectedServerId === toolsTabServerId ? 1 : 0}
initialTabIndex={initialTabIndexForSelectedServer()}
/>
) : (
<div className="w-full h-full">
@ -694,6 +737,8 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
}
onByokConnect={server.is_byok ? () => setByokModalServer(server) : undefined}
onOpenFillFields={() => setEnvVarsModalServer(server)}
onDisconnectOAuth={() => openDisconnectDialog(server, "disconnect")}
onReauthorizeOAuth={() => openDisconnectDialog(server, "reauthorize")}
onDelete={isAdminRole(userRole) ? () => handleDelete(server.server_id) : undefined}
/>
))}

View file

@ -7803,6 +7803,14 @@ export interface MCPOAuthUserCredentialStatus {
connected_at?: string | null;
}
export interface MCPServerOAuthTokenStatus {
server_id: string;
has_token: boolean;
cleared: boolean;
cleared_user_tokens: number;
cleared_client_credentials: boolean;
}
export interface MCPUserCredentialListItem {
server_id: string;
server_name?: string | null;
@ -7880,6 +7888,13 @@ export const deleteMCPOAuthUserCredential = async (
return response.json();
};
export const deleteMCPServerOAuthToken = async (
accessToken: string,
serverId: string,
): Promise<MCPServerOAuthTokenStatus> => {
return apiClient.delete<MCPServerOAuthTokenStatus>(`/v1/mcp/server/${serverId}/oauth-token`, { accessToken });
};
export const getMCPOAuthUserCredentialStatus = async (
accessToken: string,
serverId: string,