This commit is contained in:
Yana Tokareva 2026-11-06 04:59:18 +00:00 • committed by GitHub
commit d7d06da34d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 144 additions and 9 deletions

View file

@ -253,8 +253,7 @@ class DeepKeepGuardrail(CustomGuardrail):
getattr(logging_obj, "litellm_trace_id", None) if logging_obj else None,
exc_info=error,
)
return_inputs: Final[GenericGuardrailAPIInputs] = {}
return_inputs.update(inputs)
return_inputs: Final[GenericGuardrailAPIInputs] = {**inputs}
return return_inputs
def _handle_guardrail_request_error(

View file

@ -413,8 +413,7 @@ class GenericGuardrailAPI(CustomGuardrail):
exc_info=error,
)
# Keep flow going - treat as action=NONE (no modifications)
return_inputs: Final[GenericGuardrailAPIInputs] = {}
return_inputs.update(inputs)
return_inputs: Final[GenericGuardrailAPIInputs] = {**inputs}
return return_inputs
def _build_request_headers(self) -> Mapping[str, str]:
@ -592,6 +591,7 @@ class GenericGuardrailAPI(CustomGuardrail):
tools=([GuardrailToolParam.model_validate(t) for t in tools] if tools else None),
structured_messages=None if self.forward_messages_as_sent else structured_messages,
tool_calls=tool_calls,
mcp_tool_call=inputs.get("mcp_tool_call"),
additional_provider_specific_params=additional_params,
input_type=input_type,
model=model,

View file

@ -1213,8 +1213,7 @@ class StraikerGuardrail(CustomGuardrail):
inputs: GenericGuardrailAPIInputs,
parsed: StraikerWebhookResponse,
) -> GenericGuardrailAPIInputs:
return_inputs: Final[GenericGuardrailAPIInputs] = {}
return_inputs.update(inputs)
return_inputs: Final[GenericGuardrailAPIInputs] = {**inputs}
if parsed.texts is not None:
return_inputs["texts"] = parsed.texts
return return_inputs

View file

