Skip to content

feat(mcp): union team-inherited MCP grants across all of a user's teams for keyless admission - #33191

Merged
tin-berri merged 2 commits into
litellm_lit3637_session_admissionfrom
litellm_lit3637_team_union
Jul 17, 2026
Merged

feat(mcp): union team-inherited MCP grants across all of a user's teams for keyless admission#33191
tin-berri merged 2 commits into
litellm_lit3637_session_admissionfrom
litellm_lit3637_team_union

Conversation

@tin-berri

Copy link
Copy Markdown
Contributor

Relevant issues

Stacked on #33190 (session admission arm) -> #33189 -> #33188 -> #33182 -> #33174. The base of this PR is litellm_lit3637_session_admission; review and merge after those

Linear ticket

Part of LIT-3637 (PR 4b of the stack: makes the aggregate flow honor team-based server access, which is how servers are meant to be shared)

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Delays in PR merge?

If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).

Screenshots / Proof of Fix

This is a permission-resolution change with no request surface of its own, so the contract is pinned by unit tests that drive _get_allowed_mcp_servers_for_team with a user in two teams and assert the union, and separately assert a key-based caller with a team_id still sees only that one team. The interactive effect (a signed-in user reaching a server granted to their team) is exercised by the aggregate flow on a live proxy

pytest tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py -q
230 passed

Type

🐛 Bug Fix

Changes

A UserAPIKeyAuth can name at most one team, so the MCP team-grant resolver read grants from that single team_id. For a key that is fine, the key names its team. For a user-subject caller admitted without a key (the gateway DCR session bearer from the PR below this, and the already-merged bridge user-envelope) the auth carries a user_id and no team, so team-inherited grants resolved to nothing and a signed-in user saw only servers granted to them directly. Servers are meant to be shared by assigning teams rather than individuals, so in practice that user saw nothing; _reload_admitted_user documented this single-team limitation as a follow-up, and this is that follow-up

The per-team computation is factored into _allowed_mcp_servers_for_single_team, unchanged in what one team grants. _get_allowed_mcp_servers_for_team now asks _team_ids_for_mcp_grant which teams to union: a caller with a team_id (every key-based caller, and the UI-session sentinel) resolves to that single team exactly as before, so key auth is byte-identical; only a keyless caller with a user_id and no team_id fans out to the user's full team list, resolved once from the live user record, and the grants are unioned. The api_key is None gate is what keeps a personal key from silently gaining its owner's every-team access; only an identity-only admission unions

Team resolution failures (no database, a missing user, a lookup blip) return no teams, so a transient failure narrows access rather than raising or over-granting; the caller's own direct grants still apply. The org ceiling, end-user, and agent intersections downstream are untouched, so unioning teams cannot lift a cap those layers impose

QA runbook

With the aggregate DCR flow running (mcp_gateway_dcr: true), grant an MCP server to a team, add a user to that team, sign that user in through the flow to get a session bearer, and confirm tools/list at the aggregate /mcp now includes that server. Confirm a virtual key scoped to one team still lists only that team's servers (unchanged). The unit tests cover the union, the single-team key path, the keyless-with-explicit-team path, the no-teams case, and the UI sentinel

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Expands team-based MCP grant resolution so a keyless user-subject caller (DCR session bearer, bridge user-envelope) inherits MCP server access from all of their teams rather than just the single team_id on the UserAPIKeyAuth object. Key-based callers are untouched — the api_key is not None gate in _team_ids_for_mcp_grant routes them into the existing single-team path.

  • _get_allowed_mcp_servers_for_team is refactored into three helpers: _team_ids_for_mcp_grant (routing logic), _resolve_user_team_ids (live DB/cache user lookup), and _allowed_mcp_servers_for_single_team (unchanged per-team logic extracted from the old method); the outer method fans out with asyncio.gather and unions results.
  • All failure modes (no DB, missing user, lookup error) narrow access to [] rather than raising or over-granting, and the UI-sentinel team ID is filtered at every relevant branch.
  • New TestUserSubjectTeamUnion tests cover the union path, the single-team key path, the keyless-with-explicit-team_id path, the no-teams case, and the UI sentinel \u2014 all fully mocked.

Confidence Score: 4/5

Safe to merge for key-based callers (zero code-path change); keyless user-subject callers gain their intended team grants with a well-contained fail-safe.

The refactoring is clean and the behavioral invariant for key-based callers is well-protected. The two observations are about asyncio.gather defensiveness (the broad exception handler in the inner coroutine makes this a non-issue in practice) and a second get_user_object lookup per keyless request that relies on the cache to be cheap. Neither affects correctness today.

The new _resolve_user_team_ids path in user_api_key_auth_mcp.py is worth a second read, specifically the interaction between the asyncio.gather fan-out and the exception handling contract of _allowed_mcp_servers_for_single_team.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py Refactors _get_allowed_mcp_servers_for_team to fan-out across all of a keyless user's teams via asyncio.gather and a new _resolve_user_team_ids DB lookup; key-based callers are unchanged
tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py Adds TestUserSubjectTeamUnion covering union, single-team key path, explicit team_id keyless path, empty-teams case, and UI sentinel; all mocked, no live network calls

