Skip to content

fix(mcp): ignore stale ids on key save#29128

Merged
mateo-berri merged 7 commits into
litellm_internal_stagingfrom
litellm_fix_stale_mcp_virtual_key
May 30, 2026
Merged

fix(mcp): ignore stale ids on key save#29128
mateo-berri merged 7 commits into
litellm_internal_stagingfrom
litellm_fix_stale_mcp_virtual_key

Conversation

@Sameerlite

@Sameerlite Sameerlite commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • ignore deleted/stale MCP server IDs during key MCP validation so virtual key saves do not fail on obsolete IDs
  • keep authorization enforcement for active MCP server IDs by still rejecting active IDs outside team/global allow scope
  • add focused unit coverage for stale-ID drop behavior and existing active unauthorized-ID rejection behavior
image

Note

Medium Risk
Changes authorization and persistence for MCP object permissions on API keys; behavior is safer for stale IDs but alters when 403 is raised versus silent stripping.

Overview
Virtual key create/update no longer fails when object_permission still lists deleted or unknown MCP server identifiers (e.g. UI state after servers were replaced). Those entries are resolved against the MCP DB table and in-memory registry, stripped with a warning if they cannot be resolved, and the permission payload is rewritten to canonical server IDs (including mcp_tool_permissions keys).

Active servers—after alias/name normalization—are still checked against team scope and allow_all_keys servers; unauthorized active IDs still return 403. Key update now applies the normalized object_permission returned from validation. prisma_client is passed through so DB-backed MCP rows are authoritative when present.

Tests cover stale-ID removal, alias normalization, DB-only resolution, and existing out-of-scope rejection.

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

Prevent virtual key save failures when object_permission still includes MCP server IDs that were deleted from the registry. The validator now drops stale IDs before team scope checks and adds coverage for stale-ID and active unauthorized-ID behavior.

Co-authored-by: Cursor <[email protected]>
@codecov

codecov Bot commented May 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.25287% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...roxy/management_helpers/object_permission_utils.py 93.67% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes key save failures caused by stale MCP server IDs persisting in object_permission after servers are deleted and replaced. Unresolvable identifiers are now silently stripped (with a warning log) while active identifiers are normalised to canonical server IDs before the team-scope 403 check is applied.

  • object_permission_utils.py — adds DB + in-memory registry resolution helpers, in-place rewriting of mcp_servers and mcp_tool_permissions keys to canonical IDs, and wires staleness filtering into validate_key_mcp_servers_against_team, which now returns the (possibly mutated) permission dict instead of None.
  • key_management_endpoints.py — key create and update callers capture the return value and apply the normalised dict back; model_dump(exclude_unset=True) replaces model_dump() to prevent null-field injection into the Prisma write during updates.
  • Tests — new unit tests cover stale-ID drop via both mcp_servers and mcp_tool_permissions, alias normalisation, DB-only resolution, and the existing out-of-scope 403 path; existing test mocks updated to forward the object_permission argument so they remain meaningful under the new return-value contract.

Confidence Score: 4/5

Safe to merge; the authorization boundary for active servers is preserved and the stale-stripping path cannot be exploited to grant unauthorized access.

The core authorization invariant holds: an identifier that resolves to a known server is still checked against team/global scope before it can be saved on a key. The only structural gap is that mcp_tool_permissions stale entries are not stripped when mcp_servers is absent from the request (the normalization block is guarded by if requested_servers:). This means a key update that carries only mcp_access_groups while also having a stale mcp_tool_permissions residue would leave those stale keys in place rather than cleaning them up.

litellm/proxy/management_helpers/object_permission_utils.py — the if requested_servers: guard controls when _rewrite_object_permission_mcp_identifiers runs; a request with only access-groups and pre-existing stale tool-permission keys bypasses the cleanup.

Important Files Changed

Filename Overview
litellm/proxy/management_helpers/object_permission_utils.py Core logic change: stale MCP server IDs (unresolvable via DB or in-memory registry) are silently stripped; active IDs are still normalized to canonical server_ids before the 403 scope check. New helper functions added for DB lookup, alias resolution, and in-place dict rewriting.
litellm/proxy/management_endpoints/key_management_endpoints.py Key create/update paths now capture and apply the normalized object_permission returned from MCP validation. model_dump(exclude_unset=True) replaces model_dump() to prevent null-field injection into the update payload.
tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py Extensive new test coverage for stale-ID drop (both mcp_servers and mcp_tool_permissions), alias normalization, DB-only resolution, and existing 403 paths. Existing tests patched with a non-empty MCP manager registry so they continue to behave as intended.
tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py Two test mocks updated from bare AsyncMock() to identity lambdas so the new return-value-forwarding callers don't propagate a MagicMock into the key data dict. New test covers that exclude_unset=True prevents null field injection during key update.

