fix(mcp): upgrade to MCP SDK 2.3.0 and raise the per-event SSE size cap (#47034)

* fix(mcp): upgrade to MCP SDK 2.3.0 and raise the per-event SSE size cap

MCP SDK 2.x reads Streamable HTTP responses through httpx2's SSE parser,
which rejects any single event over 1 MiB, so a large tools/call result
failed with 'SSE stream ended without a response'. mcp 2.3.0 exposes
max_sse_event_size, and MCPClient now passes a 64 MiB default,
configurable with LITELLM_MCP_MAX_SSE_EVENT_SIZE_BYTES

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): fall back for invalid SSE event limits

* test(mcp): verify configured SSE limits and recovery

* chore(mcp): remove redundant dependency comment

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
This commit is contained in:
joshua-berri 2026-10-20 16:00:00 -07:00 • committed by GitHub
parent 9fbeebc988
commit 4aec80f5ee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 131 additions and 20 deletions

View file

@ -258,6 +258,9 @@ MCP_PER_USER_TOKEN_EXPIRY_BUFFER_SECONDS: Final = get_env_int("MCP_PER_USER_TOKE
# MCP timeout defaults (seconds). Override via env vars for slow/custom MCP servers.
MCP_CLIENT_TIMEOUT: Final = get_env_float("LITELLM_MCP_CLIENT_TIMEOUT", 60.0)
MCP_MAX_SSE_EVENT_SIZE_BYTES: Final = get_env_int_in_range(
"LITELLM_MCP_MAX_SSE_EVENT_SIZE_BYTES", 64 * 1024 * 1024, 1, sys.maxsize
)
MCP_TOOL_LISTING_TIMEOUT: Final = get_env_float("LITELLM_MCP_TOOL_LISTING_TIMEOUT", 30.0)
MCP_UPSTREAM_RATE_LIMIT_MAX_RETRIES: Final = get_env_int("LITELLM_MCP_UPSTREAM_RATE_LIMIT_MAX_RETRIES", 3)
MCP_UPSTREAM_RATE_LIMIT_BASE_DELAY_SECONDS: Final = get_env_float(

View file

@ -95,6 +95,7 @@ from pydantic import AnyUrl, TypeAdapter
from litellm._logging import verbose_logger
from litellm.constants import (
MCP_CLIENT_TIMEOUT,
MCP_MAX_SSE_EVENT_SIZE_BYTES,
MCP_NPM_CACHE_DIR,
MCP_TOOL_LISTING_MAX_PAGES,
MCP_TOOL_LISTING_TIMEOUT,
@ -647,6 +648,7 @@ class MCPClient:
transport_ctx: Final = streamable_http_client(
url=self.server_url,
http_client=http_client,
max_sse_event_size=MCP_MAX_SSE_EVENT_SIZE_BYTES,
)
return transport_ctx, http_client

View file

@ -75,8 +75,8 @@ proxy = [
"boto3>=1.43.1,<2.0",
"azure-identity>=1.25.2,<2.0",
"azure-storage-blob>=12.28.0,<13.0",
"mcp>=2.2.0,<3",
"httpx2>=2.5.0,<3",
"mcp>=2.3.0,<3",
"httpx2>=2.10.0,<3",
"pydantic>=2.12.0,<3",
"litellm-proxy-extras==0.4.111",
"litellm-enterprise==0.1.79",
@ -125,7 +125,7 @@ utils = [
"numpydoc>=1.8.0,<2.0",
]
caching = ["diskcache>=5.6.3,<6.0"]
mcp = ["mcp>=2.2.0,<3", "httpx2>=2.5.0,<3", "pydantic>=2.12.0,<3"]
mcp = ["mcp>=2.3.0,<3", "httpx2>=2.10.0,<3", "pydantic>=2.12.0,<3"]
# Driver for the MongoDB Atlas vector store; Atlas Vector Search has no HTTP query API.
# The floor is 4.9 because that is the release AsyncMongoClient landed in.
# SAML SSO for the admin UI. python3-saml pulls in xmlsec/lxml, whose wheels
@ -243,7 +243,7 @@ e2e-dev = [
"locust==2.45.0",
"anthropic==0.84.0",
"psutil==7.2.2",
"mcp>=2.2.0,<3",
"mcp>=2.3.0,<3",
]
admin-mcp = [
"litellm-admin-mcp @ https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz#sha256=86b930f6706fb2da10d53d1798ee7fd14e44fb0afdef0da122cf0e6bb88fa2c3 ; python_version >= '3.12'",
@ -303,7 +303,7 @@ healthcheck = [
benchmarks = [
"pytest==9.0.3",
"pytest-codspeed==4.3.0",
"mcp>=2.2.0,<3",
"mcp>=2.3.0,<3",
"a2a-sdk==1.1.0",
]
@ -363,6 +363,7 @@ override-dependencies = [
# a2a-sdk 1.x requires packaging>=24.0; lunary 1.4.x still caps at <24.0.
"packaging>=24.0",
"cryptography>=50.0.0,<51.0",
"mcp>=2.3.0,<3",
]
default-groups = ["dev"]
required-version = ">=0.10.9"

View file

@ -282,6 +282,7 @@ class ScriptedTool:
respond: Callable[[JsonRpc], Reply | JsonRpc]
description: str | Callable[[Mapping[str, str]], str] | None = None
input_schema: JsonRpc = field(default_factory=lambda: {"type": "object"})
event_stream: bool = False
def listing(self, headers: Mapping[str, str]) -> JsonRpc:
described: Final = self.description(headers) if callable(self.description) else self.description
@ -334,7 +335,10 @@ def scripted_peer(*tools: ScriptedTool) -> Iterator[McpPeer]:
if tool is None:
return jsonrpc_error(identity, -32602, "unknown tool")
produced: Final = tool.respond(body["params"])
return produced if isinstance(produced, Reply) else jsonrpc_reply(identity, produced)
reply: Final = produced if isinstance(produced, Reply) else jsonrpc_reply(identity, produced)
if not tool.event_stream:
return reply
return Reply(content_type="text/event-stream", body=b"event: message\ndata: " + reply.body + b"\n\n")
with wire_server(provider) as wire:
yield McpPeer(wire.url + "/mcp", observed)

View file

@ -2,10 +2,12 @@ import builtins
import json
import uuid
from collections.abc import Mapping
from pathlib import Path
from typing import Final
import httpx
import pytest
import yaml
from mcp.shared.exceptions import MCPError
from integration._support.client import Gateway
from integration._support.database import read_rows
@ -17,15 +19,21 @@ from integration._support.mcp import (
McpCaller,
Outcome,
PeerKind,
ScriptedTool,
_outcome_from_rpc,
_parse_rpc_body,
call_tool,
listed_tools,
mcp_peer,
official_client_outcomes,
peer_of,
register_mcp,
scripted_peer,
text_result,
tool_calls,
)
from integration._support.mcp_grants import create_toolset
from integration._support.process import owned_proxy
ADD: Final = {"http": "add", "sse": "add", "stdio": "add", "openapi": "getpet"}
ARGUMENTS: Final = {"add": {"a": 3, "b": 4}, "getpet": {"petId": "7"}}
@ -817,3 +825,67 @@ def test_sdk_progress_and_cancellation_reach_upstream_without_losing_session(
),
) as gateway:
asyncio.run(exercise(gateway))
def test_tool_result_larger_than_one_mib_in_a_single_sse_event_reaches_the_caller(gateway: Gateway) -> None:
payload: Final = "x" * (3 * 1024 * 1024)
tool: Final = ScriptedTool("dump", lambda _: text_result(payload), event_stream=True)
with scripted_peer(tool) as peer, gateway.scenario() as scenario:
alias: Final = "bigsse" + uuid.uuid4().hex[:8]
identity: Final = register_mcp(scenario, peer, alias)
key: Final = scenario.key(object_permission={"mcp_servers": [identity]})
name: Final = next(full for full in listed_tools(gateway, key, identity) if full.endswith("dump"))
response: Final = call_tool(gateway, key, identity, name, {})
assert response.status_code == 200, response.text[:500]
body: Final = response.json()
assert body["isError"] is False, response.text[:500]
assert body["content"][0]["text"] == payload
def test_configured_sse_limit_rejects_large_results_and_allows_the_next_call(tmp_path: Path) -> None:
limit: Final = 4096
payload: Final = "x" * 512
small: Final = ScriptedTool("small", lambda _: text_result(payload), event_stream=True)
large: Final = ScriptedTool("large", lambda _: text_result("x" * (2 * limit)), event_stream=True)
key: Final = "sk-local-mcp-sse-limit-regression-key"
with scripted_peer(small, large) as peer, httpx.Client() as client:
config: Final = tmp_path / "sse-limit.yaml"
config.write_text(
yaml.safe_dump(
{
"general_settings": {"master_key": key, "store_model_in_db": False},
"mcp_servers": {"limited": peer.registration()},
}
)
)
seed: Final = Gateway(client, key, peer.url)
with owned_proxy(
seed,
tmp_path / "proxy",
{
"STORE_MODEL_IN_DB": "False",
"DISABLE_SCHEMA_UPDATE": "true",
"LITELLM_LOCAL_MODEL_COST_MAP": "True",
"LITELLM_MCP_MAX_SSE_EVENT_SIZE_BYTES": str(limit),
},
config=config,
database_setup=(),
remove_environment=(
"DATABASE_URL",
"DATABASE_URL_READ_REPLICA",
"DIRECT_URL",
"INTEGRATION_PROXY_DATABASE_URL",
"INTEGRATION_PROXY_READ_REPLICA_URL",
),
) as gateway:
assert set(listed_tools(gateway, key)) == {"small", "large"}
for name in ("small", "large", "small"):
response: Final = call_tool(gateway, key, "limited", name, {})
assert response.status_code == 200, response.text[:500]
body: Final = response.json()
assert body["isError"] is (name == "large"), response.text[:500]
if name == "large":
assert str(limit) in body["content"][0]["text"], response.text[:500]
else:
assert body["content"][0]["text"] == payload, response.text[:500]

View file

@ -1500,8 +1500,8 @@ def test_mcp_extra_matches_proxy_extra_and_supports_streamable_http():
}
specifier: Final = Requirement(mcp_extra["mcp"]).specifier
assert not specifier.contains("1.28.1")
assert specifier.contains("2.2.0")
assert not specifier.contains("2.2.0")
assert specifier.contains("2.3.0")
with (pyproject_path.parent / "uv.lock").open("rb") as f:
locked = tomllib.load(f)
mcp_versions: Final = [package["version"] for package in locked["package"] if package["name"] == "mcp"]

View file

@ -1,6 +1,7 @@
import ast, asyncio, os
import inspect
import json
import sys
from unittest import mock
import httpx
@ -117,6 +118,33 @@ def test_cli_jwt_expiration_hours_from_environment(
importlib.reload(litellm.constants)
@pytest.mark.parametrize(
("raw", "expected"),
(
(None, 64 * 1024 * 1024),
("", 64 * 1024 * 1024),
("invalid", 64 * 1024 * 1024),
("0", 64 * 1024 * 1024),
("-1", 64 * 1024 * 1024),
("1", 1),
("200000", 200000),
(str(sys.maxsize), sys.maxsize),
(str(sys.maxsize + 1), 64 * 1024 * 1024),
),
)
def test_mcp_sse_event_limit_uses_positive_override_or_default(raw: str | None, expected: int) -> None:
try:
with pytest.MonkeyPatch.context() as environment:
if raw is None:
environment.delenv("LITELLM_MCP_MAX_SSE_EVENT_SIZE_BYTES", raising=False)
else:
environment.setenv("LITELLM_MCP_MAX_SSE_EVENT_SIZE_BYTES", raw)
importlib.reload(constants)
assert constants.MCP_MAX_SSE_EVENT_SIZE_BYTES == expected
finally:
importlib.reload(constants)
def _is_blank(value: object) -> bool:
return isinstance(value, str) and not value.strip()

25
uv.lock generated
View file

@ -29,6 +29,7 @@ constraints = [
]
overrides = [
{ name = "cryptography", specifier = ">=50.0.0,<51.0" },
{ name = "mcp", specifier = ">=2.3.0,<3" },
{ name = "packaging", specifier = ">=24.0" },
]
@ -4902,8 +4903,8 @@ requires-dist = [
{ name = "gunicorn", marker = "extra == 'proxy'", specifier = ">=23.0.0,<24.0" },
{ name = "hiredis", marker = "extra == 'proxy'", specifier = ">=3.0.0,<4.0" },
{ name = "httpx", extras = ["http2"], specifier = ">=0.28.0,<1.0" },
{ name = "httpx2", marker = "extra == 'mcp'", specifier = ">=2.5.0,<3" },
{ name = "httpx2", marker = "extra == 'proxy'", specifier = ">=2.5.0,<3" },
{ name = "httpx2", marker = "extra == 'mcp'", specifier = ">=2.10.0,<3" },
{ name = "httpx2", marker = "extra == 'proxy'", specifier = ">=2.10.0,<3" },
{ name = "huggingface-hub", specifier = ">=0.34.0,<2.0" },
{ name = "importlib-metadata", specifier = ">=8.0.0,<9.0" },
{ name = "inquirerpy", marker = "extra == 'cli'", specifier = ">=0.3.4,<1.0" },
@ -4916,8 +4917,8 @@ requires-dist = [
{ name = "litellm-proxy-extras", marker = "extra == 'proxy'", editable = "litellm-proxy-extras" },
{ name = "llm-sandbox", marker = "extra == 'proxy-runtime'", specifier = ">=0.3.39,<1.0" },
{ name = "mangum", marker = "extra == 'proxy-runtime'", specifier = ">=0.17.0,<1.0" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.2.0,<3" },
{ name = "mcp", marker = "extra == 'proxy'", specifier = ">=2.2.0,<3" },
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=2.3.0,<3" },
{ name = "mcp", marker = "extra == 'proxy'", specifier = ">=2.3.0,<3" },
{ name = "mlflow", marker = "extra == 'mlflow'", specifier = ">=3.11.1,<4.0" },
{ name = "numpy", marker = "extra == 'stt-nvidia-riva'", specifier = ">=1.26.0" },
{ name = "numpydoc", marker = "extra == 'utils'", specifier = ">=1.8.0,<2.0" },
@ -4977,7 +4978,7 @@ provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "mcp", "sa
admin-mcp = [{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'", url = "https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz" }]
benchmarks = [
{ name = "a2a-sdk", specifier = "==1.1.0" },
{ name = "mcp", specifier = ">=2.2.0,<3" },
{ name = "mcp", specifier = ">=2.3.0,<3" },
{ name = "pytest", specifier = "==9.0.3" },
{ name = "pytest-codspeed", specifier = "==4.3.0" },
]
@ -5055,7 +5056,7 @@ dev = [
e2e-dev = [
{ name = "anthropic", specifier = "==0.84.0" },
{ name = "locust", specifier = "==2.45.0" },
{ name = "mcp", specifier = ">=2.2.0,<3" },
{ name = "mcp", specifier = ">=2.3.0,<3" },
{ name = "playwright", specifier = "==1.61.0" },
{ name = "psutil", specifier = "==7.2.2" },
{ name = "websockets", specifier = ">=15.0.1,<16.0" },
@ -5570,7 +5571,7 @@ wheels = [
[[package]]
name = "mcp"
version = "2.2.0"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -5588,22 +5589,22 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/31/ac54fb0fdd5b37de704486e288bba4fbbb463f24cfcfedbede407b854513/mcp-2.2.0.tar.gz", hash = "sha256:2dc37ecb1974becdcebdbf7561e7c15a07dbbf20ba21ba16c3593b3038b3afbd", size = 4084129, upload-time = "2026-09-07T16:06:23.439Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/ff/8e7eade68b8a28f7da0ed1085544341b51f9c935dbf6b95c76b7edfea6a0/mcp-2.2.0-py3-none-any.whl", hash = "sha256:bde982589473a060ae145e3406e9a5333fe538c97229ba841f5a7f92be004f81", size = 365656, upload-time = "2026-09-07T16:06:19.711Z" },
{ url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" },
]
[[package]]
name = "mcp-types"
version = "2.2.0"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ae/91/762d7755d971aff8a28d75f7961656148edf27875c8026e6385aaab08ae7/mcp_types-2.2.0.tar.gz", hash = "sha256:d3ed53703ddd10d9c6399f29d322bb66f3f67ab41348ac8556ba23e07fedefad", size = 65892, upload-time = "2026-09-07T16:06:25.187Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/d7/6ffba5d8cd5dd9b8a19478875c50e04945314ba5074e84d749283f27f62d/mcp_types-2.2.0-py3-none-any.whl", hash = "sha256:ea476b73ee86709ab5abc9452385ed36cc05907e582355622e294595c9a04f13", size = 69106, upload-time = "2026-09-07T16:06:21.461Z" },
{ url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" },
]
[[package]]