Skip to content

fix(mcp): cap an agent key's tools at what the invoking user and team may call - #42478

Merged
yassin-berriai merged 3 commits into
mainfrom
litellm_agent_caller_tool_ceiling
Sep 24, 2026
Merged

yassin-berriai merged 3 commits into
mainfrom
litellm_agent_caller_tool_ceiling

Conversation

@yassin-berriai

@yassin-berriai yassin-berriai commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

How it solves it:

  • The echoed team's and user's tool grants now intersect the agent's tools on each server
  • Mirrors the existing server-level caller ceiling, so the headers still only narrow
  • A caller team that cannot be loaded denies that server's tools instead of widening

User Flow

Before: a user allowed one deepwiki tool asks an agent that holds every deepwiki tool, and the agent uses all of them on the user's behalf

  1. The admin creates MCP server deepwiki (three tools) and grants the internal user demo-user only read_wiki_structure on it
  2. The admin registers an agent whose permissions grant the whole deepwiki server, and gives the agent its own key
  3. The user opens the playground at http://localhost:3000/playground, picks /v1/a2a/message/send, selects the agent, and sends "Which tools can you call right now?"
  4. The agent's backend calls GET http://localhost:4000/mcp-rest/tools/list with its own key plus the x-litellm-user-id: demo-user and x-litellm-team-id headers the proxy forwarded to it
  5. The list comes back with ask_wiki_question, read_wiki_contents and read_wiki_structure, and the agent answers with all three
  6. The same user calling GET http://localhost:4000/mcp-rest/tools/list with their own key sees only read_wiki_structure, so the agent reached two tools the user never could

After: the same user gets an agent that can only call the one tool they hold

  1. The admin creates MCP server deepwiki (three tools) and grants the internal user demo-user only read_wiki_structure on it
  2. The admin registers an agent whose permissions grant the whole deepwiki server, and gives the agent its own key
  3. The user opens the playground at http://localhost:3000/playground, picks /v1/a2a/message/send, selects the agent, and sends "Which tools can you call right now?"
  4. The agent's backend calls GET http://localhost:4000/mcp-rest/tools/list with its own key plus the forwarded x-litellm-user-id: demo-user and x-litellm-team-id headers
  5. The list comes back with only read_wiki_structure, and the agent answers with that one tool
  6. The agent's own key with no caller headers still lists all three tools, so the admin's grant to the agent is untouched; only the on-behalf-of call is narrowed

Relevant issues

Affected release

Linear ticket

Pre-Submission checklist

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

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*, make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • 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

Shared setup: one Postgres database, STORE_MODEL_IN_DB=True, proxy started with python -m litellm.proxy.proxy_cli --config config.yaml --port 4000 --use_v2_migration_resolver where config.yaml holds one gpt-4o-mini deployment and the master key. Tool calls go to the public DeepWiki MCP server, so every tools/call below is a real upstream call. The Before leg runs the same command from a worktree at the merge base against the same database and fixtures

Fixtures created once with the master key:

curl -X POST localhost:4000/v1/mcp/server -H "$MASTER" -d '{"server_id":"deepwiki","server_name":"deepwiki","url":"https://mcp.deepwiki.com/mcp","transport":"http","auth_type":"none"}'
curl -X POST localhost:4000/team/new -H "$MASTER" -d '{"team_alias":"agent-demo-team","models":["gpt-4o-mini"],"object_permission":{"mcp_servers":["deepwiki"]}}'                       # -> $TEAM_ID
curl -X POST localhost:4000/user/new -H "$MASTER" -d '{"user_id":"demo-user","user_role":"internal_user","team_id":"'$TEAM_ID'","auto_create_key":false,"object_permission":{"mcp_servers":["deepwiki"],"mcp_tool_permissions":{"deepwiki":["read_wiki_structure"]}}}'
curl -X POST localhost:4000/key/generate -H "$MASTER" -d '{"user_id":"demo-user","team_id":"'$TEAM_ID'"}'                                                                            # -> $USER_KEY
curl -X POST localhost:4000/v1/agents -H "$MASTER" -d '{"agent_name":"demo-agent","agent_card_params":{...},"object_permission":{"mcp_servers":["deepwiki"]}}'                    # -> $AGENT_ID
curl -X POST localhost:4000/key/generate -H "$MASTER" -d '{"agent_id":"'$AGENT_ID'","team_id":"'$TEAM_ID'"}'                                                                         # -> $AGENT_KEY