@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any, Final
from fastapi import HTTPException
from mcp.types import Tool as MCPTool
from pydantic import JsonValue, TypeAdapter
from litellm._logging import verbose_proxy_logger
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
@ -43,7 +44,7 @@ from litellm.types.llms.openai import (
ChatCompletionToolParam,
ChatCompletionToolParamFunctionChunk,
)
from litellm.types.utils import GenericGuardrailAPIInputs
from litellm.types.utils import GenericGuardrailAPIInputs, GenericGuardrailAPIMCPToolCall
if TYPE_CHECKING:
from mcp.types import CallToolResult
@ -52,6 +53,29 @@ if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
_MCP_ARGUMENTS: Final[TypeAdapter[Mapping[str, JsonValue]]] = TypeAdapter(Mapping[str, JsonValue])
def _optional_text(value: object) -> str | None:
return value if isinstance(value, str) else None
def _mcp_tool_call(
tool_name: str, description: object, arguments: object, data: Mapping[str, object]
) -> GenericGuardrailAPIMCPToolCall:
return GenericGuardrailAPIMCPToolCall(
mcp_server_id=_optional_text(data.get("mcp_server_id")),
mcp_server_name=_optional_text(data.get("mcp_server_name")),
mcp_server_url=_optional_text(data.get("mcp_server_url")),
mcp_server_transport=_optional_text(data.get("mcp_server_transport")),
mcp_server_auth_type=_optional_text(data.get("mcp_server_auth_type")),
mcp_server_is_byok=is_byok if isinstance(is_byok := data.get("mcp_server_is_byok"), bool) else None,
tool_name=tool_name,
tool_description=_optional_text(description),
arguments=_MCP_ARGUMENTS.validate_python(arguments) if isinstance(arguments, Mapping) else {},
)
def _blocked(reason: str) -> HTTPException:
return HTTPException(status_code=400, detail={"error": f"Content blocked: {reason}"})
@ -188,6 +212,7 @@ class MCPGuardrailTranslationHandler(BaseTranslation):
inputs: Final[GenericGuardrailAPIInputs] = GenericGuardrailAPIInputs(
tools=[tool_def],
texts=list(scanned_texts),
mcp_tool_call=_mcp_tool_call(str(mcp_tool_name), mcp_tool_description, mcp_arguments, data),
)
guarded: Final = await guardrail_to_apply.apply_guardrail(

View file

@ -6252,6 +6252,12 @@ class MCPServerManager:
"arguments": arguments,
"server_name": server_name,
"mcp_rate_limit_server_name": server.alias or server.server_name or server.name,
"mcp_server_id": server.server_id,
"mcp_server_url": redact_mcp_resource_url(server.url),
"mcp_server_transport": server.transport,
"mcp_server_auth_type": server.auth_type,
"mcp_server_is_byok": server.is_byok,
"client_ip": client_ip,
"user_api_key_auth": checked_auth,
"user_api_key_user_id": checked_auth.user_id if checked_auth else None,
"user_api_key_team_id": checked_auth.team_id if checked_auth else None,

View file

@ -2236,6 +2236,7 @@ class ProxyLogging:
"user_api_key_user_id": kwargs.get("user_api_key_user_id"),
"user_api_key_team_id": kwargs.get("user_api_key_team_id"),
"user_api_key_end_user_id": kwargs.get("user_api_key_end_user_id"),
"requester_ip_address": kwargs.get("client_ip"),
}
# Create synthetic LLM data that guardrails can process
@ -2256,6 +2257,11 @@ class ProxyLogging:
# synthetic call_mcp_tool payload (otherwise a key with
# mcp_rpm_limit could exceed it via the MCP path).
"mcp_server_name": kwargs.get("mcp_rate_limit_server_name"),
"mcp_server_id": kwargs.get("mcp_server_id"),
"mcp_server_url": kwargs.get("mcp_server_url"),
"mcp_server_transport": kwargs.get("mcp_server_transport"),
"mcp_server_auth_type": kwargs.get("mcp_server_auth_type"),
"mcp_server_is_byok": kwargs.get("mcp_server_is_byok"),
# Raw Bearer token from the original HTTP request — allows guardrails
# (e.g. MCPJWTSigner) to independently verify the caller's identity
# before re-signing an outbound token (FR-5 verify+re-sign).
@ -2264,6 +2270,11 @@ class ProxyLogging:
}
user_api_key_auth: Final = kwargs.get("user_api_key_auth")
if isinstance(user_api_key_auth, UserAPIKeyAuth):
synthetic_metadata.update(
LiteLLMProxyRequestSetup.get_sanitized_user_information_from_key(user_api_key_auth)
)
synthetic_metadata["actor_agent_id"] = user_api_key_auth.agent_id
synthetic_metadata["target_agent_id"] = user_api_key_auth.invoked_agent_id
add_guardrails_from_auth_metadata(
user_api_key_dict=user_api_key_auth,
data=synthetic_data,

View file

@ -2,7 +2,7 @@ from collections.abc import Mapping, Sequence
from typing import Any, Final, Literal, cast # noqa: TID251 # JSON chat rows have no typed constructor across roles
from pydantic import ConfigDict, Field, SerializerFunctionWrapHandler, model_serializer
from typing_extensions import TypedDict
from typing_extensions import ReadOnly, TypedDict
from litellm.types.guardrails import GuardrailNumRetries
from litellm.types.llms.base import LiteLLMBaseModel
@ -11,7 +11,7 @@ from litellm.types.llms.openai import (
ChatCompletionToolCallChunk,
)
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
from litellm.types.utils import ChatCompletionMessageToolCall
from litellm.types.utils import ChatCompletionMessageToolCall, GenericGuardrailAPIMCPToolCall
class GuardrailToolParam(LiteLLMBaseModel):
@ -43,6 +43,9 @@ class GenericGuardrailAPIMetadata(TypedDict, total=False):
user_api_key_team_alias: str | None
user_api_key_end_user_id: str | None
user_api_key_org_id: str | None
requester_ip_address: ReadOnly[str | None]
actor_agent_id: ReadOnly[str | None]
target_agent_id: ReadOnly[str | None]
class GenericGuardrailAPIOptionalParams(LiteLLMBaseModel):
@ -164,6 +167,10 @@ class GenericGuardrailAPIRequest(LiteLLMBaseModel):
additional_provider_specific_params: dict[str, object] | None = None
tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionMessageToolCall] | None = None
model: str | None = None # the model being used for the LLM call
mcp_tool_call: GenericGuardrailAPIMCPToolCall | None = Field(
default=None,
description="The MCP server, tool, and named arguments of the MCP tool call being checked.",
)
def coerce_stream_holdback_value(value: Any) -> int:

View file

