mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
Merge pull request #19379 from BerriAI/litellm_feat_mcp_version_up
[feat] mcp version up
This commit is contained in:
commit
00814d4d90
15 changed files with 275 additions and 80 deletions
|
|
@ -44,8 +44,8 @@ commands:
|
|||
pip install "pytest-asyncio==0.21.1"
|
||||
pip install "respx==0.22.0"
|
||||
pip install "hypercorn==0.17.3"
|
||||
pip install "pydantic==2.10.2"
|
||||
pip install "mcp==1.10.1"
|
||||
pip install "pydantic==2.11.0"
|
||||
pip install "mcp==1.25.0"
|
||||
pip install "requests-mock>=1.12.1"
|
||||
pip install "responses==0.25.7"
|
||||
pip install "pytest-xdist==3.6.1"
|
||||
|
|
@ -1152,8 +1152,8 @@ jobs:
|
|||
pip install "pytest-cov==5.0.0"
|
||||
pip install "pytest-asyncio==0.21.1"
|
||||
pip install "respx==0.22.0"
|
||||
pip install "pydantic==2.10.2"
|
||||
pip install "mcp==1.21.2"
|
||||
pip install "pydantic==2.11.0"
|
||||
pip install "mcp==1.25.0"
|
||||
# Run pytest and generate JUnit XML report
|
||||
- run:
|
||||
name: Run tests
|
||||
|
|
@ -1556,8 +1556,8 @@ jobs:
|
|||
pip install "pytest-asyncio==0.21.1"
|
||||
pip install "respx==0.22.0"
|
||||
pip install "hypercorn==0.17.3"
|
||||
pip install "pydantic==2.10.2"
|
||||
pip install "mcp==1.10.1"
|
||||
pip install "pydantic==2.11.0"
|
||||
pip install "mcp==1.25.0"
|
||||
pip install "requests-mock>=1.12.1"
|
||||
pip install "responses==0.25.7"
|
||||
pip install "pytest-xdist==3.6.1"
|
||||
|
|
@ -1915,7 +1915,7 @@ jobs:
|
|||
pip install "pytest-asyncio==0.21.1"
|
||||
pip install "pytest-cov==5.0.0"
|
||||
pip install "tomli==2.2.1"
|
||||
pip install "mcp==1.10.1"
|
||||
pip install "mcp==1.25.0"
|
||||
- run:
|
||||
name: Run tests
|
||||
command: |
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ redis==5.2.1
|
|||
redisvl==0.4.1
|
||||
anthropic
|
||||
orjson==3.10.12 # fast /embedding responses
|
||||
pydantic==2.10.2
|
||||
pydantic==2.11.0
|
||||
google-cloud-aiplatform==1.43.0
|
||||
google-cloud-iam==2.19.1
|
||||
fastapi-sso==0.16.0
|
||||
uvloop==0.21.0
|
||||
mcp==1.10.1 # for MCP server
|
||||
mcp==1.25.0 # for MCP server
|
||||
semantic_router==0.1.10 # for auto-routing with litellm
|
||||
fastuuid==0.12.0
|
||||
responses==0.25.7 # for proxy client tests
|
||||
4
.github/workflows/test-mcp.yml
vendored
4
.github/workflows/test-mcp.yml
vendored
|
|
@ -34,8 +34,8 @@ jobs:
|
|||
poetry run pip install "pytest-cov==5.0.0"
|
||||
poetry run pip install "pytest-asyncio==0.21.1"
|
||||
poetry run pip install "respx==0.22.0"
|
||||
poetry run pip install "pydantic==2.10.2"
|
||||
poetry run pip install "mcp==1.10.1"
|
||||
poetry run pip install "pydantic==2.11.0"
|
||||
poetry run pip install "mcp==1.25.0"
|
||||
poetry run pip install pytest-xdist
|
||||
|
||||
- name: Setup litellm-enterprise as local package
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ LiteLLM Proxy provides an MCP Gateway that allows you to use a fixed endpoint fo
|
|||
| Supported MCP Transports | • Streamable HTTP<br/>• SSE<br/>• Standard Input/Output (stdio) |
|
||||
| LiteLLM Permission Management | • By Key<br/>• By Team<br/>• By Organization |
|
||||
|
||||
:::caution MCP protocol update
|
||||
Starting in LiteLLM v1.80.18, the LiteLLM MCP protocol version is `2025-11-25`.<br/>
|
||||
LiteLLM namespaces multiple MCP servers by prefixing each tool name with its MCP server name, so newly created servers now must use names that comply with SEP-986—noncompliant names cannot be added anymore. Existing servers that still violate SEP-986 only emit warnings today, but future MCP-side rollouts may block those names entirely, so we recommend updating any legacy server names proactively before MCP enforcement makes them unusable.
|
||||
:::
|
||||
|
||||
## Adding your MCP
|
||||
|
||||
### Prerequisites
|
||||
|
|
|
|||
|
|
@ -4,14 +4,13 @@ LiteLLM Proxy uses this MCP Client to connnect to other MCP servers.
|
|||
|
||||
import asyncio
|
||||
import base64
|
||||
from datetime import timedelta
|
||||
from typing import Awaitable, Callable, Dict, List, Optional, TypeVar, Union
|
||||
|
||||
import httpx
|
||||
from mcp import ClientSession, ReadResourceResult, Resource, StdioServerParameters
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import (
|
||||
CallToolRequestParams as MCPCallToolRequestParams,
|
||||
GetPromptRequestParams,
|
||||
|
|
@ -80,6 +79,7 @@ class MCPClient:
|
|||
) -> TSessionResult:
|
||||
"""Open a session, run the provided coroutine, and clean up."""
|
||||
transport_ctx = None
|
||||
http_client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
try:
|
||||
if self.transport_type == MCPTransport.stdio:
|
||||
|
|
@ -105,13 +105,15 @@ class MCPClient:
|
|||
headers = self._get_auth_headers()
|
||||
httpx_client_factory = self._create_httpx_client_factory()
|
||||
verbose_logger.debug(
|
||||
"litellm headers for streamablehttp_client: %s", headers
|
||||
"litellm headers for streamable_http_client: %s", headers
|
||||
)
|
||||
transport_ctx = streamablehttp_client(
|
||||
url=self.server_url,
|
||||
timeout=timedelta(seconds=self.timeout),
|
||||
http_client = httpx_client_factory(
|
||||
headers=headers,
|
||||
httpx_client_factory=httpx_client_factory,
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
)
|
||||
transport_ctx = streamable_http_client(
|
||||
url=self.server_url,
|
||||
http_client=http_client,
|
||||
)
|
||||
|
||||
if transport_ctx is None:
|
||||
|
|
@ -128,6 +130,9 @@ class MCPClient:
|
|||
"MCP client run_with_session failed for %s", self.server_url or "stdio"
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if http_client is not None:
|
||||
await http_client.aclose()
|
||||
|
||||
def update_auth_value(self, mcp_auth_value: Union[str, Dict[str, str]]):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
|||
MCPRequestHandler,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
MCP_TOOL_PREFIX_SEPARATOR,
|
||||
add_server_prefix_to_name,
|
||||
get_server_prefix,
|
||||
is_tool_name_prefixed,
|
||||
|
|
@ -61,6 +62,45 @@ from litellm.types.mcp_server.mcp_server_manager import (
|
|||
MCPOAuthMetadata,
|
||||
MCPServer,
|
||||
)
|
||||
from mcp.shared.tool_name_validation import SEP_986_URL, validate_tool_name
|
||||
|
||||
|
||||
# Probe includes characters on both sides of the separator to mimic real prefixed tool names.
|
||||
_separator_probe_tool_name = f"litellm{MCP_TOOL_PREFIX_SEPARATOR}probe"
|
||||
_separator_probe = validate_tool_name(_separator_probe_tool_name)
|
||||
if not _separator_probe.is_valid:
|
||||
verbose_logger.warning(
|
||||
"MCP tool prefix separator '%s' violates SEP-986. See %s",
|
||||
MCP_TOOL_PREFIX_SEPARATOR,
|
||||
SEP_986_URL,
|
||||
)
|
||||
|
||||
|
||||
def _warn_on_server_name_fields(
|
||||
*,
|
||||
server_id: str,
|
||||
alias: Optional[str],
|
||||
server_name: Optional[str],
|
||||
):
|
||||
def _warn(field_name: str, value: Optional[str]) -> None:
|
||||
if not value:
|
||||
return
|
||||
result = validate_tool_name(value)
|
||||
if result.is_valid:
|
||||
return
|
||||
|
||||
warning_text = "; ".join(result.warnings) if result.warnings else "Validation failed"
|
||||
verbose_logger.warning(
|
||||
"MCP server '%s' has invalid %s '%s': %s",
|
||||
server_id,
|
||||
field_name,
|
||||
value,
|
||||
warning_text,
|
||||
)
|
||||
|
||||
_warn("alias", alias)
|
||||
_warn("server_name", server_name)
|
||||
|
||||
|
||||
|
||||
def _deserialize_json_dict(data: Any) -> Optional[Dict[str, str]]:
|
||||
|
|
@ -209,6 +249,12 @@ class MCPServerManager:
|
|||
alias=alias,
|
||||
)
|
||||
|
||||
_warn_on_server_name_fields(
|
||||
server_id=server_id,
|
||||
alias=alias,
|
||||
server_name=server_name,
|
||||
)
|
||||
|
||||
auth_type = server_config.get("auth_type", None)
|
||||
if server_url and auth_type is not None and auth_type == MCPAuth.oauth2:
|
||||
mcp_oauth_metadata = await self._descovery_metadata(
|
||||
|
|
@ -2099,6 +2145,11 @@ class MCPServerManager:
|
|||
new_registry[server.server_id] = existing_server
|
||||
continue
|
||||
|
||||
_warn_on_server_name_fields(
|
||||
server_id=server.server_id,
|
||||
alias=getattr(server, "alias", None),
|
||||
server_name=getattr(server, "server_name", None),
|
||||
)
|
||||
verbose_logger.debug(
|
||||
f"Building server from DB: {server.server_id} ({server.server_name})"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -786,7 +786,6 @@ if MCP_AVAILABLE:
|
|||
add_prefix=add_prefix,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
|
||||
filtered_tools = filter_tools_by_allowed_tools(tools, server)
|
||||
|
||||
filtered_tools = await filter_tools_by_key_team_permissions(
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ from litellm._uuid import uuid
|
|||
from litellm.constants import LITELLM_PROXY_ADMIN_NAME
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
get_server_prefix,
|
||||
validate_and_normalize_mcp_server_payload,
|
||||
validate_and_normalize_mcp_server_payload as _base_validate_and_normalize_mcp_server_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/v1/mcp", tags=["mcp"])
|
||||
|
|
@ -56,6 +56,7 @@ except ImportError as e:
|
|||
MCP_AVAILABLE = False
|
||||
|
||||
if MCP_AVAILABLE:
|
||||
from mcp.shared.tool_name_validation import validate_tool_name
|
||||
from litellm.proxy._experimental.mcp_server.db import (
|
||||
create_mcp_server,
|
||||
delete_mcp_server,
|
||||
|
|
@ -97,6 +98,43 @@ if MCP_AVAILABLE:
|
|||
server: MCPServer
|
||||
expires_at: datetime
|
||||
|
||||
def _validate_mcp_server_name_fields(payload: Any) -> None:
|
||||
candidates: List[tuple[str, Optional[str]]] = []
|
||||
|
||||
server_name = getattr(payload, "server_name", None)
|
||||
alias = getattr(payload, "alias", None)
|
||||
|
||||
if server_name:
|
||||
candidates.append(("server_name", server_name))
|
||||
if alias:
|
||||
candidates.append(("alias", alias))
|
||||
|
||||
for field_name, value in candidates:
|
||||
if not value:
|
||||
continue
|
||||
|
||||
validation_result = validate_tool_name(value)
|
||||
if validation_result.is_valid:
|
||||
continue
|
||||
|
||||
error_messages_text = (
|
||||
f"Invalid MCP tool prefix '{value}' provided via {field_name}"
|
||||
)
|
||||
if validation_result.warnings:
|
||||
error_messages_text = (
|
||||
error_messages_text
|
||||
+ "\n"
|
||||
+ "\n".join(validation_result.warnings)
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": error_messages_text},
|
||||
)
|
||||
|
||||
def validate_and_normalize_mcp_server_payload(payload: Any) -> None:
|
||||
_base_validate_and_normalize_mcp_server_payload(payload)
|
||||
_validate_mcp_server_name_fields(payload)
|
||||
|
||||
def _is_public_registry_enabled() -> bool:
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings as proxy_general_settings,
|
||||
|
|
|
|||
22
poetry.lock
generated
22
poetry.lock
generated
|
|
@ -1,4 +1,4 @@
|
|||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "a2a-sdk"
|
||||
|
|
@ -2204,8 +2204,6 @@ files = [
|
|||
{file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"},
|
||||
{file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"},
|
||||
|
|
@ -2215,8 +2213,6 @@ files = [
|
|||
{file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"},
|
||||
{file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"},
|
||||
|
|
@ -2226,8 +2222,6 @@ files = [
|
|||
{file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"},
|
||||
{file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"},
|
||||
|
|
@ -2237,8 +2231,6 @@ files = [
|
|||
{file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"},
|
||||
{file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"},
|
||||
|
|
@ -2246,8 +2238,6 @@ files = [
|
|||
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"},
|
||||
{file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"},
|
||||
|
|
@ -2257,8 +2247,6 @@ files = [
|
|||
{file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"},
|
||||
{file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"},
|
||||
{file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"},
|
||||
|
|
@ -3370,15 +3358,15 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.22.0"
|
||||
version = "1.25.0"
|
||||
description = "Model Context Protocol SDK"
|
||||
optional = true
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.10\" and extra == \"proxy\""
|
||||
files = [
|
||||
{file = "mcp-1.22.0-py3-none-any.whl", hash = "sha256:bed758e24df1ed6846989c909ba4e3df339a27b4f30f1b8b627862a4bade4e98"},
|
||||
{file = "mcp-1.22.0.tar.gz", hash = "sha256:769b9ac90ed42134375b19e777a2858ca300f95f2e800982b3e2be62dfc0ba01"},
|
||||
{file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"},
|
||||
{file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -8003,4 +7991,4 @@ utils = ["numpydoc"]
|
|||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.9,<4.0"
|
||||
content-hash = "2d6b3d8d44919c29315b5e645befbf745a276714a2454c563d460a6a001b90af"
|
||||
content-hash = "3a929b2e1dc2b85edcf78f93b0c15eda2bf0cdf8d3e0e30778fc63178c650e40"
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ pynacl = {version = "^1.5.0", optional = true}
|
|||
websockets = {version = "^15.0.1", optional = true}
|
||||
boto3 = {version = "1.36.0", optional = true}
|
||||
redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"}
|
||||
mcp = {version = "^1.21.2", optional = true, python = ">=3.10"}
|
||||
mcp = {version = ">=1.25.0,<2.0.0", optional = true, python = ">=3.10"}
|
||||
a2a-sdk = {version = "^0.3.22", optional = true, python = ">=3.10"}
|
||||
litellm-proxy-extras = {version = "0.4.23", optional = true}
|
||||
rich = {version = "13.7.1", optional = true}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
# Add the project root to the path
|
||||
sys.path.insert(0, os.path.abspath("../../.."))
|
||||
|
||||
import litellm.experimental_mcp_client.client as mcp_client_module
|
||||
from litellm.experimental_mcp_client.client import MCPClient
|
||||
from litellm.types.mcp import MCPAuth, MCPTransport
|
||||
from mcp.types import Tool as MCPTool, CallToolResult as MCPCallToolResult
|
||||
|
|
@ -82,8 +83,8 @@ class TestMCPClientUnitTests:
|
|||
assert headers == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
|
||||
@patch("litellm.experimental_mcp_client.client.ClientSession")
|
||||
@patch.object(mcp_client_module, "streamable_http_client")
|
||||
@patch.object(mcp_client_module, "ClientSession")
|
||||
async def test_run_with_session(self, mock_session_class, mock_transport):
|
||||
"""Test run_with_session establishes session with auth headers."""
|
||||
# Setup mocks
|
||||
|
|
@ -110,16 +111,15 @@ class TestMCPClientUnitTests:
|
|||
|
||||
# Verify transport was created with auth headers
|
||||
call_args = mock_transport.call_args
|
||||
assert call_args[1]["headers"] == {
|
||||
"Authorization": "Bearer test_token",
|
||||
}
|
||||
http_client = call_args[1]["http_client"]
|
||||
assert http_client.headers.get("Authorization") == "Bearer test_token"
|
||||
|
||||
# Verify session was initialized
|
||||
mock_session_instance.initialize.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
|
||||
@patch("litellm.experimental_mcp_client.client.ClientSession")
|
||||
@patch.object(mcp_client_module, "streamable_http_client")
|
||||
@patch.object(mcp_client_module, "ClientSession")
|
||||
async def test_list_tools(self, mock_session_class, mock_transport):
|
||||
"""Test listing tools from the server."""
|
||||
# Setup mocks
|
||||
|
|
@ -156,8 +156,8 @@ class TestMCPClientUnitTests:
|
|||
mock_session_instance.list_tools.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
|
||||
@patch("litellm.experimental_mcp_client.client.ClientSession")
|
||||
@patch.object(mcp_client_module, "streamable_http_client")
|
||||
@patch.object(mcp_client_module, "ClientSession")
|
||||
async def test_call_tool(self, mock_session_class, mock_transport):
|
||||
"""Test calling a tool."""
|
||||
from mcp.types import CallToolRequestParams
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import pytest
|
|||
# Add the parent directory to the path so we can import litellm
|
||||
sys.path.insert(0, "../../../")
|
||||
|
||||
import litellm.experimental_mcp_client.client as mcp_client_module
|
||||
from litellm.experimental_mcp_client.client import MCPClient
|
||||
from litellm.types.mcp import MCPStdioConfig, MCPTransport
|
||||
|
||||
|
|
@ -81,7 +82,7 @@ class TestMCPClient:
|
|||
assert call_args.env == {"DEBUG": "1"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
|
||||
@patch.object(mcp_client_module, "streamable_http_client")
|
||||
@patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
|
|
@ -90,12 +91,12 @@ class TestMCPClient:
|
|||
},
|
||||
)
|
||||
async def test_mcp_client_ssl_configuration_from_env(
|
||||
self, mock_streamablehttp_client
|
||||
self, mock_streamable_http_client
|
||||
):
|
||||
"""Test that MCP client uses SSL configuration from environment variables"""
|
||||
# Setup mocks
|
||||
mock_transport = (MagicMock(), MagicMock())
|
||||
mock_streamablehttp_client.return_value.__aenter__ = AsyncMock(
|
||||
mock_streamable_http_client.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_transport
|
||||
)
|
||||
|
||||
|
|
@ -121,27 +122,23 @@ class TestMCPClient:
|
|||
await client.run_with_session(_operation)
|
||||
|
||||
# Verify streamablehttp_client was called
|
||||
mock_streamablehttp_client.assert_called_once()
|
||||
call_kwargs = mock_streamablehttp_client.call_args[1]
|
||||
mock_streamable_http_client.assert_called_once()
|
||||
call_kwargs = mock_streamable_http_client.call_args[1]
|
||||
assert "http_client" in call_kwargs
|
||||
http_client = call_kwargs["http_client"]
|
||||
assert isinstance(http_client, httpx.AsyncClient)
|
||||
|
||||
# Verify httpx_client_factory was passed
|
||||
assert "httpx_client_factory" in call_kwargs
|
||||
httpx_factory = call_kwargs["httpx_client_factory"]
|
||||
|
||||
# Test the factory creates a client with proper SSL config
|
||||
# When SSL_CERT_FILE is set, the factory should use get_ssl_configuration
|
||||
# Test the factory still creates a client with proper SSL config
|
||||
httpx_factory = client._create_httpx_client_factory()
|
||||
test_client = httpx_factory(headers={"test": "header"})
|
||||
|
||||
# Verify the client was created successfully with SSL configuration
|
||||
assert test_client is not None
|
||||
assert isinstance(test_client, httpx.AsyncClient)
|
||||
# Verify it has the expected properties
|
||||
assert test_client.headers is not None
|
||||
# Clean up
|
||||
await test_client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.experimental_mcp_client.client.sse_client")
|
||||
@patch.object(mcp_client_module, "sse_client")
|
||||
async def test_mcp_client_ssl_verify_parameter(self, mock_sse_client):
|
||||
"""Test that MCP client uses ssl_verify parameter when provided"""
|
||||
# Setup mocks
|
||||
|
|
@ -192,12 +189,12 @@ class TestMCPClient:
|
|||
await test_client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.experimental_mcp_client.client.streamablehttp_client")
|
||||
async def test_mcp_client_ssl_verify_custom_path(self, mock_streamablehttp_client):
|
||||
@patch.object(mcp_client_module, "streamable_http_client")
|
||||
async def test_mcp_client_ssl_verify_custom_path(self, mock_streamable_http_client):
|
||||
"""Test that MCP client uses custom CA bundle path from ssl_verify parameter"""
|
||||
# Setup mocks
|
||||
mock_transport = (MagicMock(), MagicMock())
|
||||
mock_streamablehttp_client.return_value.__aenter__ = AsyncMock(
|
||||
mock_streamable_http_client.return_value.__aenter__ = AsyncMock(
|
||||
return_value=mock_transport
|
||||
)
|
||||
|
||||
|
|
@ -226,23 +223,18 @@ class TestMCPClient:
|
|||
await client.run_with_session(_operation)
|
||||
|
||||
# Verify streamablehttp_client was called
|
||||
mock_streamablehttp_client.assert_called_once()
|
||||
call_kwargs = mock_streamablehttp_client.call_args[1]
|
||||
mock_streamable_http_client.assert_called_once()
|
||||
call_kwargs = mock_streamable_http_client.call_args[1]
|
||||
assert "http_client" in call_kwargs
|
||||
http_client = call_kwargs["http_client"]
|
||||
assert isinstance(http_client, httpx.AsyncClient)
|
||||
|
||||
# Verify httpx_client_factory was passed
|
||||
assert "httpx_client_factory" in call_kwargs
|
||||
httpx_factory = call_kwargs["httpx_client_factory"]
|
||||
|
||||
# Test the factory creates a client with custom CA bundle path
|
||||
# When ssl_verify is a path, the factory should use that path for SSL verification
|
||||
httpx_factory = client._create_httpx_client_factory()
|
||||
test_client = httpx_factory(headers={"test": "header"})
|
||||
|
||||
# Verify the client was created successfully
|
||||
assert test_client is not None
|
||||
assert isinstance(test_client, httpx.AsyncClient)
|
||||
# Verify it has the expected properties
|
||||
assert test_client.headers is not None
|
||||
# Clean up
|
||||
await test_client.aclose()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
|
@ -29,6 +32,15 @@ from litellm.types.mcp import MCPAuth
|
|||
from litellm.types.mcp_server.mcp_server_manager import MCPOAuthMetadata, MCPServer
|
||||
|
||||
|
||||
def _reload_mcp_manager_module():
|
||||
utils_module = sys.modules["litellm.proxy._experimental.mcp_server.utils"]
|
||||
manager_module = sys.modules[
|
||||
"litellm.proxy._experimental.mcp_server.mcp_server_manager"
|
||||
]
|
||||
importlib.reload(utils_module)
|
||||
return importlib.reload(manager_module)
|
||||
|
||||
|
||||
class TestMCPServerManager:
|
||||
"""Test MCP Server Manager stdio functionality"""
|
||||
|
||||
|
|
@ -148,6 +160,90 @@ class TestMCPServerManager:
|
|||
# When the header isn't provided, the key is omitted entirely
|
||||
assert env == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_servers_from_config_warns_on_invalid_alias(self, caplog):
|
||||
"""Invalid aliases from config should emit warnings during load."""
|
||||
|
||||
manager = MCPServerManager()
|
||||
config = {
|
||||
"validserver": {
|
||||
"alias": "bad/name",
|
||||
"url": "https://example.com",
|
||||
"transport": MCPTransport.http,
|
||||
}
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
||||
await manager.load_servers_from_config(config)
|
||||
|
||||
assert any(
|
||||
"invalid alias 'bad/name'" in message for message in caplog.messages
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_servers_from_config_accepts_valid_alias(self, caplog):
|
||||
"""Valid aliases should be accepted and populate the registry."""
|
||||
|
||||
manager = MCPServerManager()
|
||||
config = {
|
||||
"validserver": {
|
||||
"alias": "friendly_alias",
|
||||
"url": "https://example.com",
|
||||
"transport": MCPTransport.http,
|
||||
}
|
||||
}
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
||||
await manager.load_servers_from_config(config)
|
||||
|
||||
# No warnings logged for the valid alias
|
||||
assert all("invalid alias" not in message for message in caplog.messages)
|
||||
|
||||
server = next(iter(manager.config_mcp_servers.values()))
|
||||
assert server.alias == "friendly_alias"
|
||||
assert server.server_name == "validserver"
|
||||
|
||||
def test_warns_when_custom_separator_invalid(self, monkeypatch, caplog):
|
||||
"""Invalid MCP_TOOL_PREFIX_SEPARATOR values should log a warning."""
|
||||
|
||||
original_value = os.environ.get("MCP_TOOL_PREFIX_SEPARATOR")
|
||||
monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", "/")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
||||
_reload_mcp_manager_module()
|
||||
|
||||
assert any("violates SEP-986" in message for message in caplog.messages)
|
||||
|
||||
# Restore original setting and ensure warning disappears
|
||||
if original_value is None:
|
||||
monkeypatch.delenv("MCP_TOOL_PREFIX_SEPARATOR", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", original_value)
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
||||
_reload_mcp_manager_module()
|
||||
|
||||
assert all("violates SEP-986" not in message for message in caplog.messages)
|
||||
|
||||
def test_accepts_valid_custom_separator(self, monkeypatch, caplog):
|
||||
"""Valid separators should not emit warnings during module import."""
|
||||
|
||||
original_value = os.environ.get("MCP_TOOL_PREFIX_SEPARATOR")
|
||||
monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", "_")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="LiteLLM"):
|
||||
_reload_mcp_manager_module()
|
||||
|
||||
assert all("violates SEP-986" not in message for message in caplog.messages)
|
||||
|
||||
if original_value is None:
|
||||
monkeypatch.delenv("MCP_TOOL_PREFIX_SEPARATOR", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("MCP_TOOL_PREFIX_SEPARATOR", original_value)
|
||||
|
||||
_reload_mcp_manager_module()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tools_with_server_specific_auth_headers(self):
|
||||
"""Test list_tools method with server-specific auth headers"""
|
||||
|
|
|
|||
|
|
@ -2,14 +2,18 @@ import json
|
|||
import os
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from litellm._uuid import uuid
|
||||
from litellm.proxy.management_endpoints import (
|
||||
mcp_management_endpoints as mgmt_endpoints,
|
||||
)
|
||||
|
||||
sys.path.insert(
|
||||
0, os.path.abspath("../../../..")
|
||||
|
|
@ -726,8 +730,6 @@ class TestTemporaryMCPSessionEndpoints:
|
|||
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_cached_temporary_mcp_server",
|
||||
return_value=None,
|
||||
):
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_get_cached_temporary_mcp_server_or_404("missing")
|
||||
|
||||
|
|
@ -1195,6 +1197,25 @@ class TestMCPRegistryEndpoint:
|
|||
assert result[0]["server_id"] == "server-1"
|
||||
assert result[0]["status"] == "healthy"
|
||||
|
||||
|
||||
class TestManagementPayloadValidation:
|
||||
def test_rejects_invalid_alias(self):
|
||||
payload = SimpleNamespace(server_name="valid_server", alias="bad/name")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
mgmt_endpoints.validate_and_normalize_mcp_server_payload(payload)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
error_message = exc_info.value.detail["error"]
|
||||
assert "bad/name" in error_message
|
||||
|
||||
def test_accepts_valid_names(self):
|
||||
payload = SimpleNamespace(server_name="valid_server", alias=None)
|
||||
|
||||
mgmt_endpoints.validate_and_normalize_mcp_server_payload(payload)
|
||||
|
||||
assert payload.alias == "valid_server"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_view_all_mode(self):
|
||||
"""view_all mode should return health info for all MCP servers."""
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
label={
|
||||
<span className="text-sm font-medium text-gray-700 flex items-center">
|
||||
MCP Server Name
|
||||
<Tooltip title="Best practice: Use a descriptive name that indicates the server's purpose (e.g., 'GitHub_MCP', 'Email_Service'). Hyphens '-' are not allowed; use underscores '_' instead.">
|
||||
<Tooltip title="Best practice: Use a descriptive name that indicates the server's purpose (e.g., 'GitHub_MCP', 'Email_Service'). Hyphens '-' are not allowed; use underscores '_' instead. Names must comply with SEP-986 and will be rejected if invalid (https://modelcontextprotocol.io/specification/2025-11-25/server/tools#tool-names).">
|
||||
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue