mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
* refactor(mcp): drop the experimental label from the MCP server and client paths Move litellm/proxy/_experimental/mcp_server to litellm/proxy/mcp_server and litellm/experimental_mcp_client to litellm/mcp_client, with the mirrored tests moved under tests/unit/proxy/mcp_server and tests/unit/mcp_client. Both old packages stay as shims that warn with DeprecationWarning, re-export the new package's public names and alias every submodule in sys.modules so deep imports keep resolving to the same objects. The OpenAPI docs gate now puts the merge base's enterprise package on PYTHONPATH so its base counts do not mix the checked-out enterprise code with the base litellm tree Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci(mcp): keep tests/unit/proxy/_experimental as a sharded root for the shim tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp): alias namespace subpackages and bind attributes in the deprecated MCP shims pkgutil.walk_packages skipped mcp_server/auth because it has no __init__.py, so old imports of litellm.proxy._experimental.mcp_server.auth.* failed, and sys.modules entries alone left litellm.experimental_mcp_client.tools unreachable as an attribute. Walk the package on disk and setattr each alias on its old parent. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
567 lines
20 KiB
Python
567 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Awaitable, Callable, Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Final, Literal
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from litellm.proxy._types import LiteLLM_MCPServerTable, LitellmUserRoles, UserAPIKeyAuth
|
|
from litellm.proxy.mcp_server.contracts import TargetCatalog
|
|
from litellm.proxy.mcp_server.server_resolution import (
|
|
MCPServerTargetCatalog,
|
|
ResolutionSource,
|
|
ResolvedMCPServer,
|
|
authorize_mcp_server,
|
|
resolve_mcp_server,
|
|
)
|
|
from litellm.proxy.mcp_server.ui_session_utils import can_access_mcp_server
|
|
from litellm.types.mcp import MCPTransport
|
|
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FakeMCPServerManager:
|
|
servers_by_id: Mapping[str, MCPServer]
|
|
servers_by_name: Mapping[str, MCPServer]
|
|
allowed_server_ids: tuple[str, ...]
|
|
id_lookup_spy: Mock
|
|
name_lookup_spy: Mock
|
|
ip_filter_spy: Mock
|
|
allowed_servers_spy: Mock
|
|
ip_accessible: bool
|
|
|
|
def get_mcp_server_by_id(self, server_id: str) -> MCPServer | None:
|
|
self.id_lookup_spy(server_id)
|
|
return self.servers_by_id.get(server_id)
|
|
|
|
def get_mcp_server_by_name(self, server_name: str, client_ip: str | None = None) -> MCPServer | None:
|
|
self.name_lookup_spy(server_name, client_ip)
|
|
return self.servers_by_name.get(server_name)
|
|
|
|
def is_server_accessible_from_ip(self, server: MCPServer, client_ip: str | None) -> bool:
|
|
self.ip_filter_spy(server, client_ip)
|
|
return self.ip_accessible
|
|
|
|
def build_mcp_server_table(self, server: MCPServer) -> LiteLLM_MCPServerTable:
|
|
return LiteLLM_MCPServerTable(
|
|
server_id=server.server_id,
|
|
alias=server.alias,
|
|
server_name=server.server_name,
|
|
url=server.url,
|
|
transport=server.transport,
|
|
)
|
|
|
|
async def get_allowed_mcp_servers(self, user_api_key_auth: UserAPIKeyAuth) -> list[str]:
|
|
self.allowed_servers_spy(user_api_key_auth)
|
|
return list(self.allowed_server_ids)
|
|
|
|
|
|
def _runtime_server(server_id: str = "canonical-server") -> MCPServer:
|
|
return MCPServer(
|
|
server_id=server_id,
|
|
name=server_id,
|
|
alias=f"{server_id}-alias",
|
|
server_name=f"{server_id}-name",
|
|
url="https://example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
)
|
|
|
|
|
|
def _table_server(server_id: str = "database-server") -> LiteLLM_MCPServerTable:
|
|
return LiteLLM_MCPServerTable(
|
|
server_id=server_id,
|
|
alias=f"{server_id}-alias",
|
|
server_name=f"{server_id}-name",
|
|
url="https://example.com/mcp",
|
|
transport=MCPTransport.http,
|
|
)
|
|
|
|
|
|
def _manager(
|
|
*,
|
|
servers_by_id: Mapping[str, MCPServer] | None = None,
|
|
servers_by_name: Mapping[str, MCPServer] | None = None,
|
|
allowed_server_ids: tuple[str, ...] = (),
|
|
ip_accessible: bool = True,
|
|
) -> FakeMCPServerManager:
|
|
return FakeMCPServerManager(
|
|
servers_by_id={} if servers_by_id is None else servers_by_id,
|
|
servers_by_name={} if servers_by_name is None else servers_by_name,
|
|
allowed_server_ids=allowed_server_ids,
|
|
id_lookup_spy=Mock(),
|
|
name_lookup_spy=Mock(),
|
|
ip_filter_spy=Mock(),
|
|
allowed_servers_spy=Mock(),
|
|
ip_accessible=ip_accessible,
|
|
)
|
|
|
|
|
|
def _auth() -> UserAPIKeyAuth:
|
|
return UserAPIKeyAuth(
|
|
user_role=LitellmUserRoles.INTERNAL_USER,
|
|
user_id="resolver-test-user",
|
|
api_key="resolver-test-key",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_temp_resolution_precedes_db_and_registry() -> None:
|
|
temporary_server: Final = _runtime_server("temporary-server")
|
|
manager: Final = _manager(servers_by_id={temporary_server.server_id: temporary_server})
|
|
temp_lookup: Final[Mock] = Mock()
|
|
db_lookup: Final[Mock] = Mock()
|
|
|
|
async def lookup_temp(server_id: str) -> MCPServer | None:
|
|
temp_lookup(server_id)
|
|
return temporary_server
|
|
|
|
async def lookup_db(server_id: str) -> LiteLLM_MCPServerTable | None:
|
|
db_lookup(server_id)
|
|
return _table_server(server_id)
|
|
|
|
resolved: Final = await resolve_mcp_server(
|
|
"requested-id",
|
|
manager=manager,
|
|
temp_lookup=lookup_temp,
|
|
db_lookup=lookup_db,
|
|
)
|
|
|
|
assert resolved == ResolvedMCPServer(
|
|
table=manager.build_mcp_server_table(temporary_server),
|
|
runtime=temporary_server,
|
|
source="temp",
|
|
)
|
|
temp_lookup.assert_called_once_with("requested-id")
|
|
db_lookup.assert_not_called()
|
|
manager.id_lookup_spy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_resolution_precedes_registry_id() -> None:
|
|
database_server: Final = _table_server("database-server")
|
|
registry_server: Final = _runtime_server(database_server.server_id)
|
|
manager: Final = _manager(servers_by_id={registry_server.server_id: registry_server})
|
|
db_lookup: Final = Mock()
|
|
|
|
async def lookup_db(server_id: str) -> LiteLLM_MCPServerTable | None:
|
|
db_lookup(server_id)
|
|
return database_server
|
|
|
|
resolved: Final = await resolve_mcp_server(
|
|
database_server.server_id,
|
|
manager=manager,
|
|
db_lookup=lookup_db,
|
|
)
|
|
|
|
assert resolved == ResolvedMCPServer(table=database_server, runtime=None, source="db")
|
|
db_lookup.assert_called_once_with(database_server.server_id)
|
|
manager.id_lookup_spy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_registry_id_resolution_precedes_name() -> None:
|
|
server: Final = _runtime_server()
|
|
name_collision: Final = _runtime_server("other-server")
|
|
manager: Final = _manager(
|
|
servers_by_id={server.server_id: server},
|
|
servers_by_name={server.server_id: name_collision},
|
|
)
|
|
|
|
resolved: Final = await resolve_mcp_server(
|
|
server.server_id,
|
|
manager=manager,
|
|
match_name=True,
|
|
)
|
|
|
|
assert resolved == ResolvedMCPServer(
|
|
table=manager.build_mcp_server_table(server),
|
|
runtime=server,
|
|
source="registry",
|
|
)
|
|
manager.id_lookup_spy.assert_called_once_with(server.server_id)
|
|
manager.name_lookup_spy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_lookup_ip_arguments_are_scoped_and_name_matching_can_be_disabled() -> None:
|
|
server: Final = _runtime_server()
|
|
manager: Final = _manager(servers_by_name={"server-alias": server})
|
|
|
|
resolved: Final = await resolve_mcp_server(
|
|
"server-alias",
|
|
manager=manager,
|
|
id_client_ip="id-client",
|
|
name_client_ip="name-client",
|
|
match_name=True,
|
|
)
|
|
|
|
assert resolved is not None
|
|
assert resolved.source == "registry"
|
|
assert resolved.runtime == server
|
|
manager.id_lookup_spy.assert_called_once_with("server-alias")
|
|
manager.ip_filter_spy.assert_not_called()
|
|
manager.name_lookup_spy.assert_called_once_with("server-alias", "name-client")
|
|
|
|
disabled_manager: Final = _manager(servers_by_name={"server-alias": server})
|
|
not_resolved: Final = await resolve_mcp_server(
|
|
"server-alias",
|
|
manager=disabled_manager,
|
|
name_client_ip="name-client",
|
|
)
|
|
|
|
assert not_resolved is None
|
|
disabled_manager.id_lookup_spy.assert_called_once_with("server-alias")
|
|
disabled_manager.name_lookup_spy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_id_lookup_applies_ip_filter_after_unfiltered_registry_lookup() -> None:
|
|
server: Final = _runtime_server()
|
|
manager: Final = _manager(servers_by_id={server.server_id: server}, ip_accessible=False)
|
|
|
|
resolved: Final = await resolve_mcp_server(
|
|
server.server_id,
|
|
manager=manager,
|
|
id_client_ip="external-client",
|
|
)
|
|
|
|
assert resolved is None
|
|
manager.id_lookup_spy.assert_called_once_with(server.server_id)
|
|
manager.ip_filter_spy.assert_called_once_with(server, "external-client")
|
|
manager.name_lookup_spy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_db_lookup_none_skips_db_and_returns_registry_source() -> None:
|
|
server: Final = _runtime_server()
|
|
manager: Final = _manager(servers_by_id={server.server_id: server})
|
|
|
|
resolved: Final = await resolve_mcp_server(server.server_id, manager=manager, db_lookup=None)
|
|
|
|
assert resolved == ResolvedMCPServer(
|
|
table=manager.build_mcp_server_table(server),
|
|
runtime=server,
|
|
source="registry",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"is_admin_view,missing_policy,expected_status",
|
|
[
|
|
pytest.param(True, "not_found", 404, id="admin-view-not-found"),
|
|
pytest.param(False, "not_found", 404, id="non-admin-not-found"),
|
|
pytest.param(False, "forbidden", 403, id="non-admin-forbidden"),
|
|
],
|
|
)
|
|
async def test_authorize_missing_uses_caller_policy(
|
|
is_admin_view: bool,
|
|
missing_policy: Literal["not_found", "forbidden"],
|
|
expected_status: int,
|
|
) -> None:
|
|
manager: Final = _manager()
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize_mcp_server(
|
|
None,
|
|
_auth(),
|
|
manager=manager,
|
|
is_admin_view=is_admin_view,
|
|
not_found_detail={"error": "not found"},
|
|
forbidden_detail={"error": "forbidden"},
|
|
non_admin_missing=missing_policy,
|
|
)
|
|
|
|
assert exc_info.value.status_code == expected_status
|
|
assert exc_info.value.detail == ({"error": "not found"} if expected_status == 404 else {"error": "forbidden"})
|
|
manager.allowed_servers_spy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_admin_temp_resolution_is_denied_before_allowed_lookup() -> None:
|
|
server: Final = _runtime_server()
|
|
manager: Final = _manager(allowed_server_ids=(server.server_id,))
|
|
resolved: Final = ResolvedMCPServer(
|
|
table=manager.build_mcp_server_table(server),
|
|
runtime=server,
|
|
source="temp",
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize_mcp_server(
|
|
resolved,
|
|
_auth(),
|
|
manager=manager,
|
|
is_admin_view=False,
|
|
not_found_detail={"error": "not found"},
|
|
forbidden_detail={"error": "forbidden"},
|
|
non_admin_missing="not_found",
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert exc_info.value.detail == {"error": "forbidden"}
|
|
manager.allowed_servers_spy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"allowed_server_ids,expected_status",
|
|
[
|
|
pytest.param(("canonical-server",), None, id="allowed-canonical-id"),
|
|
pytest.param((), 403, id="denied-canonical-id"),
|
|
],
|
|
)
|
|
async def test_authorize_uses_real_access_helper_for_canonical_id(
|
|
allowed_server_ids: tuple[str, ...],
|
|
expected_status: int | None,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
server: Final = _runtime_server("canonical-server")
|
|
manager: Final = _manager(
|
|
servers_by_name={"display-alias": server},
|
|
allowed_server_ids=allowed_server_ids,
|
|
)
|
|
resolved: Final = await resolve_mcp_server(
|
|
"display-alias",
|
|
manager=manager,
|
|
match_name=True,
|
|
)
|
|
assert resolved is not None
|
|
access_spy: Final = Mock()
|
|
|
|
async def spy_access(
|
|
user_api_key_auth: UserAPIKeyAuth,
|
|
requested_server_id: str,
|
|
allowed_servers: Callable[[UserAPIKeyAuth], Awaitable[list[str]]],
|
|
) -> bool:
|
|
access_spy(requested_server_id)
|
|
return await can_access_mcp_server(user_api_key_auth, requested_server_id, allowed_servers)
|
|
|
|
monkeypatch.setattr(
|
|
"litellm.proxy.mcp_server.server_resolution.can_access_mcp_server",
|
|
spy_access,
|
|
)
|
|
if expected_status is None:
|
|
authorized: Final = await authorize_mcp_server(
|
|
resolved,
|
|
_auth(),
|
|
manager=manager,
|
|
is_admin_view=False,
|
|
not_found_detail={"error": "not found"},
|
|
forbidden_detail={"error": "forbidden"},
|
|
non_admin_missing="not_found",
|
|
)
|
|
assert authorized is resolved
|
|
else:
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await authorize_mcp_server(
|
|
resolved,
|
|
_auth(),
|
|
manager=manager,
|
|
is_admin_view=False,
|
|
not_found_detail={"error": "not found"},
|
|
forbidden_detail={"error": "forbidden"},
|
|
non_admin_missing="not_found",
|
|
)
|
|
|
|
assert exc_info.value.status_code == expected_status
|
|
assert exc_info.value.detail == {"error": "forbidden"}
|
|
|
|
access_spy.assert_called_once_with(server.server_id)
|
|
manager.allowed_servers_spy.assert_called_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("source", ["db", "registry", "temp"])
|
|
@pytest.mark.parametrize("admin", [False, True])
|
|
async def test_catalog_visibility_never_opens_temporary_setup_to_non_admins(
|
|
source: ResolutionSource,
|
|
admin: bool,
|
|
) -> None:
|
|
server: Final = _runtime_server()
|
|
manager: Final = _manager()
|
|
resolved: Final = ResolvedMCPServer(manager.build_mcp_server_table(server), server, source)
|
|
operation: Final = authorize_mcp_server(
|
|
resolved,
|
|
_auth(),
|
|
manager=manager,
|
|
is_admin_view=admin,
|
|
not_found_detail={"error": "missing"},
|
|
forbidden_detail={"error": "forbidden"},
|
|
non_admin_missing="forbidden",
|
|
allow_catalog_view=True,
|
|
)
|
|
if source == "temp" and not admin:
|
|
with pytest.raises(HTTPException) as error:
|
|
await operation
|
|
assert error.value.status_code == 403
|
|
assert error.value.detail == {"error": "forbidden"}
|
|
else:
|
|
assert await operation is resolved
|
|
manager.allowed_servers_spy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_temp_and_db_lookups_fall_through_to_ip_filtered_registry() -> None:
|
|
server: Final = _runtime_server()
|
|
manager: Final = _manager(servers_by_id={server.server_id: server})
|
|
lookups: Final = Mock()
|
|
|
|
async def temp_lookup(server_id: str) -> MCPServer | None:
|
|
lookups.temp(server_id)
|
|
return None
|
|
|
|
async def db_lookup(server_id: str) -> LiteLLM_MCPServerTable | None:
|
|
lookups.db(server_id)
|
|
return None
|
|
|
|
resolved: Final = await resolve_mcp_server(
|
|
server.server_id,
|
|
manager=manager,
|
|
temp_lookup=temp_lookup,
|
|
db_lookup=db_lookup,
|
|
id_client_ip="127.0.0.1",
|
|
)
|
|
assert resolved is not None
|
|
assert resolved.runtime is server
|
|
assert resolved.source == "registry"
|
|
assert [call[0] for call in lookups.mock_calls] == ["temp", "db"]
|
|
manager.ip_filter_spy.assert_called_once_with(server, "127.0.0.1")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("failure", [RuntimeError, asyncio.CancelledError])
|
|
@pytest.mark.parametrize("source", ["db", "temp"])
|
|
async def test_lookup_failure_or_cancellation_never_falls_back(
|
|
failure: type[RuntimeError] | type[asyncio.CancelledError],
|
|
source: str,
|
|
) -> None:
|
|
server: Final = _runtime_server()
|
|
manager: Final = _manager(servers_by_id={server.server_id: server})
|
|
|
|
async def lookup(server_id: str) -> None:
|
|
raise failure(server_id)
|
|
|
|
with pytest.raises(failure, match=server.server_id):
|
|
await resolve_mcp_server(
|
|
server.server_id,
|
|
manager=manager,
|
|
db_lookup=lookup if source == "db" else None,
|
|
temp_lookup=lookup if source == "temp" else None,
|
|
)
|
|
manager.id_lookup_spy.assert_not_called()
|
|
manager.name_lookup_spy.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_alias_does_not_produce_a_resolution() -> None:
|
|
manager: Final = _manager()
|
|
assert await resolve_mcp_server("missing", manager=manager, match_name=True) is None
|
|
manager.name_lookup_spy.assert_called_once_with("missing", None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("source", ["db", "registry", "temp"])
|
|
@pytest.mark.parametrize("allowed", [False, True])
|
|
async def test_target_catalog_authorizes_canonical_identity(source: ResolutionSource, allowed: bool) -> None:
|
|
server: Final = _runtime_server()
|
|
manager: Final = _manager(
|
|
servers_by_name={"requested-alias": server},
|
|
allowed_server_ids=(server.server_id,) if allowed else ("requested-alias",),
|
|
)
|
|
|
|
async def database_lookup(server_id: str) -> LiteLLM_MCPServerTable | None:
|
|
return _table_server(server.server_id)
|
|
|
|
async def temporary_lookup(server_id: str) -> MCPServer | None:
|
|
return server
|
|
|
|
catalog: Final[TargetCatalog] = MCPServerTargetCatalog(
|
|
manager=manager,
|
|
db_lookup=database_lookup if source == "db" else None,
|
|
temp_lookup=temporary_lookup if source == "temp" else None,
|
|
match_name=True,
|
|
)
|
|
operation: Final = catalog.resolve(
|
|
"requested-alias",
|
|
_auth(),
|
|
is_admin_view=False,
|
|
not_found_detail={"error": "missing"},
|
|
forbidden_detail={"error": "denied"},
|
|
non_admin_missing="forbidden",
|
|
)
|
|
if allowed and source != "temp":
|
|
result: Final = await operation
|
|
assert result.table.server_id == server.server_id
|
|
assert result.source == source
|
|
assert result.runtime is (None if source == "db" else server)
|
|
else:
|
|
with pytest.raises(HTTPException) as error:
|
|
await operation
|
|
assert (error.value.status_code, error.value.detail) == (403, {"error": "denied"})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"admin,missing,status_code", [(True, "forbidden", 404), (False, "forbidden", 403), (False, "not_found", 404)]
|
|
)
|
|
async def test_target_catalog_preserves_missing_target_policy(
|
|
admin: bool,
|
|
missing: Literal["forbidden", "not_found"],
|
|
status_code: int,
|
|
) -> None:
|
|
catalog: Final[TargetCatalog] = MCPServerTargetCatalog(manager=_manager())
|
|
with pytest.raises(HTTPException) as error:
|
|
await catalog.resolve(
|
|
"missing",
|
|
_auth(),
|
|
is_admin_view=admin,
|
|
not_found_detail={"error": "missing"},
|
|
forbidden_detail={"error": "denied"},
|
|
non_admin_missing=missing,
|
|
)
|
|
assert error.value.status_code == status_code
|
|
assert error.value.detail == {"error": "missing" if status_code == 404 else "denied"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_target_catalog_does_not_reuse_admin_authorization_for_another_caller() -> None:
|
|
server: Final = _runtime_server()
|
|
manager: Final = _manager(servers_by_id={server.server_id: server})
|
|
catalog: Final[TargetCatalog] = MCPServerTargetCatalog(manager=manager)
|
|
admin: Final = await catalog.resolve(
|
|
server.server_id,
|
|
UserAPIKeyAuth(user_id="admin"),
|
|
is_admin_view=True,
|
|
not_found_detail={"error": "missing"},
|
|
forbidden_detail={"error": "denied"},
|
|
non_admin_missing="forbidden",
|
|
)
|
|
assert admin.runtime is server
|
|
with pytest.raises(HTTPException) as error:
|
|
await catalog.resolve(
|
|
server.server_id,
|
|
_auth(),
|
|
is_admin_view=False,
|
|
not_found_detail={"error": "missing"},
|
|
forbidden_detail={"error": "denied"},
|
|
non_admin_missing="forbidden",
|
|
)
|
|
assert (error.value.status_code, error.value.detail) == (403, {"error": "denied"})
|
|
manager.allowed_servers_spy.assert_called_once_with(_auth())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_catalog_without_listing_dependency_fails_explicitly():
|
|
from mcp.types import ListToolsRequest
|
|
|
|
from litellm.proxy.mcp_server.contracts import OperationContext
|
|
from litellm.proxy.mcp_server.mcp_server_manager import MCPServerManager
|
|
from litellm.proxy.mcp_server.server_resolution import MCPServerTargetCatalog
|
|
|
|
catalog = MCPServerTargetCatalog(MCPServerManager())
|
|
with pytest.raises(RuntimeError, match="listing dependency"):
|
|
await catalog.list(OperationContext(_caller=None), ListToolsRequest())
|