@ -5362,6 +5362,18 @@ class PriorityReservationSettings(LiteLLMBaseModel):
model_config = ConfigDict(protected_namespaces=())
class GenericGuardrailAPIMCPToolCall(TypedDict, total=False):
mcp_server_id: ReadOnly[str | None]
mcp_server_name: ReadOnly[str | None]
mcp_server_url: ReadOnly[str | None]
mcp_server_transport: ReadOnly[str | None]
mcp_server_auth_type: ReadOnly[str | None]
mcp_server_is_byok: ReadOnly[bool | None]
tool_name: ReadOnly[str]
tool_description: ReadOnly[str | None]
arguments: ReadOnly[Mapping[str, JsonValue]]
class GenericGuardrailAPIInputs(TypedDict, total=False):
texts: list[str] # extracted text from the LLM response - for basic text guardrails
images: list[str] # extracted images from the LLM response - for image guardrails
@ -5371,6 +5383,7 @@ class GenericGuardrailAPIInputs(TypedDict, total=False):
AllMessageValues
] # structured messages sent to the LLM - indicates if text is from system or user
model: str | None # the model being used for the LLM call
mcp_tool_call: ReadOnly[GenericGuardrailAPIMCPToolCall]
stream_holdback_chars: list[
int
] # trailing chars to withhold from streaming emission per text (word-boundary safety)

View file

@ -21,6 +21,7 @@ from mcp.types import CallToolResult, TextContent
import litellm
from litellm import ModelResponse
from litellm._version import version as litellm_version
from litellm.caching.caching import DualCache
from litellm.exceptions import GuardrailRaisedException, Timeout
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, MaskedHTTPStatusError
from litellm.proxy._experimental.mcp_server.guardrail_translation.handler import (
@ -34,7 +35,9 @@ from litellm.proxy.guardrails.guardrail_hooks.generic_guardrail_api.generic_guar
_HEADER_PRESENT_PLACEHOLDER,
)
from litellm.proxy.guardrails.guardrail_hooks.unified_guardrail.unified_guardrail import UnifiedLLMGuardrails
from litellm.proxy.utils import ProxyLogging
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.utils import Choices, GenericGuardrailAPIInputs, Message
@ -2692,3 +2695,75 @@ async def test_during_mcp_call_blocked_raises() -> None:
)
assert exc_info.value.message == "credential detected in tool input"
@pytest.mark.asyncio
async def test_pre_mcp_call_sends_the_server_tool_arguments_and_caller() -> None:
sent_bodies: Final[SimpleQueue[bytes]] = SimpleQueue()
async def answer(request: httpx.Request) -> httpx.Response:
sent_bodies.put(request.content)
return httpx.Response(200, json={"action": "NONE"}, request=request)
guardrail: Final = _mcp_guardrail("pre_mcp_call")
arguments: Final = {"repo": "octo/hello", "options": {"force": True, "depth": 2}}
caller: Final = UserAPIKeyAuth(
api_key="hashed-key",
key_alias="ci-bot",
user_id="u-1",
user_email="dev@example.com",
team_id="t-1",
team_alias="platform",
org_id="o-1",
end_user_id="e-1",
agent_id="agent-1",
).model_copy(update={"invoked_agent_id": "agent-2"})
pre_hook_kwargs: Final = {
"name": "delete_repo",
"arguments": arguments,
"server_name": "github",
"mcp_rate_limit_server_name": "github",
"mcp_server_id": "srv-1",
"mcp_server_url": "https://mcp.example.com",
"mcp_server_transport": MCPTransport.http,
"mcp_server_auth_type": MCPAuth.oauth2,
"mcp_server_is_byok": True,
"client_ip": "203.0.113.7",
"tool_description": "Delete a repository",
"user_api_key_auth": caller,
}
proxy_logging_obj: Final = ProxyLogging(user_api_key_cache=DualCache())
data: Final = proxy_logging_obj._convert_mcp_to_llm_format(
proxy_logging_obj._create_mcp_request_object_from_kwargs(pre_hook_kwargs), pre_hook_kwargs
)
with patch.object(guardrail, "async_handler", AsyncHTTPHandler(transport=httpx.MockTransport(answer))):
await MCPGuardrailTranslationHandler().process_input_messages(data=data, guardrail_to_apply=guardrail)
body: Final = json.loads(sent_bodies.get_nowait())
assert body["mcp_tool_call"] == {
"mcp_server_id": "srv-1",
"mcp_server_name": "github",
"mcp_server_url": "https://mcp.example.com",
"mcp_server_transport": "http",
"mcp_server_auth_type": "oauth2",
"mcp_server_is_byok": True,
"tool_name": "delete_repo",
"tool_description": "Delete a repository",
"arguments": arguments,
}
assert body["request_data"] == {
"user_api_key_hash": "hashed-key",
"user_api_key_alias": "ci-bot",
"user_api_key_user_id": "u-1",
"user_api_key_user_email": "dev@example.com",
"user_api_key_team_id": "t-1",
"user_api_key_team_alias": "platform",
"user_api_key_end_user_id": "e-1",
"user_api_key_org_id": "o-1",
"requester_ip_address": "203.0.113.7",
"actor_agent_id": "agent-1",
"target_agent_id": "agent-2",
}
assert body["texts"] == ["Delete a repository", "octo/hello"]