Repository navigation
fix(mcp): stop handing the tools cache to callers - #4424
Merged
Merged
Conversation
8 of 9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_MCPServerWithClientSessionhands its live tools cache to callers, so ordinary use of the returned list corrupts the server's cache.Two exits return the cached list object itself:
list_tools()setsfiltered_tools = toolsand returns it unchanged when there is no tool filter.toolsisself._tools_liston a cache hit, and on a miss it is the same list that was just assigned toself._tools_list, so the alias exists whether or not the cache was warm — and whether or notcache_tools_listis set, since the fetch path always populates_tools_list.cached_toolsproperty returnsself._tools_listdirectly.A caller that mutates what it was given silently changes what every later call returns:
The
cached_toolsdirection is the worse of the two, because the mutation flows toward the model rather than away from it — appending an entry injects a tool definition that is then served as if the MCP server had advertised it:Both are one-line consequences of the same missing boundary: the cache is internal state and must not leave the object.
What this changes
cached_toolsreturns a new list, preserving theNonesentinel that distinguishes "nothing fetched yet" from "fetched, empty".list_tools()copies only when the value it is about to return is the cache itself:The identity check is deliberate rather than an unconditional
list(...). Both filter paths already build new lists (_apply_static_tool_filteruses comprehensions,_apply_dynamic_tool_filteraccumulates into a fresh list), so copying again would allocate on every filtered call for no benefit. The check also covers the one filtered case that does alias: a static filter dict carrying neitherallowed_tool_namesnorblocked_tool_namesfalls through_apply_static_tool_filterand returns its input unchanged. That case is covered by a test rather than left to the reader.Scope
Only the two places that leak the cache.
list_tools()returns a shallow copy — theMCPToolobjects are still shared, which matches how the rest of the SDK passes tool definitions around, and the reported failure is list mutation, not tool mutation. Deep-copying every tool on every call would be a real cost for a case with no evidence behind it.cached_toolson theMCPServerbase class returnsNoneand needed no change.Test plan
Four tests in
tests/mcp/test_caching.py:test_list_tools_does_not_expose_the_tools_cache—pop()on the returned list; the next call still returns both tools, andmock_list_tools.call_countstays at 1 so the assertion cannot pass by silently refetching.test_list_tools_does_not_expose_the_cache_with_a_no_op_static_filter— same, throughtool_filter={}, pinning the aliasing filter path.test_cached_tools_returns_a_snapshot—append()on the snapshot; neithercached_toolsnorlist_tools()reports the injected tool.test_cached_tools_is_none_before_the_first_list— negative control that the copy preservesNoneinstead of returning[].Fail-first: the first three fail on
mainwith the fix reverted and the fourth passes, confirming it is a control rather than a second assertion of the same bug. Thecall_countassertion matters — a caller callingclear()instead ofpop()happens to self-heal, because the cache-hit branch testsself._tools_listfor truthiness and an emptied cache falls through to a refetch.pop()andappend()leave the cache truthy and corrupt.tests/mcp/is 456 passed / 24 skipped, unchanged frommain. The two existingcached_tools is Noneassertions intest_server_errors.pyandtest_client_session_retries.pystill hold, andtest_paginated_tools_are_cached_before_filteringcompares with==rather thanis, so the snapshot does not disturb it.Full stack via
.agents/skills/code-change-verification/scripts/run.sh:make format,make lint,make typecheck,make testsall pass.Issue number
None — found while reading the caching path.
Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PRDisclosure: this change was prepared with AI assistance. The bug, the fix, and the tests were verified locally as described above.