fix(mcp): apply convention ceiling to echoed caller team and read overrides before field delete

Co-Authored-By: bot_apk <apk@cognition.ai>
This commit is contained in:
Devin AI 2026-09-25 04:22:07 +00:00
parent b7a82bb7b9
commit 1f48e23e31
3 changed files with 48 additions and 13 deletions

View file

@ -3312,27 +3312,18 @@ class MCPRequestHandler:
axis twin of ``_apply_agent_caller_ceiling``, so the headers only ever narrow. Denies every tool
on the server when the caller's team cannot be loaded, since a caller we cannot resolve must not
read as unrestricted."""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
caller_auth: Final = agent_caller_auth(user_api_key_auth) if user_api_key_auth else None
if caller_auth is None:
return allowed_tools
try:
team_obj_perm: Final = await MCPRequestHandler._get_team_object_permission(caller_auth)
team_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server(team_obj_perm, server_id)
resolved_inventory: Final = await MCPRequestHandler._manager_inventory(server_id)
team_tools: Final = await MCPRequestHandler._row_level_tools(team_obj_perm, server_id, resolved_inventory)
except Exception as e: # noqa: BLE001 # an unresolved caller team must deny, not widen
verbose_logger.warning(
"MCP agent caller team tool ceiling unresolvable, denying tools on %r: %s", server_id, e
)
return ()
team_direct_tools: Final = (
global_mcp_server_manager.expand_tool_permissions(team_obj_perm.mcp_tool_permissions).get(server_id)
if team_obj_perm
else None
)
team_tools: Final = MCPRequestHandler._union_tool_grants(team_direct_tools, team_toolset_tools)
team_capped: Final = (
allowed_tools
if team_tools is None
@ -3340,7 +3331,9 @@ class MCPRequestHandler:
if allowed_tools is None
else tuple(frozenset(allowed_tools) & frozenset(team_tools))
)
return await MCPRequestHandler._apply_user_tool_ceiling(team_capped, server_id, caller_auth)
return await MCPRequestHandler._apply_user_tool_ceiling(
team_capped, server_id, caller_auth, inventory=resolved_inventory
)
@staticmethod
async def _apply_end_user_tool_ceiling(

View file

@ -4384,6 +4384,48 @@ class TestAgentMCPPermissions:
assert await MCPRequestHandler.get_allowed_tools_for_server("server-a", agent_key) == ["ask_wiki_question"]
assert await MCPRequestHandler.get_allowed_tools_for_server("server-c", agent_key) == []
async def test_agent_key_acting_for_a_user_is_capped_at_the_callers_convention_tools(self):
"""A converted caller team granting server-a with no tool list still caps the agent to the
convention set: inventory non-deletes minus explicit denies."""
agent_key = self._agent_key_acting_for(user_id="alice", team_id="callers")
async def caller_team_permission(
user_api_key_auth: UserAPIKeyAuth | None = None,
) -> LiteLLM_ObjectPermissionTable | None:
assert user_api_key_auth is not None
if user_api_key_auth.team_id != "callers":
return None
return LiteLLM_ObjectPermissionTable(
object_permission_id="perm-callers",
mcp_servers=["server-a"],
mcp_permission_version=1,
)
with (
patch.object( # test-quality-ok: the level loaders read proxy_server globals with no injection seam
MCPRequestHandler, "_get_key_object_permission", return_value=None
),
patch.object( # test-quality-ok: same seam, keyed by which team is being asked about
MCPRequestHandler, "_get_team_object_permission", AsyncMock(side_effect=caller_team_permission)
),
patch.object( # test-quality-ok: same seam
MCPRequestHandler, "_get_user_object_permission", AsyncMock(return_value=None)
),
patch.object( # test-quality-ok: agent object_permission lookup hits the DB, not under test here
MCPRequestHandler, "_get_agent_object_permission", AsyncMock(return_value=None)
),
patch.object( # test-quality-ok: the discovered catalog is process state with no injection seam
MCPRequestHandler,
"_manager_inventory",
AsyncMock(
return_value={"read_wiki_structure": "reads a page", "delete_wiki_page": "deletes a page"}
),
),
):
assert await MCPRequestHandler.get_allowed_tools_for_server("server-a", agent_key) == [
"read_wiki_structure"
]
async def test_agent_key_not_acting_for_anyone_ignores_the_caller_tool_ceiling(self):
agent_key = UserAPIKeyAuth(api_key="agent-key", user_id="agent-owner", team_id="agent-team", agent_id="agent-1")

View file

@ -484,7 +484,6 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
if (toolsets && toolsets.length > 0) {
formValues.object_permission.mcp_toolsets = toolsets;
}
delete formValues.allowed_mcp_servers_and_groups;
}
if (formValues.mcp_tool_permissions && Object.keys(formValues.mcp_tool_permissions).length > 0) {
@ -505,6 +504,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
}
delete formValues.mcp_tool_overrides;
}
delete formValues.allowed_mcp_servers_and_groups;
}
// Transform allowed_mcp_access_groups into object_permission