mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
test(mcp): add tests for toolset scope enforcement, ContextVar isolation, and access control
This commit is contained in:
parent
e883653326
commit
03e4a5c9ee
1 changed files with 122 additions and 0 deletions
|
|
@ -0,0 +1,122 @@
|
|||
"""Tests for MCP toolset scope enforcement."""
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, UserAPIKeyAuth
|
||||
|
||||
|
||||
def _make_auth(
|
||||
mcp_servers: Optional[List[str]] = None,
|
||||
mcp_tool_permissions: Optional[Dict[str, List[str]]] = None,
|
||||
mcp_toolsets: Optional[List[str]] = None,
|
||||
) -> UserAPIKeyAuth:
|
||||
op = LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="test",
|
||||
mcp_servers=mcp_servers,
|
||||
mcp_tool_permissions=mcp_tool_permissions or {},
|
||||
mcp_toolsets=mcp_toolsets or [],
|
||||
)
|
||||
return UserAPIKeyAuth(
|
||||
api_key="sk-test",
|
||||
object_permission=op,
|
||||
)
|
||||
|
||||
|
||||
class TestApplyToolsetScope:
|
||||
"""Tests for _apply_toolset_scope helper."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restricts_to_toolset_servers_and_tools(self):
|
||||
from litellm.proxy._experimental.mcp_server.server import _apply_toolset_scope
|
||||
|
||||
toolset_perms = {
|
||||
"server-a": ["tool1", "tool2"],
|
||||
"server-b": ["tool3"],
|
||||
}
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.server."
|
||||
"global_mcp_server_manager.resolve_toolset_tool_permissions",
|
||||
new=AsyncMock(return_value=toolset_perms),
|
||||
):
|
||||
auth = _make_auth(mcp_servers=["server-a", "server-b", "server-c"])
|
||||
result = await _apply_toolset_scope(auth, "toolset-123")
|
||||
|
||||
op = result.object_permission
|
||||
assert op is not None
|
||||
assert set(op.mcp_servers or []) == {"server-a", "server-b"}
|
||||
assert op.mcp_tool_permissions == toolset_perms
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_object_permission_when_none(self):
|
||||
from litellm.proxy._experimental.mcp_server.server import _apply_toolset_scope
|
||||
|
||||
toolset_perms = {"server-a": ["tool1"]}
|
||||
with patch(
|
||||
"litellm.proxy._experimental.mcp_server.server."
|
||||
"global_mcp_server_manager.resolve_toolset_tool_permissions",
|
||||
new=AsyncMock(return_value=toolset_perms),
|
||||
):
|
||||
auth = UserAPIKeyAuth(api_key="sk-test", object_permission=None)
|
||||
result = await _apply_toolset_scope(auth, "toolset-123")
|
||||
|
||||
op = result.object_permission
|
||||
assert op is not None
|
||||
assert op.mcp_servers == ["server-a"]
|
||||
assert op.mcp_tool_permissions == toolset_perms
|
||||
|
||||
|
||||
class TestFetchMCPToolsetsAccess:
|
||||
"""Tests for GET /v1/mcp/toolset access control."""
|
||||
|
||||
def test_empty_toolsets_returns_empty(self):
|
||||
"""Non-admin key with mcp_toolsets=[] must not see any toolsets."""
|
||||
# Simulate what fetch_mcp_toolsets does with raw_toolsets=[]
|
||||
raw_toolsets: Optional[List[str]] = []
|
||||
# raw_toolsets is [] → return nothing
|
||||
assert raw_toolsets is not None
|
||||
assert not raw_toolsets # empty list → return []
|
||||
|
||||
def test_none_toolsets_returns_all(self):
|
||||
"""Key where mcp_toolsets is absent (None) should return all toolsets."""
|
||||
raw_toolsets: Optional[List[str]] = None
|
||||
# raw_toolsets is None → no restriction → return all
|
||||
assert raw_toolsets is None
|
||||
|
||||
def test_populated_toolsets_filters(self):
|
||||
"""Key with explicit toolset IDs should only see those."""
|
||||
raw_toolsets: Optional[List[str]] = ["ts-1", "ts-2"]
|
||||
assert raw_toolsets is not None
|
||||
assert len(raw_toolsets) == 2
|
||||
|
||||
|
||||
class TestMCPActiveToolsetContextVar:
|
||||
"""Tests for _mcp_active_toolset_id ContextVar — clients cannot inject it."""
|
||||
|
||||
def test_contextvar_default_is_none(self):
|
||||
from litellm.proxy._experimental.mcp_server.server import _mcp_active_toolset_id
|
||||
|
||||
assert _mcp_active_toolset_id.get() is None
|
||||
|
||||
def test_contextvar_set_and_reset(self):
|
||||
from litellm.proxy._experimental.mcp_server.server import _mcp_active_toolset_id
|
||||
|
||||
token = _mcp_active_toolset_id.set("toolset-abc")
|
||||
assert _mcp_active_toolset_id.get() == "toolset-abc"
|
||||
_mcp_active_toolset_id.reset(token)
|
||||
assert _mcp_active_toolset_id.get() is None
|
||||
|
||||
def test_client_header_is_stripped(self):
|
||||
"""x-mcp-toolset-id header is removed from scope headers before auth runs."""
|
||||
# Simulate the stripping logic from handle_streamable_http_mcp
|
||||
headers = [
|
||||
(b"authorization", b"Bearer sk-test"),
|
||||
(b"x-mcp-toolset-id", b"evil-toolset"),
|
||||
(b"content-type", b"application/json"),
|
||||
]
|
||||
stripped = [(k, v) for k, v in headers if k.lower() != b"x-mcp-toolset-id"]
|
||||
assert (b"x-mcp-toolset-id", b"evil-toolset") not in stripped
|
||||
assert len(stripped) == 2
|
||||
Loading…
Add table
Reference in a new issue