diff --git a/litellm/constants.py b/litellm/constants.py
index d4827bb7483..2ebc9beb632 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -183,6 +183,8 @@ MCP_TOOL_LISTING_TIMEOUT: Final = float(os.getenv("LITELLM_MCP_TOOL_LISTING_TIME
MCP_METADATA_TIMEOUT: Final = float(os.getenv("LITELLM_MCP_METADATA_TIMEOUT", "10.0"))
MCP_HEALTH_CHECK_TIMEOUT: Final = float(os.getenv("LITELLM_MCP_HEALTH_CHECK_TIMEOUT", "10.0"))
MCP_TOOL_LISTING_MAX_PAGES: Final = 1000
+MCP_GATEWAY_SESSION_ID_PREFIX_LENGTH: Final = 8
+MCP_ADMIN_TERMINATED_SESSION_IDS_MAX: Final = 1024
# Allowlist of commands permitted for MCP stdio transport.
# Prevents arbitrary command execution via /mcp-rest/test/* endpoints or server creation.
diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py
index 789b2ffaef4..a04e2f5c9b8 100644
--- a/litellm/proxy/_experimental/mcp_server/db.py
+++ b/litellm/proxy/_experimental/mcp_server/db.py
@@ -24,6 +24,7 @@ from litellm.proxy._types import (
MCPApprovalStatus,
MCPEnvVar,
MCPEnvVarScope,
+ MCPServerUserCredentialListItem,
MCPSubmissionsSummary,
NewMCPServerRequest,
SpecialMCPServerName,
@@ -1504,6 +1505,37 @@ async def get_user_oauth_credential(
return _parse_oauth_payload(decoded)
+def _server_user_credential_item(
+ row: "prisma_db_models.LiteLLM_MCPUserCredentials",
+) -> MCPServerUserCredentialListItem:
+ oauth_payload: Final = _decode_oauth_payload(row.credential_b64)
+ if oauth_payload is None:
+ return MCPServerUserCredentialListItem(
+ user_id=row.user_id,
+ credential_type="byok",
+ updated_at=row.updated_at.isoformat(),
+ )
+ return MCPServerUserCredentialListItem(
+ user_id=row.user_id,
+ credential_type="oauth2",
+ expires_at=oauth_payload.get("expires_at"),
+ connected_at=oauth_payload.get("connected_at"),
+ updated_at=row.updated_at.isoformat(),
+ )
+
+
+async def list_server_user_credentials(
+ prisma_client: PrismaClient,
+ server_id: str,
+) -> tuple[MCPServerUserCredentialListItem, ...]:
+ """Every user's stored credential for one server, typed but without the secret, for admins."""
+ rows: Final = await _db_find_user_credential_rows(
+ prisma_client,
+ {"server_id": server_id}, # mutable-ok: prisma where-inputs must be plain dicts
+ )
+ return tuple(_server_user_credential_item(row) for row in rows)
+
+
async def list_user_oauth_credentials(
prisma_client: PrismaClient,
user_id: str,
diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py
index 524bac747ad..80d9274859d 100644
--- a/litellm/proxy/_experimental/mcp_server/server.py
+++ b/litellm/proxy/_experimental/mcp_server/server.py
@@ -14,7 +14,7 @@ import time
import traceback
import types
import uuid
-from collections import Counter
+from collections import Counter, deque
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol
@@ -28,7 +28,11 @@ from starlette.types import Message, Receive, Scope, Send
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_logger
-from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG
+from litellm.constants import (
+ MAXIMUM_TRACEBACK_LINES_TO_LOG,
+ MCP_ADMIN_TERMINATED_SESSION_IDS_MAX,
+ MCP_GATEWAY_SESSION_ID_PREFIX_LENGTH,
+)
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
@@ -91,6 +95,7 @@ from litellm.types.mcp import (
MCPGatewaySession,
MCPGatewaySessionGroupCount,
MCPGatewaySessionsResponse,
+ MCPGatewaySessionsTerminateResponse,
MCPSpecVersion,
)
from litellm.types.mcp_server.mcp_server_manager import MCPInfo, MCPServer
@@ -618,6 +623,9 @@ if MCP_AVAILABLE:
_stateful_session_locks: Final[dict[str, asyncio.Lock]] = {}
_stateful_session_active_request_counts: Final[dict[str, int]] = {}
_stateful_session_client_info: Final[dict[str, Implementation]] = {} # mutable-ok: cleared on session teardown
+ _admin_terminated_session_ids: Final[deque[str]] = deque( # mutable-ok: bounded ring, appended on admin termination
+ maxlen=MCP_ADMIN_TERMINATED_SESSION_IDS_MAX
+ )
class _TerminableTransport(Protocol):
async def terminate(self) -> None: ...
@@ -3850,7 +3858,7 @@ if MCP_AVAILABLE:
client_info: Final = _stateful_session_client_info.get(session_id)
key_auth: Final = auth_user.user_api_key_auth
return MCPGatewaySession(
- session_id_prefix=session_id[:8],
+ session_id_prefix=session_id[:MCP_GATEWAY_SESSION_ID_PREFIX_LENGTH],
client_name=client_info.name if client_info is not None else None,
client_version=client_info.version if client_info is not None else None,
user_id=key_auth.user_id if key_auth is not None else None,
@@ -3885,6 +3893,53 @@ if MCP_AVAILABLE:
sessions=sessions,
)
+ def _session_matches_admin_selector(
+ session_id: str,
+ auth_user: MCPAuthenticatedUser,
+ session_id_prefix: str | None,
+ user_id: str | None,
+ ) -> bool:
+ if session_id_prefix is not None and not session_id.startswith(session_id_prefix):
+ return False
+ if user_id is None:
+ return True
+ key_auth: Final = auth_user.user_api_key_auth
+ return key_auth is not None and key_auth.user_id == user_id
+
+ async def terminate_mcp_gateway_sessions(
+ *,
+ session_id_prefix: str | None = None,
+ user_id: str | None = None,
+ ) -> MCPGatewaySessionsTerminateResponse:
+ """Force-close every live stateful session on this worker matching the selector.
+
+ The transport is terminated (open streams close), all per-session
+ tracking is dropped, and the id is remembered so a client that keeps
+ sending it receives 404 and has to ``initialize`` again, which re-runs
+ admission. Only sessions held by this worker process are affected.
+ """
+ now: Final = time.monotonic()
+ server_instances: Final = _stateful_server_instances()
+ targets: Final = tuple(
+ (session_id, auth_user)
+ for session_id, auth_user in tuple(_stateful_session_auth_contexts.items())
+ if session_id in server_instances
+ and _session_matches_admin_selector(session_id, auth_user, session_id_prefix, user_id)
+ )
+ terminated: Final = tuple(_gateway_session_for(session_id, auth_user, now) for session_id, auth_user in targets)
+ for session_id, _ in targets:
+ _admin_terminated_session_ids.append(session_id)
+ transport = server_instances.pop(session_id, None)
+ _remove_stateful_session_tracking(session_id)
+ if transport is not None:
+ await transport.terminate()
+ verbose_logger.warning("MCP session '%s' terminated by an administrator.", session_id)
+ return MCPGatewaySessionsTerminateResponse(
+ worker_pid=os.getpid(),
+ terminated_sessions=len(terminated),
+ sessions=terminated,
+ )
+
async def _read_request_body_for_routing(
receive: Receive,
) -> tuple[list[Message], bytes]:
@@ -4009,6 +4064,17 @@ if MCP_AVAILABLE:
await success_response(scope, receive, send)
return True
+ if _session_id in _admin_terminated_session_ids:
+ terminated_response: Final = JSONResponse(
+ status_code=404,
+ content={ # mutable-ok: JSONResponse content must be a plain dict
+ "error": "Not Found",
+ "details": "mcp-session-id was terminated by an administrator. Send initialize to start a new session.",
+ },
+ )
+ await terminated_response(scope, receive, send)
+ return True
+
# Non-DELETE: strip stale session ID to allow new session creation
verbose_logger.warning(
"MCP session ID '%s' not found in this worker's memory. "
diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 82b709feb92..1e9b1f5b743 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -27648,6 +27648,32 @@
"title": "MCPGatewaySessionsResponse",
"type": "object"
},
+ "MCPGatewaySessionsTerminateResponse": {
+ "description": "Stateful sessions an administrator force-closed on this proxy worker.",
+ "properties": {
+ "sessions": {
+ "items": {
+ "$ref": "#/components/schemas/MCPGatewaySession"
+ },
+ "title": "Sessions",
+ "type": "array"
+ },
+ "terminated_sessions": {
+ "title": "Terminated Sessions",
+ "type": "integer"
+ },
+ "worker_pid": {
+ "title": "Worker Pid",
+ "type": "integer"
+ }
+ },
+ "required": [
+ "worker_pid",
+ "terminated_sessions"
+ ],
+ "title": "MCPGatewaySessionsTerminateResponse",
+ "type": "object"
+ },
"MCPOAuthUserCredentialRequest": {
"description": "Stores a user's OAuth2 token for an OpenAPI MCP server.",
"properties": {
@@ -27744,6 +27770,56 @@
"title": "MCPOAuthUserCredentialStatus",
"type": "object"
},
+ "MCPServerUserCredentialListItem": {
+ "description": "One user's stored credential for an MCP server, as an admin sees it. Never carries the secret.",
+ "properties": {
+ "connected_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Connected At"
+ },
+ "credential_type": {
+ "enum": [
+ "oauth2",
+ "byok"
+ ],
+ "title": "Credential Type",
+ "type": "string"
+ },
+ "expires_at": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Expires At"
+ },
+ "updated_at": {
+ "title": "Updated At",
+ "type": "string"
+ },
+ "user_id": {
+ "title": "User Id",
+ "type": "string"
+ }
+ },
+ "required": [
+ "user_id",
+ "credential_type",
+ "updated_at"
+ ],
+ "title": "MCPServerUserCredentialListItem",
+ "type": "object"
+ },
"MCPSubmissionsSummary": {
"properties": {
"active": {
@@ -29920,7 +29996,7 @@
},
"/v1/mcp/server/{server_id}/oauth-user-credential": {
"delete": {
- "description": "Revoke the calling user's stored OAuth2 token for an MCP server",
+ "description": "Revoke the calling user's stored OAuth2 token for an MCP server. A proxy admin may pass user_id to revoke another user's stored token.",
"operationId": "delete_mcp_oauth_user_credential_v1_mcp_server__server_id__oauth_user_credential_delete",
"parameters": [
{
@@ -29931,6 +30007,23 @@
"title": "Server Id",
"type": "string"
}
+ },
+ {
+ "in": "query",
+ "name": "user_id",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "minLength": 1,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "User Id"
+ }
}
],
"responses": {
@@ -30130,7 +30223,7 @@
},
"/v1/mcp/server/{server_id}/user-credential": {
"delete": {
- "description": "Delete the calling user's stored API key for a BYOK MCP server",
+ "description": "Delete the calling user's stored API key for a BYOK MCP server. A proxy admin may pass user_id to revoke another user's stored key.",
"operationId": "delete_mcp_user_credential_v1_mcp_server__server_id__user_credential_delete",
"parameters": [
{
@@ -30141,6 +30234,23 @@
"title": "Server Id",
"type": "string"
}
+ },
+ {
+ "in": "query",
+ "name": "user_id",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "minLength": 1,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "User Id"
+ }
}
],
"responses": {
@@ -30232,6 +30342,58 @@
]
}
},
+ "/v1/mcp/server/{server_id}/user-credentials": {
+ "get": {
+ "description": "List every user's stored BYOK or OAuth2 credential for an MCP server (admin only, no secrets)",
+ "operationId": "list_mcp_server_user_credentials_v1_mcp_server__server_id__user_credentials_get",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "server_id",
+ "required": true,
+ "schema": {
+ "title": "Server Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "$ref": "#/components/schemas/MCPServerUserCredentialListItem"
+ },
+ "title": "Response List Mcp Server User Credentials V1 Mcp Server Server Id User Credentials Get",
+ "type": "array"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "security": [
+ {
+ "APIKeyHeader": []
+ }
+ ],
+ "summary": "List Mcp Server User Credentials",
+ "tags": [
+ "mcp_management"
+ ]
+ }
+ },
"/v1/mcp/server/{server_id}/user-env-vars": {
"delete": {
"description": "Clear the calling user's per-user MCP env var values for this server.",
@@ -30383,6 +30545,77 @@
}
},
"/v1/mcp/sessions": {
+ "delete": {
+ "description": "Force-close live stateful MCP gateway sessions on this proxy worker, selected by session id prefix and/or by the LiteLLM user that opened them (proxy admin only).",
+ "operationId": "delete_mcp_gateway_sessions_v1_mcp_sessions_delete",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "session_id_prefix",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "minLength": 8,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Session Id Prefix"
+ }
+ },
+ {
+ "in": "query",
+ "name": "user_id",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "minLength": 1,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "User Id"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MCPGatewaySessionsTerminateResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "security": [
+ {
+ "APIKeyHeader": []
+ }
+ ],
+ "summary": "Delete Mcp Gateway Sessions",
+ "tags": [
+ "mcp_management"
+ ]
+ },
"get": {
"description": "Live stateful MCP gateway sessions on this proxy worker, grouped by AI client and by user.",
"operationId": "get_mcp_gateway_sessions_v1_mcp_sessions_get",
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index ff32d4784df..ee92e64b046 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -1716,6 +1716,16 @@ class MCPUserCredentialListItem(LiteLLMPydanticObjectBase):
connected_at: str | None = None # ISO-8601
+class MCPServerUserCredentialListItem(LiteLLMPydanticObjectBase):
+ """One user's stored credential for an MCP server, as an admin sees it. Never carries the secret."""
+
+ user_id: str
+ credential_type: Literal["oauth2", "byok"]
+ expires_at: str | None = None
+ connected_at: str | None = None
+ updated_at: str
+
+
class MCPUserEnvVarsRequest(LiteLLMPydanticObjectBase):
"""Payload for storing the calling user's per-user env var values."""
diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py
index 82a1cdcdd00..728ba9568e8 100644
--- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py
@@ -47,7 +47,7 @@ except ImportError:
import litellm
from litellm._logging import verbose_logger, verbose_proxy_logger
from litellm._uuid import uuid
-from litellm.constants import LITELLM_PROXY_ADMIN_NAME
+from litellm.constants import LITELLM_PROXY_ADMIN_NAME, MCP_GATEWAY_SESSION_ID_PREFIX_LENGTH
from litellm.proxy._experimental.mcp_server.utils import (
LITELLM_MCP_SERVER_DESCRIPTION,
LITELLM_MCP_SERVER_NAME,
@@ -145,6 +145,7 @@ if MCP_AVAILABLE:
get_user_env_vars,
get_user_env_vars_bulk,
get_user_oauth_credential,
+ list_server_user_credentials,
list_user_oauth_credentials,
mcp_oauth_token_identity,
merge_user_env_vars,
@@ -180,6 +181,7 @@ if MCP_AVAILABLE:
MCPApprovalStatus,
MCPOAuthUserCredentialRequest,
MCPOAuthUserCredentialStatus,
+ MCPServerUserCredentialListItem,
MCPSubmissionsSummary,
MCPTransport,
MCPUserCredentialListItem,
@@ -221,6 +223,7 @@ if MCP_AVAILABLE:
MCPAuth,
MCPCredentials,
MCPGatewaySessionsResponse,
+ MCPGatewaySessionsTerminateResponse,
normalize_upstream_header_name,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@@ -662,6 +665,31 @@ if MCP_AVAILABLE:
"""
return user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
+ def _resolve_credential_target_user_id(user_api_key_dict: UserAPIKeyAuth, requested_user_id: str | None) -> str:
+ """The user whose stored MCP credential a request acts on.
+
+ Defaults to the caller. Naming another user is a revocation and needs
+ ``PROXY_ADMIN``; a read-only admin or a regular user gets 403.
+ """
+ caller_user_id: Final = user_api_key_dict.user_id or ""
+ if requested_user_id is not None and requested_user_id != caller_user_id:
+ if not _user_is_full_admin(user_api_key_dict):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail={ # mutable-ok: FastAPI HTTPException detail requires a plain dict
+ "error": "Proxy admin access required to revoke another user's MCP credential.",
+ },
+ )
+ return requested_user_id
+ if not caller_user_id:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail={
+ "error": "User ID not found in token"
+ }, # mutable-ok: FastAPI HTTPException detail requires a plain dict
+ )
+ return caller_user_id
+
def _is_restricted_virtual_key_request(user_api_key_dict: UserAPIKeyAuth) -> bool:
"""Best-effort detection for route-restricted virtual keys.
@@ -1373,6 +1401,41 @@ if MCP_AVAILABLE:
return get_mcp_gateway_sessions_report()
+ @router.delete(
+ "/sessions",
+ description=(
+ "Force-close live stateful MCP gateway sessions on this proxy worker, selected by session id prefix "
+ "and/or by the LiteLLM user that opened them (proxy admin only)."
+ ),
+ dependencies=(Depends(user_api_key_auth),),
+ response_model=MCPGatewaySessionsTerminateResponse,
+ )
+ @management_endpoint_wrapper
+ async def delete_mcp_gateway_sessions(
+ user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
+ session_id_prefix: Annotated[str | None, Query(min_length=MCP_GATEWAY_SESSION_ID_PREFIX_LENGTH)] = None,
+ user_id: Annotated[str | None, Query(min_length=1)] = None,
+ ) -> MCPGatewaySessionsTerminateResponse:
+ if not _user_is_full_admin(user_api_key_dict):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail={ # mutable-ok: FastAPI HTTPException detail requires a plain dict
+ "error": "Proxy admin access required to terminate MCP gateway sessions.",
+ },
+ )
+ if session_id_prefix is None and user_id is None:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail={ # mutable-ok: FastAPI HTTPException detail requires a plain dict
+ "error": "Provide session_id_prefix and/or user_id to select the sessions to terminate.",
+ },
+ )
+ from litellm.proxy._experimental.mcp_server.server import (
+ terminate_mcp_gateway_sessions,
+ )
+
+ return await terminate_mcp_gateway_sessions(session_id_prefix=session_id_prefix, user_id=user_id)
+
@router.get(
"/server/submissions",
description="Returns all MCP servers submitted by non-admin users (admin review queue). Mirrors GET /guardrails/submissions.",
@@ -2261,7 +2324,10 @@ if MCP_AVAILABLE:
@router.delete(
"/server/{server_id}/user-credential",
- description="Delete the calling user's stored API key for a BYOK MCP server",
+ description=(
+ "Delete the calling user's stored API key for a BYOK MCP server. "
+ "A proxy admin may pass user_id to revoke another user's stored key."
+ ),
dependencies=[Depends(user_api_key_auth)],
response_model=MCPUserCredentialResponse,
)
@@ -2269,24 +2335,20 @@ if MCP_AVAILABLE:
async def delete_mcp_user_credential(
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
+ user_id: Annotated[str | None, Query(min_length=1)] = None,
):
- """Remove the calling user's BYOK credential."""
+ """Remove the target user's BYOK credential (the caller unless an admin names another user)."""
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
- user_id: Final = user_api_key_dict.user_id or ""
- if not user_id:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail={"error": "User ID not found in token"},
- )
+ target_user_id: Final = _resolve_credential_target_user_id(user_api_key_dict, user_id)
try:
- await delete_user_credential(prisma_client, user_id, server_id)
+ await delete_user_credential(prisma_client, target_user_id, server_id)
except RecordNotFoundError:
pass # Already deleted or didn't exist
from litellm.proxy._experimental.mcp_server.server import (
_invalidate_byok_cred_cache,
)
- _invalidate_byok_cred_cache(user_id, server_id)
+ _invalidate_byok_cred_cache(target_user_id, server_id)
return MCPUserCredentialResponse(server_id=server_id, has_credential=False)
# ── OAuth2 user-credential endpoints ──────────────────────────────────────
@@ -2362,7 +2424,10 @@ if MCP_AVAILABLE:
@router.delete(
"/server/{server_id}/oauth-user-credential",
- description="Revoke the calling user's stored OAuth2 token for an MCP server",
+ description=(
+ "Revoke the calling user's stored OAuth2 token for an MCP server. "
+ "A proxy admin may pass user_id to revoke another user's stored token."
+ ),
dependencies=[Depends(user_api_key_auth)],
response_model=MCPOAuthUserCredentialStatus,
)
@@ -2370,29 +2435,25 @@ if MCP_AVAILABLE:
async def delete_mcp_oauth_user_credential(
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
+ user_id: Annotated[str | None, Query(min_length=1)] = None,
):
- """Revoke/delete the user's OAuth2 credential."""
+ """Revoke the target user's OAuth2 credential (the caller unless an admin names another user)."""
prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
- user_id: Final = user_api_key_dict.user_id or ""
- if not user_id:
- raise HTTPException(
- status_code=status.HTTP_400_BAD_REQUEST,
- detail={"error": "User ID not found in token"},
- )
+ target_user_id: Final = _resolve_credential_target_user_id(user_api_key_dict, user_id)
# Only delete if the stored credential is actually an OAuth2 token.
# This prevents accidentally deleting a BYOK credential if one exists
# for the same (user_id, server_id) pair.
- cred_to_delete: Final = await get_user_oauth_credential(prisma_client, user_id, server_id)
+ cred_to_delete: Final = await get_user_oauth_credential(prisma_client, target_user_id, server_id)
if cred_to_delete is not None:
try:
- await delete_user_credential(prisma_client, user_id, server_id)
+ await delete_user_credential(prisma_client, target_user_id, server_id)
except RecordNotFoundError:
pass # Already gone — treat as a successful delete
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415
global_mcp_server_manager,
)
- await global_mcp_server_manager.invalidate_user_oauth_token_cache(user_id, server_id)
+ await global_mcp_server_manager.invalidate_user_oauth_token_cache(target_user_id, server_id)
return MCPOAuthUserCredentialStatus(
server_id=server_id,
has_credential=False,
@@ -2481,6 +2542,30 @@ if MCP_AVAILABLE:
)
return items
+ @router.get(
+ "/server/{server_id}/user-credentials",
+ description="List every user's stored BYOK or OAuth2 credential for an MCP server (admin only, no secrets)",
+ dependencies=(Depends(user_api_key_auth),),
+ response_model=list[MCPServerUserCredentialListItem],
+ )
+ @management_endpoint_wrapper
+ async def list_mcp_server_user_credentials(
+ server_id: str,
+ user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
+ ) -> tuple[MCPServerUserCredentialListItem, ...]:
+ if user_api_key_dict.user_role not in (
+ LitellmUserRoles.PROXY_ADMIN,
+ LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
+ ):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail={ # mutable-ok: FastAPI HTTPException detail requires a plain dict
+ "error": "Admin access required to view MCP server user credentials.",
+ },
+ )
+ prisma_client: Final = get_prisma_client_or_throw("Database not connected. Connect a database to your proxy")
+ return await list_server_user_credentials(prisma_client, server_id)
+
# ── Per-user MCP env var endpoints ────────────────────────────────────────
async def _authorize_and_fetch_mcp_server(
diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py
index 2d06bb9a009..c5a26c997b7 100644
--- a/litellm/types/mcp.py
+++ b/litellm/types/mcp.py
@@ -464,3 +464,11 @@ class MCPGatewaySessionsResponse(BaseModel):
by_client: list[MCPGatewaySessionGroupCount] = Field(default_factory=list)
by_user: list[MCPGatewaySessionGroupCount] = Field(default_factory=list)
sessions: list[MCPGatewaySession] = Field(default_factory=list)
+
+
+class MCPGatewaySessionsTerminateResponse(BaseModel):
+ """Stateful sessions an administrator force-closed on this proxy worker."""
+
+ worker_pid: int
+ terminated_sessions: int
+ sessions: list[MCPGatewaySession] = Field(default_factory=list)
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py
index 60a5e1a22bb..cfcff73b857 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py
@@ -212,6 +212,53 @@ 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_list_server_user_credentials_types_each_row_without_leaking_the_secret():
+ """The admin view of one server's stored credentials names the user and the kind of
+ credential (OAuth2 vs BYOK) and echoes OAuth expiry, but never the token or key itself."""
+ from litellm.proxy._experimental.mcp_server.db import list_server_user_credentials
+
+ oauth_row = _legacy_row(
+ json.dumps(
+ {
+ "type": "oauth2",
+ "access_token": "tok-alice",
+ "expires_at": "2026-12-31T00:00:00+00:00",
+ "connected_at": "2026-01-01T00:00:00+00:00",
+ }
+ )
+ )
+ oauth_row.user_id = "alice"
+ oauth_row.updated_at = datetime(2026, 1, 1, tzinfo=timezone.utc)
+ byok_row = _byok_row("carol")
+ byok_row.updated_at = datetime(2026, 2, 1, tzinfo=timezone.utc)
+ prisma = MagicMock()
+ prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[oauth_row, byok_row])
+
+ items = await list_server_user_credentials(prisma, "srv-1")
+
+ prisma.db.litellm_mcpusercredentials.find_many.assert_awaited_once_with(where={"server_id": "srv-1"})
+ assert [item.model_dump() for item in items] == [
+ {
+ "user_id": "alice",
+ "credential_type": "oauth2",
+ "expires_at": "2026-12-31T00:00:00+00:00",
+ "connected_at": "2026-01-01T00:00:00+00:00",
+ "updated_at": "2026-01-01T00:00:00+00:00",
+ },
+ {
+ "user_id": "carol",
+ "credential_type": "byok",
+ "expires_at": None,
+ "connected_at": None,
+ "updated_at": "2026-02-01T00:00:00+00:00",
+ },
+ ]
+ serialized = "".join(item.model_dump_json() for item in items)
+ assert "tok-alice" not in serialized
+ assert "sk-byok-carol" not in serialized
+
+
@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
diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
index ef424255f04..4311a69d465 100644
--- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
+++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py
@@ -2871,6 +2871,183 @@ def test_remove_stateful_session_tracking_drops_client_info():
assert session_id not in mcp_server._stateful_session_client_info
+def _admin_terminate_fixture(mcp_server):
+ def auth_user(user_id: str):
+ return mcp_server.MCPAuthenticatedUser(
+ user_api_key_auth=UserAPIKeyAuth(api_key=f"key-{user_id}", user_id=user_id),
+ )
+
+ contexts = {
+ "alice-session-1": auth_user("alice"),
+ "alice-session-2": auth_user("alice"),
+ "bob-session-1": auth_user("bob"),
+ "anon-session-1": mcp_server.MCPAuthenticatedUser(user_api_key_auth=None),
+ "gone-session-1": auth_user("alice"),
+ }
+ transports = {
+ session_id: MagicMock(terminate=AsyncMock())
+ for session_id in ("alice-session-1", "alice-session-2", "bob-session-1", "anon-session-1")
+ }
+ return contexts, transports
+
+
+@pytest.mark.asyncio
+async def test_terminate_mcp_gateway_sessions_by_user_closes_every_live_session_of_that_user():
+ try:
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy._experimental.mcp_server.server import session_manager_stateful
+ except ImportError:
+ pytest.skip("MCP server not available")
+
+ contexts, transports = _admin_terminate_fixture(mcp_server)
+ live_transports = dict(transports)
+ last_seen = {session_id: 100.0 for session_id in contexts}
+ locks = {session_id: asyncio.Lock() for session_id in contexts}
+
+ with (
+ patch.object( # test-quality-ok: the transport registry is a module-level singleton; the suite's only seam
+ session_manager_stateful, "_server_instances", live_transports
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_contexts, contexts, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_context_last_seen, last_seen, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_locks, locks, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_owners, {session_id: "owner" for session_id in contexts}, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_active_request_counts, {}, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_client_info, {}, clear=True
+ ),
+ ):
+ result = await mcp_server.terminate_mcp_gateway_sessions(user_id="alice")
+
+ assert set(live_transports) == {"bob-session-1", "anon-session-1"}
+ assert set(mcp_server._stateful_session_auth_contexts) == {"bob-session-1", "anon-session-1", "gone-session-1"}
+ assert set(mcp_server._stateful_session_locks) == {"bob-session-1", "anon-session-1", "gone-session-1"}
+ assert set(mcp_server._stateful_session_owners) == {"bob-session-1", "anon-session-1", "gone-session-1"}
+ assert set(mcp_server._stateful_session_auth_context_last_seen) == {
+ "bob-session-1",
+ "anon-session-1",
+ "gone-session-1",
+ }
+
+ transports["alice-session-1"].terminate.assert_awaited_once()
+ transports["alice-session-2"].terminate.assert_awaited_once()
+ transports["bob-session-1"].terminate.assert_not_awaited()
+ transports["anon-session-1"].terminate.assert_not_awaited()
+ assert result.terminated_sessions == 2
+ assert sorted(session.session_id_prefix for session in result.sessions) == ["alice-se", "alice-se"]
+ assert {session.user_id for session in result.sessions} == {"alice"}
+ assert "key-alice" not in result.model_dump_json()
+ assert "alice-session-1" not in result.model_dump_json()
+
+
+@pytest.mark.asyncio
+async def test_terminate_mcp_gateway_sessions_prefix_and_user_must_both_match():
+ try:
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy._experimental.mcp_server.server import session_manager_stateful
+ except ImportError:
+ pytest.skip("MCP server not available")
+
+ contexts, transports = _admin_terminate_fixture(mcp_server)
+ live_transports = dict(transports)
+
+ with (
+ patch.object( # test-quality-ok: the transport registry is a module-level singleton; the suite's only seam
+ session_manager_stateful, "_server_instances", live_transports
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_contexts, contexts, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_client_info, {}, clear=True
+ ),
+ ):
+ mismatch = await mcp_server.terminate_mcp_gateway_sessions(session_id_prefix="alice-session-1", user_id="bob")
+ assert mismatch.terminated_sessions == 0
+ assert set(live_transports) == set(transports)
+
+ stale = await mcp_server.terminate_mcp_gateway_sessions(session_id_prefix="gone-session-1")
+ assert stale.terminated_sessions == 0
+
+ exact = await mcp_server.terminate_mcp_gateway_sessions(session_id_prefix="alice-session-1", user_id="alice")
+ assert exact.terminated_sessions == 1
+ assert set(live_transports) == {"alice-session-2", "bob-session-1", "anon-session-1"}
+
+
+@pytest.mark.asyncio
+async def test_admin_terminated_session_id_gets_404_instead_of_a_fresh_stateless_session():
+ """Once an admin closes a session, a client replaying its id must not be silently upgraded to a
+ new stateless session by the stale-header path; it gets 404 and has to initialize again."""
+ try:
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy._experimental.mcp_server.server import session_manager_stateful
+ from starlette.types import Scope
+ except ImportError:
+ pytest.skip("MCP server not available")
+
+ session_id = "admin-closed-session-1"
+ live_transports = {session_id: MagicMock(terminate=AsyncMock())}
+ contexts = {
+ session_id: mcp_server.MCPAuthenticatedUser(
+ user_api_key_auth=UserAPIKeyAuth(api_key="key-alice", user_id="alice"),
+ )
+ }
+
+ def scope_with_session_header() -> Scope:
+ return {
+ "type": "http",
+ "method": "POST",
+ "headers": [(b"content-type", b"application/json"), (b"mcp-session-id", session_id.encode())],
+ }
+
+ try:
+ with (
+ patch.object( # test-quality-ok: the transport registry is a module-level singleton; the suite's only seam
+ session_manager_stateful, "_server_instances", live_transports
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_contexts, contexts, clear=True
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_client_info, {}, clear=True
+ ),
+ ):
+ await mcp_server.terminate_mcp_gateway_sessions(session_id_prefix=session_id)
+
+ terminated_scope = scope_with_session_header()
+ send = AsyncMock()
+ handled = await mcp_server._handle_stale_mcp_session(
+ terminated_scope, AsyncMock(), send, session_manager_stateful
+ )
+
+ assert handled is True
+ statuses = [m["status"] for (m,), _ in send.await_args_list if m["type"] == "http.response.start"]
+ assert statuses == [404]
+ assert [k for k, _ in terminated_scope["headers"]] == [b"content-type", b"mcp-session-id"]
+
+ unknown_scope = scope_with_session_header()
+ unknown_scope["headers"][1] = (b"mcp-session-id", b"never-seen-session")
+ assert (
+ await mcp_server._handle_stale_mcp_session(
+ unknown_scope, AsyncMock(), AsyncMock(), session_manager_stateful
+ )
+ is False
+ )
+ assert [k for k, _ in unknown_scope["headers"]] == [b"content-type"]
+ finally:
+ mcp_server._admin_terminated_session_ids.clear()
+
+
@pytest.mark.asyncio
async def test_initialize_request_with_existing_session_tracks_new_session():
try:
diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
index 7c874aff3df..2f33018599f 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py
@@ -5136,6 +5136,261 @@ async def test_delete_mcp_oauth_user_credential_invalidates_when_record_already_
assert result.has_credential is False
+def _make_admin_auth(role: LitellmUserRoles = LitellmUserRoles.PROXY_ADMIN) -> "UserAPIKeyAuth":
+ return UserAPIKeyAuth(api_key="sk-admin", user_id="admin-user", user_role=role)
+
+
+@pytest.mark.asyncio
+async def test_admin_revokes_another_users_byok_credential():
+ """A proxy admin naming user_id deletes and cache-invalidates that user's stored key, not their own."""
+ if not mgmt_endpoints.MCP_AVAILABLE:
+ pytest.skip("MCP module not installed")
+
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ delete_mcp_user_credential,
+ )
+
+ delete_mock = AsyncMock(return_value=None)
+ invalidate_mock = MagicMock()
+ with (
+ patch( # test-quality-ok: endpoint test stubs the Prisma client lookup
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
+ return_value=_make_prisma_client(),
+ ),
+ patch( # test-quality-ok: endpoint test stubs the credential row delete
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.delete_user_credential",
+ new=delete_mock,
+ ),
+ patch.object( # test-quality-ok: the cache invalidator is module scoped; the suite's only seam
+ mcp_server, "_invalidate_byok_cred_cache", new=invalidate_mock
+ ),
+ ):
+ result = await delete_mcp_user_credential(
+ server_id="srv-byok-admin",
+ user_api_key_dict=_make_admin_auth(),
+ user_id="mallory",
+ )
+
+ delete_mock.assert_awaited_once()
+ assert delete_mock.await_args.args[1:] == ("mallory", "srv-byok-admin")
+ invalidate_mock.assert_called_once_with("mallory", "srv-byok-admin")
+ assert result.has_credential is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
+async def test_non_full_admin_cannot_revoke_another_users_byok_credential(role):
+ if not mgmt_endpoints.MCP_AVAILABLE:
+ pytest.skip("MCP module not installed")
+
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ delete_mcp_user_credential,
+ )
+
+ delete_mock = AsyncMock(return_value=None)
+ with (
+ patch( # test-quality-ok: endpoint test stubs the Prisma client lookup
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
+ return_value=_make_prisma_client(),
+ ),
+ patch( # test-quality-ok: endpoint test stubs the credential row delete
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.delete_user_credential",
+ new=delete_mock,
+ ),
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await delete_mcp_user_credential(
+ server_id="srv-byok-forbidden",
+ user_api_key_dict=_make_admin_auth(role),
+ user_id="mallory",
+ )
+
+ assert exc_info.value.status_code == 403
+ delete_mock.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_user_naming_themselves_still_deletes_own_byok_credential():
+ if not mgmt_endpoints.MCP_AVAILABLE:
+ pytest.skip("MCP module not installed")
+
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ delete_mcp_user_credential,
+ )
+
+ delete_mock = AsyncMock(return_value=None)
+ with (
+ patch( # test-quality-ok: endpoint test stubs the Prisma client lookup
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
+ return_value=_make_prisma_client(),
+ ),
+ patch( # test-quality-ok: endpoint test stubs the credential row delete
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.delete_user_credential",
+ new=delete_mock,
+ ),
+ patch.object( # test-quality-ok: the cache invalidator is module scoped; the suite's only seam
+ mcp_server, "_invalidate_byok_cred_cache", new=MagicMock()
+ ),
+ ):
+ await delete_mcp_user_credential(
+ server_id="srv-byok-self",
+ user_api_key_dict=_make_user_auth("user-self"),
+ user_id="user-self",
+ )
+
+ assert delete_mock.await_args.args[1:] == ("user-self", "srv-byok-self")
+
+
+@pytest.mark.asyncio
+async def test_admin_revokes_another_users_oauth_credential():
+ """A proxy admin naming user_id reads, deletes, and cache-invalidates that user's OAuth token."""
+ if not mgmt_endpoints.MCP_AVAILABLE:
+ pytest.skip("MCP module not installed")
+
+ from litellm.proxy._experimental.mcp_server import mcp_server_manager as manager_module
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ delete_mcp_oauth_user_credential,
+ )
+
+ get_mock = AsyncMock(return_value={"type": "oauth2", "access_token": "mallory-tok"})
+ delete_mock = AsyncMock(return_value=None)
+ invalidate_mock = AsyncMock(return_value=None)
+ with (
+ patch( # test-quality-ok: endpoint test stubs the Prisma client lookup
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
+ return_value=_make_prisma_client(),
+ ),
+ patch( # test-quality-ok: endpoint test stubs the stored OAuth token read
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_user_oauth_credential",
+ new=get_mock,
+ ),
+ patch( # test-quality-ok: endpoint test stubs the credential row delete
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.delete_user_credential",
+ new=delete_mock,
+ ),
+ patch.object( # test-quality-ok: the OAuth cache lives on the global manager; the suite's only seam
+ manager_module.global_mcp_server_manager,
+ "invalidate_user_oauth_token_cache",
+ new=invalidate_mock,
+ ),
+ ):
+ result = await delete_mcp_oauth_user_credential(
+ server_id="srv-oauth-admin",
+ user_api_key_dict=_make_admin_auth(),
+ user_id="mallory",
+ )
+
+ assert get_mock.await_args.args[1:] == ("mallory", "srv-oauth-admin")
+ assert delete_mock.await_args.args[1:] == ("mallory", "srv-oauth-admin")
+ invalidate_mock.assert_awaited_once_with("mallory", "srv-oauth-admin")
+ assert result.has_credential is False
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
+async def test_non_full_admin_cannot_revoke_another_users_oauth_credential(role):
+ if not mgmt_endpoints.MCP_AVAILABLE:
+ pytest.skip("MCP module not installed")
+
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ delete_mcp_oauth_user_credential,
+ )
+
+ get_mock = AsyncMock(return_value={"type": "oauth2", "access_token": "mallory-tok"})
+ delete_mock = AsyncMock(return_value=None)
+ with (
+ patch( # test-quality-ok: endpoint test stubs the Prisma client lookup
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
+ return_value=_make_prisma_client(),
+ ),
+ patch( # test-quality-ok: endpoint test stubs the stored OAuth token read
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_user_oauth_credential",
+ new=get_mock,
+ ),
+ patch( # test-quality-ok: endpoint test stubs the credential row delete
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.delete_user_credential",
+ new=delete_mock,
+ ),
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await delete_mcp_oauth_user_credential(
+ server_id="srv-oauth-forbidden",
+ user_api_key_dict=_make_admin_auth(role),
+ user_id="mallory",
+ )
+
+ assert exc_info.value.status_code == 403
+ get_mock.assert_not_awaited()
+ delete_mock.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
+async def test_admin_lists_every_users_credential_for_a_server(role):
+ if not mgmt_endpoints.MCP_AVAILABLE:
+ pytest.skip("MCP module not installed")
+
+ from litellm.proxy._types import MCPServerUserCredentialListItem
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ list_mcp_server_user_credentials,
+ )
+
+ items = (
+ MCPServerUserCredentialListItem(user_id="alice", credential_type="byok", updated_at="2026-01-01T00:00:00"),
+ MCPServerUserCredentialListItem(user_id="bob", credential_type="oauth2", updated_at="2026-01-02T00:00:00"),
+ )
+ list_mock = AsyncMock(return_value=items)
+ with (
+ patch( # test-quality-ok: endpoint test stubs the Prisma client lookup
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
+ return_value=_make_prisma_client(),
+ ),
+ patch( # test-quality-ok: endpoint test stubs the credential row listing
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.list_server_user_credentials",
+ new=list_mock,
+ ),
+ ):
+ result = await list_mcp_server_user_credentials(
+ server_id="srv-list-admin",
+ user_api_key_dict=_make_admin_auth(role),
+ )
+
+ assert list_mock.await_args.args[1:] == ("srv-list-admin",)
+ assert [(item.user_id, item.credential_type) for item in result] == [("alice", "byok"), ("bob", "oauth2")]
+
+
+@pytest.mark.asyncio
+async def test_non_admin_cannot_list_a_servers_user_credentials():
+ if not mgmt_endpoints.MCP_AVAILABLE:
+ pytest.skip("MCP module not installed")
+
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ list_mcp_server_user_credentials,
+ )
+
+ list_mock = AsyncMock(return_value=())
+ with (
+ patch( # test-quality-ok: endpoint test stubs the Prisma client lookup
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
+ return_value=_make_prisma_client(),
+ ),
+ patch( # test-quality-ok: endpoint test stubs the credential row listing
+ "litellm.proxy.management_endpoints.mcp_management_endpoints.list_server_user_credentials",
+ new=list_mock,
+ ),
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await list_mcp_server_user_credentials(
+ server_id="srv-list-forbidden",
+ user_api_key_dict=_make_user_auth("user-plain"),
+ )
+
+ assert exc_info.value.status_code == 403
+ list_mock.assert_not_awaited()
+
+
@pytest.mark.asyncio
async def test_list_mcp_user_credentials_batch_server_fetch():
"""list_mcp_user_credentials uses a single batch DB call, not N+1 queries."""
@@ -7321,3 +7576,146 @@ class TestGetMCPGatewaySessions:
assert [(group.label, group.count) for group in result.by_client] == [("cursor", 1)]
assert [(group.label, group.count) for group in result.by_user] == [("alice", 1)]
assert "sk-live-secret" not in result.model_dump_json()
+
+
+class TestDeleteMCPGatewaySessions:
+ @pytest.fixture(autouse=True)
+ def _forget_admin_terminated_ids(self):
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+
+ yield
+ mcp_server._admin_terminated_session_ids.clear()
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY])
+ async def test_non_full_admin_forbidden_before_any_session_is_touched(self, role):
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ delete_mcp_gateway_sessions,
+ )
+
+ session_id = "gateway-terminate-forbidden-1"
+ transport = MagicMock(terminate=AsyncMock())
+ auth_user = mcp_server.MCPAuthenticatedUser(
+ user_api_key_auth=UserAPIKeyAuth(api_key="sk-live", user_id="alice"),
+ )
+ with (
+ patch.object( # test-quality-ok: the transport registry is a module-level singleton; the suite's only seam
+ mcp_server.session_manager_stateful, "_server_instances", {session_id: transport}
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_contexts, {session_id: auth_user}, clear=True
+ ),
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await delete_mcp_gateway_sessions(
+ user_api_key_dict=generate_mock_user_api_key_auth(user_role=role),
+ session_id_prefix=session_id,
+ user_id=None,
+ )
+ assert exc_info.value.status_code == 403
+ transport.terminate.assert_not_awaited()
+ assert session_id in mcp_server._stateful_session_auth_contexts
+
+ @pytest.mark.asyncio
+ async def test_requires_a_selector(self):
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ delete_mcp_gateway_sessions,
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ await delete_mcp_gateway_sessions(
+ user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN),
+ session_id_prefix=None,
+ user_id=None,
+ )
+ assert exc_info.value.status_code == 400
+
+ @pytest.mark.asyncio
+ async def test_admin_terminates_only_the_selected_session(self):
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ delete_mcp_gateway_sessions,
+ )
+ from litellm.types.mcp import MCPGatewaySessionsTerminateResponse
+
+ target_id = "11111111-target-session"
+ other_id = "22222222-other-session"
+ target_transport = MagicMock(terminate=AsyncMock())
+ other_transport = MagicMock(terminate=AsyncMock())
+ transports = {target_id: target_transport, other_id: other_transport}
+ contexts = {
+ target_id: mcp_server.MCPAuthenticatedUser(
+ user_api_key_auth=UserAPIKeyAuth(api_key="sk-live-target", user_id="alice"),
+ ),
+ other_id: mcp_server.MCPAuthenticatedUser(
+ user_api_key_auth=UserAPIKeyAuth(api_key="sk-live-other", user_id="bob"),
+ ),
+ }
+ with (
+ patch.object( # test-quality-ok: the transport registry is a module-level singleton; the suite's only seam
+ mcp_server.session_manager_stateful, "_server_instances", transports
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_contexts, contexts, clear=True
+ ),
+ ):
+ result = await delete_mcp_gateway_sessions(
+ user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN),
+ session_id_prefix=target_id[:8],
+ user_id=None,
+ )
+ assert target_id not in transports
+ assert other_id in transports
+ assert target_id not in mcp_server._stateful_session_auth_contexts
+ assert other_id in mcp_server._stateful_session_auth_contexts
+
+ target_transport.terminate.assert_awaited_once()
+ other_transport.terminate.assert_not_awaited()
+ assert isinstance(result, MCPGatewaySessionsTerminateResponse)
+ assert result.terminated_sessions == 1
+ assert [(s.session_id_prefix, s.user_id) for s in result.sessions] == [(target_id[:8], "alice")]
+ assert target_id not in result.model_dump_json()
+ assert "sk-live-target" not in result.model_dump_json()
+
+ @pytest.mark.asyncio
+ async def test_admin_terminates_every_session_of_the_selected_user(self):
+ from litellm.proxy._experimental.mcp_server import server as mcp_server
+ from litellm.proxy.management_endpoints.mcp_management_endpoints import (
+ delete_mcp_gateway_sessions,
+ )
+
+ def auth_user(user_id: str):
+ return mcp_server.MCPAuthenticatedUser(
+ user_api_key_auth=UserAPIKeyAuth(api_key=f"sk-live-{user_id}", user_id=user_id),
+ )
+
+ transports = {
+ "bob-session-1": MagicMock(terminate=AsyncMock()),
+ "bob-session-2": MagicMock(terminate=AsyncMock()),
+ "alice-session-1": MagicMock(terminate=AsyncMock()),
+ }
+ contexts = {
+ "bob-session-1": auth_user("bob"),
+ "bob-session-2": auth_user("bob"),
+ "alice-session-1": auth_user("alice"),
+ }
+ with (
+ patch.object( # test-quality-ok: the transport registry is a module-level singleton; the suite's only seam
+ mcp_server.session_manager_stateful, "_server_instances", transports
+ ),
+ patch.dict( # test-quality-ok: the session tables are module-level singletons; the suite's only seam
+ mcp_server._stateful_session_auth_contexts, contexts, clear=True
+ ),
+ ):
+ result = await delete_mcp_gateway_sessions(
+ user_api_key_dict=generate_mock_user_api_key_auth(user_role=LitellmUserRoles.PROXY_ADMIN),
+ session_id_prefix=None,
+ user_id="bob",
+ )
+ assert set(transports) == {"alice-session-1"}
+ assert set(mcp_server._stateful_session_auth_contexts) == {"alice-session-1"}
+
+ assert result.terminated_sessions == 2
+ assert {s.user_id for s in result.sessions} == {"bob"}
+ assert "sk-live-bob" not in result.model_dump_json()
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx
index 11328ff1a3d..f1ddf709038 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPGatewaySessionsTab.integration.test.tsx
@@ -1,13 +1,15 @@
import React from "react";
import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { MCPGatewaySessionsTab, formatIdleSeconds } from "./MCPGatewaySessionsTab";
+import { MCPGatewaySessionsTab, describeTerminateResult, formatIdleSeconds } from "./MCPGatewaySessionsTab";
import * as networking from "@/components/networking";
-import type { MCPGatewaySessionsResponse } from "@/components/mcp_tools/types";
+import type { MCPGatewaySessionsResponse, MCPGatewaySessionsTerminateResponse } from "@/components/mcp_tools/types";
vi.mock("@/components/networking", () => ({
fetchMCPGatewaySessions: vi.fn(),
+ terminateMCPGatewaySessions: vi.fn(),
}));
const REPORT: MCPGatewaySessionsResponse = {
@@ -64,11 +66,11 @@ const REPORT: MCPGatewaySessionsResponse = {
],
};
-const renderTab = () => {
+const renderTab = ({ canTerminate = false }: { canTerminate?: boolean } = {}) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
return render(
Loading user credentials...
+No user has a stored credential for this server.
++ Per-user OAuth2 tokens and BYOK API keys stored for this server. Revoking one deletes it from the database + and clears the cached copy, so the user must connect again before the gateway will call this server for + them. +
+