litellm/tests/integration/mcp/test_mcp_toolsets.py
devin-ai-integration[bot] d7e6154863
fix(mcp): resolve team-granted toolsets for non-admin keys and dashboard sessions (#43908)
* fix(mcp): expand team and dashboard grants when listing and serving toolsets

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

* fix(mcp): honor team toolset grants on the responses gateway path and expose a public team permission lookup

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

* chore(mcp): drop the toolset route docstring tweak so the OpenAPI snapshot stays unchanged

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

* fix(mcp): scope inherited toolset grants by the key's own MCP ceiling

A key that declares any MCP grant of its own keeps only its own toolsets, one
that declares none inherits its team's, and require_key_mcp_access_defined
stops a virtual key inheriting while dashboard sessions and admitted users
still do. Adds the direct, no-grant, admin and key-ceiling integration cases
and makes the LLM gateway toolset case discriminate a scoped toolset from the
aggregate grant

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

* fix(mcp): resolve toolset grants per admitted source and enforce the live team roster

Dashboard sessions and gateway-admitted users now expand into the admitted
subject's per-team sources when resolving toolset grants, so a team-granted
toolset is not capped by the user's own MCP row and is reachable on the
namespaced route. A cached team id no longer grants a toolset unless the live
roster still lists the user, a team lookup fault only drops that team's
inherited grant, and /team/member_add evicts the cached team object so the new
member is authoritative immediately

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

* fix(mcp): read a key's named object permission before letting it inherit team toolsets

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

* refactor(mcp): inject the toolset grant resolver into scope helpers so tests stop patching MCPRequestHandler

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

* fix(mcp): drop the duplicate admitted_subject_sources wrapper after merging main and follow its renamed resolvers

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

* fix(mcp): honour fresh policy and the session resource scope on pinned toolsets

A pinned toolset scope now reads the toolset through the writer when the admitted session
requires fresh policy, so a tool revoked from the toolset is gone on the next request. A
gateway bearer scoped to one server can only open a toolset that names that server

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

* fix(mcp): keep operator-open servers out of toolset gateway urls

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

* test(mcp): audit cells for team-granted toolsets across every surface

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

* test(mcp): bound toolset edit convergence by both cache layers

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

* test(mcp): drop toolset integration cells that test behavior this PR does not change

Repeat-read byte identity, 20 concurrent calls, a stopped peer and a killed worker are covered
generically by test_mcp_resilience.py and test_mcp_user_env_vars.py. The toolset edit cell asserts
pre-existing cache propagation and flaked locally with connection resets while polling

* chore(mcp): drop mutable-ok suppressions that main's LIT013 now flags as unused

* chore(mcp): keep the require_key_mcp_access_defined read from adding an unknown-argument type error

---------

Co-authored-by: ryan <ryan@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-01 14:15:06 -07:00

509 lines
27 KiB
Python

import secrets
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Final
import httpx
import pytest
import yaml
from integration._support.client import Gateway, Scenario, object_value
from integration._support.mcp import (
INITIALIZE,
Outcome,
_outcome_from_rest,
_outcome_from_rpc,
mcp_peer,
register_mcp,
tool_calls,
)
from integration._support.mcp_grants import create_toolset
from integration._support.process import owned_proxy
from litellm.models.user import LiteLLM_UserTable
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import LITELLM_SESSION_TOKEN_PREFIX, ExperimentalUIJWTToken
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_bearer_token
ADD: Final = {"a": 4, "b": 5}
def _dashboard_ui_session_token(user_id: str) -> str:
user: Final = LiteLLM_UserTable(user_id=user_id, user_role="internal_user", models=[])
return ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(user)
def _toolset(scenario: Scenario, server_id: str, tool: str) -> tuple[str, str]:
name: Final = "lit6029_" + uuid.uuid4().hex[:10]
return create_toolset(scenario, ((server_id, tool),), toolset_name=name), name
def _toolset_rpc(
gateway: Gateway, headers: dict[str, str], name: str, method: str, params: dict[str, object]
) -> Outcome:
def post(rpc_method: str, rpc_params: dict[str, object]) -> httpx.Response:
return gateway.client.post(
f"/toolset/{name}/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": rpc_method, "params": rpc_params},
headers={**headers, "Accept": "application/json, text/event-stream"},
)
initialized: Final = _outcome_from_rpc(post("initialize", dict(INITIALIZE)))
if not initialized.ok:
return initialized
return _outcome_from_rpc(post(method, params))
def _listed_toolset_ids(gateway: Gateway, headers: dict[str, str]) -> tuple[str, ...]:
response: Final = gateway.client.get("/v1/mcp/toolset", headers=headers)
assert response.status_code == 200, response.text
return tuple(toolset["toolset_id"] for toolset in response.json())
def _assert_team_grants_only(gateway: Gateway, team_id: str, key: str, toolset_id: str) -> None:
team: Final = object_value(gateway.get("/team/info", {"team_id": team_id})["team_info"])
assert object_value(team["object_permission"])["mcp_toolsets"] == [toolset_id], team
key_info: Final = object_value(gateway.get("/key/info", {"key": key})["info"])
assert key_info.get("object_permission") is None, f"key must carry no grant of its own: {key_info}"
def test_team_granted_toolset_is_listed_and_served_to_a_team_key(gateway: Gateway) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
server_id: Final = register_mcp(scenario, peer, alias)
granted_id, granted_name = _toolset(scenario, server_id, "add")
withheld_id, withheld_name = _toolset(scenario, server_id, "multiply")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
key: Final = scenario.key(team_id=team_id)
_assert_team_grants_only(gateway, team_id, key, granted_id)
headers: Final = {"Authorization": f"Bearer {key}"}
assert _listed_toolset_ids(gateway, headers) == (granted_id,)
detail: Final = gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers)
assert detail.status_code == 200, detail.text
assert detail.json()["toolset_name"] == granted_name, detail.text
withheld_detail: Final = gateway.client.get(f"/v1/mcp/toolset/{withheld_id}", headers=headers)
assert withheld_detail.status_code == 403, withheld_detail.text
listed: Final = _toolset_rpc(gateway, headers, granted_name, "tools/list", {})
assert listed.ok, listed.raw
assert listed.tools == (f"{alias}-add",), listed.raw
peer.drain()
called: Final = _toolset_rpc(
gateway, headers, granted_name, "tools/call", {"name": f"{alias}-add", "arguments": ADD}
)
assert called.ok and called.text == "9", called.raw
assert len(tool_calls(peer.drain())) == 1
denied: Final = _toolset_rpc(gateway, headers, withheld_name, "tools/list", {})
assert denied.status == 403, denied.raw
def test_dashboard_session_of_a_team_member_lists_the_team_granted_toolset(
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
server_id: Final = register_mcp(scenario, peer, alias)
granted_id, granted_name = _toolset(scenario, server_id, "add")
_toolset(scenario, server_id, "multiply")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
user_id: Final = scenario.user(user_role="internal_user", teams=[team_id])
user: Final = object_value(gateway.get("/user/info", {"user_id": user_id})["user_info"])
assert user["teams"] == [team_id], user
headers: Final = {"Authorization": f"Bearer {_dashboard_ui_session_token(user_id)}"}
assert _listed_toolset_ids(gateway, headers) == (granted_id,)
detail: Final = gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers)
assert detail.status_code == 200, detail.text
assert detail.json()["toolset_name"] == granted_name, detail.text
def test_direct_grants_no_grants_and_admin_listing_are_unchanged_by_team_resolution(gateway: Gateway) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
server_id: Final = register_mcp(scenario, peer, alias)
granted_id, granted_name = _toolset(scenario, server_id, "add")
withheld_id, withheld_name = _toolset(scenario, server_id, "multiply")
direct: Final = {"Authorization": f"Bearer {scenario.key(object_permission={'mcp_toolsets': [granted_id]})}"}
ungranted_team: Final = scenario.team()
no_grant: Final = {"Authorization": f"Bearer {scenario.key(team_id=ungranted_team)}"}
admin: Final = {"Authorization": f"Bearer {gateway.key}"}
assert _listed_toolset_ids(gateway, direct) == (granted_id,)
assert _toolset_rpc(gateway, direct, granted_name, "tools/list", {}).tools == (f"{alias}-add",)
assert _toolset_rpc(gateway, direct, withheld_name, "tools/list", {}).status == 403
assert gateway.client.get(f"/v1/mcp/toolset/{withheld_id}", headers=direct).status_code == 403
assert _listed_toolset_ids(gateway, no_grant) == ()
assert gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=no_grant).status_code == 403
assert _toolset_rpc(gateway, no_grant, granted_name, "tools/list", {}).status == 403
assert {granted_id, withheld_id} <= set(_listed_toolset_ids(gateway, admin))
assert gateway.client.get(f"/v1/mcp/toolset/{withheld_id}", headers=admin).status_code == 200
assert _toolset_rpc(gateway, admin, withheld_name, "tools/list", {}).tools == (f"{alias}-multiply",)
def test_a_key_with_its_own_toolset_grant_does_not_inherit_the_team_toolset(gateway: Gateway) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
server_id: Final = register_mcp(scenario, peer, alias)
own_id, own_name = _toolset(scenario, server_id, "add")
team_only_id, team_only_name = _toolset(scenario, server_id, "multiply")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [own_id, team_only_id]})
key: Final = scenario.key(team_id=team_id, object_permission={"mcp_toolsets": [own_id]})
headers: Final = {"Authorization": f"Bearer {key}"}
assert _listed_toolset_ids(gateway, headers) == (own_id,)
assert gateway.client.get(f"/v1/mcp/toolset/{team_only_id}", headers=headers).status_code == 403
assert _toolset_rpc(gateway, headers, team_only_name, "tools/list", {}).status == 403
assert _toolset_rpc(gateway, headers, own_name, "tools/list", {}).tools == (f"{alias}-add",)
def _team_member_with_own_grant(scenario: Scenario, team_id: str, own_server_id: str) -> str:
user_id: Final = scenario.user(user_role="internal_user", object_permission={"mcp_servers": [own_server_id]})
scenario.gateway.post("/team/member_add", {"team_id": team_id, "member": {"role": "user", "user_id": user_id}})
return user_id
def test_dashboard_session_serves_the_team_toolset_despite_a_disjoint_grant_on_the_user_row(
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The member's own row grants a different server outright. That grant must not cap the team's toolset
to nothing, and the team's sibling toolset must not leak onto the granted toolset's route."""
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
server_id: Final = register_mcp(scenario, peer, alias)
own_server_id: Final = register_mcp(scenario, peer, "lit6029_own_" + uuid.uuid4().hex[:8])
granted_id, granted_name = _toolset(scenario, server_id, "add")
sibling_id, sibling_name = _toolset(scenario, server_id, "multiply")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id, sibling_id]})
user_id: Final = _team_member_with_own_grant(scenario, team_id, own_server_id)
headers: Final = {"Authorization": f"Bearer {_dashboard_ui_session_token(user_id)}"}
assert set(_listed_toolset_ids(gateway, headers)) == {granted_id, sibling_id}
listed: Final = _toolset_rpc(gateway, headers, granted_name, "tools/list", {})
assert listed.ok, listed.raw
assert listed.tools == (f"{alias}-add",), listed.raw
assert _toolset_rpc(gateway, headers, sibling_name, "tools/list", {}).tools == (f"{alias}-multiply",)
peer.drain()
called: Final = _toolset_rpc(
gateway, headers, granted_name, "tools/call", {"name": f"{alias}-add", "arguments": ADD}
)
assert called.ok and called.text == "9", called.raw
assert len(tool_calls(peer.drain())) == 1
stranger: Final = {
"Authorization": f"Bearer {_dashboard_ui_session_token(scenario.user(user_role='internal_user'))}"
}
assert _toolset_rpc(gateway, stranger, granted_name, "tools/list", {}).status == 403
def test_a_member_removed_from_the_team_loses_its_toolset_on_the_dashboard_session(
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029_" + uuid.uuid4().hex[:8]
server_id: Final = register_mcp(scenario, peer, alias)
granted_id, granted_name = _toolset(scenario, server_id, "add")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
user_id: Final = scenario.member(team_id)
headers: Final = {"Authorization": f"Bearer {_dashboard_ui_session_token(user_id)}"}
assert _listed_toolset_ids(gateway, headers) == (granted_id,)
assert _toolset_rpc(gateway, headers, granted_name, "tools/list", {}).tools == (f"{alias}-add",)
gateway.post("/team/member_delete", {"team_id": team_id, "user_id": user_id})
assert _listed_toolset_ids(gateway, headers) == ()
assert gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers).status_code == 403
assert _toolset_rpc(gateway, headers, granted_name, "tools/list", {}).status == 403
def _bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _expired_dashboard_token(user_id: str) -> str:
expired: Final = (datetime.now(timezone.utc) - timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3]
stale: Final = UserAPIKeyAuth(
token="ui-token",
key_name="ui-token",
key_alias="ui-token",
expires=expired + "+00:00",
user_id=user_id,
team_id="litellm-dashboard",
models=[],
user_role=LitellmUserRoles.INTERNAL_USER,
)
return encrypt_bearer_token(stale.model_dump_json(exclude_none=True), prefix=LITELLM_SESSION_TOKEN_PREFIX)
def _rest_list(gateway: Gateway, headers: dict[str, str], params: object) -> httpx.Response:
return gateway.client.get("/mcp-rest/tools/list", headers=headers, params=params)
def _rest_call(gateway: Gateway, headers: dict[str, str], name: str, server_id: str) -> Outcome:
return _outcome_from_rest(
gateway.client.post(
"/mcp-rest/tools/call",
headers=headers,
json={"name": name, "arguments": dict(ADD), "server_id": server_id},
)
)
def _route_tools(gateway: Gateway, headers: dict[str, str], name: str) -> Outcome:
return _toolset_rpc(gateway, headers, name, "tools/list", {})
def _route_call(gateway: Gateway, headers: dict[str, str], name: str, tool: str) -> Outcome:
return _toolset_rpc(gateway, headers, name, "tools/call", {"name": tool, "arguments": dict(ADD)})
def _strict_config(directory: Path) -> Path:
base: Final = yaml.safe_load((Path(__file__).resolve().parents[1] / "proxy_config.yaml").read_text())
strict: Final = {
**base,
"general_settings": {**base.get("general_settings", {}), "require_key_mcp_access_defined": True},
}
path: Final = directory / "require_key_mcp_access.yaml"
path.write_text(yaml.safe_dump(strict))
return path
def test_mcp_rest_toolset_name_narrows_the_list_and_serves_the_call_for_a_team_key_and_a_dashboard_member(
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029rest" + uuid.uuid4().hex[:6]
server_id: Final = register_mcp(scenario, peer, alias)
granted_id, granted_name = _toolset(scenario, server_id, "add")
_, withheld_name = _toolset(scenario, server_id, "multiply")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
key: Final = scenario.key(team_id=team_id)
member: Final = scenario.member(team_id)
for headers in (_bearer(key), _bearer(_dashboard_ui_session_token(member))):
listed: Final = _rest_list(gateway, headers, {"toolset_name": granted_name})
assert listed.status_code == 200, listed.text
listed_names: Final = tuple(tool["name"] for tool in listed.json()["tools"])
assert len(listed_names) == 1 and listed_names[0].endswith("add"), listed.text
denied: Final = _rest_list(gateway, headers, {"toolset_name": withheld_name})
assert denied.status_code == 200 and denied.json()["tools"] == [], denied.text
assert "does not have access to toolset" in denied.json()["message"], denied.text
peer.drain()
called: Final = _rest_call(gateway, headers, listed_names[0], server_id)
assert called.ok and called.text == "9", called.raw
assert len(tool_calls(peer.drain())) == 1
def test_a_key_restricted_to_its_own_servers_does_not_inherit_the_team_toolset(gateway: Gateway) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029own" + uuid.uuid4().hex[:6]
server_id: Final = register_mcp(scenario, peer, alias)
other_id: Final = register_mcp(scenario, peer, "lit6029other" + uuid.uuid4().hex[:6])
granted_id, granted_name = _toolset(scenario, server_id, "add")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id], "mcp_servers": [other_id]})
key: Final = scenario.key(team_id=team_id, object_permission={"mcp_servers": [other_id]})
headers: Final = _bearer(key)
assert _listed_toolset_ids(gateway, headers) == ()
assert gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers).status_code == 403
assert _route_tools(gateway, headers, granted_name).status == 403
assert tool_calls(peer.drain()) == ()
def test_a_team_with_an_empty_or_absent_toolset_grant_gives_its_keys_nothing(gateway: Gateway) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
server_id: Final = register_mcp(scenario, peer, "lit6029empty" + uuid.uuid4().hex[:6])
granted_id, granted_name = _toolset(scenario, server_id, "add")
teams: Final = (
scenario.team(object_permission={"mcp_toolsets": []}),
scenario.team(object_permission={"mcp_toolsets": None}),
scenario.team(),
)
for team_id in teams:
headers: Final = _bearer(scenario.key(team_id=team_id))
assert _listed_toolset_ids(gateway, headers) == (), team_id
assert gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers).status_code == 403
assert _route_tools(gateway, headers, granted_name).status == 403, team_id
assert tool_calls(peer.drain()) == ()
def test_an_unknown_or_malformed_toolset_name_is_refused_without_peer_traffic_and_the_route_keeps_serving(
gateway: Gateway,
) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029bad" + uuid.uuid4().hex[:6]
server_id: Final = register_mcp(scenario, peer, alias)
granted_id, granted_name = _toolset(scenario, server_id, "add")
_, withheld_name = _toolset(scenario, server_id, "multiply")
headers: Final = _bearer(scenario.key(team_id=scenario.team(object_permission={"mcp_toolsets": [granted_id]})))
unknown: Final = "missing" + uuid.uuid4().hex[:8]
assert _route_tools(gateway, headers, unknown).status == 404
assert _rest_list(gateway, headers, {"toolset_name": unknown}).status_code == 404
malformed: Final = (
[("toolset_name", granted_name), ("toolset_name", withheld_name)],
{"toolset_name": "x" * 5000},
{"toolset_name": ""},
{"toolset_name": granted_name + "\x00"},
)
statuses: Final = tuple(_rest_list(gateway, headers, params).status_code for params in malformed)
assert all(status < 500 for status in statuses), statuses
assert tool_calls(peer.drain()) == ()
assert gateway.client.get("/health/liveliness").status_code == 200
served: Final = _route_tools(gateway, headers, granted_name)
assert served.tools == (f"{alias}-add",), served.raw
def test_garbage_expired_and_tampered_credentials_are_refused_on_every_toolset_surface(
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
with mcp_peer() as peer, gateway.scenario() as scenario:
server_id: Final = register_mcp(scenario, peer, "lit6029cred" + uuid.uuid4().hex[:6])
granted_id, granted_name = _toolset(scenario, server_id, "add")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
member: Final = scenario.member(team_id)
forged: Final = (
"sk-" + secrets.token_urlsafe(24),
_expired_dashboard_token(member),
"llm_session_" + secrets.token_urlsafe(32),
)
for bearer in forged:
headers: Final = _bearer(bearer)
listed: Final = gateway.client.get("/v1/mcp/toolset", headers=headers)
assert listed.status_code == 401, (bearer[:12], listed.text)
detail: Final = gateway.client.get(f"/v1/mcp/toolset/{granted_id}", headers=headers)
assert detail.status_code == 401, (bearer[:12], detail.text)
routed: Final = _route_tools(gateway, headers, granted_name)
assert routed.status == 401, (bearer[:12], routed.raw)
rest: Final = _rest_list(gateway, headers, {"toolset_name": granted_name})
assert rest.status_code == 401, (bearer[:12], rest.text)
assert peer.drain() == ()
def test_a_dashboard_member_of_a_deleted_team_loses_the_toolset_while_a_direct_grant_survives(
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029gone" + uuid.uuid4().hex[:6]
server_id: Final = register_mcp(scenario, peer, alias)
team_id_, team_name = _toolset(scenario, server_id, "add")
own_id, own_name = _toolset(scenario, server_id, "multiply")
doomed: Final = scenario.gateway.post(
"/team/new",
{"team_alias": f"integration-{uuid.uuid4().hex}", "object_permission": {"mcp_toolsets": [team_id_]}},
)
doomed_team: Final = str(doomed["team_id"])
member: Final = scenario.user(user_role="internal_user", teams=[doomed_team])
granted: Final = scenario.user(
user_role="internal_user", teams=[doomed_team], object_permission={"mcp_toolsets": [own_id]}
)
member_headers: Final = _bearer(_dashboard_ui_session_token(member))
granted_headers: Final = _bearer(_dashboard_ui_session_token(granted))
assert _listed_toolset_ids(gateway, member_headers) == (team_id_,)
assert set(_listed_toolset_ids(gateway, granted_headers)) == {team_id_, own_id}
scenario.delete_team(doomed_team)
assert _listed_toolset_ids(gateway, member_headers) == ()
assert gateway.client.get(f"/v1/mcp/toolset/{team_id_}", headers=member_headers).status_code == 403
assert _route_tools(gateway, member_headers, team_name).status == 403
assert _listed_toolset_ids(gateway, granted_headers) == (own_id,)
assert _route_tools(gateway, granted_headers, team_name).status == 403
assert _route_tools(gateway, granted_headers, own_name).tools == (f"{alias}-multiply",)
peer.drain()
kept: Final = _route_call(gateway, granted_headers, own_name, f"{alias}-multiply")
assert kept.ok and kept.text == "20", kept.raw
assert [call["body"]["params"]["name"] for call in tool_calls(peer.drain())] == ["multiply"]
def test_require_key_mcp_access_defined_stops_key_inheritance_but_not_the_dashboard_member(
gateway: Gateway, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
with (
owned_proxy(gateway, tmp_path, {}, config=_strict_config(tmp_path), workers=2) as strict,
mcp_peer() as peer,
strict.scenario() as scenario,
):
alias: Final = "lit6029strict" + uuid.uuid4().hex[:6]
server_id: Final = register_mcp(scenario, peer, alias)
granted_id, granted_name = _toolset(scenario, server_id, "add")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
inheriting: Final = _bearer(scenario.key(team_id=team_id))
own: Final = _bearer(scenario.key(team_id=team_id, object_permission={"mcp_toolsets": [granted_id]}))
member: Final = _bearer(_dashboard_ui_session_token(scenario.member(team_id)))
assert _listed_toolset_ids(strict, inheriting) == ()
assert _route_tools(strict, inheriting, granted_name).status == 403
assert _listed_toolset_ids(strict, own) == (granted_id,)
assert _route_tools(strict, own, granted_name).tools == (f"{alias}-add",)
assert _listed_toolset_ids(strict, member) == (granted_id,)
assert _route_tools(strict, member, granted_name).tools == (f"{alias}-add",)
peer.drain()
called: Final = _route_call(strict, member, granted_name, f"{alias}-add")
assert called.ok and called.text == "9", called.raw
assert len(tool_calls(peer.drain())) == 1
def test_a_key_with_only_a_vector_store_grant_still_inherits_the_team_toolset(gateway: Gateway) -> None:
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029vs" + uuid.uuid4().hex[:6]
server_id: Final = register_mcp(scenario, peer, alias)
granted_id, granted_name = _toolset(scenario, server_id, "add")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
headers: Final = _bearer(
scenario.key(team_id=team_id, object_permission={"vector_stores": ["vs-" + uuid.uuid4().hex[:8]]})
)
assert _listed_toolset_ids(gateway, headers) == (granted_id,)
assert _route_tools(gateway, headers, granted_name).tools == (f"{alias}-add",)
peer.drain()
called: Final = _route_call(gateway, headers, granted_name, f"{alias}-add")
assert called.ok and called.text == "9", called.raw
assert len(tool_calls(peer.drain())) == 1
def test_a_member_added_after_the_team_was_cached_sees_the_toolset_on_every_following_request(
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
with mcp_peer() as peer, gateway.scenario() as scenario:
server_id: Final = register_mcp(scenario, peer, "lit6029cache" + uuid.uuid4().hex[:6])
granted_id, _ = _toolset(scenario, server_id, "add")
team_id: Final = scenario.team(object_permission={"mcp_toolsets": [granted_id]})
user_id: Final = scenario.user(user_role="internal_user")
headers: Final = _bearer(_dashboard_ui_session_token(user_id))
warm: Final = _bearer(scenario.key(team_id=team_id))
assert tuple(_listed_toolset_ids(gateway, warm) for _ in range(4)) == ((granted_id,),) * 4
assert tuple(_listed_toolset_ids(gateway, headers) for _ in range(4)) == ((),) * 4
added: Final = gateway.request(
"POST", "/team/member_add", {"team_id": team_id, "member": {"user_id": user_id, "role": "user"}}
)
assert added.status_code == 200, added.text
listings: Final = tuple(_listed_toolset_ids(gateway, headers) for _ in range(8))
assert listings == ((granted_id,),) * 8, listings
def test_a_member_of_two_teams_sees_the_union_and_each_route_stays_narrowed_to_its_own_toolset(
gateway: Gateway, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-integration-salt")
with mcp_peer() as peer, gateway.scenario() as scenario:
alias: Final = "lit6029two" + uuid.uuid4().hex[:6]
server_id: Final = register_mcp(scenario, peer, alias)
first_id, first_name = _toolset(scenario, server_id, "add")
second_id, second_name = _toolset(scenario, server_id, "multiply")
teams: Final = (
scenario.team(object_permission={"mcp_toolsets": [first_id]}),
scenario.team(object_permission={"mcp_toolsets": [second_id]}),
)
headers: Final = _bearer(
_dashboard_ui_session_token(scenario.user(user_role="internal_user", teams=list(teams)))
)
assert set(_listed_toolset_ids(gateway, headers)) == {first_id, second_id}
assert _route_tools(gateway, headers, first_name).tools == (f"{alias}-add",)
assert _route_tools(gateway, headers, second_name).tools == (f"{alias}-multiply",)
peer.drain()
crossed: Final = _route_call(gateway, headers, first_name, f"{alias}-multiply")
assert not crossed.ok, crossed.raw
assert tool_calls(peer.drain()) == ()