From 4bbc4eec3b1c1a977930504b6a78e61c7426783c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:36:38 -0400 Subject: [PATCH] fix: ResponseLimitingMiddleware no longer breaks outputSchema tools (#3756) --- .../server/middleware/response_limiting.py | 18 ++++++++++--- .../middleware/test_response_limiting.py | 26 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/middleware/response_limiting.py b/src/fastmcp/server/middleware/response_limiting.py index 3afaf0705..24e1cbc12 100644 --- a/src/fastmcp/server/middleware/response_limiting.py +++ b/src/fastmcp/server/middleware/response_limiting.py @@ -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) diff --git a/tests/server/middleware/test_response_limiting.py b/tests/server/middleware/test_response_limiting.py index b2c6cd810..bbf615c16 100644 --- a/tests/server/middleware/test_response_limiting.py +++ b/tests/server/middleware/test_response_limiting.py @@ -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)