$CALLER below stands for -H "x-litellm-user-id: demo-user" -H "x-litellm-team-id: $TEAM_ID", the headers the proxy forwards to an agent and the agent echoes back on its own MCP calls

Before (9cc5b78)

Tool discovery per identity

  1. curl localhost:4000/mcp-rest/tools/list -H "Authorization: Bearer $USER_KEY"
  2. read_wiki_structure
  3. curl localhost:4000/mcp-rest/tools/list -H "Authorization: Bearer $AGENT_KEY"
  4. ask_wiki_question read_wiki_contents read_wiki_structure
  5. curl localhost:4000/mcp-rest/tools/list -H "Authorization: Bearer $AGENT_KEY" $CALLER
  6. ask_wiki_question read_wiki_contents read_wiki_structure (acting for the user, the agent still lists the two tools the user does not hold)

Tool call on the user's behalf

  1. curl -X POST localhost:4000/mcp-rest/tools/call -H "Authorization: Bearer $AGENT_KEY" $CALLER -d '{"server_id":"deepwiki","name":"deepwiki-ask_wiki_question","arguments":{"repoName":"BerriAI/litellm","question":"What is litellm?"}}'
  2. HTTP 200 with {"content":[{"type":"text","text":"LiteLLM is an open-source AI Gateway that provides a unified, OpenAI-compatible interface to over 100 ... (a tool the user is not allowed to call ran upstream)
  3. curl -X POST localhost:4000/mcp-rest/tools/call -H "Authorization: Bearer $AGENT_KEY" $CALLER -d '{"server_id":"deepwiki","name":"deepwiki-read_wiki_structure","arguments":{"repoName":"BerriAI/litellm"}}'
  4. HTTP 200 with {"content":[{"type":"text","text":"Available pages for BerriAI/litellm:\n\n- 1 Overview\n - 1.1 Getting Started and Repo Orientation ...

After (7978d18)

Tool discovery per identity

  1. curl localhost:4000/mcp-rest/tools/list -H "Authorization: Bearer $USER_KEY"
  2. read_wiki_structure
  3. curl localhost:4000/mcp-rest/tools/list -H "Authorization: Bearer $AGENT_KEY"
  4. ask_wiki_question read_wiki_contents read_wiki_structure (the agent's own grant is unchanged)
  5. curl localhost:4000/mcp-rest/tools/list -H "Authorization: Bearer $AGENT_KEY" $CALLER
  6. read_wiki_structure (the intersection of what the agent holds and what the user holds)

Tool call on the user's behalf

  1. curl -X POST localhost:4000/mcp-rest/tools/call -H "Authorization: Bearer $AGENT_KEY" $CALLER -d '{"server_id":"deepwiki","name":"deepwiki-ask_wiki_question","arguments":{"repoName":"BerriAI/litellm","question":"What is litellm?"}}'
  2. HTTP 403 with {"detail":{"error":"Tool 'ask_wiki_question' is not allowed for your key/team on server 'deepwiki'. Contact proxy admin for access."}}
  3. curl -X POST localhost:4000/mcp-rest/tools/call -H "Authorization: Bearer $AGENT_KEY" $CALLER -d '{"server_id":"deepwiki","name":"deepwiki-read_wiki_structure","arguments":{"repoName":"BerriAI/litellm"}}'
  4. HTTP 200 with {"content":[{"type":"text","text":"Available pages for BerriAI/litellm:\n\n- 1 Overview\n - 1.1 Getting Started and Repo Orientation ...

Tests

The five new tests in TestAgentMCPPermissions cover the intersection, a caller with no tool grants, a team grant on a different server, an agent key with no echoed caller, and an unreadable caller team. Mutation check at the tip: deleting the _apply_agent_caller_tool_ceiling call from get_allowed_tools_for_server fails test_agent_key_acting_for_a_user_only_sees_the_tools_that_user_may_call, test_agent_key_acting_for_a_team_is_capped_at_that_teams_tools_on_the_server and test_agent_key_acting_for_a_caller_whose_team_is_unreadable_gets_no_tools; returning allowed_tools instead of [] from the except branch fails the unreadable-team test. Restoring the code brings the mapped file back to 411 passed

Type

🐛 Bug Fix

Caveats (if any)

Medium

  • The ceiling only applies when the agent echoes the forwarded x-litellm-user-id / x-litellm-team-id headers on its MCP calls, same as the existing server-level ceiling. An agent that drops them keeps its full grant
  • The agent's own LLM and MCP log rows carry the invoking user only as end_user. The echoed caller headers are not recorded on those rows; tying them to the human is a follow-up on the logging side

Low

  • The tests stub the per-level permission loaders with patch.object, the same seam the sibling server-level ceiling tests in this file use, because those loaders read proxy globals with no injection point. Giving MCPRequestHandler an injected loader is a separate refactor across every level, not something this fix should carry
  • _get_team_object_permission gained a return annotation so the new caller reads a typed value. Same body, no behavior change

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

Link to Devin session: https://app.devin.ai/sessions/0ebe19879668488c889d0a3cc252a58d
Open in Devin Desktop: https://app.devin.ai/desktop/session/0ebe19879668488c889d0a3cc252a58d?variant=devin
Requested by: @yassin-berriai

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge, with no outstanding findings or newly introduced issues since the previous review.

Findings

  1. P2 Critical-path database lookups ▶

Summary

This PR strengthens MCP authorization for agent keys acting on behalf of another caller.

  • Intersects an agent’s MCP tool access with the invoking team’s and user’s effective grants.
  • Fails closed when the invoking team’s permissions cannot be resolved.
  • Adds focused tests for user and team ceilings, unrestricted grants, absent caller context, and unreadable team permissions.
  • No code has changed since the previous review.

Reviews (5) · Last reviewed commit: "fix(mcp): return immutable tool sequence..."

@codspeed

codspeed Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_agent_caller_tool_ceiling (1f8d1d2) with main (3fa6882)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (6a7796e) during the generation of this report, so 3fa6882 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

@codecov

codecov Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@devin-ai-integration

Copy link
Copy Markdown
Contributor

@greptileai

)
)

allowed_tools = _as_list(

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 Critical-path database lookups

This caller ceiling adds team and user permission lookups to each applicable MCP tool authorization. The repository forbids new database requests in the critical request path, so this requirement must be satisfied before merging

Rule Used: What: Avoid creating new database requests or Router objects in the critical request path. Why: Creating these objects on every request causes performance degradation and unnecessary resource consumption. (source)

Knowledge Base Used: Proxy authentication and authorization

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!

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.

Both loaders go through user_api_key_cache and the shared object_permission_id cache, and the server-level caller ceiling already warms them for these same principals earlier in the request, so a warm request reads no rows

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.

That’s a valid clarification. Because the caller team/user resolvers use user_api_key_cache and the shared object-permission cache, and the existing server-level caller ceiling resolves the same principals earlier in the request, this addition does not introduce additional database queries on the normal warm path. The cold-cache lookup is already part of the existing caller-ceiling flow, so my critical-path database finding does not apply here; I’m withdrawing it.

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Playground check at 7978d18: the caller-scoped agent lists one tool, the forbidden call is 403, the standalone agent key keeps all three

Caller-scoped discovery Denied and permitted calls
Discovery Calls

@devin-ai-integration

Copy link
Copy Markdown
Contributor

@greptileai

yassin-berriai and others added 3 commits September 24, 2026 23:36
… may call

The invoking user's and team's x-litellm-user-id / x-litellm-team-id, echoed back by the
agent, already narrowed which MCP servers the agent key could reach, but not which tools on
those servers. An agent granted every tool on a server kept them all when acting for a user
who may only call a subset. The caller's team and user tool grants now intersect the agent's
tool list on each server, mirroring the servers axis, so the headers only ever narrow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the tool grant stub

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ceiling

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration
devin-ai-integration Bot force-pushed the litellm_agent_caller_tool_ceiling branch from 7978d18 to 1f8d1d2 Compare September 24, 2026 23:36
@yassin-berriai
yassin-berriai merged commit 27d1974 into main Sep 24, 2026
107 of 108 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_agent_caller_tool_ceiling branch September 24, 2026 23:47

This branch is waiting to be deployed

1 waiting deployment
e2e-changed — 1f8d1d28 Waiting Sep 24, 2026 by devin-ai-integration[bot] via oauth #950
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.

3 participants