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.
This commit is contained in:
Aliaksei Venski 2026-08-17 09:17:28 +02:00
parent a7894dbb37
commit fcec049e91

View file

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