fix: ResponseLimitingMiddleware no longer breaks outputSchema tools (#3756)

This commit is contained in:
Jeremiah Lowin 2026-04-05 10:36:38 -04:00 committed by GitHub
commit 4bbc4eec3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 41 additions and 3 deletions

View file

@ -3,6 +3,7 @@
from __future__ import annotations
import logging
from typing import Any
import mcp.types as mt
import pydantic_core
@ -67,7 +68,11 @@ class ResponseLimitingMiddleware(Middleware):
self.truncation_suffix = truncation_suffix
self.tools = set(tools) if tools is not None else None
def _truncate_to_result(self, text: str) -> ToolResult:
def _truncate_to_result(
self,
text: str,
meta: dict[str, Any] | None = None,
) -> ToolResult:
"""Truncate text to fit within max_size and wrap in ToolResult."""
suffix_bytes = len(self.truncation_suffix.encode("utf-8"))
# Account for JSON wrapper overhead: {"content":[{"type":"text","text":"..."}]}
@ -88,7 +93,14 @@ class ResponseLimitingMiddleware(Middleware):
+ self.truncation_suffix
)
return ToolResult(content=[TextContent(type="text", text=truncated)])
# Preserve original meta, falling back to {} when absent. Having
# meta set ensures to_mcp_result() returns a CallToolResult, which
# bypasses MCP SDK outputSchema validation — a truncated response
# is no longer valid structured output.
return ToolResult(
content=[TextContent(type="text", text=truncated)],
meta=meta if meta is not None else {},
)
async def on_call_tool(
self,
@ -122,4 +134,4 @@ class ResponseLimitingMiddleware(Middleware):
else serialized.decode("utf-8", errors="replace")
)
return self._truncate_to_result(text)
return self._truncate_to_result(text, meta=result.meta)

View file

@ -2,12 +2,18 @@
import pytest
from mcp.types import ImageContent, TextContent
from pydantic import BaseModel
from fastmcp import Client, FastMCP
from fastmcp.server.middleware.response_limiting import ResponseLimitingMiddleware
from fastmcp.tools.base import ToolResult
# Regression test model for #3717
class Answer(BaseModel):
text: str
class TestResponseLimitingMiddleware:
"""Tests for ResponseLimitingMiddleware."""
@ -143,6 +149,26 @@ class TestResponseLimitingMiddleware:
with pytest.raises(ValueError, match="max_size must be positive"):
ResponseLimitingMiddleware(max_size=-100)
async def test_truncation_does_not_break_output_schema_tools(
self, mcp_server: FastMCP
):
"""Truncating a tool with outputSchema must not cause validation errors.
Regression test for #3717: the MCP SDK rejects truncated results
from tools with outputSchema because structured_content is dropped.
We verify the server returns a successful (non-error) truncated result.
"""
mcp_server.add_middleware(ResponseLimitingMiddleware(max_size=1_000))
@mcp_server.tool()
def big_answer() -> Answer:
return Answer(text="x" * 2_000)
result = await mcp_server.call_tool("big_answer", {})
first = result.content[0]
assert isinstance(first, TextContent)
assert "[Response truncated" in first.text
def test_utf8_truncation_preserves_characters(self):
"""Test that UTF-8 truncation doesn't break multi-byte characters."""
middleware = ResponseLimitingMiddleware(max_size=100)