Merge pull request #19379 from BerriAI/litellm_feat_mcp_version_up

[feat] mcp version up
This commit is contained in:
YutaSaito 2026-01-20 13:09:29 +09:00 committed by GitHub
commit 00814d4d90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 275 additions and 80 deletions

View file

@ -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: |

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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]]):
"""

View file

@ -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})"
)

View file

@ -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(

View file

@ -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
View file

@ -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"

View file

@ -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}

View file

@ -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

View file

@ -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()

View file

@ -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"""

View file

@ -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."""

View file

@ -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>