diff --git a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py index 53db6fbf9d1..bc1dea50a74 100644 --- a/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py +++ b/litellm/proxy/_experimental/mcp_server/gateway_dcr_flow.py @@ -154,7 +154,7 @@ PROXY_API_AUDIENCE: Final[SessionAudience] = "proxy_api" proxy base URL itself as the RFC 8707 ``resource``: the grant then mints the proxy-API CLI credential that LLM routes accept, instead of the MCP-only session pair.""" -ProxyCredentialMintFailure = Literal[ReloadUserFailure, "not_a_member"] +ProxyCredentialMintFailure = Literal[ReloadUserFailure, "not_a_member", "team_required"] class MintedProxyCredential(BaseModel): @@ -167,7 +167,8 @@ class MintedProxyCredential(BaseModel): class MintProxyCredential(Protocol): """Injected proxy-API credential minter ``(user_id, team_id)``: reloads the user live, - checks team membership, and mints the same credential ``lite login`` mints.""" + checks team membership, refuses a teamless grant for a user who has teams to pick from, + and mints the same credential ``lite login`` mints.""" def __call__( self, user_id: str, team_id: str | None, / @@ -924,6 +925,10 @@ def _mint_failure_response(failure: ProxyCredentialMintFailure) -> Response: return _oauth_error( 400, "invalid_grant", "the user is no longer a member of the team this grant was issued for" ) + case "team_required": + return _oauth_error( + 400, "invalid_grant", "this user belongs to a team; sign in again and pick the team for this credential" + ) case "unavailable" | "unresolvable" | "no_active_key": return _reload_failure_response(failure) case _: diff --git a/litellm/proxy/_experimental/mcp_server/proxy_api_credentials.py b/litellm/proxy/_experimental/mcp_server/proxy_api_credentials.py index a24afc529bd..27d0ebbd5e6 100644 --- a/litellm/proxy/_experimental/mcp_server/proxy_api_credentials.py +++ b/litellm/proxy/_experimental/mcp_server/proxy_api_credentials.py @@ -45,7 +45,11 @@ async def mint_proxy_credential( live, so a team the user left between consent and redemption (or between refreshes) refuses the grant instead of minting a credential attributed to a team they are no longer on. The team is exactly the one the consent page sealed into the grant; nothing - is picked on the user's behalf here, so a refresh can never move the credential. The + is picked on the user's behalf here, so a refresh can never move the credential, and a + grant that names no team is refused for a user with a live team to pick from (the same + rule ``lite login`` applies), so a user cannot step outside their teams' attribution by + posting the consent form without one. Memberships whose team rows are gone count as no + team at all, the way ``lite login`` treats them, so they can never lock a user out. The user row handed to the minter carries no team list, exactly like ``lite login``'s, so the minter's own first-team fallback stays inert.""" user: Final = await load_active_user_by_id(user_id) @@ -55,9 +59,11 @@ async def mint_proxy_credential( return "no_active_key" if team_id is not None and team_id not in user.teams: return "not_a_member" - details: Final = await _team_details(user.teams) if team_id is not None else () + details: Final = await _team_details(user.teams) if user.teams else () if details is None: return "unavailable" + if team_id is None and any(detail.team_id is not None for detail in details): + return "team_required" selected: Final = selected_cli_sso_team_detail(details, team_id) if selected is None: return "not_a_member" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py index 4e665c552f3..6c2b1274aa4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_gateway_dcr_flow.py @@ -1414,6 +1414,7 @@ async def test_native_code_without_a_minter_is_refused_server_side(): "failure, status, error", [ ("not_a_member", 400, "invalid_grant"), + ("team_required", 400, "invalid_grant"), ("no_active_key", 400, "invalid_grant"), ("unavailable", 503, "temporarily_unavailable"), ("unresolvable", 500, "server_error"), diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_proxy_api_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_proxy_api_credentials.py index 5d7c2b91046..8bb8bdada7d 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_proxy_api_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_proxy_api_credentials.py @@ -74,16 +74,26 @@ async def test_mint_refuses_a_user_without_a_role(load_user, fetch_teams): @pytest.mark.asyncio -async def test_mint_without_a_chosen_team_never_picks_one_for_a_team_member(load_user, fetch_teams): +async def test_mint_refuses_a_teamless_grant_for_a_team_member(load_user, fetch_teams): """The consent page is the only place a team gets chosen, so a grant sealed without one - stays unscoped on every redemption and refresh instead of drifting onto the first team.""" + is refused for a user with teams instead of minting an unscoped credential or drifting + onto the first team, on redemption and on every refresh alike.""" + assert await mint_proxy_credential("u1", None) == "team_required" + load_user.assert_awaited_once_with("u1") + fetch_teams.assert_awaited_once_with(ANY, ["team-a", "team-b"]) + + +@pytest.mark.asyncio +async def test_mint_for_a_member_of_only_deleted_teams_is_unscoped(load_user, fetch_teams): + """Memberships whose team rows are gone offer nothing to pick, so they never lock the + user out of signing in.""" + load_user.return_value = _user(teams=["team-gone"]) + fetch_teams.return_value = () minted = await mint_proxy_credential("u1", None) assert isinstance(minted, MintedProxyCredential) assert minted.user_id == "u1" assert minted.team_id is None assert minted.expires_in == CLI_JWT_EXPIRATION_HOURS * 3600 - load_user.assert_awaited_once_with("u1") - fetch_teams.assert_not_awaited() decoded = _decoded(minted) assert decoded.user_id == "u1" assert decoded.team_id is None