From 886776f5fced4b312154bf770b0662128b23936d Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:25:46 -0400 Subject: [PATCH] Canonicalize response cache arguments (#4753) Co-authored-by: LHMQ878 --- .../fastmcp/server/middleware/caching.py | 11 ++++- tests/server/middleware/test_caching.py | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/fastmcp_slim/fastmcp/server/middleware/caching.py b/fastmcp_slim/fastmcp/server/middleware/caching.py index d470f1b90..e96948fb0 100644 --- a/fastmcp_slim/fastmcp/server/middleware/caching.py +++ b/fastmcp_slim/fastmcp/server/middleware/caching.py @@ -1,6 +1,7 @@ """A middleware for response caching.""" import hashlib +import json from collections.abc import Sequence from logging import Logger from typing import Any, TypedDict @@ -593,13 +594,19 @@ class ResponseCachingMiddleware(Middleware): def _get_arguments_str(arguments: dict[str, Any] | None) -> str: - """Get a string representation of the arguments.""" + """Get a canonical string representation of the arguments.""" if arguments is None: return "null" try: - return pydantic_core.to_json(value=arguments, fallback=str).decode() + return json.dumps( + pydantic_core.to_jsonable_python(arguments, fallback=str), + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + default=str, + ) except TypeError: return repr(arguments) diff --git a/tests/server/middleware/test_caching.py b/tests/server/middleware/test_caching.py index b45961bcf..fc7c1ffc2 100644 --- a/tests/server/middleware/test_caching.py +++ b/tests/server/middleware/test_caching.py @@ -4,6 +4,7 @@ import sys import tempfile import warnings from pathlib import Path +from typing import Any from unittest.mock import AsyncMock, MagicMock import mcp_types @@ -284,6 +285,42 @@ class TestResponseCachingMiddleware: ) assert middleware1._matches_tool_cache_settings(tool_name=tool_name) is result + @pytest.mark.parametrize( + ("first", "second"), + [ + ({"a": 5, "b": 3}, {"b": 3, "a": 5}), + ({"q": {"x": 1, "y": 2}}, {"q": {"y": 2, "x": 1}}), + ({"items": [{"x": 1, "y": 2}]}, {"items": [{"y": 2, "x": 1}]}), + ], + ids=["top level", "nested dict", "dict inside a list"], + ) + def test_call_tool_cache_key_ignores_argument_order( + self, first: dict[str, Any], second: dict[str, Any] + ): + assert _make_call_tool_cache_key( + mcp_types.CallToolRequestParams(name="tool", arguments=first) + ) == _make_call_tool_cache_key( + mcp_types.CallToolRequestParams(name="tool", arguments=second) + ) + + def test_get_prompt_cache_key_ignores_argument_order(self): + assert _make_get_prompt_cache_key( + mcp_types.GetPromptRequestParams( + name="prompt", arguments={"a": "5", "b": "3"} + ) + ) == _make_get_prompt_cache_key( + mcp_types.GetPromptRequestParams( + name="prompt", arguments={"b": "3", "a": "5"} + ) + ) + + def test_call_tool_cache_key_distinguishes_arguments(self): + assert _make_call_tool_cache_key( + mcp_types.CallToolRequestParams(name="tool", arguments={"a": 5, "b": 3}) + ) != _make_call_tool_cache_key( + mcp_types.CallToolRequestParams(name="tool", arguments={"a": 3, "b": 5}) + ) + @pytest.mark.skipif( sys.platform == "win32", @@ -424,6 +461,18 @@ class TestResponseCachingMiddlewareIntegration: ) assert call_tool_result_one == call_tool_result_two + async def test_call_tool_with_reordered_arguments_hits_cache( + self, + caching_server: FastMCP, + tracking_calculator: TrackingCalculator, + ): + async with Client[FastMCPTransport](transport=caching_server) as client: + first = await client.call_tool("add", {"a": 5, "b": 3}) + second = await client.call_tool("add", {"b": 3, "a": 5}) + + assert first == second + assert tracking_calculator.add_calls == 1 + async def test_call_tool_very_large_value( self, caching_server: FastMCP,