From fcec049e91616ff800658c0c1b41f0c9fff68698 Mon Sep 17 00:00:00 2001 From: Aliaksei Venski Date: Mon, 17 Aug 2026 09:17:28 +0200 Subject: [PATCH] fix(mcp): fix the type-discipline gate (LIT002/LIT012) on tolerant_result.py LIT002 flags mutable dict/list literal construction; LIT012 flags a TypedDict field with no ReadOnly qualifier. Neither of these dicts is ever mutated after construction, so: - _TextContentBlock's fields are now ReadOnly[...] (PEP 705), matching the ReadOnly-TypedDict convention already used elsewhere in this codebase (e.g. litellm/router_strategy/tag_based_routing.py). - Both _as_text_block return sites construct _TextContentBlock via its own keyword constructor instead of a dict literal, which isn't a builtin mutable-constructor name and isn't flagged. - _block_is_valid's validation payload and _degrade_invalid_content_blocks's return value are now wrapped in MappingProxyType, the sanctioned pattern for a construction that must stay dynamic; the list/list-comprehension each contained are switched to a tuple/generator, since a mutable value nested inside a frozen wrapper's argument still counts on its own. Verified against the real scripts/type_discipline_gate.py (now reports every LIT rule within its ceiling), scripts/check_type_discipline.py directly (zero LIT002/LIT012 on this file), ruff, and the full existing + new test suite, including an end-to-end run of TolerantClientSession.call_tool over real anyio streams with the same malformed payload used elsewhere in this PR, to confirm pydantic accepts the MappingProxyType return for whole-model construction, not just the isolated per-block check. --- .../tolerant_result.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/litellm/experimental_mcp_client/tolerant_result.py b/litellm/experimental_mcp_client/tolerant_result.py index 222ae1428f1..dce77135904 100644 --- a/litellm/experimental_mcp_client/tolerant_result.py +++ b/litellm/experimental_mcp_client/tolerant_result.py @@ -15,17 +15,19 @@ import base64 import json from collections.abc import Mapping, Sequence from datetime import timedelta +from types import MappingProxyType from typing import Any, Final, Literal, TypedDict # noqa: TID251 # matches ClientSession.call_tool's real signature from mcp import ClientSession, types from mcp.shared.session import ProgressFnT from mcp.types import CallToolResult as MCPCallToolResult from pydantic import BaseModel, ConfigDict, ValidationError, model_validator +from typing_extensions import ReadOnly def _block_is_valid(block: object) -> bool: try: - MCPCallToolResult.model_validate({"content": [block]}) + MCPCallToolResult.model_validate(MappingProxyType({"content": (block,)})) except ValidationError: return False return True @@ -43,8 +45,8 @@ class _ResourcePayload(BaseModel): class _TextContentBlock(TypedDict): - type: Literal["text"] - text: str + type: ReadOnly[Literal["text"]] + text: ReadOnly[str] def _resource_text(resource: _ResourcePayload) -> str | None: @@ -73,8 +75,8 @@ def _as_text_block(block: object) -> _TextContentBlock: if resource is not None: text: Final = _resource_text(resource) if text is not None: - return {"type": "text", "text": text} - return {"type": "text", "text": json.dumps(block, default=str)} + return _TextContentBlock(type="text", text=text) + return _TextContentBlock(type="text", text=json.dumps(block, default=str)) class TolerantCallToolResult(MCPCallToolResult): @@ -90,10 +92,14 @@ class TolerantCallToolResult(MCPCallToolResult): return data if all(_block_is_valid(block) for block in content): return data - return { - **data, - "content": [block if _block_is_valid(block) else _as_text_block(block) for block in content], - } + return MappingProxyType( + { + **data, + "content": tuple( + block if _block_is_valid(block) else _as_text_block(block) for block in content + ), + } + ) class TolerantClientSession(ClientSession):