Skip to content

Conversation

@strawgate
Copy link
Collaborator

@strawgate strawgate commented Oct 29, 2025

Summary

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.

Example

Before this fix:

parent = FastMCP("parent")
parent.add_middleware(ResponseCachingMiddleware())
await parent.import_server(child, prefix="child")

# First call - works
tools = await client.list_tools()  # Returns ["child_example_tool"]

# Second call - broken
tools = await client.list_tools()  # Returns ["example_tool"] (missing prefix!)

After this fix, both calls correctly return ["child_example_tool"].

Changes

  1. Fixed FastMCPComponent serialization to include the _key field
  2. Updated caching middleware to preserve key when creating cacheable objects
  3. Added comprehensive test coverage

Fixes #2300

Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Fixed caching middleware to correctly preserve mounted server prefixes for tools, resources, and prompts across multiple cached calls.

…ls/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 <[email protected]>
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 29, 2025

Walkthrough

The pull request adds a key parameter to Tool, Resource, and Prompt constructors to preserve mounted-server prefixes during caching operations. Custom serialization and validation methods in FastMCPComponent ensure the key field persists across model validation and serialization. A new test verifies that caching maintains prefixed names across multiple cached calls.

Changes

Cohort / File(s) Summary
Constructor Updates
src/fastmcp/tools/tool.py, src/fastmcp/resources/resource.py, src/fastmcp/prompts/prompt.py
Added key: str parameter to Tool, Resource, and Prompt constructors to support prefix propagation in cached representations
Serialization & Validation
src/fastmcp/utilities/components.py
Added _serialize_model() to include internal _key in serialized output and model_validate() classmethod to extract and preserve the key during deserialization
Caching Middleware
src/fastmcp/server/middleware/caching.py
Updated on_list_tools(), on_list_resources(), and on_list_prompts() to propagate the key field when constructing cached normalized representations
Integration Tests
tests/server/middleware/test_caching.py
Added test_mounted_server_prefixes_preserved() to verify cached responses maintain mounted-server prefixes and stability across multiple calls

Sequence Diagram

sequenceDiagram
    participant Client
    participant CachingMiddleware
    participant FastMCPComponent
    participant Cache
    
    Client->>CachingMiddleware: list_tools() - first call
    CachingMiddleware->>FastMCPComponent: retrieve tools
    FastMCPComponent->>FastMCPComponent: construct Tool with key
    activate FastMCPComponent
    FastMCPComponent->>Cache: store normalized representation
    deactivate FastMCPComponent
    CachingMiddleware-->>Client: return tools with prefixes (child_add, etc.)
    
    Client->>CachingMiddleware: list_tools() - second call
    activate Cache
    CachingMiddleware->>Cache: check cache
    Cache-->>CachingMiddleware: return cached items
    deactivate Cache
    rect rgb(200, 220, 255)
    note over CachingMiddleware: Key field preserved<br/>in cached representation
    end
    CachingMiddleware-->>Client: return same tools with prefixes intact
Loading

Poem

