mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
* 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>
151 lines
6.2 KiB
Python
151 lines
6.2 KiB
Python
import uuid
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Final, Literal
|
|
|
|
from integration._support.client import Gateway, Scenario, string_value
|
|
|
|
Subject = Literal["key", "team", "org", "user", "end_user", "agent", "access_group", "toolset", "allowed_tools"]
|
|
SUBJECTS: Final[tuple[Subject, ...]] = (
|
|
"key",
|
|
"team",
|
|
"org",
|
|
"user",
|
|
"end_user",
|
|
"agent",
|
|
"access_group",
|
|
"toolset",
|
|
"allowed_tools",
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Caller:
|
|
"""A key plus the request headers that make the proxy resolve the granted subject."""
|
|
|
|
key: str
|
|
headers: Mapping[str, str]
|
|
|
|
|
|
def _mcp_permission(server_ids: tuple[str, ...]) -> dict[str, list[str]]:
|
|
return {"mcp_servers": list(server_ids)}
|
|
|
|
|
|
def delete_organization(gateway: Gateway, identity: str) -> None:
|
|
response: Final = gateway.request("DELETE", "/organization/delete", {"organization_ids": [identity]})
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
def delete_end_user(gateway: Gateway, identity: str) -> None:
|
|
response: Final = gateway.request("POST", "/end_user/delete", {"user_ids": [identity]})
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
def delete_agent(gateway: Gateway, identity: str) -> None:
|
|
response: Final = gateway.request("DELETE", f"/v1/agents/{identity}")
|
|
assert response.status_code == 200, response.text
|
|
|
|
|
|
def delete_toolset(gateway: Gateway, identity: str) -> None:
|
|
response: Final = gateway.request("DELETE", f"/v1/mcp/toolset/{identity}")
|
|
assert response.status_code in (200, 202, 204), response.text
|
|
|
|
|
|
def create_toolset(scenario: Scenario, tools: tuple[tuple[str, str], ...], toolset_name: str | None = None) -> str:
|
|
response: Final = scenario.gateway.request(
|
|
"POST",
|
|
"/v1/mcp/toolset",
|
|
{
|
|
"toolset_name": toolset_name or f"integration-{uuid.uuid4().hex[:10]}",
|
|
"tools": [{"server_id": server_id, "tool_name": tool} for server_id, tool in tools],
|
|
},
|
|
)
|
|
assert response.status_code == 201, response.text
|
|
identity: Final = string_value(response.json()["toolset_id"])
|
|
scenario.cleanups.callback(delete_toolset, scenario.gateway, identity)
|
|
return identity
|
|
|
|
|
|
def grant(
|
|
scenario: Scenario,
|
|
subject: Subject,
|
|
granted: tuple[str, ...],
|
|
ceiling: tuple[str, ...],
|
|
*,
|
|
access_group: str | None = None,
|
|
allowed_tools: Mapping[str, tuple[str, ...]] | None = None,
|
|
) -> Caller:
|
|
"""Build a caller whose ``subject`` level grants exactly ``granted`` out of ``ceiling``.
|
|
|
|
``ceiling`` is what the key itself can reach before the subject narrows it; the key subject grants
|
|
``granted`` directly. Access groups take the group name that the granted servers were registered with,
|
|
and ``allowed_tools`` maps server id to the tools the key may call on it."""
|
|
gateway: Final = scenario.gateway
|
|
match subject:
|
|
case "key":
|
|
return Caller(scenario.key(object_permission=_mcp_permission(granted)), {})
|
|
case "team":
|
|
team: Final = scenario.team(object_permission=_mcp_permission(granted))
|
|
return Caller(scenario.key(team_id=team), {})
|
|
case "org":
|
|
created: Final = gateway.post(
|
|
"/organization/new",
|
|
{
|
|
"organization_alias": f"integration-{uuid.uuid4().hex[:10]}",
|
|
"object_permission": _mcp_permission(granted),
|
|
},
|
|
)
|
|
org: Final = string_value(created["organization_id"])
|
|
scenario.cleanups.callback(delete_organization, gateway, org)
|
|
org_team: Final = scenario.team(organization_id=org, object_permission=_mcp_permission(ceiling))
|
|
return Caller(scenario.key(team_id=org_team), {})
|
|
case "user":
|
|
user: Final = scenario.user(object_permission=_mcp_permission(granted))
|
|
return Caller(scenario.key(user_id=user, object_permission=_mcp_permission(ceiling)), {})
|
|
case "end_user":
|
|
end_user: Final = f"integration-{uuid.uuid4().hex[:10]}"
|
|
response: Final = gateway.request(
|
|
"POST", "/end_user/new", {"user_id": end_user, "object_permission": _mcp_permission(granted)}
|
|
)
|
|
assert response.status_code == 200, response.text
|
|
scenario.cleanups.callback(delete_end_user, gateway, end_user)
|
|
return Caller(scenario.key(object_permission=_mcp_permission(ceiling)), {"x-litellm-end-user-id": end_user})
|
|
case "agent":
|
|
agent: Final = gateway.post(
|
|
"/v1/agents",
|
|
{
|
|
"agent_name": f"integration-{uuid.uuid4().hex[:10]}",
|
|
"agent_card_params": {
|
|
"protocolVersion": "0.3.0",
|
|
"name": "integration",
|
|
"description": "integration agent",
|
|
"url": "http://127.0.0.1:1/agent",
|
|
"version": "1",
|
|
"capabilities": {},
|
|
"defaultInputModes": ["text"],
|
|
"defaultOutputModes": ["text"],
|
|
"skills": [],
|
|
},
|
|
"object_permission": _mcp_permission(granted),
|
|
},
|
|
)
|
|
agent_id: Final = string_value(agent["agent_id"])
|
|
scenario.cleanups.callback(delete_agent, gateway, agent_id)
|
|
return Caller(scenario.key(agent_id=agent_id, object_permission=_mcp_permission(ceiling)), {})
|
|
case "access_group":
|
|
assert access_group is not None
|
|
return Caller(scenario.key(object_permission={"mcp_access_groups": [access_group]}), {})
|
|
case "toolset":
|
|
toolset: Final = create_toolset(scenario, tuple((server, "add") for server in granted))
|
|
return Caller(scenario.key(object_permission={"mcp_toolsets": [toolset]}), {})
|
|
case "allowed_tools":
|
|
assert allowed_tools is not None
|
|
return Caller(
|
|
scenario.key(
|
|
object_permission={
|
|
"mcp_servers": list(granted),
|
|
"mcp_tool_permissions": {server: list(tools) for server, tools in allowed_tools.items()},
|
|
}
|
|
),
|
|
{},
|
|
)
|