Merge pull request #18630 from BerriAI/litellm_fix_mcp_guardrail

fix: MCP handling in unified guardrail
This commit is contained in:
YutaSaito
2026-01-05 12:25:14 +09:00
committed by GitHub
8 changed files with 337 additions and 44 deletions
+16
View File
@@ -45,6 +45,7 @@ def get_cost_for_web_search_request(
return 0.0
elif custom_llm_provider == "xai":
from .xai.cost_calculator import cost_per_web_search_request
return cost_per_web_search_request(usage=usage, model_info=model_info)
else:
return None
@@ -110,6 +111,21 @@ def discover_guardrail_translation_mappings() -> (
verbose_logger.error(f"Error processing {module_path}: {e}")
continue
try:
from litellm.proxy._experimental.mcp_server.guardrail_translation import (
guardrail_translation_mappings as mcp_guardrail_translation_mappings,
)
discovered_mappings.update(mcp_guardrail_translation_mappings)
verbose_logger.debug(
"Loaded MCP guardrail translation mappings: %s",
list(mcp_guardrail_translation_mappings.keys()),
)
except ImportError:
verbose_logger.debug(
"MCP guardrail translation mappings not available; skipping"
)
verbose_logger.debug(
f"Discovered {len(discovered_mappings)} guardrail translation mappings: {list(discovered_mappings.keys())}"
)
@@ -0,0 +1,16 @@
"""Guardrail translation mapping for MCP tool calls."""
from litellm.proxy._experimental.mcp_server.guardrail_translation.handler import (
MCPGuardrailTranslationHandler,
)
from litellm.types.utils import CallTypes
# This mapping lives alongside the MCP server implementation because MCP
# integrations are managed by the proxy subsystem, not litellm.llms providers.
# Unified guardrails import this module explicitly to register the handler.
guardrail_translation_mappings = {
CallTypes.call_mcp_tool: MCPGuardrailTranslationHandler,
}
__all__ = ["guardrail_translation_mappings", "MCPGuardrailTranslationHandler"]
@@ -0,0 +1,89 @@
"""
MCP Guardrail Handler for Unified Guardrails.
This handler works with the synthetic "messages" payload generated by
`ProxyLogging._convert_mcp_to_llm_format`, which always produces a single user
message whose `content` string encodes the MCP tool name and arguments. The
handler simply feeds that text through the configured guardrail and writes the
result back onto the message.
"""
from typing import TYPE_CHECKING, Any, Dict, Optional
from litellm._logging import verbose_proxy_logger
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.types.utils import GenericGuardrailAPIInputs
if TYPE_CHECKING:
from litellm.integrations.custom_guardrail import CustomGuardrail
from mcp.types import CallToolResult
class MCPGuardrailTranslationHandler(BaseTranslation):
"""Guardrail translation handler for MCP tool calls."""
async def process_input_messages(
self,
data: Dict[str, Any],
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Optional[Any] = None,
) -> Dict[str, Any]:
messages = data.get("messages")
if not isinstance(messages, list) or not messages:
verbose_proxy_logger.debug("MCP Guardrail: No messages to process")
return data
first_message = messages[0]
content: Optional[str] = None
if isinstance(first_message, dict):
content = first_message.get("content")
else:
content = getattr(first_message, "content", None)
if not isinstance(content, str):
verbose_proxy_logger.debug(
"MCP Guardrail: Message content missing or not a string",
)
return data
guardrailed_inputs = await guardrail_to_apply.apply_guardrail(
inputs=GenericGuardrailAPIInputs(texts=[content]),
request_data=data,
input_type="request",
logging_obj=litellm_logging_obj,
)
guardrailed_texts = (
guardrailed_inputs.get("texts", []) if guardrailed_inputs else []
)
if guardrailed_texts:
new_content = guardrailed_texts[0]
if isinstance(first_message, dict):
first_message["content"] = new_content
else:
setattr(first_message, "content", new_content)
verbose_proxy_logger.debug(
"MCP Guardrail: Updated content for tool %s",
data.get("mcp_tool_name"),
)
else:
verbose_proxy_logger.debug(
"MCP Guardrail: Guardrail returned no text updates for tool %s",
data.get("mcp_tool_name"),
)
return data
async def process_output_response(
self,
response: "CallToolResult",
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Optional[Any] = None,
user_api_key_dict: Optional[Any] = None,
) -> Any:
# Not implemented: MCP guardrail translation never calls this path today.
verbose_proxy_logger.debug(
"MCP Guardrail: Output processing not implemented for MCP tools",
)
return response
@@ -30,6 +30,7 @@ from pydantic import AnyUrl
import litellm
from litellm._logging import verbose_logger
from litellm.types.utils import CallTypes
from litellm.exceptions import BlockedPiiEntityError, GuardrailRaisedException
from litellm.experimental_mcp_client.client import MCPClient
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
@@ -1676,11 +1677,11 @@ class MCPServerManager:
)
try:
# Use standard pre_call_hook with call_type="mcp_call"
# Use standard pre_call_hook
modified_data = await proxy_logging_obj.pre_call_hook(
user_api_key_dict=user_api_key_auth, # type: ignore
data=synthetic_llm_data,
call_type="mcp_call", # type: ignore
call_type=CallTypes.call_mcp_tool.value,
)
if modified_data:
# Convert response back to MCP format and apply modifications
@@ -1737,7 +1738,7 @@ class MCPServerManager:
proxy_logging_obj.during_call_hook(
user_api_key_dict=user_api_key_auth,
data=synthetic_llm_data,
call_type="mcp_call", # type: ignore
call_type=CallTypes.call_mcp_tool.value,
)
)
@@ -1893,7 +1894,7 @@ class MCPServerManager:
#########################################################
# Pre MCP Tool Call Hook
# Allow validation and modification of tool calls before execution
# Using standard pre_call_hook with call_type="mcp_call"
# Using standard pre_call_hook
#########################################################
if proxy_logging_obj:
await self.pre_call_tool_check(
@@ -28,7 +28,6 @@ class UnifiedLLMGuardrails(CustomLogger):
self,
**kwargs,
):
# store kwargs as optional_params
self.optional_params = kwargs
@@ -63,6 +62,9 @@ class UnifiedLLMGuardrails(CustomLogger):
return data
event_type: GuardrailEventHooks = GuardrailEventHooks.pre_call
if call_type == CallTypes.call_mcp_tool.value:
event_type = GuardrailEventHooks.pre_mcp_call
if (
guardrail_to_apply.should_run_guardrail(data=data, event_type=event_type)
is not True
@@ -114,6 +116,9 @@ class UnifiedLLMGuardrails(CustomLogger):
return data
event_type: GuardrailEventHooks = GuardrailEventHooks.during_call
if call_type == CallTypes.call_mcp_tool.value:
event_type = GuardrailEventHooks.during_mcp_call
if (
guardrail_to_apply.should_run_guardrail(data=data, event_type=event_type)
is not True
@@ -128,7 +133,10 @@ class UnifiedLLMGuardrails(CustomLogger):
endpoint_guardrail_translation_mappings = (
load_guardrail_translation_mappings()
)
if call_type is not None and CallTypes(call_type) not in endpoint_guardrail_translation_mappings:
if (
call_type is not None
and CallTypes(call_type) not in endpoint_guardrail_translation_mappings
):
return data
endpoint_translation = endpoint_guardrail_translation_mappings[
@@ -180,8 +188,8 @@ class UnifiedLLMGuardrails(CustomLogger):
call_type: Optional[CallTypesLiteral] = None
if user_api_key_dict.request_route is not None:
call_types = get_call_types_for_route(user_api_key_dict.request_route)
if call_types is not None and len(call_types) > 0: # type: ignore
call_type = call_types[0] # type: ignore
if call_types is not None and len(call_types) > 0: # type: ignore
call_type = call_types[0] # type: ignore
if call_type is None:
call_type = _infer_call_type(call_type=None, completion_response=response) # type: ignore
@@ -330,7 +338,6 @@ class UnifiedLLMGuardrails(CustomLogger):
# Process chunk based on sampling rate
if chunk_counter % sampling_rate == 0:
verbose_proxy_logger.debug(
"Processing streaming chunk %s (sampling_rate=%s) with guardrail %s",
chunk_counter,
+37 -35
View File
@@ -151,25 +151,25 @@ def _get_email_logger_class():
"""
Determine which email logger class to use based on environment variables.
Priority: SendGrid > Resend > SMTP > BaseEmailLogger (fallback)
Returns:
The email logger class to use, or None if BaseEmailLogger is not available
"""
if BaseEmailLogger is None:
return None
# Check for SendGrid API key
if SendGridEmailLogger is not None and os.getenv("SENDGRID_API_KEY"):
return SendGridEmailLogger
# Check for Resend API key
if ResendEmailLogger is not None and os.getenv("RESEND_API_KEY"):
return ResendEmailLogger
# Check for SMTP configuration
if SMTPEmailLogger is not None and os.getenv("SMTP_HOST"):
return SMTPEmailLogger
# Fallback to BaseEmailLogger (though it won't actually send emails)
return BaseEmailLogger
@@ -452,7 +452,6 @@ class ProxyLogging:
litellm.logging_callback_manager.add_litellm_callback(self.service_logging_obj) # type: ignore
for callback in litellm.callbacks:
if isinstance(callback, str):
callback = litellm.litellm_core_utils.litellm_logging._init_custom_logger_compatible_class( # type: ignore
cast(_custom_logger_compatible_callbacks_literal, callback),
internal_usage_cache=self.internal_usage_cache.dual_cache,
@@ -965,7 +964,7 @@ class ProxyLogging:
# Determine the event type based on call type
event_type = GuardrailEventHooks.pre_call
if call_type == "mcp_call":
if call_type == CallTypes.call_mcp_tool.value:
event_type = GuardrailEventHooks.pre_mcp_call
# Check if the guardrail should run for this request
@@ -1038,7 +1037,6 @@ class ProxyLogging:
data.pop("prompt_id", None)
if custom_logger and prompt_spec is not None:
(
model,
messages,
@@ -1261,7 +1259,7 @@ class ProxyLogging:
from litellm.types.guardrails import GuardrailEventHooks
event_type = GuardrailEventHooks.during_call
if call_type == "mcp_call":
if call_type == CallTypes.call_mcp_tool.value:
event_type = GuardrailEventHooks.during_mcp_call
if (
@@ -1270,7 +1268,7 @@ class ProxyLogging:
):
continue
# Convert user_api_key_dict to proper format for async_moderation_hook
if call_type == "mcp_call":
if call_type == CallTypes.call_mcp_tool.value:
user_api_key_auth_dict = self._convert_user_api_key_auth_to_dict(
user_api_key_dict
)
@@ -1288,7 +1286,6 @@ class ProxyLogging:
call_type=call_type,
)
else:
guardrail_task = callback.async_moderation_hook(
data=data,
user_api_key_dict=user_api_key_auth_dict, # type: ignore
@@ -1337,7 +1334,7 @@ class ProxyLogging:
if self.alerting is None:
# do nothing if alerting is not switched on
return
if "slack" in self.alerting:
await self.slack_alerting_instance.budget_alerts(
type=type,
@@ -1548,7 +1545,10 @@ class ProxyLogging:
traceback_str=traceback_str,
)
# If callback returned an HTTPException, use it (first one wins)
if isinstance(hook_result, HTTPException) and transformed_exception is None:
if (
isinstance(hook_result, HTTPException)
and transformed_exception is None
):
transformed_exception = hook_result
except HTTPException as e:
# If callback raised an HTTPException, use it (first one wins)
@@ -1849,7 +1849,6 @@ class ProxyLogging:
current_response = response
for callback in litellm.callbacks:
_callback: Optional[CustomLogger] = None
if isinstance(callback, str):
_callback = litellm.litellm_core_utils.litellm_logging.get_custom_logger_compatible_class(
@@ -3568,11 +3567,13 @@ class ProxyUpdateSpend:
)
# Atomically read and remove logs to process (protected by lock)
async with prisma_client._spend_log_transactions_lock:
logs_to_process = prisma_client.spend_log_transactions[:MAX_LOGS_PER_INTERVAL]
logs_to_process = prisma_client.spend_log_transactions[
:MAX_LOGS_PER_INTERVAL
]
# Remove the logs we're about to process
prisma_client.spend_log_transactions = (
prisma_client.spend_log_transactions[len(logs_to_process):]
)
prisma_client.spend_log_transactions = prisma_client.spend_log_transactions[
len(logs_to_process) :
]
start_time = time.time()
try:
for i in range(n_retry_times + 1):
@@ -3675,9 +3676,7 @@ async def update_spend( # noqa: PLR0915
# Check queue size with lock protection
async with prisma_client._spend_log_transactions_lock:
queue_size = len(prisma_client.spend_log_transactions)
verbose_proxy_logger.debug(
"Spend Logs transactions: {}".format(queue_size)
)
verbose_proxy_logger.debug("Spend Logs transactions: {}".format(queue_size))
# Process spend log transactions when called directly.
# This keeps backwards compatibility with the old behavior.
@@ -3699,19 +3698,19 @@ async def update_spend_logs_job(
):
"""
Job to process spend_log_transactions queue.
This job is triggered based on queue size rather than time.
Processes spend log transactions when the queue reaches a threshold.
"""
n_retry_times = 3
# Check queue size with lock protection
async with prisma_client._spend_log_transactions_lock:
queue_size = len(prisma_client.spend_log_transactions)
if queue_size == 0:
return
await ProxyUpdateSpend.update_spend_logs(
n_retry_times=n_retry_times,
prisma_client=prisma_client,
@@ -3728,7 +3727,7 @@ async def _monitor_spend_logs_queue(
"""
Background task that monitors the spend_log_transactions queue size
and triggers processing when the threshold is reached.
Args:
prisma_client: Prisma client instance
db_writer_client: Optional HTTP handler for external spend logs endpoint
@@ -3738,23 +3737,23 @@ async def _monitor_spend_logs_queue(
SPEND_LOG_QUEUE_POLL_INTERVAL,
SPEND_LOG_QUEUE_SIZE_THRESHOLD,
)
threshold = SPEND_LOG_QUEUE_SIZE_THRESHOLD
base_interval = SPEND_LOG_QUEUE_POLL_INTERVAL
max_backoff = 30.0 # Maximum backoff interval in seconds
backoff_multiplier = 1.5 # Exponential backoff multiplier
current_interval = base_interval
verbose_proxy_logger.info(
f"Starting spend logs queue monitor (threshold: {threshold}, poll_interval: {base_interval}s)"
)
while True:
try:
# Check queue size with lock protection
async with prisma_client._spend_log_transactions_lock:
queue_size = len(prisma_client.spend_log_transactions)
if queue_size > 0:
if queue_size >= threshold:
verbose_proxy_logger.debug(
@@ -3767,8 +3766,10 @@ async def _monitor_spend_logs_queue(
f"Spend logs queue size ({queue_size}) below threshold ({threshold}), processing with backoff"
)
# Exponential backoff when below threshold but still processing
current_interval = min(current_interval * backoff_multiplier, max_backoff)
current_interval = min(
current_interval * backoff_multiplier, max_backoff
)
await update_spend_logs_job(
prisma_client=prisma_client,
db_writer_client=db_writer_client,
@@ -3776,8 +3777,10 @@ async def _monitor_spend_logs_queue(
)
else:
# Exponential backoff when no logs to process
current_interval = min(current_interval * backoff_multiplier, max_backoff)
current_interval = min(
current_interval * backoff_multiplier, max_backoff
)
await asyncio.sleep(current_interval)
except Exception as e:
verbose_proxy_logger.error(
@@ -3788,7 +3791,6 @@ async def _monitor_spend_logs_queue(
await asyncio.sleep(current_interval)
def _raise_failed_update_spend_exception(
e: Exception, start_time: float, proxy_logging_obj: ProxyLogging
):
@@ -0,0 +1,78 @@
"""Tests for the MCP guardrail translation handler."""
import pytest
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._experimental.mcp_server.guardrail_translation.handler import (
MCPGuardrailTranslationHandler,
)
class MockGuardrail(CustomGuardrail):
"""Simple guardrail mock that records invocations."""
def __init__(self, return_texts=None):
super().__init__(guardrail_name="mock-mcp-guardrail")
self.return_texts = return_texts
self.call_count = 0
self.last_inputs = None
async def apply_guardrail(self, inputs, request_data, input_type, **kwargs):
self.call_count += 1
self.last_inputs = inputs
if self.return_texts is not None:
return {"texts": self.return_texts}
texts = inputs.get("texts", [])
return {"texts": [f"{text} [SAFE]" for text in texts]}
@pytest.mark.asyncio
async def test_process_input_messages_updates_content():
"""Handler should update the synthetic message content when guardrail modifies text."""
handler = MCPGuardrailTranslationHandler()
guardrail = MockGuardrail()
original_content = "Tool: weather\nArguments: {'city': 'tokyo'}"
data = {
"messages": [{"role": "user", "content": original_content}],
"mcp_tool_name": "weather",
}
result = await handler.process_input_messages(data, guardrail)
assert result["messages"][0]["content"].endswith("[SAFE]")
assert guardrail.last_inputs == {"texts": [original_content]}
assert guardrail.call_count == 1
@pytest.mark.asyncio
async def test_process_input_messages_skips_when_no_messages():
"""Handler should skip guardrail invocation if messages array is missing or empty."""
handler = MCPGuardrailTranslationHandler()
guardrail = MockGuardrail()
data = {"mcp_tool_name": "noop"}
result = await handler.process_input_messages(data, guardrail)
assert result == data
assert guardrail.call_count == 0
@pytest.mark.asyncio
async def test_process_input_messages_handles_empty_guardrail_result():
"""Handler should leave content untouched when guardrail returns no text updates."""
handler = MCPGuardrailTranslationHandler()
guardrail = MockGuardrail(return_texts=[])
original_content = "Tool: calendar\nArguments: {'date': '2024-12-25'}"
data = {
"messages": [{"role": "user", "content": original_content}],
"mcp_tool_name": "calendar",
}
result = await handler.process_input_messages(data, guardrail)
assert result["messages"][0]["content"] == original_content
assert guardrail.call_count == 1
@@ -0,0 +1,84 @@
"""Tests for unified guardrail."""
import pytest
from litellm.caching import DualCache
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy._experimental.mcp_server.guardrail_translation.handler import (
MCPGuardrailTranslationHandler,
)
from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail import unified_guardrail as unified_module
from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import (
UnifiedLLMGuardrails,
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import CallTypes
class RecordingGuardrail(CustomGuardrail):
"""Records the event types it is asked to run for."""
def __init__(self):
super().__init__(guardrail_name="recording-guardrail")
self.event_history = []
def should_run_guardrail(self, data, event_type): # type: ignore[override]
self.event_history.append(event_type)
return True
async def apply_guardrail(self, inputs, request_data, input_type, **kwargs):
return {"texts": inputs.get("texts", [])}
@pytest.fixture(autouse=True)
def _inject_mcp_handler_mapping():
"""Inject MCP handler mapping so the unified guardrail can run inside tests."""
unified_module.endpoint_guardrail_translation_mappings = {
CallTypes.call_mcp_tool: MCPGuardrailTranslationHandler,
}
yield
unified_module.endpoint_guardrail_translation_mappings = None
@pytest.mark.asyncio
async def test_pre_call_hook_uses_mcp_event_type():
"""pre_call hook should swap to GuardrailEventHooks.pre_mcp_call for MCP calls."""
handler = UnifiedLLMGuardrails()
guardrail = RecordingGuardrail()
cache = DualCache()
data = {
"guardrail_to_apply": guardrail,
"messages": [{"role": "user", "content": "Tool: test\nArguments: {}"}],
"model": "mcp-tool-call",
}
await handler.async_pre_call_hook(
user_api_key_dict=None,
cache=cache,
data=data,
call_type=CallTypes.call_mcp_tool.value,
)
assert guardrail.event_history == [GuardrailEventHooks.pre_mcp_call]
@pytest.mark.asyncio
async def test_moderation_hook_uses_mcp_event_type():
"""moderation hook should request GuardrailEventHooks.during_mcp_call for MCP calls."""
handler = UnifiedLLMGuardrails()
guardrail = RecordingGuardrail()
data = {
"guardrail_to_apply": guardrail,
"messages": [{"role": "user", "content": "Tool: test\nArguments: {}"}],
"model": "mcp-tool-call",
}
await handler.async_moderation_hook(
data=data,
user_api_key_dict=None,
call_type=CallTypes.call_mcp_tool.value,
)
assert guardrail.event_history == [GuardrailEventHooks.during_mcp_call]