🐰 A key beneath the cache we store,
Through serialization's magic door,
Prefixes dance on every call,
The rabbit's middleware keeps them all! 🗝️

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Description Check ⚠️ Warning The PR description provides good content including a clear summary, a helpful before/after example demonstrating the bug and fix, a list of changes, an issue reference (#2300), and attribution. However, the description is missing required structural sections from the template: the "Contributors Checklist" section with items about issue closure, workflow adherence, testing, and documentation, and the "Review Checklist" section with self-review and readiness items. While the essential information for understanding the changes is present, the template structure with these governance checklists is not followed. Add the missing checklist sections from the template to the PR description: include the "Contributors Checklist" with all four checkbox items (issue closure, workflow adherence, testing, documentation) and the "Review Checklist" with the two checkbox items (self-review, readiness for review). These sections are required per the repository's template structure and help ensure proper governance and review readiness.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The PR title "[Draft] preserve key field in ResponseCachingMiddleware for prefixed tools/resources/prompts" is directly related to the main change in the PR, which involves fixing the serialization of the _key field in FastMCPComponent and updating the caching middleware to preserve this key for prefixed tools, resources, and prompts. The title accurately captures the core issue and is specific enough for reviewers to understand the primary change. While the title is somewhat verbose, it remains clear and purposeful without unnecessary noise or vague terminology.
Docstring Coverage ✅ Passed Docstring coverage is 88.89% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch claude/issue-2300-20251029-1512

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9c750fe and d346dc6.

📒 Files selected for processing (3)
  • src/fastmcp/server/middleware/caching.py (3 hunks)
  • src/fastmcp/utilities/components.py (2 hunks)
  • tests/server/middleware/test_caching.py (1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
src/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

Use Python ≥ 3.10 and provide full type annotations for library code

Files:

  • src/fastmcp/server/middleware/caching.py
  • src/fastmcp/utilities/components.py
**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

Never use bare except; always catch specific exception types

Files:

  • src/fastmcp/server/middleware/caching.py
  • tests/server/middleware/test_caching.py
  • src/fastmcp/utilities/components.py
tests/**/*.py

📄 CodeRabbit inference engine (AGENTS.md)

tests/**/*.py: Tests must be atomic, self-contained, and cover a single functionality
Use pytest parameterization for multiple examples of the same functionality
Use separate tests for distinct pieces of functionality
Always put imports at the top of test files; do not import inside test bodies
Do not add @pytest.mark.asyncio; asyncio_mode = "auto" is set globally
Prefer in-memory transport for tests; use HTTP transport only when explicitly testing networking
For slow/long-running tests, mark them as integration or optimize (default timeout is 5s)
In tests, use # type: ignore[attr-defined] for MCP results instead of type assertions

Files:

  • tests/server/middleware/test_caching.py
🧬 Code graph analysis (3)
src/fastmcp/server/middleware/caching.py (2)
src/fastmcp/utilities/components.py (1)
  • key (66-73)
src/fastmcp/resources/resource.py (1)
  • key (148-155)
tests/server/middleware/test_caching.py (2)
src/fastmcp/server/server.py (2)
  • name (359-360)
  • resource (1613-1751)
src/fastmcp/server/middleware/caching.py (1)
  • ResponseCachingMiddleware (120-459)
src/fastmcp/utilities/components.py (2)
src/fastmcp/resources/resource.py (1)
  • key (148-155)
src/fastmcp/resources/template.py (1)
  • key (228-235)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Run tests: Python 3.10 on windows-latest
  • GitHub Check: Run tests with lowest-direct dependencies
  • GitHub Check: label-issue-or-pr
🔇 Additional comments (6)
src/fastmcp/utilities/components.py (2)

122-129: LGTM - Serialization correctly preserves key.

The custom serializer properly includes the _key private attribute in serialized output. Note that this will affect equality comparisons in the __eq__ method (line 168) since it uses model_dump(), meaning components with different keys will be considered unequal. This appears intentional given the PR's objective to preserve key identity.


131-161: LGTM - Validation correctly handles key extraction.

The custom validator properly extracts the key field from dict inputs and assigns it to the private _key attribute. The implementation correctly avoids mutating the input dict and handles edge cases appropriately (e.g., key=None, non-dict inputs).

tests/server/middleware/test_caching.py (1)

509-583: Excellent test coverage for prefix preservation.

This integration test thoroughly validates that mounted server prefixes are preserved across cached calls for tools, resources, and prompts. The test directly addresses the bug described in the PR objectives and follows the coding guidelines appropriately.

The test structure is clear:

  • Tests both cache miss (first call) and cache hit (second call)
  • Validates prefix presence on all returned items
  • Ensures consistency between cached and uncached results
  • Includes functional verification by calling a prefixed tool
src/fastmcp/server/middleware/caching.py (3)

242-256: LGTM - Correctly preserves tool key during caching.

The addition of key=tool.key ensures that prefixed keys from mounted servers are preserved when creating cacheable Tool instances. This is essential for maintaining component identity across cache boundaries.


283-297: LGTM - Correctly preserves resource key during caching.

Consistent with the tools change, this ensures resource keys (including prefixes from mounted servers) are preserved in cached representations.


324-336: LGTM - Correctly preserves prompt key during caching.

This completes the fix by ensuring prompt keys are also preserved during caching, maintaining consistency across all component types (tools, resources, and prompts).


Comment @coderabbitai help to get the list of available commands and usage tips.

@marvin-context-protocol marvin-context-protocol bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. tests labels Oct 29, 2025
@strawgate
Copy link
Collaborator Author

/marvin im not sure i want a custom validator and serializer on the fastmcp component, we might just need to use custom cachable models in the response caching middleware, can you explore our options here and outline a recommendation?

@marvin-context-protocol
Copy link
Contributor

marvin-context-protocol bot commented Oct 29, 2025

Claude finished @strawgate's task —— View job


Analysis: Custom Serializers vs. Custom Cacheable Models

I've analyzed both approaches for preserving key fields in cached responses. Here's my recommendation:

Current Implementation (Custom Serializers on FastMCPComponent)

Pros:

  • Minimal code changes in middleware - just pass key=tool.key to constructors
  • Leverages Pydantic's serialization/validation infrastructure
  • Key preservation happens automatically through round-trip

Cons:

  • Modifies base class used by all components (Tool, Resource, Prompt, ResourceTemplate)
  • Changes equality semantics for ALL components (line 168 uses model_dump() which now includes key)
  • Adds complexity to fundamental building blocks for a middleware-specific need
  • Makes _key participate in serialization globally, not just for caching
  • Potential unintended side effects in other serialization contexts

Alternative Approach (Custom Cacheable Models in Middleware)

Pros:

  • Isolates caching concerns to the middleware itself
  • No changes to base component classes
  • Explicit control over what gets cached and how
  • Follows existing pattern (CachableReadResourceContents, CachableToolResult)
  • Clear separation of concerns - cache models vs. domain models

Cons:

  • More boilerplate code (3 new classes: CachableTool, CachableResource, CachablePrompt)
  • Explicit conversion methods needed (from_tool(), to_tool())
  • Slightly more verbose middleware implementation

Recommendation: Use Custom Cacheable Models ✅

The alternative approach is architecturally superior because:

  1. Separation of Concerns: Caching is a middleware responsibility, not a component responsibility. The base FastMCPComponent class shouldn't need to know about caching requirements.

  2. Principle of Least Surprise: Modifying serialization behavior of core classes can have unexpected side effects. For example, the current implementation changes equality semantics for ALL components everywhere, not just in caching contexts.

  3. Consistency: The middleware already uses this pattern for CachableReadResourceContents and CachableToolResult. Adding CachableTool, CachableResource, and CachablePrompt maintains consistency.

  4. Maintainability: When someone reads FastMCPComponent, they shouldn't need to understand caching middleware concerns. The custom serializer/validator adds cognitive overhead to understanding the base class.

  5. Flexibility: Custom cacheable models give complete control over what gets cached without affecting the original component hierarchy.

Implementation Notes

If you go with custom cacheable models:

  • Follow the pattern of CachableReadResourceContents (lines 38-60 in caching.py)
  • Add explicit key: str field (not private) to cacheable models
  • Use @classmethod for from_X() factory methods and instance methods for to_X() conversions
  • Keep the cacheable models private to the middleware module

The extra boilerplate (~100 lines) is worth the architectural cleanliness and avoiding potential bugs from changing base class behavior.


@strawgate
Copy link
Collaborator Author

/marvin do you think we could just remove key and just mutate the tool name when it gets returned from mounted servers?

@marvin-context-protocol
Copy link
Contributor

marvin-context-protocol bot commented Nov 1, 2025

Claude finished @strawgate's task —— View job


Analysis: Should We Remove key and Just Mutate Names?

I've analyzed the codebase and the proposed alternative approach. Here's my assessment:

Current Architecture

The codebase uses a dual-identity system for components:

  • name: The user-facing identifier used in MCP protocol calls (e.g., "child_add")
  • key: Internal bookkeeping identifier for tracking components across server hierarchies (components.py:66-73)

When servers are imported with prefixes (server.py:2417-2448):

  1. Tools: key becomes "child_add", name stays unchanged initially but gets prefixed
  2. Resources: Similar pattern with URI-based prefixing
  3. The key property falls back to name if _key is not set (components.py:73)

Why Mutating Names Won't Work

The problem with just mutating names is you lose the distinction between the original identifier and the prefixed identifier. Here's why this matters:

  1. Cache Key Stability: When you serialize/deserialize components through the cache, you need to know the original key to properly reconstruct them. If you only mutate the name, when the object comes back from cache, you've lost the information about what the original key was vs. what the name should be.

  2. Server Hierarchy Tracking: The key field exists specifically for "internal bookkeeping and may reflect e.g. prefixes or other identifiers" (components.py:68-71). Removing it means you can't distinguish between:

    • A tool originally named "child_add" (no prefix)
    • A tool with key "child_add" that came from importing with prefix "child" where the original was "add"
  3. Protocol vs. Internal Identity: The MCP protocol uses name (what clients see), but internally FastMCP needs key to track where components came from, especially with multiple levels of imported servers.

The Real Problem

The issue isn't whether we need key or not—the real problem is that the current PR adds custom serializers to the base FastMCPComponent class, which:

  • Changes equality semantics globally (components.py:168 uses model_dump() which now includes key)
  • Affects all components everywhere, not just in caching contexts
  • Violates separation of concerns

Better Solution

I agree with @marvin-context-protocol's earlier analysis: use custom cacheable models in the middleware (like CachableReadResourceContents at caching.py:38-60). This:

  1. Keeps the key field (which serves a real purpose)
  2. Isolates caching concerns to the middleware
  3. Doesn't modify base component serialization behavior
  4. Follows existing patterns in the codebase

The key field is fundamentally useful for tracking component identity across server hierarchies. The question isn't "should we remove it?" but rather "where should we handle serializing it for caching?" And the answer is: in the middleware, not in the base class.

Recommendation

Keep the key field but implement custom cacheable models in ResponseCachingMiddleware:

class CachableTool(BaseModel):
    name: str
    key: str  # Explicitly include key
    # ... other fields
    
    @classmethod
    def from_tool(cls, tool: Tool) -> Self:
        return cls(name=tool.name, key=tool.key, ...)

This gives you the benefits of preserving prefix information without the architectural problems of modifying the base class.


@strawgate
Copy link
Collaborator Author

/marvin im not sure that's convincing, for a client to call a tool we must be able to distinguish prefixes without keys already? Can you review this again? What if we just stopped using key for bookkeeping?

@marvin-context-protocol
Copy link
Contributor

marvin-context-protocol bot commented Nov 2, 2025

Claude finished @strawgate's task —— View job


Analysis: Do We Actually Need the key Field?

Verdict: No, we don't need the key field. You're absolutely right to question this.


The Key Discovery

Looking at server.py:682-697, here's what actually happens when listing tools:

async def _list_tools_mcp(self) -> list[MCPTool]:
    tools = await self._list_tools_middleware()
    return [
        tool.to_mcp_tool(
            name=tool.key,  # ← The key BECOMES the name sent to clients!
            include_fastmcp_meta=self.include_fastmcp_meta,
        )
        for tool in tools
    ]

And in to_mcp_tool() (tool.py:154):

return MCPTool(
    name=overrides.get("name", self.name),  # ← Uses the override!
    ...
)

The key field is literally just used to set the name field when converting to MCP format.

The Complete Flow

Without Caching (Working)

  1. Parent calls _list_tools() on child (server.py:741)
  2. Child returns tools with key="add", name="add"
  3. Parent adds prefix: key = f"child_{tool.key}" (server.py:749)
  4. Parent copies tool with new key: tool.model_copy(key="child_add") (server.py:750)
  5. Parent returns tool with key="child_add", name="add" (original name unchanged)
  6. When converting to MCP, parent uses: tool.to_mcp_tool(name=tool.key) (server.py:693)
  7. Client sees tool named "child_add"

With Caching (Broken Before This PR)

  1. First call works as above, tool has key="child_add"
  2. Middleware creates cacheable tool: Tool(name=tool.name, ...) (caching.py:243)
  3. This creates tool with name="add", _key=None (because key wasn't in constructor!)
  4. Tool is serialized and cached
  5. Second call deserializes from cache
  6. Tool now has key="add" (falls back to name since _key is None)
  7. When converting to MCP: tool.to_mcp_tool(name=tool.key) uses key="add"
  8. Client sees tool named "add" (missing prefix!)

The Real Question: Why Have key At All?

You're asking the right question. Looking at the code:

Where key is used:

  1. Internal storage in ToolManager: self._tools[tool.key] = tool (tool_manager.py:121)
  2. Converting to MCP: tool.to_mcp_tool(name=tool.key) (server.py:693)
  3. Calling tools: Server looks up by key when mounted (server.py:1113)

But here's the thing: In all these cases, the key is effectively acting as "the name we want clients to see and use."

The Solution: Just Mutate name

Instead of having both name and key, we could just:

  1. When importing with prefix, mutate the name field directly:

    tool = tool.model_copy(update={"name": f"{prefix}_{tool.name}"})
  2. Store tools by their name: self._tools[tool.name] = tool

  3. When converting to MCP: tool.to_mcp_tool() (no override needed)

  4. Remove the key property entirely

Why This Works

For calling tools: When a client calls "child_add":

  • Parent receives "child_add"
  • Parent strips prefix to get "add"
  • Parent calls child with "add"
  • Child has tool stored at name "add"

For listing tools:

  • Parent's tool has name="child_add"
  • Converts to MCP with name="child_add"

For caching:

  • Tool serializes with name="child_add"
  • Deserializes with name="child_add"
  • No special handling needed!

The Fix

Instead of this PR's approach (custom serializers on FastMCPComponent), just:

  1. Change server.py:2420 from:

    tool = tool.model_copy(key=f"{prefix}_{key}")

    to:

    tool = tool.model_copy(update={"name": f"{prefix}_{tool.name}"})
  2. Change server.py:749-750 from:

    key = f"{mounted.prefix}_{tool.key}"
    tool = tool.model_copy(key=key)

    to:

    tool = tool.model_copy(update={"name": f"{mounted.prefix}_{tool.name}"})
  3. Change server.py:693 from:

    tool.to_mcp_tool(name=tool.key, ...)

    to:

    tool.to_mcp_tool(...)
  4. Change tool_manager.py:109, 121, 139 to use tool.name instead of tool.key

  5. Remove the key property and _key attribute entirely from FastMCPComponent

  6. Revert the caching middleware changes - no special handling needed!

Recommendation

Close this PR and implement the simpler solution: just mutate name directly. The key field adds unnecessary complexity for what is essentially just "the name to expose to clients." By making name mutable during import/mount operations, everything becomes simpler and caching "just works" with no special handling.


@jlowin
Copy link
Owner

jlowin commented Nov 2, 2025

I dont think marvin's final conclusion is valid: if you mutate the name of a tool to add a prefix, then the tool will also have that prefix in the server it was originally added in. So the ultimate (parent) server has all properly named tools, but every subserver has weirdly named tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. server Related to FastMCP server implementation or server-side functionality. tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ResponseCachingMiddleware breaks imported/mounted server prefixes

3 participants