Reviews (7): Last reviewed commit: "fix(mcp): preserve provided object_permi..." | Re-trigger Greptile

Comment thread litellm/proxy/management_helpers/object_permission_utils.py Outdated
…ssion test

Addresses greptile feedback on PR #29128:

P1 – Race-condition / authoritative-signal concern:
  Extract _get_stale_mcp_server_ids() helper that performs a single
  DB find_many (LiteLLM_MCPServerTable) when prisma_client is available,
  making server-existence checks authoritative even during in-memory
  registry warm-up. Falls back to the in-memory registry only when no
  DB client is present (config-only deployments / unit tests).
  Pass prisma_client through both call sites in key_management_endpoints.

P2 – Missing test coverage for mcp_tool_permissions:
  Add test_validate_stale_ids_in_mcp_tool_permissions_silently_dropped
  to confirm stale server IDs that appear only as keys in
  mcp_tool_permissions are also stripped without raising 403.

Co-authored-by: Cursor <[email protected]>
@Sameerlite

Copy link
Copy Markdown
Collaborator Author

@greptileai

Comment thread litellm/proxy/management_helpers/object_permission_utils.py Outdated
…isclassifying config-file servers as stale

Config-file MCP servers are never written to LiteLLM_MCPServerTable, so a
DB-only query would classify them as stale and silently drop their IDs from
the authorization check, allowing unauthorized key access.

Fix: treat a server ID as stale only when it is absent from BOTH the DB and
the in-memory registry (which holds config-file servers).

Co-authored-by: Cursor <[email protected]>
@Sameerlite

Copy link
Copy Markdown
Collaborator Author

@greptileai

Comment thread litellm/proxy/management_helpers/object_permission_utils.py Outdated
@veria-ai

veria-ai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@Sameerlite

Copy link
Copy Markdown
Collaborator Author

@cursoragent fix the comment

@cursor

cursor Bot commented May 28, 2026

Copy link
Copy Markdown

Implemented and pushed the fix.

Summary:

  • Normalizes MCP permission entries (server_id, alias, server_name) to canonical server IDs before authorization.
  • Treats entries as stale only when absent from both DB and config/in-memory registry.
  • Removes genuinely stale mcp_servers / mcp_tool_permissions entries before save.
  • Preserves 403 enforcement for active unauthorized servers referenced by alias/name.
  • Added focused tests for:
    • alias outside team scope rejected
    • DB-backed alias rejected even when registry is empty
    • alias/name normalization before save
    • stale server cleanup

Verification:

  • python3 -m pytest tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py -q
  • Result: 31 passed

Note: this cloud environment was missing uv, Black, pytest, and proxy/runtime deps, so I installed them locally with pip to run formatting/tests. For future agents, consider running an env setup agent with:

“Set up LiteLLM cloud agent environment with uv, project editable install including proxy extras, pytest/pytest-asyncio, and Black available on PATH so focused proxy unit tests and formatting can run without manual pip installs.”

Open in Web Open in Cursor 

@cursor

cursor Bot commented May 28, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@CLAassistant

CLAassistant commented May 28, 2026

Copy link
Copy Markdown

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

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

@Sameerlite

Copy link
Copy Markdown
Collaborator Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Collaborator

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Unused helper function never called anywhere in codebase
    • Removed the unused _get_stale_mcp_server_ids helper from object_permission_utils.py since it had no callers and its logic is performed inline in validate_key_mcp_servers_against_team.
  • ✅ Fixed: Key generation discards normalized object permission return value
    • The key generation path now captures the return value of validate_key_mcp_servers_against_team and assigns it back to data_json["object_permission"], matching the explicit pattern used in the key update path.
Preview (da7852e314)
diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py
--- a/litellm/proxy/management_endpoints/key_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/key_management_endpoints.py
@@ -833,10 +833,13 @@
         data_json.pop("tags")
 
     # Validate MCP servers in object_permission are within team scope
-    await validate_key_mcp_servers_against_team(
+    normalized_object_permission = await validate_key_mcp_servers_against_team(
         object_permission=data_json.get("object_permission"),
         team_obj=team_table,
+        prisma_client=prisma_client,
     )
+    if normalized_object_permission is not None:
+        data_json["object_permission"] = normalized_object_permission
     await validate_key_search_tools_against_team(
         object_permission=data_json.get("object_permission"),
         team_obj=team_table,
@@ -2112,7 +2115,7 @@
     existing_key_row: Any,
     prisma_client: Any,
     user_api_key_cache: Any,
-) -> None:
+) -> Optional[dict]:
     """Validate MCP servers in object_permission against the effective team."""
     effective_team_obj = team_obj
     # If team_id isn't being changed, resolve the existing key's team
