fix(mcp): key the tool-list cache on credentials, not just the endpoint - #6715
Open
LHMQ878 wants to merge 1 commit into
Open
fix(mcp): key the tool-list cache on credentials, not just the endpoint#6715LHMQ878 wants to merge 1 commit into
LHMQ878 wants to merge 1 commit into
Conversation
`_mcp_schema_cache` is module-level, so it is shared by every MCPClient in
the process, but `_get_cache_key` only covered the URL (HTTP/SSE) or the
command and args (stdio). Anything else that changes what the server
answers was invisible to it.
So two clients on the same URL with different `Authorization` headers
shared one cache entry: whichever connected first populated it, and the
second was served the first one's tool list. Same for a stdio server
launched with a different API key in `env`, and for `streamable=True` vs
`False`, which selects a different protocol.
This is the documented usage -- the MCP overview pairs
`headers={"Authorization": "Bearer your_token"}` and
`env={"API_KEY": ...}` with `cache_tools_list=True` in its examples --
and an MCP server routinely returns a different tool set per caller, so a
multi-tenant crew could see tools belonging to another tenant.
The key now includes a SHA-256 digest of the headers/env rather than the
values themselves, since cache keys end up in logs and error messages.
The digest is over a sort_keys JSON dump, so header order does not split
the key and caching still hits for genuinely equivalent clients.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughChangesMCP cache key isolation
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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
_mcp_schema_cache(lib/crewai/src/crewai/mcp/client.py:50) is a module-level dict, so it is shared by everyMCPClientin the process. But_get_cache_keyonly covered the endpoint:Everything else that changes what the server answers was invisible to it — most importantly who is asking. An MCP server routinely returns a different tool set per caller, so two clients on the same URL with different
Authorizationheaders shared a single cache entry: whichever connected first populated it, and the second was served the first one's tool list.Three collisions, all reproduced:
HTTPTransport.headers/SSETransport.headersAuthorization— a crew serving two tenants can see the other tenant's toolsStdioTransport.envenv={"API_KEY": ...}selects the account the local server acts asHTTPTransport.streamableSTREAMABLE_HTTPvsHTTP), so the response can differThis is the documented usage, not an exotic setup
docs/edge/en/mcp/overview.mdxpairs credentials with caching in its own examples — the collision lands squarely on what the docs recommend:headersis a first-class field on bothMCPServerHTTPandMCPServerSSE(mcp/config.py:72,:109), documented as "Optional HTTP headers for authentication or other purposes", andMCPToolResolver._create_transport(mcp/tool_resolver.py:296-307) passes it straight through to the transport. The whole path fromAgent(mcps=[...])to the cache lookup is normal user flow.Note
tool_filterdoes not mask this: filtering happens intool_resolver.py:382on the list returned bylist_tools(), after the cache has already answered.Changes
_get_cache_keynow includes the credential-bearing fields, plusstreamable:The values are hashed rather than interpolated, because these are tokens and cache keys end up in logs and error messages:
sort_keys=Truematters: header dict order is not meaningful, so it must not split the key — otherwise the fix would quietly disable caching for clients that are in fact equivalent. There is a test for exactly that.Nothing else changed; the cache mechanism, TTL and
cache_tools_listdefault are untouched. 31 insertions.Validation
New
lib/crewai/tests/mcp/test_client_cache_key.py, 8 tests. The cache key had no test coverage before this.test_http_clients_with_different_auth_headers_do_not_share_a_cache_entrytest_stdio_clients_with_different_env_do_not_share_a_cache_entrytest_sse_clients_with_different_auth_headers_do_not_share_a_cache_entrytest_streamable_flag_changes_the_cache_keytest_identical_transports_still_share_a_cache_entrytest_credentials_are_not_present_verbatim_in_the_cache_keytest_resource_type_still_separates_entriestoolsvspromptsseparation preservedtest_no_credentials_still_yields_a_stable_keyAn
autousefixture clears_mcp_schema_cachearound each test, since it is module-level state.Control experiment —
client.pyreverted, tests kept:Exactly the four defect tests fail; the four controls stay green. (
test_credentials_are_not_present_verbatim_in_the_cache_keypassing onmainis correct — credentials were not in the key there because they were not considered at all.) With the fix: 8 passed.No regressions.
lib/crewai/tests/mcp/:mainThe 5 are
test_amp_mcp.py::TestFetchAmpMCPConfigs::*, identical onmainwithclient.pyreverted — they need network/token state my machine does not have, and are unrelated to this change.ruff format,ruff checkandmypyclean onclient.py. (The test file sits underlib/crewai/tests/, whichpyproject.tomlputs in ruff'sextend-exclude.)One thing I left out
_retry_operation(client.py:682) has a separate, smaller bug I noticed while reading:for attempt in range(self.max_retries)meansmax_retries=0never runs the operation at all and falls through toraise ConnectionError(f"Operation failed: {last_error}")withlast_error=None— you getOperation failed: Nonewithout a single attempt having been made. It also reads as "attempts" rather than "retries", unliketask.py:1320(max_attempts = self.guardrail_max_retries + 1).I left it out to keep this diff focused per
AGENTS.md. Happy to send it separately, or fold it in here if you would rather have both.Duplicate check
Searched open and closed PRs and issues for
_mcp_schema_cache,_get_cache_key,cache_tools_list, andmcp cache— no existing or overlapping work. Targetingmain.