litellm/tests/unit/proxy/mcp_server/test_result_conversion.py
Mateo Wang 0a1dbc0fb4
refactor(mcp): drop the experimental label from the MCP server and client paths (#48513)
* 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>
2026-10-27 17:47:50 +00:00

272 lines
12 KiB
Python

import json
from typing import Final, Literal
import pytest
from mcp.types import CallToolResult, ImageContent, InputRequiredResult, TextContent, Tool
from mcp_types.methods import serialize_server_result
from mcp_types.version import KNOWN_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
from pydantic import JsonValue, ValidationError
from litellm.proxy.mcp_server.result_conversion import (
INPUT_REQUIRED_UNSUPPORTED_MESSAGE,
JsonResult,
TextResult,
WireCompat,
complete_call_tool_result,
error_text_result,
handler_outcome,
parse_http_body,
to_call_tool_result,
to_gateway_tool,
wire_compat_for,
)
BOTH: Final = (WireCompat.LEGACY, WireCompat.MODERN)
def _interim() -> InputRequiredResult:
return InputRequiredResult.model_validate(
{
"resultType": "input_required",
"inputRequests": {
"req-1": {
"method": "elicitation/create",
"params": {"message": "Pick one", "requestedSchema": {"type": "object", "properties": {}}},
}
},
"requestState": "abc",
}
)
def _wire(result: CallToolResult | InputRequiredResult, version: str) -> dict[str, object]:
return serialize_server_result(
"tools/call", version, result.model_dump(by_alias=True, mode="json", exclude_none=True)
)
class TestWireCompatFor:
def test_only_modern_revisions_map_to_modern(self):
for version in KNOWN_PROTOCOL_VERSIONS:
expected: Final = WireCompat.MODERN if version in MODERN_PROTOCOL_VERSIONS else WireCompat.LEGACY
assert wire_compat_for(version) is expected, version
assert wire_compat_for("1999-01-01") is WireCompat.LEGACY
class TestParseHttpBody:
@pytest.mark.parametrize("body", ["", " ", "{not json", "null"])
def test_non_structured_bodies_stay_text(self, body: str):
assert parse_http_body(body) == TextResult(body)
@pytest.mark.parametrize(
"body, value",
[
('{"a": 1}', {"a": 1}),
("[1, 2]", [1, 2]),
("1.10", 1.1),
("true", True),
('"hi"', "hi"),
],
)
def test_json_bodies_keep_original_text(self, body: str, value: object):
assert parse_http_body(body) == JsonResult(value=value, original_text=body)
def test_handler_outcome_stringifies_unknown_values(self):
assert handler_outcome(42) == TextResult("42")
assert handler_outcome(TextResult("x")) == TextResult("x")
class TestTextAndJsonArms:
@pytest.mark.parametrize("compat", BOTH)
def test_text_result(self, compat: WireCompat):
result = to_call_tool_result(TextResult("plain"), compat)
assert isinstance(result, CallToolResult)
assert result.is_error is False
assert [c.text for c in result.content if isinstance(c, TextContent)] == ["plain"]
assert result.structured_content is None
@pytest.mark.parametrize("compat", BOTH)
def test_json_object_is_structured_everywhere_and_text_is_verbatim(self, compat: WireCompat):
body: Final = '{"n": 1.10,\n"k": "v"}'
result = to_call_tool_result(parse_http_body(body), compat)
assert isinstance(result, CallToolResult)
assert result.structured_content == {"n": 1.1, "k": "v"}
assert [c.text for c in result.content if isinstance(c, TextContent)] == [body]
@pytest.mark.parametrize("body", ["[1, 2]", "3", "true", '"s"'])
def test_non_object_json_is_structured_only_on_modern(self, body: str):
legacy = to_call_tool_result(parse_http_body(body), WireCompat.LEGACY)
modern = to_call_tool_result(parse_http_body(body), WireCompat.MODERN)
assert isinstance(legacy, CallToolResult) and isinstance(modern, CallToolResult)
assert legacy.structured_content is None
assert modern.structured_content == json.loads(body)
for result in (legacy, modern):
assert [c.text for c in result.content if isinstance(c, TextContent)] == [body]
@pytest.mark.parametrize("compat", BOTH)
def test_json_null_keeps_text_and_claims_no_structured_field(self, compat: WireCompat):
result = to_call_tool_result(parse_http_body("null"), compat)
assert isinstance(result, CallToolResult)
assert [c.text for c in result.content if isinstance(c, TextContent)] == ["null"]
assert "structuredContent" not in _wire(result, "2026-07-28")
class TestSdkResultArm:
def _incoming(self, content: list[TextContent]) -> CallToolResult:
return CallToolResult(content=content, structured_content=[1, 2], meta={"trace": "t1"}, is_error=False)
def test_modern_passes_through_the_same_object(self):
incoming = self._incoming([])
assert to_call_tool_result(incoming, WireCompat.MODERN) is incoming
def test_legacy_downgrade_with_empty_content_appends_json_text(self):
incoming = self._incoming([])
result = to_call_tool_result(incoming, WireCompat.LEGACY)
assert isinstance(result, CallToolResult)
assert result.structured_content is None
assert [c.text for c in result.content if isinstance(c, TextContent)] == ["[1, 2]"]
assert result.meta == {"trace": "t1"}
assert incoming.structured_content == [1, 2] and incoming.content == []
def test_legacy_downgrade_keeps_unrelated_content_and_appends_json_text(self):
incoming = self._incoming([TextContent(type="text", text="Done")])
result = to_call_tool_result(incoming, WireCompat.LEGACY)
assert isinstance(result, CallToolResult)
assert [c.text for c in result.content if isinstance(c, TextContent)] == ["Done", "[1, 2]"]
assert incoming.content == [TextContent(type="text", text="Done")]
assert incoming.structured_content == [1, 2]
def test_legacy_keeps_object_structured_content(self):
incoming = CallToolResult(content=[], structured_content={"a": 1}, is_error=False)
assert to_call_tool_result(incoming, WireCompat.LEGACY) is incoming
@pytest.mark.parametrize("value", [False, 0, "", []])
def test_legacy_downgrade_preserves_falsy_values_and_non_text_blocks(self, value: JsonValue) -> None:
incoming: Final = CallToolResult(
content=[
ImageContent(type="image", data="AA==", mime_type="image/png"),
TextContent(type="text", text="Done"),
],
structured_content=value,
meta={"trace": "t1"},
is_error=True,
)
before: Final = incoming.model_dump(by_alias=True)
result: Final = to_call_tool_result(incoming, WireCompat.LEGACY)
assert isinstance(result, CallToolResult)
assert result.content == [*incoming.content, TextContent(type="text", text=json.dumps(value))]
assert result.structured_content is None
assert result.meta == incoming.meta
assert result.is_error is True
assert incoming.model_dump(by_alias=True) == before
def test_is_error_survives_downgrade(self):
incoming = CallToolResult(content=[], structured_content=7, is_error=True)
result = to_call_tool_result(incoming, WireCompat.LEGACY)
assert isinstance(result, CallToolResult) and result.is_error is True
class TestInterimAndExceptionArms:
def test_modern_interim_passes_through(self):
interim = _interim()
assert to_call_tool_result(interim, WireCompat.MODERN) is interim
def test_legacy_interim_becomes_error_result(self):
result = to_call_tool_result(_interim(), WireCompat.LEGACY)
assert isinstance(result, CallToolResult)
assert result.is_error is True
assert [c.text for c in result.content if isinstance(c, TextContent)] == [INPUT_REQUIRED_UNSUPPORTED_MESSAGE]
def test_complete_call_tool_result_never_returns_interim(self):
result = complete_call_tool_result(_interim(), WireCompat.MODERN)
assert isinstance(result, CallToolResult) and result.is_error is True
@pytest.mark.parametrize("compat", BOTH)
def test_exception_arm_matches_error_text_result(self, compat: WireCompat):
exc = ValueError("boom")
result = to_call_tool_result(exc, compat)
assert result == error_text_result(exc)
assert isinstance(result, CallToolResult) and result.is_error is True
assert [c.text for c in result.content if isinstance(c, TextContent)] == ["ValueError: boom"]
class TestSdkWireSerialization:
@pytest.mark.parametrize("version", KNOWN_PROTOCOL_VERSIONS)
def test_converted_results_serialize_on_their_negotiated_revision(self, version: str):
compat = wire_compat_for(version)
for body in ('{"a": 1}', "[1, 2]", "3", "null", "text"):
result = to_call_tool_result(parse_http_body(body), compat)
frame = _wire(result, version)
assert frame["content"] == [{"type": "text", "text": body}]
structured = json.loads(body) if body != "text" else None
expects_structured = structured is not None and (
compat is WireCompat.MODERN or isinstance(structured, dict)
)
assert ("structuredContent" in frame) is expects_structured, (version, body)
if expects_structured:
assert frame["structuredContent"] == structured
assert ("resultType" in frame) is (compat is WireCompat.MODERN), (version, body)
@pytest.mark.parametrize("version", KNOWN_PROTOCOL_VERSIONS)
def test_downgraded_sdk_result_serializes_where_the_raw_one_would_not(self, version: str):
incoming = CallToolResult(content=[TextContent(type="text", text="Done")], structured_content=[1, 2])
converted = to_call_tool_result(incoming, wire_compat_for(version))
frame = _wire(converted, version)
if version in MODERN_PROTOCOL_VERSIONS:
assert frame["structuredContent"] == [1, 2]
return
with pytest.raises(ValidationError):
_wire(incoming, version)
assert "structuredContent" not in frame
assert frame["content"] == [{"type": "text", "text": "Done"}, {"type": "text", "text": "[1, 2]"}]
def test_modern_interim_serializes_with_its_fields_intact(self):
frame = _wire(_interim(), "2026-07-28")
assert frame["resultType"] == "input_required"
assert frame["requestState"] == "abc"
assert frame["inputRequests"]["req-1"]["params"]["message"] == "Pick one"
class TestToGatewayTool:
def test_rename_is_a_deep_copy_that_keeps_every_other_field(self):
tool = Tool(
name="orig",
description="d",
inputSchema={"type": "object", "properties": {"q": {"type": "string"}}},
_meta={"owner": "x"},
)
renamed = to_gateway_tool(tool, "srv-orig")
assert renamed.name == "srv-orig"
assert tool.name == "orig"
assert renamed.input_schema == tool.input_schema and renamed.input_schema is not tool.input_schema
assert renamed.meta == {"owner": "x"}
assert renamed.description == "d"
@pytest.mark.parametrize("elapsed,expected", [(0, 1000), (0.0001, 999), (0.5, 500), (1, 0), (2, 0), (-1, 1000)])
def test_freshness_aging_preserves_content_and_scope(elapsed: float, expected: int) -> None:
from mcp.types import ListPromptsResult, Prompt
from litellm.proxy.mcp_server.result_conversion import age_freshness
result: Final = ListPromptsResult(prompts=[Prompt(name="kept")], ttl_ms=1000, cache_scope="public")
aged: Final = age_freshness(result, elapsed)
assert aged.ttl_ms == expected
assert aged.cache_scope == "public"
assert aged.prompts == result.prompts
assert result.ttl_ms == 1000
@pytest.mark.parametrize(
"scopes,expected", [((), "private"), (("public", "public"), "public"), (("public", "private"), "private")]
)
def test_aggregate_freshness_never_broadens_sharing(
scopes: tuple[Literal["private", "public"], ...], expected: str
) -> None:
from mcp.types import CacheableResult
from litellm.proxy.mcp_server.result_conversion import aggregate_freshness
result: Final = aggregate_freshness(tuple(CacheableResult(ttl_ms=1000, cache_scope=scope) for scope in scopes))
assert result.cache_scope == expected
assert result.ttl_ms == (1000 if scopes else 0)