@@ -2130,14 +2133,16 @@
             if hasattr(data.object_permission, "model_dump")
             else dict(data.object_permission)  # type: ignore[arg-type]
         )
-    await validate_key_mcp_servers_against_team(
+    normalized_object_permission = await validate_key_mcp_servers_against_team(
         object_permission=object_permission_dict,
         team_obj=effective_team_obj,
+        prisma_client=prisma_client,
     )
     await validate_key_search_tools_against_team(
         object_permission=object_permission_dict,
         team_obj=effective_team_obj,
     )
+    return normalized_object_permission
 
 
 async def _validate_update_key_data(
@@ -2350,13 +2355,17 @@
 
     # Validate MCP servers in object_permission against the effective team
     if data.object_permission is not None:
-        await _validate_mcp_servers_for_key_update(
+        normalized_object_permission = await _validate_mcp_servers_for_key_update(
             data=data,
             team_obj=team_obj,
             existing_key_row=existing_key_row,
             prisma_client=prisma_client,
             user_api_key_cache=user_api_key_cache,
         )
+        if normalized_object_permission is not None:
+            data.object_permission = LiteLLM_ObjectPermissionBase(
+                **normalized_object_permission
+            )
 
 
 @router.post(

diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py
--- a/litellm/proxy/management_helpers/object_permission_utils.py
+++ b/litellm/proxy/management_helpers/object_permission_utils.py
@@ -4,7 +4,7 @@
 """
 
 import json
-from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union
 
 from fastapi import HTTPException, status
 
@@ -192,8 +192,155 @@
     return data_json
 
 
+def _dedupe_preserving_order(values: List[str]) -> List[str]:
+    seen: Set[str] = set()
+    result: List[str] = []
+    for value in values:
+        if value in seen:
+            continue
+        seen.add(value)
+        result.append(value)
+    return result
+
+
+def _mcp_server_identifier_matches(server: Any, identifier: str) -> bool:
+    return identifier in {
+        getattr(server, "server_id", None),
+        getattr(server, "alias", None),
+        getattr(server, "server_name", None),
+        getattr(server, "name", None),
+    }
+
+
+async def _get_db_mcp_servers_by_identifiers(
+    identifiers: Set[str],
+    prisma_client: Optional[PrismaClient],
+) -> List[Any]:
+    if prisma_client is None or not identifiers:
+        return []
+
+    identifier_list = list(identifiers)
+    return await prisma_client.db.litellm_mcpservertable.find_many(
+        where={
+            "OR": [
+                {"server_id": {"in": identifier_list}},
+                {"alias": {"in": identifier_list}},
+                {"server_name": {"in": identifier_list}},
+            ]
+        }
+    )
+
+
+async def _resolve_mcp_server_identifiers_to_ids(
+    identifiers: Set[str],
+    prisma_client: Optional[PrismaClient],
+) -> Dict[str, Set[str]]:
+    """
+    Resolve MCP permission entries written as server_id, alias, or server_name
+    to canonical server IDs.
+
+    DB rows are authoritative when available; the in-memory registry is still
+    consulted for config-file servers, which are not persisted in the MCP table.
+    """
+    if not identifiers:
+        return {}
+
+    from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
+        global_mcp_server_manager,
+    )
+
+    resolved: Dict[str, Set[str]] = {identifier: set() for identifier in identifiers}
+
+    for server in await _get_db_mcp_servers_by_identifiers(
+        identifiers=identifiers,
+        prisma_client=prisma_client,
+    ):
+        server_id = getattr(server, "server_id", None)
+        if not server_id:
+            continue
+        for identifier in identifiers:
+            if _mcp_server_identifier_matches(server, identifier):
+                resolved[identifier].add(server_id)
+
+    for registry_key, server in global_mcp_server_manager.get_registry().items():
+        server_id = getattr(server, "server_id", None) or registry_key
+        if not server_id:
+            continue
+        for identifier in identifiers:
+            if identifier == registry_key or _mcp_server_identifier_matches(
+                server, identifier
+            ):
+                resolved[identifier].add(server_id)
+
+    return resolved
+
+
+def _rewrite_object_permission_mcp_servers(
+    object_permission: dict,
+    identifier_to_server_ids: Dict[str, Set[str]],
+) -> None:
+    mcp_servers = object_permission.get("mcp_servers")
+    if not isinstance(mcp_servers, list):
+        return
+
+    normalized_servers: List[str] = []
+    for identifier in mcp_servers:
+        normalized_servers.extend(sorted(identifier_to_server_ids.get(identifier, [])))
+    object_permission["mcp_servers"] = _dedupe_preserving_order(normalized_servers)
+
+
+def _rewrite_object_permission_mcp_tool_permissions(
+    object_permission: dict,
+    identifier_to_server_ids: Dict[str, Set[str]],
+) -> None:
+    mcp_tool_permissions = object_permission.get("mcp_tool_permissions")
+    if not isinstance(mcp_tool_permissions, dict):
+        return
+
+    normalized_tool_permissions: Dict[str, List[str]] = {}
+    for identifier, tools in mcp_tool_permissions.items():
+        if not isinstance(tools, list):
+            tools = []
+        for server_id in sorted(identifier_to_server_ids.get(identifier, [])):
+            normalized_tool_permissions.setdefault(server_id, [])
+            normalized_tool_permissions[server_id].extend(tools)
+
+    object_permission["mcp_tool_permissions"] = {
+        server_id: _dedupe_preserving_order(tools)
+        for server_id, tools in normalized_tool_permissions.items()
+    }
+
+
+def _rewrite_object_permission_mcp_identifiers(
+    object_permission: Optional[dict],
+    identifier_to_server_ids: Dict[str, Set[str]],
+) -> None:
+    if not object_permission or not isinstance(object_permission, dict):
+        return
+
+    _rewrite_object_permission_mcp_servers(
+        object_permission=object_permission,
+        identifier_to_server_ids=identifier_to_server_ids,
+    )
+    _rewrite_object_permission_mcp_tool_permissions(
+        object_permission=object_permission,
+        identifier_to_server_ids=identifier_to_server_ids,
+    )
+
+
+def _flatten_resolved_mcp_server_ids(
+    identifier_to_server_ids: Dict[str, Set[str]],
+) -> Set[str]:
+    return {
+        server_id
+        for server_ids in identifier_to_server_ids.values()
+        for server_id in server_ids
+    }
+
+
 async def _resolve_team_allowed_mcp_servers(
     team_object_permission: "LiteLLM_ObjectPermissionTable",
+    prisma_client: Optional[PrismaClient] = None,
 ) -> Set[str]:
     """
     Resolve the full set of MCP server IDs a team has access to.
@@ -217,7 +364,15 @@
     if isinstance(raw_tool_perms, str):
         raw_tool_perms = json.loads(raw_tool_perms)
     tool_perm_servers: List[str] = list(raw_tool_perms.keys())
-    return set(direct_servers + access_group_servers + tool_perm_servers)
+    raw_servers = set(direct_servers + access_group_servers + tool_perm_servers)
+    resolved_servers = await _resolve_mcp_server_identifiers_to_ids(
+        identifiers=raw_servers,
+        prisma_client=prisma_client,
+    )
+    unresolved_servers = {
+        server_id for server_id in raw_servers if not resolved_servers.get(server_id)
+    }
+    return _flatten_resolved_mcp_server_ids(resolved_servers) | unresolved_servers
 
 
 def _get_allow_all_keys_server_ids() -> Set[str]:
@@ -231,6 +386,7 @@
 
 async def _get_team_allowed_mcp_servers(
     team_obj: Optional["LiteLLM_TeamTableCachedObj"],
+    prisma_client: Optional[PrismaClient] = None,
 ) -> Set[str]:
     """
     Get the full set of MCP server IDs a team allows.
@@ -245,7 +401,10 @@
     if team_object_permission is None:
         return set()
 
-    return await _resolve_team_allowed_mcp_servers(team_object_permission)
+    return await _resolve_team_allowed_mcp_servers(
+        team_object_permission=team_object_permission,
+        prisma_client=prisma_client,
+    )
 
 
 def _extract_requested_mcp_server_ids(
@@ -302,7 +461,8 @@
 async def validate_key_mcp_servers_against_team(
     object_permission: Optional[dict],
     team_obj: Optional["LiteLLM_TeamTableCachedObj"],
-):
+    prisma_client: Optional[PrismaClient] = None,
+) -> Optional[dict]:
     """
     Validate that MCP servers requested on a key are within the allowed scope.
 
@@ -322,17 +482,44 @@
 
     # Nothing to validate
     if not requested_servers and not requested_access_groups and not requested_toolsets:
-        return
+        return object_permission
 
     allow_all_keys_servers = _get_allow_all_keys_server_ids()
-    team_allowed_servers = await _get_team_allowed_mcp_servers(team_obj)
+    team_allowed_servers = await _get_team_allowed_mcp_servers(
+        team_obj=team_obj,
+        prisma_client=prisma_client,
+    )
 
     # Combined allowed set = team servers + allow_all_keys servers
     all_allowed_servers = team_allowed_servers | allow_all_keys_servers
 
     # Validate requested server IDs
     if requested_servers:
-        disallowed_servers = requested_servers - all_allowed_servers
+        # Normalize aliases/names before authorization. Only entries that do not
+        # resolve to a server in the DB or config registry are treated as stale.
+        identifier_to_server_ids = await _resolve_mcp_server_identifiers_to_ids(
+            identifiers=requested_servers,
+            prisma_client=prisma_client,
+        )
+        stale_identifiers = {
+            identifier
+            for identifier in requested_servers
+            if not identifier_to_server_ids.get(identifier)
+        }
+        if stale_identifiers:
+            verbose_proxy_logger.warning(
+                "validate_key_mcp_servers_against_team: ignoring stale MCP server "
+                f"identifiers (no longer in registry or DB): {sorted(stale_identifiers)}"
+            )
+        _rewrite_object_permission_mcp_identifiers(
+            object_permission=object_permission,
+            identifier_to_server_ids=identifier_to_server_ids,
+        )
+        active_requested_servers = _flatten_resolved_mcp_server_ids(
+            identifier_to_server_ids
+        )
+
+        disallowed_servers = active_requested_servers - all_allowed_servers
         if disallowed_servers:
             if team_obj is not None:
                 team_id = team_obj.team_id
@@ -404,7 +591,9 @@
                     },
                 )
 
+    return object_permission
 
+
 def _extract_requested_search_tools(object_permission: Optional[dict]) -> List[str]:
     """Return search_tool_name values from a key's object_permission dict."""
     if not object_permission or not isinstance(object_permission, dict):

diff --git a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py
--- a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py
+++ b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py
@@ -152,6 +152,34 @@
     return mock_team
 
 
+def _make_mock_mcp_server(
+    server_id: str,
+    alias=None,
+    server_name=None,
+    name=None,
+):
+    mock_server = MagicMock()
+    mock_server.server_id = server_id
+    mock_server.alias = alias
+    mock_server.server_name = server_name
+    mock_server.name = name or server_name or alias or server_id
+    return mock_server
+
+
+def _make_mock_mcp_manager(*existing_ids: str, servers=None):
+    """
+    Return a mock global_mcp_server_manager with a registry containing every
+    explicit server plus simple server objects for every ID in *existing_ids.
+    """
+    mock_mgr = MagicMock()
+    server_objs = {server.server_id: server for server in (servers or [])}
+    for server_id in existing_ids:
+        server_objs.setdefault(server_id, _make_mock_mcp_server(server_id))
+    mock_mgr.get_registry.return_value = server_objs
+    mock_mgr.get_mcp_server_by_id.side_effect = lambda sid: server_objs.get(sid)
+    return mock_mgr
+
+
 @pytest.mark.asyncio
 @patch(
     "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
@@ -172,6 +200,10 @@
 
 @pytest.mark.asyncio
 @patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager("server-1", "server-2"),
+)
+@patch(
     "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
     return_value=set(),
 )
@@ -193,6 +225,10 @@
 
 @pytest.mark.asyncio
 @patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager("server-1", "server-outside"),
+)
+@patch(
     "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
     return_value=set(),
 )
@@ -204,7 +240,7 @@
 async def test_validate_key_servers_outside_team_scope_raises(
     mock_access_groups, mock_allow_all
 ):
-    """Key requests servers NOT in the team's scope — should raise 403."""
+    """Key requests a server that exists but is NOT in the team's scope — should raise 403."""
     team_obj = _make_team_obj(mcp_servers=["server-1"])
     with pytest.raises(HTTPException) as exc_info:
         await validate_key_mcp_servers_against_team(
@@ -217,6 +253,10 @@
 
 @pytest.mark.asyncio
 @patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager("server-1", "global-server"),
+)
+@patch(
     "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
     return_value={"global-server"},
 )
@@ -238,6 +278,10 @@
 
 @pytest.mark.asyncio
 @patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager("global-server"),
+)
+@patch(
     "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
     return_value={"global-server"},
 )