Reviews (1): Last reviewed commit: "feat(mcp): union team-inherited MCP gran..." | Re-trigger Greptile

Comment on lines +1613 to +1618
per_team = await asyncio.gather(
*(
MCPRequestHandler._allowed_mcp_servers_for_single_team(team_id, user_api_key_auth)
for team_id in team_ids
)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 asyncio.gather will propagate an uncaught exception from any team coroutine

_allowed_mcp_servers_for_single_team wraps all of its logic in a broad except Exception block, so in practice nothing escapes. However, if that guard ever develops a gap (e.g., a BaseException subclass like asyncio.CancelledError, or an error raised during the generator expression itself), the gather without return_exceptions=True will cancel remaining tasks and surface the exception to the caller, which has no error handling at this level — potentially returning a 500 to the MCP client. Using return_exceptions=True and filtering out Exception instances before the set union would make the fan-out robust regardless of what _allowed_mcp_servers_for_single_team throws.

Comment on lines +1639 to +1666
async def _resolve_user_team_ids(user_id: str, user_api_key_auth: UserAPIKeyAuth) -> list[str]:
"""The distinct team ids a user belongs to, from the live user record. Returns [] on
no DB, a missing user, or any resolution failure so a lookup blip narrows access
rather than raising; the caller's direct grants still apply."""
from litellm.proxy.auth.auth_checks import get_user_object
from litellm.proxy.proxy_server import (
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)

if prisma_client is None:
return []
try:
user_object = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
parent_otel_span=user_api_key_auth.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
except Exception as e: # noqa: BLE001 # a team-resolution blip narrows access, never raises
verbose_logger.warning(f"Failed to resolve user teams for MCP grant: {str(e)}")
return []
if user_object is None or not user_object.teams:
return []
return list(dict.fromkeys(t for t in user_object.teams if t and t != UI_TEAM_ID))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Extra get_user_object call on every keyless MCP request

For a keyless user-subject caller this path fires get_user_object to look up team membership. The user was already fetched and placed in user_api_key_cache during the upstream admission check, so a warm-cache hit is cheap. On a cold-cache miss, however, this adds a second DB round-trip per request specifically in the MCP auth path, on top of the N parallel get_team_object calls triggered afterward. If UserAPIKeyAuth is ever extended to carry the user's team list (it already carries user_id), this fetch could be eliminated; for now, worth noting the per-request cost for deployments with a large team count per user.

Rule Used: What: Avoid creating new database requests or Rout... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.74359% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...erimental/mcp_server/auth/user_api_key_auth_mcp.py 89.74% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a gap in MCP server access for keyless user-subject callers (gateway DCR session bearers and bridge user-envelopes): previously _get_allowed_mcp_servers_for_team read grants from the single team_id on the UserAPIKeyAuth object, but keyless callers carry no team_id, so team-inherited grants resolved to nothing. The fix unions grants across all teams the user belongs to, fetched once from the live user record.

  • Refactors _get_allowed_mcp_servers_for_team into three focused helpers: _team_ids_for_mcp_grant (decides which teams to query), _resolve_user_team_ids (fetches a keyless user's full team list from DB/cache), and _allowed_mcp_servers_for_single_team (unchanged logic for one team's grants).
  • Key-based callers (any caller with api_key set) continue through the original single-team path — the api_key is not None gate ensures no key-based caller silently gains union access.
  • Team resolution failures (get_user_object throwing, missing user, no DB) return [] and narrow access rather than raising, so the caller's direct grants still apply; the UI_TEAM_ID sentinel is filtered at all three layers defensively.

Confidence Score: 4/5

Safe to merge; key-based auth paths are byte-identical to before and the new union branch is well-gated.

The api_key is not None boundary that separates single-team from union-fan-out is correctly placed and tested. The only gap is a missing test for the _resolve_user_team_ids exception fallback path — the fail-safe returns [] and narrows access, but nothing pins that contract against future changes to the exception handler.

user_api_key_auth_mcp.py lines 1652-1663: the except Exception branch in _resolve_user_team_ids is the only untested behavioral path.

Important Files Changed

Filename Overview
litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py Refactors team MCP grant resolution: extracts _allowed_mcp_servers_for_single_team, adds _team_ids_for_mcp_grant and _resolve_user_team_ids to union grants across all teams for keyless callers; key-based paths unchanged
tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py Adds TestUserSubjectTeamUnion class covering: keyless union across teams, key-based single-team, explicit-team-id keyless, no-teams, UI_TEAM_ID sentinel, and _team_ids_for_mcp_grant shape gate; no test for the _resolve_user_team_ids exception fallback path

Reviews (2): Last reviewed commit: "feat(mcp): union team-inherited MCP gran..." | Re-trigger Greptile

Comment on lines +1652 to +1666
try:
user_object = await get_user_object(
user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,
parent_otel_span=user_api_key_auth.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
except Exception as e: # noqa: BLE001 # a team-resolution blip narrows access, never raises
verbose_logger.warning(f"Failed to resolve user teams for MCP grant: {str(e)}")
return []
if user_object is None or not user_object.teams:
return []
return list(dict.fromkeys(t for t in user_object.teams if t and t != UI_TEAM_ID))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Exception fallback path is untested

The except Exception branch in _resolve_user_team_ids silently returns [] on any DB failure, narrowing the user's team-inherited access to nothing. This is the explicitly documented fail-safe behavior, but there is no test in TestUserSubjectTeamUnion that exercises this path (e.g., get_user_object raising or returning a network error). A test that patches get_user_object to raise and asserts the result is [] would pin this contract so future exception handling changes cannot accidentally widen access or surface unhandled errors.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

user_id=user_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
user_id_upsert=False,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Medium: Team removal does not immediately revoke inherited access

get_user_object reads from user_api_key_cache by default. If a user's team membership is changed without evicting that cache entry, a user holding a valid gateway session can continue executing MCP tools granted by the removed team until the configurable management-object cache TTL expires. Resolve this authorization decision from the database or ensure every membership mutation invalidates the user cache.

Suggested change
user_id_upsert=False,
user_id_upsert=False,
check_db_only=True,

@veria-ai

veria-ai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request updates MCP keyless admission so a user can inherit MCP server grants from all teams they belong to, rather than relying on a single team context. The touched authentication code resolves user/team-derived MCP access during admission and tool authorization.

There are still significant authorization gaps in the current implementation. A user may be able to forge MCP admission through key metadata on a personal key, and inherited team server grants may lose their per-team tool restrictions, allowing broader tool execution than intended. Team membership removals also may not take effect until cached user data expires, delaying revocation. Overall, the PR is moving toward broader inherited access support but still needs tighter credential validation, permission aggregation, and cache invalidation before it is safe.

Open issues (3)

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

@tin-berri
tin-berri force-pushed the litellm_lit3637_session_admission branch from 1731965 to 65211a9 Compare July 14, 2026 16:57
@tin-berri
tin-berri force-pushed the litellm_lit3637_team_union branch from 5daf091 to 8e5d0a6 Compare July 14, 2026 17:01
return []
per_team = await asyncio.gather(
*(
MCPRequestHandler._allowed_mcp_servers_for_single_team(team_id, user_api_key_auth)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High: Team tool restrictions are not preserved

This grants servers from every team, but get_allowed_tools_for_server() still obtains team permissions through _get_team_object_permission(), which returns no team permission when this admitted auth has no team_id. A user can consequently call any tool on an inherited server even when the granting team limits mcp_tool_permissions; aggregate the corresponding per-team tool permissions alongside these server grants and enforce their union during tool listing and execution.

@tin-berri
tin-berri force-pushed the litellm_lit3637_session_admission branch from 65211a9 to 001064b Compare July 15, 2026 00:41
…ey absence

A JWT-authenticated caller is also keyless with a user_id and, absent a team claim, no team_id, so gating the multi-team union on api_key-is-None silently broadened JWT MCP access to the union of every team the user belongs to. _reload_admitted_user now stamps MCP_ADMITTED_USER_SUBJECT_METADATA and the union fires only for that positive marker, so the gateway session and bridge user paths union while JWT and other keyless auth keep their prior behavior. Regression-tested.
@tin-berri
tin-berri force-pushed the litellm_lit3637_team_union branch from 8e5d0a6 to 1010c32 Compare July 15, 2026 00:42
return []
if user_api_key_auth.team_id:
return [] if user_api_key_auth.team_id == UI_TEAM_ID else [user_api_key_auth.team_id]
if not user_api_key_auth.user_id or not _is_mcp_admitted_user_subject(user_api_key_auth):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

High: Key metadata can forge MCP session admission

UserAPIKeyAuth.metadata contains the metadata supplied when a virtual key is created, and /key/generate permits personal keys with no team_id. A user can therefore create such a key with mcp_admitted_user_subject: true and use it to execute tools on servers inherited from every team, bypassing the key's intended team scope. Keep the positive marker check, but also require that this is actually a keyless credential.

Suggested change
if not user_api_key_auth.user_id or not _is_mcp_admitted_user_subject(user_api_key_auth):
if (
user_api_key_auth.api_key is not None
or not user_api_key_auth.user_id
or not _is_mcp_admitted_user_subject(user_api_key_auth)
):

@tin-berri
tin-berri merged commit d21b5d1 into litellm_lit3637_session_admission Jul 17, 2026
98 of 100 checks passed
@tin-berri
tin-berri deleted the litellm_lit3637_team_union branch July 17, 2026 21:52
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.

1 participant