diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 308813039ca..e7f4b9dae02 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -5082,6 +5082,18 @@ class MCPServerManager: synthetic_llm_data: Final = proxy_logging_obj._convert_mcp_to_llm_format(mcp_request_obj, pre_hook_kwargs) synthetic_llm_data["litellm_logging_obj"] = litellm_logging_obj + # The synthetic request never went through ``add_litellm_data_to_request``, so without this + # a guardrail attached to the key/team/project (rather than ``default_on``) is invisible to + # ``CustomGuardrail.should_run_guardrail`` and silently skipped on the MCP path. + if user_api_key_auth is not None: + from litellm.proxy.litellm_pre_call_utils import move_guardrails_to_metadata + + await move_guardrails_to_metadata( + data=synthetic_llm_data, + _metadata_variable_name="metadata", + user_api_key_dict=user_api_key_auth, + ) + try: # Use standard pre_call_hook modified_data: Final = await proxy_logging_obj.pre_call_hook( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index cdea803ebf3..7a936461823 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -10882,3 +10882,56 @@ class TestOpenApiHandlerRelaysUpstreamAuth: assert result.isError is True assert "upstream returned HTTP 503" in result.content[0].text + + +class TestKeyAttachedGuardrailsReachTheMCPPreCallHook: + """MCP tool calls build their own synthetic request instead of going through + ``add_litellm_data_to_request``, so guardrails attached to the key/team/project + have to be moved into that request's metadata here. Without it, + ``CustomGuardrail.should_run_guardrail`` sees no requested guardrails and skips + everything that is not ``default_on``, i.e. the guardrail a key was explicitly + granted never runs on the MCP path. + """ + + def _server(self) -> MCPServer: + return MCPServer( + server_id="guardrail-metadata-server", + name="guardrail_metadata_server", + server_name="guardrail_metadata_server", + url="https://example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.none, + ) + + async def _synthetic_request_metadata(self, key_metadata: dict[str, Any]) -> dict[str, Any]: + from litellm.proxy._types import UserAPIKeyAuth + from litellm.proxy.utils import ProxyLogging + + proxy_logging_obj: Final = ProxyLogging(user_api_key_cache=MagicMock()) + seen: Final[dict[str, Any]] = {} + + async def capture_pre_call_hook(*, user_api_key_dict, data, call_type): + seen.update(data) + return None + + with patch.object(proxy_logging_obj, "pre_call_hook", side_effect=capture_pre_call_hook): + await MCPServerManager().pre_call_tool_check( + name="echo", + arguments={"text": "hi"}, + server_name="guardrail_metadata_server", + user_api_key_auth=UserAPIKeyAuth(api_key="sk-test", metadata=key_metadata), + proxy_logging_obj=proxy_logging_obj, + server=self._server(), + ) + + return seen["metadata"] + + @pytest.mark.asyncio + async def test_key_guardrails_are_moved_into_the_synthetic_request_metadata(self): + metadata = await self._synthetic_request_metadata({"guardrails": ["model-armor-guard"]}) + assert metadata["guardrails"] == ["model-armor-guard"] + + @pytest.mark.asyncio + async def test_key_without_guardrails_stays_untouched(self): + metadata = await self._synthetic_request_metadata({}) + assert "guardrails" not in metadata