From ea0a12f9bac89ead4bde9b36338f5a254ee27570 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 6 May 2026 00:25:58 +0000 Subject: [PATCH] fix(mcp): fall back to client_ip in stateful session owner fingerprint Addresses Greptile review on PR #26857: when no API key, user_id, or OAuth bearer is available (e.g. unauthenticated/passthrough callers), the owner fingerprint collapsed to a single 'anonymous' value, allowing two unrelated callers to drive each other's stateful MCP sessions. Fold client IP into the fingerprint as a fallback identity signal so distinct anonymous sources do not share an owner identity. Co-authored-by: Mateo Wang --- litellm/proxy/_experimental/mcp_server/server.py | 16 ++++++++++++++-- .../_experimental/mcp_server/test_mcp_server.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index d8e32ef46e9..5a063456dc3 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -2674,6 +2674,7 @@ if MCP_AVAILABLE: def _owner_fingerprint_for( user_api_key_auth: Optional[UserAPIKeyAuth], oauth2_headers: Optional[Dict[str, str]] = None, + client_ip: Optional[str] = None, ) -> str: """ Stable, non-reversible identifier for the caller used to bind an @@ -2684,6 +2685,13 @@ if MCP_AVAILABLE: the caller's identity is the upstream OAuth bearer; hash it so two OAuth callers with different tokens don't both fingerprint to ``anonymous`` and end up sharing a session. + + When no caller-identifying credentials are available at all + (e.g. proxy running without master key, or an unauthenticated + passthrough path), fall back to the client IP so two unrelated + anonymous callers from different sources do not collapse to a + single ``anonymous`` owner and end up able to drive each other's + stateful sessions. """ if user_api_key_auth is not None: if user_api_key_auth.api_key: @@ -2696,6 +2704,8 @@ if MCP_AVAILABLE: ) if authz: return f"oauth:{hashlib.sha256(authz.encode('utf-8')).hexdigest()}" + if client_ip: + return f"ip:{hashlib.sha256(client_ip.encode('utf-8')).hexdigest()}" return "anonymous" def _is_initialize_request(body: bytes) -> bool: @@ -3003,7 +3013,7 @@ if MCP_AVAILABLE: if session_id: expected_owner = _stateful_session_owners.get(session_id) request_owner = _owner_fingerprint_for( - user_api_key_auth, oauth2_headers + user_api_key_auth, oauth2_headers, _client_ip ) if expected_owner is not None and expected_owner != request_owner: verbose_logger.warning( @@ -3074,7 +3084,9 @@ if MCP_AVAILABLE: local_send = _wrap_send_with_stateful_session_auth_context( local_send, auth_user, - _owner_fingerprint_for(user_api_key_auth, oauth2_headers), + _owner_fingerprint_for( + user_api_key_auth, oauth2_headers, _client_ip + ), ) async with _gateway_initialize_instructions_request_scope( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 4e42d15b266..10ea6559af5 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -1453,6 +1453,19 @@ async def test_owner_fingerprint_distinguishes_oauth_callers(): assert fp_no_oauth == "anonymous" assert "Bearer token-A" not in fp_a + # When no API key, user_id, or OAuth bearer is available, fall back to + # client IP so two unrelated unauthenticated callers from different + # sources don't collapse to a single 'anonymous' owner and end up able + # to drive each other's stateful sessions. + fp_ip_a = _owner_fingerprint_for(anon_auth, None, "10.0.0.1") + fp_ip_b = _owner_fingerprint_for(anon_auth, None, "10.0.0.2") + fp_ip_a_again = _owner_fingerprint_for(anon_auth, None, "10.0.0.1") + + assert fp_ip_a != fp_ip_b + assert fp_ip_a == fp_ip_a_again + assert fp_ip_a.startswith("ip:") + assert "10.0.0.1" not in fp_ip_a + @pytest.mark.asyncio async def test_stateful_mcp_session_owner_mismatch_returns_403():