Skip to content

fix(mcp): let proxy admins assign MCP servers to teamless keys - #31126

Merged
ishaan-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_mcp_access_group_no_team
Jun 24, 2026
Merged

ishaan-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_mcp_access_group_no_team

Conversation

@tin-berri

@tin-berri tin-berri commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

Relevant issues

Admins cannot create teamless keys with MCP server access

Linear ticket

LIT-3815

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 unit tests on make test-unit
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have requested a Greptile review by commenting @greptileai and received a Confidence Score of at least 4/5 before requesting a maintainer review

CI (LiteLLM team)

  • Branch creation CI run
    Link:

  • CI run for the last commit
    Link:

  • Merge / cherry-pick CI run
    Links:

Screenshots / Proof of Fix

https://www.loom.com/share/fc8401ceb867497ab3db2f87fd2d3b12

Run against a live proxy with a proxy-admin key ($KEY) and the id of a specific, non-allow_all_keys MCP server ($SERVER)

Before the fix, creating a teamless key that references the server is rejected

curl -s -X POST "$PROXY/key/generate" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d "{\"object_permission\": {\"mcp_servers\": [\"$SERVER\"]}}"

returns the ticket's error

{"error":{"message":"... Key is not in a team. Only globally available (allow_all_keys) MCP servers can be assigned: []. Disallowed servers: ['<id>'].","type":"internal_server_error","code":"403"}}

After the fix, the same request returns a created key, and the key can call the server's tools at runtime

curl -s -X POST "$PROXY/key/generate" \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d "{\"object_permission\": {\"mcp_servers\": [\"$SERVER\"]}}"

Edge cases verified against the same proxy: a non-admin caller still gets the 403; a proxy admin can likewise assign an access group to a teamless key

Type

🐛 Bug Fix

Changes

In short: a proxy admin couldn't attach a specific MCP server to a teamless key — the request was rejected with a 403, so the only way to grant a non-allow_all_keys server was to put the key on a team first. After this fix the admin can create a teamless key, attach the MCP server (on /key/generate or later via /key/update), and the key can actually use that server's tools at runtime. Attaching the grant and using it now line up.

Creating or updating a key that references a specific (non-allow_all_keys) MCP server or access group failed with a 403 when the key had no team. The error advertised on the ticket was "Key is not in a team. Only globally available (allow_all_keys) MCP servers can be assigned"

The root cause is an asymmetry between the create/update path and the runtime path. validate_key_mcp_servers_against_team computed the allowed set as the team's servers plus the allow_all_keys servers; for a teamless key the team set is empty, so the allowed set collapsed to just allow_all_keys and any explicitly-selected server or access group was rejected. The runtime path tells a different story: get_allowed_mcp_servers honors a teamless key's own object_permission.mcp_servers verbatim, with no team gate and no allow_all_keys filter. So the create path refused to persist a grant the run path would have happily served

The fix threads is_proxy_admin into the validator from both call sites (/key/generate and /key/update). When a key has no team and the caller is a proxy admin, the requested servers and access groups are folded into the allowed set so the existing subset checks pass. A proxy admin can already reach every MCP server, so there is nothing to escalate; the grant is scoped to exactly what the admin selected. Non-admins and every team-scoped key are unchanged, so the override is strictly team_obj is None and is_proxy_admin. The team-scoped branch is deliberately left alone because runtime intersects a teamed key with its team, so an out-of-team grant there would be silently dropped anyway

Tests live in the mapped file tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py: a proxy admin can assign a private server to a teamless key, a proxy admin can assign an access group to a teamless key, a non-admin still gets the 403, and a proxy admin is still bounded by team scope on a teamed key. Neutralizing the fix makes the two positive tests fail with the exact ticket error, so they pin the regression

@tin-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes an asymmetry between the key-create/update validation path and the runtime path for MCP server assignment: proxy admins can now assign non-allow_all_keys MCP servers or access groups to teamless keys, matching what the runtime path already honors.

  • Core logic (object_permission_utils.py): validate_key_mcp_servers_against_team gains an is_proxy_admin parameter. When the key has no team and the caller is a proxy admin (teamless_admin_assignment), the requested servers/groups are unioned into the allowed set so the subset checks pass without error; teamed-key paths are unchanged.
  • Call sites (key_management_endpoints.py): Both /key/generate and /key/update now derive and forward is_proxy_admin from the authenticated user_api_key_dict.user_role, with consistent sourcing at both points.
  • Tests: Four new mock-only unit tests pin the four meaningful cases (admin+teamless=pass for servers, admin+teamless=pass for groups, non-admin=403, admin+team=still bounded).

Confidence Score: 5/5

The change is safe to merge. The bypass is gated on two conditions (team_obj is None and is_proxy_admin), both server-authoritative — is_proxy_admin comes from the authenticated session, not user-supplied request data. The teamed-key path is untouched and four targeted unit tests pin the regression.

The fix is minimal and well-scoped: three small additions to the function signature and two union operations inside the existing guard blocks. The is_proxy_admin flag is derived consistently from the same expression at both call sites. There are no structural changes to the auth flow, no new DB queries, and no modifications to existing tests.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/management_helpers/object_permission_utils.py Adds is_proxy_admin parameter to validate_key_mcp_servers_against_team; when the caller is a proxy admin and the key has no team, the requested servers/groups are folded into the allowed set so the subset check always passes cleanly.
litellm/proxy/management_endpoints/key_management_endpoints.py Threads is_proxy_admin (derived from user_api_key_dict.user_role) into both key-create and key-update call sites; the flag propagation is consistent and correctly sourced from the authenticated caller.
tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py Adds four new unit tests covering proxy-admin private-server assignment, proxy-admin access-group assignment, non-admin rejection, and admin-still-bounded-by-team-scope; all use mocks with no real network calls.

Reviews (1): Last reviewed commit: "fix(mcp): let proxy admins assign MCP se..." | Re-trigger Greptile

@codecov

codecov Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@greptile-apps

greptile-apps Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes an asymmetry between the key create/update path and the runtime path for MCP server access: validate_key_mcp_servers_against_team previously collapsed the allowed set to only allow_all_keys servers for teamless keys, while the runtime path grants access directly from the key's own object_permission with no such restriction. The fix threads is_proxy_admin into the validator so a proxy admin can persist any server or access group onto a teamless key.

  • object_permission_utils.py: adds is_proxy_admin: bool = False to validate_key_mcp_servers_against_team; when team_obj is None and is_proxy_admin, requested servers/access groups are unioned into the allowed set — matching the pre-existing teamless pattern already in place for toolsets (line 596).
  • key_management_endpoints.py: both call sites (_common_key_generation_helper and _validate_mcp_servers_for_key_update) now derive and forward is_proxy_admin from user_api_key_dict.user_role; team-scoped keys are entirely unchanged.
  • Four new unit tests cover the four key scenarios (admin + teamless passes, non-admin still 403, admin + access group passes, admin + team still bounded).

Confidence Score: 5/5

Safe to merge; the bypass is narrowly scoped to teamless keys with a verified proxy-admin caller, the runtime path already grants the same access, and team-scoped keys are untouched.

The change is small and well-bounded. is_proxy_admin is derived from the authenticated user_api_key_dict.user_role at both call sites, not from caller-supplied input. The union trick (allowed = allowed | requested) is logically equivalent to skipping the subset check but keeps the existing error-message branches intact. The teamless-toolset path at line 596 already used the same team_obj is not None guard without an admin check, so the servers/access-groups paths are now consistent. All four behaviorally distinct cases are covered by the new tests.

No files require special attention.

Important Files Changed

Filename Overview
litellm/proxy/management_helpers/object_permission_utils.py Adds is_proxy_admin parameter to validate_key_mcp_servers_against_team; when the key has no team and the caller is a proxy admin, requested servers and access groups are folded into the allowed set so subset checks pass — consistent with the existing teamless-toolset pattern at line 596.
litellm/proxy/management_endpoints/key_management_endpoints.py Threads is_proxy_admin from user_api_key_dict.user_role into both call sites: _common_key_generation_helper (key create) and _validate_mcp_servers_for_key_update (key update); no other logic changed.
tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py Adds four new async unit tests covering: proxy admin assigns private server to teamless key (pass), non-admin still gets 403, proxy admin assigns access group to teamless key (pass), and proxy admin on a team-scoped key is still bounded by team scope.

Reviews (2): Last reviewed commit: "fix(mcp): let proxy admins assign MCP se..." | Re-trigger Greptile

Creating or updating a key with a specific (non-allow_all_keys) MCP
server or access group failed with a 403 when the key had no team:

    Key is not in a team. Only globally available (allow_all_keys) MCP
    servers can be assigned

validate_key_mcp_servers_against_team computed the allowed set as
team servers + allow_all_keys servers. For a teamless key the team
set is empty, so the allowed set collapsed to just allow_all_keys
servers and any explicitly-picked server or access group was rejected.

This was asymmetric with runtime: get_allowed_mcp_servers honors a
teamless key's own object_permission.mcp_servers verbatim, with no
team gate and no allow_all_keys filter. So the create/update path
refused to persist a grant the run path would have served.

Thread is_proxy_admin into the validator from both call sites
(/key/generate and /key/update). When a key has no team and the
caller is a proxy admin, the requested servers and access groups are
folded into the allowed set so the existing subset checks pass. A
proxy admin can already reach every MCP server, so there is nothing
to escalate. Non-admins and every team-scoped key are unchanged.

Resolves LIT-3815
@tin-berri
tin-berri force-pushed the litellm_mcp_access_group_no_team branch from 8dd7601 to 2adeb41 Compare June 23, 2026 22:53
@ishaan-berri
ishaan-berri merged commit 6003187 into litellm_internal_staging Jun 24, 2026
129 checks passed
@ishaan-berri
ishaan-berri deleted the litellm_mcp_access_group_no_team branch June 24, 2026 20:20
@ishaan-berri ishaan-berri mentioned this pull request Jun 25, 2026
9 of 13 tasks
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.

2 participants