Skip to content

fix(mcp): key the tool-list cache on credentials, not just the endpoint - #6715

Open
LHMQ878 wants to merge 1 commit into
crewAIInc:mainfrom
LHMQ878:fix/mcp-cache-key-credentials
Open

fix(mcp): key the tool-list cache on credentials, not just the endpoint#6715
LHMQ878 wants to merge 1 commit into
crewAIInc:mainfrom
LHMQ878:fix/mcp-cache-key-credentials

Conversation

@LHMQ878

@LHMQ878 LHMQ878 commented Jul 29, 2026

Copy link
Copy Markdown

Summary

_mcp_schema_cache (lib/crewai/src/crewai/mcp/client.py:50) is a module-level dict, so it is shared by every MCPClient in the process. But _get_cache_key only covered the endpoint:

if isinstance(self.transport, StdioTransport):
    key = f"stdio:{self.transport.command}:{':'.join(self.transport.args)}"
elif isinstance(self.transport, HTTPTransport):
    key = f"http:{self.transport.url}"
elif isinstance(self.transport, SSETransport):
    key = f"sse:{self.transport.url}"

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 Authorization headers shared a single cache entry: whichever connected first populated it, and the second was served the first one's tool list.

tenant A key: mcp:http:https://mcp.example.com/mcp:tools
tenant B key: mcp:http:https://mcp.example.com/mcp:tools
SAME KEY? True
after A caches, B's lookup hits: [{'name': 'tenant_a_only_tool'}]

Three collisions, all reproduced:

what differs collided why it matters
HTTPTransport.headers / SSETransport.headers yes Authorization — a crew serving two tenants can see the other tenant's tools
StdioTransport.env yes env={"API_KEY": ...} selects the account the local server acts as
HTTPTransport.streamable yes picks a different protocol (STREAMABLE_HTTP vs HTTP), so the response can differ

This is the documented usage, not an exotic setup

docs/edge/en/mcp/overview.mdx pairs credentials with caching in its own examples — the collision lands squarely on what the docs recommend:

MCPServerHTTP(
    url="https://api.example.com/mcp",
    headers={"Authorization": "Bearer your_token"},
    streamable=True,
    cache_tools_list=True,          # ← same URL + different token = shared entry
)
MCPServerStdio(
    command="npx", args=["-y", "@modelcontextprotocol/server-filesystem"],
    env={"API_KEY": "your_key"},
    cache_tools_list=True,
)

headers is a first-class field on both MCPServerHTTP and MCPServerSSE (mcp/config.py:72, :109), documented as "Optional HTTP headers for authentication or other purposes", and MCPToolResolver._create_transport (mcp/tool_resolver.py:296-307) passes it straight through to the transport. The whole path from Agent(mcps=[...]) to the cache lookup is normal user flow.

Note tool_filter does not mask this: filtering happens in tool_resolver.py:382 on the list returned by list_tools(), after the cache has already answered.

Changes

_get_cache_key now includes the credential-bearing fields, plus streamable:

if isinstance(self.transport, StdioTransport):
    key = (
        f"stdio:{self.transport.command}:{':'.join(self.transport.args)}"
        f":{self._fingerprint(self.transport.env)}"
    )
elif isinstance(self.transport, HTTPTransport):
    key = (
        f"http:{self.transport.url}:{self.transport.streamable}"
        f":{self._fingerprint(self.transport.headers)}"
    )
elif isinstance(self.transport, SSETransport):
    key = f"sse:{self.transport.url}:{self._fingerprint(self.transport.headers)}"

The values are hashed rather than interpolated, because these are tokens and cache keys end up in logs and error messages:

@staticmethod
def _fingerprint(values: dict[str, str]) -> str:
    if not values:
        return "-"
    canonical = json.dumps(values, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]

sort_keys=True matters: 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_list default 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 guards
test_http_clients_with_different_auth_headers_do_not_share_a_cache_entry the main defect; also asserts A's cached entry is not reachable via B's key
test_stdio_clients_with_different_env_do_not_share_a_cache_entry stdio credentials
test_sse_clients_with_different_auth_headers_do_not_share_a_cache_entry SSE had the same defect
test_streamable_flag_changes_the_cache_key protocol selection
test_identical_transports_still_share_a_cache_entry control — header order must not split the key, i.e. caching still works
test_credentials_are_not_present_verbatim_in_the_cache_key tokens stay out of the key
test_resource_type_still_separates_entries control — existing tools vs prompts separation preserved
test_no_credentials_still_yields_a_stable_key control — the no-headers case stays deterministic

An autouse fixture clears _mcp_schema_cache around each test, since it is module-level state.

Control experimentclient.py reverted, tests kept:

4 failed, 4 passed

FAILED test_http_clients_with_different_auth_headers_do_not_share_a_cache_entry
FAILED test_stdio_clients_with_different_env_do_not_share_a_cache_entry
FAILED test_sse_clients_with_different_auth_headers_do_not_share_a_cache_entry
FAILED test_streamable_flag_changes_the_cache_key

Exactly the four defect tests fail; the four controls stay green. (test_credentials_are_not_present_verbatim_in_the_cache_key passing on main is 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/:

tree result
this branch 43 passed, 5 failed
unmodified main same 5 failures

The 5 are test_amp_mcp.py::TestFetchAmpMCPConfigs::*, identical on main with client.py reverted — they need network/token state my machine does not have, and are unrelated to this change.

ruff format, ruff check and mypy clean on client.py. (The test file sits under lib/crewai/tests/, which pyproject.toml puts in ruff's extend-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) means max_retries=0 never runs the operation at all and falls through to raise ConnectionError(f"Operation failed: {last_error}") with last_error=None — you get Operation failed: None without a single attempt having been made. It also reads as "attempts" rather than "retries", unlike task.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, and mcp cache — no existing or overlapping work. Targeting main.

`_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.
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 86767019-e512-49c3-aff4-a9f8c0914cd1

📥 Commits

Reviewing files that changed from the base of the PR and between f15844b and 05ba5c5.

📒 Files selected for processing (2)
  • lib/crewai/src/crewai/mcp/client.py
  • lib/crewai/tests/mcp/test_client_cache_key.py

📝 Walkthrough

Walkthrough

Changes

MCP cache key isolation

Layer / File(s) Summary
Credential fingerprinting and cache key construction
lib/crewai/src/crewai/mcp/client.py
Adds canonical SHA-256 fingerprints for credential dictionaries and incorporates them, transport type, URL or command, and HTTP streamable state into MCP schema cache keys.
Cache separation and determinism tests
lib/crewai/tests/mcp/test_client_cache_key.py
Verifies tenant-specific separation, protocol separation, canonical key generation, secret obfuscation, resource isolation, and stable keys without credentials.

Suggested reviewers: greysonlalonde

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: making the MCP tool-list cache key depend on credentials rather than only the endpoint.
Description check ✅ Passed The description is detailed and directly describes the same cache-key collision fix and the added tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant