Skip to content

Cache component lists in ProxyProvider#3479

Merged
jlowin merged 4 commits intomainfrom
fix/proxy-provider-component-cache
Mar 13, 2026
Merged

Cache component lists in ProxyProvider#3479
jlowin merged 4 commits intomainfrom
fix/proxy-provider-component-cache

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Mar 13, 2026

Closes #3466

Every call_tool through a proxy was opening two full MCP sessions to the backend: one to resolve the tool by name (via _get_tool_list_tools()), and another to actually execute it in ProxyTool.run(). For stateless HTTP proxies, each session means a fresh TCP connection and MCP initialization handshake — so 100 tool calls produced 200 handshakes.

ProxyProvider now caches component lists (tools, resources, templates, prompts) on the instance with a configurable TTL (default 300s). When _get_tool is called during tool resolution, it resolves from the cache instead of opening a backend connection. The cache refreshes whenever an explicit _list_* call is made.

proxy = ProxyProvider(lambda: ProxyClient("http://backend:8000/mcp"))

# Tune the TTL or disable caching entirely
proxy = ProxyProvider(client_factory, cache_ttl=60)
proxy = ProxyProvider(client_factory, cache_ttl=0)

For the remaining per-call handshake, the docs now show how to opt into session reuse for stateless backends — return the same client instance from the factory to skip the MCP initialization on every call:

base_client = ProxyClient("http://backend:8000/mcp")
shared_client = base_client.new()

proxy = FastMCPProxy(
    client_factory=lambda: shared_client,
    name="ReusedSessionProxy",
)

…d connections

Every call_tool through a proxy was triggering _list_tools() to resolve
the tool by name, opening a full MCP session just for the lookup, then
opening a second session for the actual execution. This caches component
lists on the ProxyProvider with a configurable TTL (default 300s),
cutting backend handshakes in half for repeated calls.
@jlowin jlowin added enhancement Improvement to existing functionality. For issues and smaller PR improvements. server Related to FastMCP server implementation or server-side functionality. labels Mar 13, 2026
@marvin-context-protocol marvin-context-protocol Bot added the provider Related to the FastMCP Provider class label Mar 13, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79e086f867

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +583 to +586
cache = self._tools_cache
if cache is None or not cache.is_fresh(self._cache_ttl):
await self._list_tools()
cache = self._tools_cache
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope cache by client/session context

_get_tool now serves lookups from a single provider-wide cache and skips _list_tools() while the TTL is fresh, so client_factory is not consulted per request. This breaks valid setups where client_factory is context-dependent (for example StatefulProxyClient.new_stateful or factories that pick a backend/token from request context): the first request’s catalog is reused for subsequent sessions, causing false "not found" results (or stale component exposure) until expiry. The same pattern is repeated for resources/templates/prompts, so the cache needs to be keyed by request/session identity or disabled for dynamic factories.

Useful? React with 👍 / 👎.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The Windows CI job failed due to a timing race in test_cache_expires_after_ttl — on Windows, time.monotonic() has coarser resolution (~15ms), causing two rapid successive calls to return the same timestamp.

Root Cause: In test_cache_expires_after_ttl, the cache is warmed by _list_tools(), then _get_tool("greet") is called immediately. Both calls complete so quickly that time.monotonic() returns the same value (1529.734) on Windows. The assertion provider._tools_cache.timestamp > original_ts fails because both timestamps are equal.

E       assert 1529.734 > 1529.734

The sibling test test_list_tools_refreshes_cache correctly uses time.sleep(0.01) between calls to advance the monotonic clock — test_cache_expires_after_ttl is missing that small sleep.

Suggested Solution: Add time.sleep(0.01) between capturing original_ts and calling _get_tool("greet") in test_cache_expires_after_ttl, matching the pattern used by test_list_tools_refreshes_cache:

# In test_cache_expires_after_ttl
original_ts = provider._tools_cache.timestamp

time.sleep(0.01)  # allow monotonic clock to advance (especially on Windows)

await provider._get_tool("greet")
assert provider._tools_cache.timestamp > original_ts

File to change: tests/server/providers/proxy/test_proxy_server.py

Detailed Analysis

Failing test (tests/server/providers/proxy/test_proxy_server.py:819):

async def test_cache_expires_after_ttl(self, fastmcp_server):
    """After TTL expires, _get_tool should re-fetch from the backend."""
    provider = ProxyProvider(
        lambda: ProxyClient(FastMCPTransport(fastmcp_server)),
        cache_ttl=0.0,
    )
    # Warm the cache
    await provider._list_tools()
    assert provider._tools_cache is not None
    original_ts = provider._tools_cache.timestamp

    await provider._get_tool("greet")
    assert provider._tools_cache.timestamp > original_ts  # <-- fails on Windows

Passing sibling test (uses time.sleep(0.01) correctly):

async def test_list_tools_refreshes_cache(self, fastmcp_server):
    ...
    # Tiny sleep so monotonic clock advances
    time.sleep(0.01)

    await provider._list_tools()
    assert provider._tools_cache.timestamp > first_ts

Log excerpt:

E       assert 1529.734 > 1529.734
E        +  where 1529.734 = <fastmcp.server.providers.proxy._CacheEntry object at 0x00000198423F2B60>.timestamp
tests\server\providers\proxy\test_proxy_server.py:819: AssertionError

Only 1 test failed (4804 passed). The fix is a one-line time.sleep(0.01) addition.

Related Files
  • tests/server/providers/proxy/test_proxy_server.py — the failing test (TestProxyProviderCache::test_cache_expires_after_ttl)
  • src/fastmcp/server/providers/proxy.py_CacheEntry class and time.monotonic() usage for cache timestamps

@jlowin jlowin merged commit a52036e into main Mar 13, 2026
9 checks passed
@jlowin jlowin deleted the fix/proxy-provider-component-cache branch March 13, 2026 23:44
ajram23 pushed a commit to ajram23/fastmcp that referenced this pull request Apr 19, 2026
Ports the proxy-component cache from FastMCP 3.2.0 PR PrefectHQ#3479 onto the
v2.14.4 base. ProxyToolManager/ProxyResourceManager/ProxyPromptManager now
memoize their get_*() results for cache_ttl seconds (default 300s, set via
new optional cache_ttl kwarg on each __init__).

The 2.x proxy layer lives in src/fastmcp/server/proxy.py as three separate
managers (vs 3.x's single ProxyProvider), so the upstream 3.x logic is
adapted class-by-class while preserving the public constructor signatures.

Since ToolManager.get_tool(key) calls get_tools() and indexes the returned
dict, caching get_tools() transparently accelerates per-call dispatch —
which is the mechanism causing the ~14.5s per-tool-call regression on the
Orbit agent path.

New test file tests/server/test_proxy_cache.py covers cache hit/miss,
TTL expiry, cache_ttl=0 disables, invalidate_cache(), isolation between
tool/resource/template/prompt managers, and that get_tool(key) lookups
reuse the cached list.

See ORBIT_FORK.md for provenance and deprecation criteria.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to existing functionality. For issues and smaller PR improvements. provider Related to the FastMCP Provider class server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance Overhead of Mounted Multi-Server Proxy

1 participant