@@ -248,7 +292,6 @@
 )
 async def test_validate_no_team_only_allow_all_keys(mock_access_groups, mock_allow_all):
     """Key without a team can only use allow_all_keys servers."""
-    # This should pass — requesting a global server without a team
     await validate_key_mcp_servers_against_team(
         object_permission={"mcp_servers": ["global-server"]},
         team_obj=None,
@@ -257,6 +300,10 @@
 
 @pytest.mark.asyncio
 @patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager("private-server"),
+)
+@patch(
     "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
     return_value={"global-server"},
 )
@@ -268,7 +315,7 @@
 async def test_validate_no_team_non_global_server_raises(
     mock_access_groups, mock_allow_all
 ):
-    """Key without a team requesting a non-global server — should raise 403."""
+    """Key without a team requesting an existing non-global server — should raise 403."""
     with pytest.raises(HTTPException) as exc_info:
         await validate_key_mcp_servers_against_team(
             object_permission={"mcp_servers": ["private-server"]},
@@ -280,6 +327,10 @@
 
 @pytest.mark.asyncio
 @patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager("some-server"),
+)
+@patch(
     "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
     return_value=set(),
 )
@@ -303,6 +354,10 @@
 
 @pytest.mark.asyncio
 @patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager("server-outside"),
