fix: preserve key field in ResponseCachingMiddleware for prefixed tools/resources/prompts

The ResponseCachingMiddleware was losing prefix information when caching
tools, resources, and prompts from mounted/imported servers. The root cause
was that the private _key attribute wasn't being serialized by Pydantic.

Changes:
- Add model_serializer to FastMCPComponent to include _key in serialization
- Update model_validate to restore _key from deserialized data
- Add key parameter when creating cached Tool/Resource/Prompt objects
- Add comprehensive test for mounted server prefix preservation

Fixes #2300

Co-authored-by: William Easton <strawgate@users.noreply.github.com>
This commit is contained in:
claude[bot] 2025-10-29 15:24:33 +00:00
commit d346dc66e7
3 changed files with 121 additions and 1 deletions

View file

@ -242,6 +242,7 @@ class ResponseCachingMiddleware(Middleware):
cachable_tools: list[Tool] = [
Tool(
name=tool.name,
key=tool.key,
title=tool.title,
description=tool.description,
parameters=tool.parameters,
@ -282,6 +283,7 @@ class ResponseCachingMiddleware(Middleware):
cachable_resources: list[Resource] = [
Resource(
name=resource.name,
key=resource.key,
title=resource.title,
description=resource.description,
tags=resource.tags,
@ -322,6 +324,7 @@ class ResponseCachingMiddleware(Middleware):
cachable_prompts: list[Prompt] = [
Prompt(
name=prompt.name,
key=prompt.key,
title=prompt.title,
description=prompt.description,
tags=prompt.tags,

View file

@ -4,7 +4,7 @@ from collections.abc import Sequence
from typing import Annotated, Any, TypedDict
from mcp.types import Icon
from pydantic import BeforeValidator, Field, PrivateAttr
from pydantic import BeforeValidator, Field, PrivateAttr, model_serializer
from typing_extensions import Self, TypeVar
import fastmcp
@ -119,6 +119,47 @@ class FastMCPComponent(FastMCPBaseModel):
copy._key = key
return copy
@model_serializer(mode="wrap")
def _serialize_model(self, serializer: Any, info: Any) -> dict[str, Any]:
"""Custom serializer to include the key field."""
data = serializer(self)
# Include _key in serialization if it's set
if self._key is not None:
data["key"] = self._key
return data
@classmethod
def model_validate(
cls,
obj: Any,
*,
strict: bool | None = None,
from_attributes: bool | None = None,
context: dict[str, Any] | None = None,
) -> Self:
"""Validate and create a model instance, handling the key attribute."""
if isinstance(obj, dict):
# Extract key from dict if present (don't mutate original dict)
key = obj.get("key")
if key is not None:
# Create a copy without the key field
obj = {k: v for k, v in obj.items() if k != "key"}
instance = super().model_validate(
obj,
strict=strict,
from_attributes=from_attributes,
context=context,
)
if key is not None:
instance._key = key
return instance
return super().model_validate(
obj,
strict=strict,
from_attributes=from_attributes,
context=context,
)
def __eq__(self, other: object) -> bool:
if type(self) is not type(other):
return False

View file

@ -505,3 +505,79 @@ class TestResponseCachingMiddlewareIntegration:
),
)
)
async def test_mounted_server_prefixes_preserved(self):
"""Test that caching preserves prefixes from mounted servers."""
# Create child servers with tools, resources, and prompts
child = FastMCP("child")
calculator = TrackingCalculator()
calculator.add_tools(fastmcp=child)
calculator.add_resources(fastmcp=child)
calculator.add_prompts(fastmcp=child)
# Create parent with caching middleware
parent = FastMCP("parent")
parent.add_middleware(ResponseCachingMiddleware())
await parent.import_server(child, prefix="child")
async with Client[FastMCPTransport](transport=parent) as client:
# First call - populates cache
tools1 = await client.list_tools()
tool_names1 = [tool.name for tool in tools1]
# Second call - from cache (this is where the bug would occur)
tools2 = await client.list_tools()
tool_names2 = [tool.name for tool in tools2]
# All tools should have the prefix in both calls
for name in tool_names1:
assert name.startswith("child_"), (
f"Tool {name} missing prefix (first call)"
)
for name in tool_names2:
assert name.startswith("child_"), (
f"Tool {name} missing prefix (cached call)"
)
# Both calls should return the same tools
assert tool_names1 == tool_names2
# Verify tool can be called with prefixed name
result = await client.call_tool("child_add", {"a": 5, "b": 3})
assert not result.is_error
# Test resources
resources1 = await client.list_resources()
resource_names1 = [resource.name for resource in resources1]
resources2 = await client.list_resources()
resource_names2 = [resource.name for resource in resources2]
for name in resource_names1:
assert name.startswith("child_"), (
f"Resource {name} missing prefix (first call)"
)
for name in resource_names2:
assert name.startswith("child_"), (
f"Resource {name} missing prefix (cached call)"
)
assert resource_names1 == resource_names2
# Test prompts
prompts1 = await client.list_prompts()
prompt_names1 = [prompt.name for prompt in prompts1]
prompts2 = await client.list_prompts()
prompt_names2 = [prompt.name for prompt in prompts2]
for name in prompt_names1:
assert name.startswith("child_"), (
f"Prompt {name} missing prefix (first call)"
)
for name in prompt_names2:
assert name.startswith("child_"), (
f"Prompt {name} missing prefix (cached call)"
)
assert prompt_names1 == prompt_names2