From e308a34e7fbc91f001e0803c259d5bd5e9b98da3 Mon Sep 17 00:00:00 2001 From: Russell Brenner Date: Mon, 27 Apr 2026 17:45:58 +1000 Subject: [PATCH] fix(semantic-filter): run for anthropic_messages call type The SemanticToolFilterHook was skipping /v1/messages requests because 'anthropic_messages' was missing from the call_type allowlist. Claude Code and other Anthropic SDK clients send requests via this path, so the filter had no effect and all MCP tools were forwarded on every request. Add `anthropic_messages` to the allowlist alongside `completion`, `acompletion`, and `aresponses`. Tests in tests/test_litellm/proxy/_experimental/mcp_server/ test_semantic_tool_filter.py: - test_semantic_filter_hook_runs_for_anthropic_messages verifies the hook fires for call_type='anthropic_messages' and that the user query is correctly extracted from list-typed content blocks (the Anthropic SDK shape). - test_semantic_filter_hook_skips_non_allowlisted_call_type guards against accidentally widening the allowlist: a call type outside the four supported endpoints (e.g. 'image_generation') must still short-circuit the hook. Co-Authored-By: Claude Opus 4.7 AI-Generated: true --- .../proxy/hooks/mcp_semantic_filter/hook.py | 13 +- .../mcp_server/test_semantic_tool_filter.py | 128 +++++++++++++++--- 2 files changed, 118 insertions(+), 23 deletions(-) diff --git a/litellm/proxy/hooks/mcp_semantic_filter/hook.py b/litellm/proxy/hooks/mcp_semantic_filter/hook.py index 6343faaa965..2ac036fe77d 100644 --- a/litellm/proxy/hooks/mcp_semantic_filter/hook.py +++ b/litellm/proxy/hooks/mcp_semantic_filter/hook.py @@ -150,8 +150,17 @@ class SemanticToolFilterHook(CustomLogger): Returns: Modified data dict with filtered tools, or None if no changes """ - # Only filter endpoints that support tools - if call_type not in ("completion", "acompletion", "aresponses"): + # Only filter endpoints that support tools. + # `anthropic_messages` is the call type used for /v1/messages requests + # (Claude Code, Anthropic SDK clients). Without it on the allowlist, + # the filter never runs for those clients and every MCP tool is + # forwarded on every request. + if call_type not in ( + "completion", + "acompletion", + "aresponses", + "anthropic_messages", + ): verbose_proxy_logger.debug( f"Skipping semantic filter for call_type={call_type}" ) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py index 2558df8533b..c8ba6df352e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_semantic_tool_filter.py @@ -489,9 +489,7 @@ class TestGetToolsByNames: {"name": "send_email", "description": "send mail"}, ] - matched = filter_instance._get_tools_by_names( - ["send_email"], available_tools - ) + matched = filter_instance._get_tools_by_names(["send_email"], available_tools) assert len(matched) == 1 assert matched[0]["name"] == "send_email" @@ -503,9 +501,7 @@ class TestGetToolsByNames: client_name = "litellm_" + canonical available_tools = [{"name": client_name, "description": "scrape"}] - matched = filter_instance._get_tools_by_names( - [canonical], available_tools - ) + matched = filter_instance._get_tools_by_names([canonical], available_tools) assert len(matched) == 1 # Must return the incoming tool unchanged so the client-facing @@ -516,13 +512,9 @@ class TestGetToolsByNames: """Some clients use dash as alias separator; accept that too.""" filter_instance = self._make_filter() canonical = "weather_svc-get_weather" - available_tools = [ - {"name": "mcp-" + canonical, "description": "weather"} - ] + available_tools = [{"name": "mcp-" + canonical, "description": "weather"}] - matched = filter_instance._get_tools_by_names( - [canonical], available_tools - ) + matched = filter_instance._get_tools_by_names([canonical], available_tools) assert len(matched) == 1 assert matched[0]["name"] == "mcp-" + canonical @@ -552,9 +544,7 @@ class TestGetToolsByNames: {"name": "litellm_" + canonical, "description": "wrapped"}, ] - matched = filter_instance._get_tools_by_names( - [canonical], available_tools - ) + matched = filter_instance._get_tools_by_names([canonical], available_tools) assert len(matched) == 1 assert matched[0]["name"] == canonical @@ -567,9 +557,7 @@ class TestGetToolsByNames: separator-anchored suffixes of ``litellm_api-fs-read_file``. """ filter_instance = self._make_filter() - available_tools = [ - {"name": "litellm_api-fs-read_file", "description": "read"} - ] + available_tools = [{"name": "litellm_api-fs-read_file", "description": "read"}] matched = filter_instance._get_tools_by_names( ["fs-read_file", "api-fs-read_file"], available_tools @@ -590,9 +578,7 @@ class TestGetToolsByNames: {"name": "my_" + canonical, "description": "plain search"}, ] - matched = filter_instance._get_tools_by_names( - [canonical], available_tools - ) + matched = filter_instance._get_tools_by_names([canonical], available_tools) assert len(matched) == 1 assert matched[0]["name"] == "my_" + canonical @@ -640,3 +626,103 @@ class TestGetToolsByNames: ) assert matched == [] + + +# --------------------------------------------------------------------------- +# SemanticToolFilterHook — anthropic_messages call type allowlist +# --------------------------------------------------------------------------- +class _AnthropicMessagesMockFilter: + """Lightweight stub used only by the anthropic_messages allowlist tests.""" + + def __init__(self, top_k: int = 2): + self.enabled = True + self.top_k = top_k + self.received_query = None + self.call_count = 0 + + def extract_user_query(self, messages): + # Anthropic-style content blocks (list of typed blocks). + for m in reversed(messages): + if m.get("role") == "user": + content = m.get("content", "") + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + return block.get("text", "") + return "" + return content + return "" + + async def filter_tools(self, query, available_tools): + self.call_count += 1 + self.received_query = query + return list(available_tools)[: self.top_k] + + +@pytest.mark.asyncio +async def test_semantic_filter_hook_runs_for_anthropic_messages(): + """ + /v1/messages requests (call_type='anthropic_messages') used by Claude + Code and other Anthropic SDK clients must trigger the semantic filter + just like /v1/chat/completions. Before this fix the filter silently + skipped them, so every MCP tool was forwarded on every request. + """ + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + + mock_filter = _AnthropicMessagesMockFilter(top_k=2) + hook = SemanticToolFilterHook(mock_filter) + + data = { + "model": "claude-3-5-sonnet", + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "send an email"}], + } + ], + "tools": [ + {"type": "function", "function": {"name": "email_send"}}, + {"type": "function", "function": {"name": "calendar_create"}}, + {"type": "function", "function": {"name": "calendar_update"}}, + ], + "metadata": {}, + } + + result = await hook.async_pre_call_hook( + user_api_key_dict=Mock(), + cache=Mock(), + data=data, + call_type="anthropic_messages", + ) + + assert result is not None, "hook must run for anthropic_messages call type" + assert mock_filter.call_count == 1 + assert mock_filter.received_query == "send an email" + assert len(result["tools"]) == 2 + + +@pytest.mark.asyncio +async def test_semantic_filter_hook_skips_non_allowlisted_call_type(): + """ + Hook must remain a no-op for call types that are not on the allowlist. + Adding `anthropic_messages` must not silently widen the allowlist to + every call type. + """ + from litellm.proxy.hooks.mcp_semantic_filter import SemanticToolFilterHook + + mock_filter = _AnthropicMessagesMockFilter() + hook = SemanticToolFilterHook(mock_filter) + + data = { + "messages": [{"role": "user", "content": "hello"}], + "tools": [{"type": "function", "function": {"name": "t1"}}], + "metadata": {}, + } + result = await hook.async_pre_call_hook( + user_api_key_dict=Mock(), + cache=Mock(), + data=data, + call_type="image_generation", + ) + assert result is None + assert mock_filter.call_count == 0