+)
+@patch(
     "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
     return_value=set(),
 )
@@ -314,7 +369,7 @@
 async def test_validate_tool_permissions_validated_against_team(
     mock_access_groups, mock_allow_all
 ):
-    """Server IDs in mcp_tool_permissions should also be validated."""
+    """Server IDs in mcp_tool_permissions should also be validated when they exist."""
     team_obj = _make_team_obj(mcp_servers=["server-1"])
     with pytest.raises(HTTPException) as exc_info:
         await validate_key_mcp_servers_against_team(
@@ -327,6 +382,10 @@
 
 @pytest.mark.asyncio
 @patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager(),  # empty registry — all IDs are stale
+)
+@patch(
     "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
     return_value=set(),
 )
@@ -335,6 +394,204 @@
     new_callable=AsyncMock,
     return_value=[],
 )
+async def test_validate_stale_mcp_server_ids_are_silently_dropped(
+    mock_access_groups, mock_allow_all
+):
+    """
+    Stale MCP server IDs (servers deleted and no longer in the registry) must not
+    block a key save with a 403. They are silently stripped instead.
+
+    Scenario: key/team were configured with S1+S2, those servers were deleted and
+    replaced with S3+S4. The UI form still holds S1+S2 in its local state. Saving
+    should succeed, not raise a 403.
+    """
+    team_obj = _make_team_obj(mcp_servers=["s3", "s4"])
+    await validate_key_mcp_servers_against_team(
+        object_permission={"mcp_servers": ["s1-stale", "s2-stale"]},
+        team_obj=team_obj,
+    )  # Must not raise
+
+
+@pytest.mark.asyncio
+@patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager(),  # empty registry — all IDs are stale
+)
+@patch(
+    "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
+    return_value=set(),
+)
+@patch(
+    "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
+    new_callable=AsyncMock,
+    return_value=[],
+)
+async def test_validate_stale_ids_in_mcp_tool_permissions_silently_dropped(
+    mock_access_groups, mock_allow_all
+):
+    """
+    Stale server IDs referenced only as keys in mcp_tool_permissions (not in
+    mcp_servers) must also be silently stripped rather than raising a 403.
+    """
+    team_obj = _make_team_obj(mcp_servers=["s3", "s4"])
+    object_permission = {"mcp_tool_permissions": {"s1-stale": ["tool1"]}}
+    await validate_key_mcp_servers_against_team(
+        object_permission=object_permission,
+        team_obj=team_obj,
+    )  # Must not raise
+    assert object_permission["mcp_tool_permissions"] == {}
+
+
+@pytest.mark.asyncio
+@patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager(),  # empty registry — all IDs are stale
+)
+@patch(
+    "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
+    return_value=set(),
+)
+@patch(
+    "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
+    new_callable=AsyncMock,
+    return_value=[],
+)
+async def test_validate_stale_mcp_server_ids_are_removed_from_object_permission(
+    mock_access_groups, mock_allow_all
+):
+    team_obj = _make_team_obj(mcp_servers=["s3", "s4"])
+    object_permission = {"mcp_servers": ["s1-stale", "s2-stale"]}
+    await validate_key_mcp_servers_against_team(
+        object_permission=object_permission,
+        team_obj=team_obj,
+    )
+    assert object_permission["mcp_servers"] == []
+
+
+@pytest.mark.asyncio
+@patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager(
+        "team-server",
+        servers=[
+            _make_mock_mcp_server(
+                "private-server-id",
+                alias="private-alias",
+                server_name="Private Server",
+            )
+        ],
+    ),
+)
+@patch(
+    "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
+    return_value=set(),
+)
+@patch(
+    "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
+    new_callable=AsyncMock,
+    return_value=[],
+)
+async def test_validate_mcp_server_alias_outside_team_scope_raises(
+    mock_access_groups, mock_allow_all
+):
+    team_obj = _make_team_obj(mcp_servers=["team-server"])
+    with pytest.raises(HTTPException) as exc_info:
+        await validate_key_mcp_servers_against_team(
+            object_permission={"mcp_servers": ["private-alias"]},
+            team_obj=team_obj,
+        )
+    assert exc_info.value.status_code == 403
+    assert "private-server-id" in str(exc_info.value.detail)
+
+
+@pytest.mark.asyncio
+@patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager(
+        servers=[
+            _make_mock_mcp_server(
+                "allowed-server-id",
+                alias="allowed-alias",
+                server_name="Allowed Server",
+            )
+        ],
+    ),
+)
+@patch(
+    "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
+    return_value=set(),
+)
+@patch(
+    "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
+    new_callable=AsyncMock,
+    return_value=[],
+)
+async def test_validate_mcp_server_alias_is_normalized_before_save(
+    mock_access_groups, mock_allow_all
+):
+    team_obj = _make_team_obj(mcp_servers=["allowed-server-id"])
+    object_permission = {
+        "mcp_servers": ["allowed-alias"],
+        "mcp_tool_permissions": {"Allowed Server": ["tool1"], "stale-id": ["tool2"]},
+    }
+
+    await validate_key_mcp_servers_against_team(
+        object_permission=object_permission,
+        team_obj=team_obj,
+    )
+
+    assert object_permission["mcp_servers"] == ["allowed-server-id"]
+    assert object_permission["mcp_tool_permissions"] == {"allowed-server-id": ["tool1"]}
+
+
+@pytest.mark.asyncio
+@patch(
+    "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
+    new=_make_mock_mcp_manager(),
+)
+@patch(
+    "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
+    return_value=set(),
+)
+@patch(
+    "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
+    new_callable=AsyncMock,
+    return_value=[],
+)
+async def test_validate_db_mcp_server_alias_outside_team_scope_raises_when_registry_empty(
+    mock_access_groups, mock_allow_all
+):
+    mock_prisma_client = MagicMock()
+    mock_db_server = MagicMock()
+    mock_db_server.server_id = "private-server-id"
+    mock_db_server.alias = "private-alias"
+    mock_db_server.server_name = "Private Server"
+    mock_prisma_client.db.litellm_mcpservertable.find_many = AsyncMock(
+        return_value=[mock_db_server]
+    )
+
+    team_obj = _make_team_obj(mcp_servers=[])
+    with pytest.raises(HTTPException) as exc_info:
+        await validate_key_mcp_servers_against_team(
+            object_permission={"mcp_servers": ["private-alias"]},
+            team_obj=team_obj,
+            prisma_client=mock_prisma_client,
+        )
+
+    assert exc_info.value.status_code == 403
+    assert "private-server-id" in str(exc_info.value.detail)
+
+
+@pytest.mark.asyncio
+@patch(
+    "litellm.proxy.management_helpers.object_permission_utils._get_allow_all_keys_server_ids",
+    return_value=set(),
+)
+@patch(
+    "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.MCPRequestHandler._get_mcp_servers_from_access_groups",
+    new_callable=AsyncMock,
+    return_value=[],
+)
 async def test_validate_access_groups_within_team_scope(
     mock_access_groups, mock_allow_all
 ):

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

Reviewed by Cursor Bugbot for commit e01e698. Configure here.

identifier
for identifier in requested_servers
if not identifier_to_server_ids.get(identifier)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unused helper function never called anywhere in codebase

Low Severity

The _get_stale_mcp_server_ids function is defined but never called. Its logic for identifying stale MCP server identifiers was inlined within validate_key_mcp_servers_against_team, leaving this function as dead code.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e01e698. Configure here.

await validate_key_mcp_servers_against_team(
object_permission=data_json.get("object_permission"),
team_obj=team_table,
prisma_client=prisma_client,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Key generation discards normalized object permission return value

Low Severity

In the key generation path, the return value of validate_key_mcp_servers_against_team is discarded (await without assignment), while the key update path explicitly captures and applies it via LiteLLM_ObjectPermissionBase(**normalized_object_permission). The generation path relies silently on in-place dict mutation propagating through data_json, creating an asymmetric and fragile pattern that would silently break if the function ever stops mutating in-place.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e01e698. Configure here.

…mission on key generation

Co-authored-by: Yassin Kortam <[email protected]>
@mateo-berri

Copy link
Copy Markdown
Collaborator

@greptileai

…th return contract

validate_key_mcp_servers_against_team now returns the (normalized) object_permission, and the key-generation helper assigns that return value back into the request data. The two key-generation tests stubbed the function with a bare AsyncMock, whose default MagicMock return value clobbered object_permission and skipped permission-record creation. Make the stubs pass object_permission through unchanged.
@mateo-berri

Copy link
Copy Markdown
Collaborator

@greptileai

The key-update path reconstructs data.object_permission from a full
model_dump(), which marks every field as set. Downstream
model_dump(exclude_unset=True) then emits models/blocked_tools/
search_tools as None, and those are non-nullable array columns, so the
Prisma write fails whenever the UI submits object_permission with only a
subset of fields populated (e.g. a TPM/RPM-only edit). Build the dict
with exclude_unset=True so the normalized object_permission keeps the
caller's original field set.
@mateo-berri

Copy link
Copy Markdown
Collaborator

@greptileai

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM; thanks!

@mateo-berri mateo-berri merged commit 83fcaca into litellm_internal_staging May 30, 2026
115 of 118 checks passed
@mateo-berri mateo-berri deleted the litellm_fix_stale_mcp_virtual_key branch May 30, 2026 03:50
fzowl pushed a commit to fzowl/litellm that referenced this pull request Jun 24, 2026
* fix(mcp): ignore stale server ids during key permission validation

Prevent virtual key save failures when object_permission still includes MCP server IDs that were deleted from the registry. The validator now drops stale IDs before team scope checks and adds coverage for stale-ID and active unauthorized-ID behavior.

Co-authored-by: Cursor <[email protected]>

* fix(mcp): use DB find_many to detect stale server IDs, add tool-permission test

Addresses greptile feedback on PR BerriAI#29128:

P1 – Race-condition / authoritative-signal concern:
  Extract _get_stale_mcp_server_ids() helper that performs a single
  DB find_many (LiteLLM_MCPServerTable) when prisma_client is available,
  making server-existence checks authoritative even during in-memory
  registry warm-up. Falls back to the in-memory registry only when no
  DB client is present (config-only deployments / unit tests).
  Pass prisma_client through both call sites in key_management_endpoints.

P2 – Missing test coverage for mcp_tool_permissions:
  Add test_validate_stale_ids_in_mcp_tool_permissions_silently_dropped
  to confirm stale server IDs that appear only as keys in
  mcp_tool_permissions are also stripped without raising 403.

Co-authored-by: Cursor <[email protected]>

* fix(mcp): also check in-memory registry when DB is present to avoid misclassifying config-file servers as stale

Config-file MCP servers are never written to LiteLLM_MCPServerTable, so a
DB-only query would classify them as stale and silently drop their IDs from
the authorization check, allowing unauthorized key access.

Fix: treat a server ID as stale only when it is absent from BOTH the DB and
the in-memory registry (which holds config-file servers).

Co-authored-by: Cursor <[email protected]>

* fix(mcp): normalize server aliases on key save

Co-authored-by: Sameer Kankute <[email protected]>

* fix: remove unused stale MCP helper and capture normalized object_permission on key generation

Co-authored-by: Yassin Kortam <[email protected]>

* test(mcp): keep validate_key_mcp_servers_against_team stub in sync with return contract

validate_key_mcp_servers_against_team now returns the (normalized) object_permission, and the key-generation helper assigns that return value back into the request data. The two key-generation tests stubbed the function with a bare AsyncMock, whose default MagicMock return value clobbered object_permission and skipped permission-record creation. Make the stubs pass object_permission through unchanged.

* fix(mcp): preserve provided object_permission fields on key update

The key-update path reconstructs data.object_permission from a full
model_dump(), which marks every field as set. Downstream
model_dump(exclude_unset=True) then emits models/blocked_tools/
search_tools as None, and those are non-nullable array columns, so the
Prisma write fails whenever the UI submits object_permission with only a
subset of fields populated (e.g. a TPM/RPM-only edit). Build the dict
with exclude_unset=True so the normalized object_permission keeps the
caller's original field set.

---------

Co-authored-by: Cursor <[email protected]>
Co-authored-by: Sameer Kankute <[email protected]>
Co-authored-by: Yassin Kortam <[email protected]>
Co-authored-by: mateo-berri <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants