feat(guardrails): add pre_mcp_call support to Content Filter - #32936
Conversation
|
bugbot run |
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9ba423f. Configure here.
|
|
||
| def _scan_mcp_tool_call_arguments(self, request_data: dict, detections: List[ContentFilterDetection]) -> None: | ||
| if not self._event_hook_is_event_type(GuardrailEventHooks.pre_mcp_call): | ||
| return |
There was a problem hiding this comment.
Mixed mode scans chat MCP fields
Medium Severity
_scan_mcp_tool_call_arguments uses _event_hook_is_event_type(pre_mcp_call), which is true whenever pre_mcp_call appears in the guardrail’s configured mode list, not only on MCP hook invocations. With a mixed mode such as ["pre_call", "pre_mcp_call"], a normal chat pre_call run can still scan and block or rewrite mcp_tool_name / mcp_arguments if those keys are present on request_data.
Reviewed by Cursor Bugbot for commit 9ba423f. Configure here.
Greptile SummaryThis PR adds
Confidence Score: 5/5Additive feature; existing modes are untouched and the MCP scan fires only when the proxy-owned call type and canonical mcp_tool_name key are both present. The new scanning logic is well-scoped: gated on both the guardrail event hook and the logging object call type, so forged MCP keys in chat bodies cannot trigger it. BLOCK, MASK, numeric escalation, key smuggling, depth-capping, chaining, and non-ASCII detection are all exercised by dedicated tests that fail on the base branch. No files require special attention.
|
| Filename | Overview |
|---|---|
| litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py | Adds pre_mcp_call to supported event hooks and implements _scan_mcp_tool_call_arguments / _filter_mcp_argument_value to recursively scan MCP tool-call arguments for blocked words and mask patterns; the BLOCK / MASK semantics are correct, the depth cap fails closed, key smuggling is handled, and the call-type guard prevents forged MCP keys in chat bodies from triggering the scan. |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py | Adds TestContentFilterMCPPreCall with 15 tests covering mode acceptance, block/mask/pass-through, non-ASCII keywords, anchored regex, key smuggling, numeric-value masking escalation, depth-cap fail-closed, chained guardrails, mixed-mode chat vs MCP discrimination, and canonical-key gating; no real network calls; tests correctly target the new code paths. |
| tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py | Legitimate updates to three existing tests: the UI-settings assertion now correctly expects pre_mcp_call present and during_mcp_call absent for litellm_content_filter; the runtime-rejection test and the strict-mode test both switch to during_mcp_call as the unsupported example now that pre_mcp_call is supported — coverage is maintained, not weakened. |
| tests/code_coverage_tests/recursive_detector.py | Adds _filter_mcp_argument_value to the recursive-detector ignore list with a rationale matching the existing pattern for other depth-capped functions; exemption is appropriate since the function fails closed by blocking the MCP call at the cap. |
Reviews (6): Last reviewed commit: "fix(guardrails): use builtin generics in..." | Re-trigger Greptile
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
1 similar comment
|
Ran a full live E2E matrix against this PR's HEAD (bf415ec) versus the base commit (f604034), with two proxies, two isolated Postgres instances, and a deterministic QA MCP server (streamable-http FastMCP) that echoes the exact arguments it receives, appends them to a server-side receive log, and maintains an execution counter with reset/read tools. The two strongest signals are what the MCP server actually received and whether the counter stayed at zero on blocked calls
Pre-existing observations, unchanged by this PR and verified identical on the base commit: /mcp-rest/tools/call with a non-dict arguments value 500s in MCPPreCallRequestObject pydantic validation before any guardrail runs, and spend-log mcp_tool_call_metadata.arguments stores the original pre-mask tool arguments (written by the MCP logger independently of guardrails; possible follow-up if masked-at-rest is wanted) |
…with the recursion detector
…r pre_mcp_call support
68fd181 to
f9c3bf1
Compare
…sfy strict-rule budget Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>


Relevant issues
Linear ticket
Resolves LIT-4226
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Setup: proxy on localhost:4226 with a real Postgres, one OpenAI model (
gpt-4.1-mini, real API traffic), and a local streamable-http MCP server (FastMCP, disclosed: the MCP tool server is a two-tool local server since guardrail evaluation is fully local regex/keyword matching; the LLM side hits the real OpenAI API)Before (base code), creating the guardrail with mode
pre_mcp_callfails with the exact error from the ticketAfter (this PR), the same request succeeds
Blocked keyword inside MCP tool call arguments blocks the call
Non-ASCII blocked word (
机密) in arguments also blocks; before theensure_ascii=Falsefix the serialized form was\uXXXXescaped and sailed throughClean arguments pass through
MASK pattern (email) redacts the argument value before it reaches the tool; the tool's echo proves the masked value is what was executed
Anchored custom regex
^secret$(BLOCK) matches a whole argument value and respects its anchors; under the earlier whole-document serialization approach the first call would have passedBlocked content smuggled in an argument key blocks, and a MASK rule (prebuilt
visa) matching a numeric argument blocks instead of corrupting the numberA mixed mode
["pre_call", "pre_mcp_call"]guardrail enforces on genuine MCP calls and on real chat content, but ignores MCP keys planted in a chat body: the planted-keys request sailed past the guardrail and reached OpenAI (which rejected the unknown fields, pre-existing proxy behavior unrelated to guardrails), while the same keyword in an actual chat message is blockedA regular chat completion is not affected by the
pre_mcp_callguardrail; real OpenAI call with the blocked keyword passes through untouchedType
🆕 New Feature
Changes
LIT-4226 reported that the Admin UI offers
pre_mcp_callas a guardrail mode but the proxy rejects it for the LiteLLM Content Filter guardrail. The UI-side fix (filtering the mode dropdown per provider) is PR #32712; this PR is the feature half for Content Filter: it makespre_mcp_callan actually supported and enforced modeGuardrailEventHooks.pre_mcp_callis added toContentFilterGuardrail'ssupported_event_hooks, which fixes the 400 at guardrail creation. Since the MCP guardrail translation handler delivers the tool call viarequest_datarather thaninputs["texts"],apply_guardrailgains a small request-side scan: when the guardrail's configured mode includespre_mcp_calland the canonical MCP keys are present (mcp_tool_nameplus a non-emptymcp_argumentsdict, which_convert_mcp_to_llm_formatalways sets on both the MCP protocol path and the/mcp-restpath) it recursively walks the arguments and runs every string value through the existing_filter_single_textpipeline, so all existing detection features (blocked words, prebuilt and custom regex patterns, categories) work on MCP tool calls with no new configuration surface. Scanning per value rather than one JSON serialization of the whole dict means anchored custom regexes (^secret$) match argument values as users would expect, values containing quotes or newlines are scanned in raw form, and masking rewrites a value in place so it can never corrupt the argument structure. Argument keys are scanned too, since callers control keys as fully as values; a MASK rule matching a key blocks rather than renaming it (a renamed key would break the tool's schema). Numeric values are scanned as their string form; a MASK match on a number likewise blocks, since a redaction tag cannot be represented in a numberThe scan is deliberately narrow. It does not fall back to bare
name/argumentskeys because the pass-through guardrail path handsapply_guardrailthe raw upstream body asrequest_data, and a body that happens to carry those keys must not be treated as an MCP call. It is gated on the guardrail's own event hook, and additionally on the proxy-owned logging object's call type, so neither apre_callmode Content Filter nor a mixed["pre_call", "pre_mcp_call"]one runs the MCP scan on a chat invocation, even if a caller plantsmcp_tool_name/mcp_argumentsin a chat body (the proxy writeslitellm_logging_objinto the request data itself right before the hooks run, so that signal cannot be forged). Scope note: only the tool call arguments are scanned; the tool name itself is not (tool allow/deny is the Tool Permission guardrail's job), and a call with empty arguments is not scannedBLOCK raises the same HTTPException contract as other hooks. MASK writes the masked arguments to both
request_data["mcp_arguments"]andrequest_data["modified_arguments"], the same dual-write the Cisco AI Defense guardrail does;modified_argumentsis what_convert_mcp_hook_response_to_kwargsapplies to the outbound tool call, and rewritingmcp_argumentsmeans a later guardrail in the chain scans the sanitized arguments instead of resurrecting the originals (covered by a chained-guardrails regression test)Backward compatibility: the change is additive. A Content Filter configured with
mode: pre_callstill does not run on MCP calls (no implicit mode aliasing was added), and amode: pre_mcp_callguardrail does not run on chat completions (verified live above). The MCP scan only fires when the MCP request shape is present, so/apply_guardrail, UI test-guardrail, chat, and realtime paths are unaffectedNow that #32712 is merged, this branch is rebased on top of it: the added hook lives in Content Filter's
get_supported_event_hooks()classmethod, so the Admin UI mode dropdown and the runtime validator pick it up from the same list. The #32712 regression tests are updated accordingly: the UI settings map now assertspre_mcp_callpresent andduring_mcp_callabsent forlitellm_content_filter, and the runtime-rejection and strict-mode tests useduring_mcp_callas the unsupported exampleTests:
TestContentFilterMCPPreCallin the mapped test file covers mode acceptance, block on blocked keyword in arguments, non-ASCII blocked word in arguments, anchored regex matching a whole argument value, blocked content smuggled in top-level and nested argument keys, a MASK rule matching a numeric argument escalating to block, mask withmcp_argumentsplusmodified_argumentswrite-back, mask preserving nested dict and list structure, mask surviving chained masking guardrails, clean pass-through, that a non-MCP body with top-levelnameandargumentsis not scanned (in both pre_call and pre_mcp_call modes), thatpre_calland mixed["pre_call", "pre_mcp_call"]guardrails ignore planted MCP keys on chat invocations while the mixed mode still scans genuine MCP invocations, and that the response side does not scan. All fail on base and pass with this change