diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 19f508e6af9..b7ac6a8c325 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -624,7 +624,9 @@ class MCPRequestHandler: Permission hierarchy (all rules are intersections): 1. Get allowed servers from key permissions - 2. Get allowed servers from team permissions (key inherits from team, or intersection) + 2. Get allowed servers from team permissions (key inherits from team, or + intersection; or inherits nothing when require_key_mcp_access_defined + is enabled, making the team a ceiling rather than a default) 3. Get allowed servers from end_user permissions (intersected if set) 4. Get allowed servers from agent permissions (intersected if set) 5. Get allowed servers from org permissions — org acts as a ceiling: if the org @@ -677,7 +679,16 @@ class MCPRequestHandler: if not team_set: base = key_set # no team restriction elif not key_set: - base = team_set # key has no own perms → inherits team + # A key that grants no MCP servers of its own inherits the + # team's by default. With require_key_mcp_access_defined the + # team is a ceiling rather than a default, so the key must + # grant servers explicitly (or via an access group) to reach + # any — it inherits none. + base = ( + set() + if general_settings.get("require_key_mcp_access_defined", False) + else team_set + ) else: base = key_set & team_set # both restrict → intersect diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 20fd5c1d86a..b3b0e8adcf6 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -170,6 +170,104 @@ class TestMCPRequestHandler: mock_key_servers.assert_called_once_with(user_api_key_auth) mock_team_servers.assert_called_once_with(user_api_key_auth) + @pytest.mark.parametrize( + "require_key_mcp_access_defined,expected", + [ + # Default (flag off): a key with no MCP scope of its own inherits + # the team's servers. + (False, ["team_server1", "team_server2"]), + # Flag on: the team is a ceiling, not a default — the key inherits + # nothing and must grant servers explicitly. + (True, []), + ], + ) + async def test_require_key_mcp_access_defined_gates_team_inheritance( + self, require_key_mcp_access_defined, expected + ): + """The require_key_mcp_access_defined general setting flips an empty key + from inheriting its team's MCP servers (default) to inheriting none.""" + auth = UserAPIKeyAuth( + api_key="test-key", user_id="test-user", team_id="test-team" + ) + with ( + patch.object( + MCPRequestHandler, + "_get_allowed_mcp_servers_for_key", + new_callable=AsyncMock, + return_value=[], + ), + patch.object( + MCPRequestHandler, + "_get_allowed_mcp_servers_for_team", + new_callable=AsyncMock, + return_value=["team_server1", "team_server2"], + ), + patch.object( + MCPRequestHandler, + "_get_key_access_group_mcp_server_extras", + new_callable=AsyncMock, + return_value=[], + ), + patch( + "litellm.proxy.proxy_server.general_settings", + {"require_key_mcp_access_defined": require_key_mcp_access_defined}, + ), + ): + result = await MCPRequestHandler.get_allowed_mcp_servers(auth) + + assert sorted(result) == sorted(expected) + + @pytest.mark.parametrize( + "key_servers,grants,expected,scenario", + [ + # Explicit key subset is still honored under the flag (intersected + # with the team ceiling) — the flag only removes empty-key inheritance. + (["team_server1"], [], ["team_server1"], "explicit_subset_survives"), + # An access-group grant is the escape hatch: it surfaces even though + # the key inherits nothing from the team. + ([], ["granted_server"], ["granted_server"], "access_group_grant_survives"), + ], + ) + async def test_require_key_mcp_access_defined_preserves_explicit_grants( + self, key_servers, grants, expected, scenario + ): + """With require_key_mcp_access_defined on, a key still reaches servers it + grants explicitly or via an access group — only blanket team inheritance + is removed.""" + auth = UserAPIKeyAuth( + api_key="test-key", + user_id="test-user", + team_id="test-team", + access_group_ids=["grp"] if grants else [], + ) + with ( + patch.object( + MCPRequestHandler, + "_get_allowed_mcp_servers_for_key", + new_callable=AsyncMock, + return_value=key_servers, + ), + patch.object( + MCPRequestHandler, + "_get_allowed_mcp_servers_for_team", + new_callable=AsyncMock, + return_value=["team_server1", "team_server2"], + ), + patch.object( + MCPRequestHandler, + "_get_key_access_group_mcp_server_extras", + new_callable=AsyncMock, + return_value=grants, + ), + patch( + "litellm.proxy.proxy_server.general_settings", + {"require_key_mcp_access_defined": True}, + ), + ): + result = await MCPRequestHandler.get_allowed_mcp_servers(auth) + + assert sorted(result) == sorted(expected) + @pytest.mark.parametrize("team_servers", [[], ["team_server1", "team_server2"]]) async def test_no_mcp_servers_sentinel_returns_empty(self, team_servers): """A key scoped to the no-mcp-servers sentinel resolves to zero servers,