Skip to content

feat(guardrails): add pre_mcp_call support to Content Filter - #32936

Merged
yuneng-berri merged 6 commits into
litellm_internal_stagingfrom
litellm_lit4226_content_filter_pre_mcp_call
Jul 11, 2026
Merged

feat(guardrails): add pre_mcp_call support to Content Filter#32936
yuneng-berri merged 6 commits into
litellm_internal_stagingfrom
litellm_lit4226_content_filter_pre_mcp_call

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Linear ticket

Resolves LIT-4226

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to 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_call fails with the exact error from the ticket

curl -sS -X POST http://localhost:4226/guardrails -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{
  "guardrail": {
    "guardrail_name": "content-filter-mcp",
    "litellm_params": {
      "guardrail": "litellm_content_filter",
      "mode": "pre_mcp_call",
      "default_on": true,
      "blocked_words": [{"keyword": "confidential", "action": "BLOCK"}],
      "patterns": [{"pattern_type": "prebuilt", "pattern_name": "email", "action": "MASK"}]
    },
    "guardrail_info": {}
  }
}'
{"detail":"400: Guardrail configuration error: Event hook GuardrailEventHooks.pre_mcp_call is not in the supported event hooks [<GuardrailEventHooks.pre_call: 'pre_call'>, <GuardrailEventHooks.post_call: 'post_call'>, <GuardrailEventHooks.during_call: 'during_call'>, <GuardrailEventHooks.realtime_input_transcription: 'realtime_input_transcription'>]"}

After (this PR), the same request succeeds

{"guardrail_name":"content-filter-mcp", ..., "guardrail":"litellm_content_filter","mode":"pre_mcp_call"},"guardrail_info":{},"guardrail_id":"6a276af8-0835-4bef-a4f3-91ef8d45f0a2"}

Blocked keyword inside MCP tool call arguments blocks the call

curl -sS -X POST http://localhost:4226/mcp-rest/tools/call -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"server_id":"a110d0044e2e7fa61ab9df5482daf770","name":"send_email","arguments":{"to":"bob@corp.example","body":"here is the confidential roadmap"}}'
{"detail":{"error":"Content blocked: keyword 'confidential' detected","keyword":"confidential","description":null,"guardrail_name":"content-filter-mcp","guardrail_mode":"pre_mcp_call"}}
HTTP 400

Non-ASCII blocked word (机密) in arguments also blocks; before the ensure_ascii=False fix the serialized form was \uXXXX escaped and sailed through

curl -sS -X POST http://localhost:4226/mcp-rest/tools/call -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"server_id":"a110d0044e2e7fa61ab9df5482daf770","name":"send_email","arguments":{"to":"bob","body":"这是机密数据"}}'
{"detail":{"error":"Content blocked: keyword '机密' detected","keyword":"机密","description":null,"guardrail_name":"content-filter-mcp-cjk","guardrail_mode":"pre_mcp_call"}}
HTTP 400

Clean arguments pass through

curl -sS -X POST http://localhost:4226/mcp-rest/tools/call -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"server_id":"a110d0044e2e7fa61ab9df5482daf770","name":"get_weather","arguments":{"city":"Paris"}}'
{"_meta":null,"content":[{"type":"text","text":"weather in Paris: sunny","annotations":null,"_meta":null}],"structuredContent":{"result":"weather in Paris: sunny"},"isError":false}
HTTP 200

MASK pattern (email) redacts the argument value before it reaches the tool; the tool's echo proves the masked value is what was executed

curl -sS -X POST http://localhost:4226/mcp-rest/tools/call -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"server_id":"a110d0044e2e7fa61ab9df5482daf770","name":"send_email","arguments":{"to":"team","body":"forward this to jane.doe@example.com please"}}'
{"_meta":null,"content":[{"type":"text","text":"email sent to team: forward this to [EMAIL_REDACTED] please","annotations":null,"_meta":null}],"structuredContent":{"result":"email sent to team: forward this to [EMAIL_REDACTED] please"},"isError":false}
HTTP 200

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 passed

curl -sS -X POST http://localhost:4226/mcp-rest/tools/call -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"server_id":"a110d0044e2e7fa61ab9df5482daf770","name":"send_email","arguments":{"to":"bob","body":"secret"}}'
{"detail":{"error":"Content blocked: anchored_secret pattern detected","pattern":"anchored_secret","guardrail_name":"cf-mcp-anchored","guardrail_mode":"pre_mcp_call"}}
HTTP 400
curl -sS -X POST http://localhost:4226/mcp-rest/tools/call -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"server_id":"a110d0044e2e7fa61ab9df5482daf770","name":"send_email","arguments":{"to":"bob","body":"the secret garden"}}'
{"_meta":null,"content":[{"type":"text","text":"email sent to bob: the secret garden", ...}],"isError":false}
HTTP 200

Blocked content smuggled in an argument key blocks, and a MASK rule (prebuilt visa) matching a numeric argument blocks instead of corrupting the number

curl -sS -X POST http://localhost:4226/mcp-rest/tools/call -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"server_id":"a110d0044e2e7fa61ab9df5482daf770","name":"send_email","arguments":{"to":"bob","body":"hello","this is confidential data":"x"}}'
{"detail":{"error":"Content blocked: keyword 'confidential' detected","keyword":"confidential", ... "guardrail_mode":"pre_mcp_call"}}
HTTP 400
curl -sS -X POST http://localhost:4226/mcp-rest/tools/call -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"server_id":"a110d0044e2e7fa61ab9df5482daf770","name":"send_email","arguments":{"to":"bob","body":4111111111111111}}'
{"detail":{"error":"Content blocked: MCP tool call argument matched a masking rule on a non-rewritable field", ... "guardrail_mode":"pre_mcp_call"}}
HTTP 400

A 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 blocked

curl -sS -X POST http://localhost:4226/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4.1-mini","messages":[{"role":"user","content":"Reply with exactly: hi"}],"mcp_tool_name":"send_email","mcp_arguments":{"body":"contains topsecretword here"}}'
{"error":{"message":"litellm.BadRequestError: OpenAIException - Unrecognized request arguments supplied: mcp_arguments, mcp_tool_name. ...","code":"400"}}
curl -sS -X POST http://localhost:4226/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4.1-mini","messages":[{"role":"user","content":"tell me about topsecretword"}]}'
{"error":{"message":"Content blocked: keyword 'topsecretword' detected", ... "guardrail_name":"cf-mixed-mode","guardrail_mode":["pre_call","pre_mcp_call"]}}
HTTP 400

A regular chat completion is not affected by the pre_mcp_call guardrail; real OpenAI call with the blocked keyword passes through untouched

curl -sS -X POST http://localhost:4226/v1/chat/completions -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
  -d '{"model":"gpt-4.1-mini","messages":[{"role":"user","content":"Reply with exactly: confidential ok"}]}'
{"id":"chatcmpl-E0XSp0TJL2eVZmwrp9sjA8xJ7nzFf", ... "message":{"content":"confidential ok","role":"assistant", ...}
Screenshot 2026-07-11 at 4 07 35 PM

Type

🆕 New Feature

Changes

LIT-4226 reported that the Admin UI offers pre_mcp_call as 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 makes pre_mcp_call an actually supported and enforced mode

GuardrailEventHooks.pre_mcp_call is added to ContentFilterGuardrail's supported_event_hooks, which fixes the 400 at guardrail creation. Since the MCP guardrail translation handler delivers the tool call via request_data rather than inputs["texts"], apply_guardrail gains a small request-side scan: when the guardrail's configured mode includes pre_mcp_call and the canonical MCP keys are present (mcp_tool_name plus a non-empty mcp_arguments dict, which _convert_mcp_to_llm_format always sets on both the MCP protocol path and the /mcp-rest path) it recursively walks the arguments and runs every string value through the existing _filter_single_text pipeline, 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 number

The scan is deliberately narrow. It does not fall back to bare name/arguments keys because the pass-through guardrail path hands apply_guardrail the raw upstream body as request_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 a pre_call mode Content Filter nor a mixed ["pre_call", "pre_mcp_call"] one runs the MCP scan on a chat invocation, even if a caller plants mcp_tool_name/mcp_arguments in a chat body (the proxy writes litellm_logging_obj into 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 scanned

BLOCK raises the same HTTPException contract as other hooks. MASK writes the masked arguments to both request_data["mcp_arguments"] and request_data["modified_arguments"], the same dual-write the Cisco AI Defense guardrail does; modified_arguments is what _convert_mcp_hook_response_to_kwargs applies to the outbound tool call, and rewriting mcp_arguments means 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_call still does not run on MCP calls (no implicit mode aliasing was added), and a mode: pre_mcp_call guardrail 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 unaffected

Now 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 asserts pre_mcp_call present and during_mcp_call absent for litellm_content_filter, and the runtime-rejection and strict-mode tests use during_mcp_call as the unsupported example

Tests: TestContentFilterMCPPreCall in 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 with mcp_arguments plus modified_arguments write-back, mask preserving nested dict and list structure, mask surviving chained masking guardrails, clean pass-through, that a non-MCP body with top-level name and arguments is not scanned (in both pre_call and pre_mcp_call modes), that pre_call and 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

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.45455% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...ail_hooks/litellm_content_filter/content_filter.py 95.45% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9ba423f. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds pre_mcp_call as a supported event hook on ContentFilterGuardrail, fixing LIT-4226 where the Admin UI offered the mode but the proxy rejected it at guardrail creation. All existing detection features — blocked words, prebuilt/custom regex patterns, and categories — now work on MCP tool-call arguments with no new configuration surface.

  • Adds _scan_mcp_tool_call_arguments / _filter_mcp_argument_value to recursively walk mcp_arguments, scanning each string value and dict key through the existing _filter_single_text pipeline; BLOCK raises the same HTTPException contract as other hooks, and MASK rewrites both mcp_arguments and modified_arguments in request_data so chained guardrails see sanitized arguments.
  • Numeric values that match a MASK rule block rather than corrupt (a redaction tag cannot represent a number), depth-capped recursion fails closed, and the call-type guard on logging_obj prevents forged mcp_tool_name/mcp_arguments keys planted in a chat body from triggering the MCP scan.
  • Three existing tests in test_guardrail_endpoints.py are updated to reflect pre_mcp_call now being supported; the runtime-rejection and strict-mode tests switch to during_mcp_call as the unsupported example, maintaining coverage without weakening it.

Confidence Score: 5/5

Additive 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.

Important Files Changed

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

@codspeed-hq

codspeed-hq Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_lit4226_content_filter_pre_mcp_call (90498df) with litellm_internal_staging (f2fb6b8)

Open in CodSpeed

Comment thread litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py Outdated
@veria-ai

veria-ai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

1 similar comment
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

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

Scenario Baseline PR Result
Save pre_mcp_call config (single and mixed mode) 400 unsupported event hook 200, loads, runs pass
MCP protection via closest baseline mode (pre_call email mask) raw email reached the tool (server receive log) n/a baseline gap proven
Safe args, type preservation (str/int/bool/null) not protectable byte-identical echo pass
Top-level, nested dict, array, mixed-structure masking - only matching leaves rewritten, structure and types intact pass
Anchored regex ^QA_EXACT_SECRET$ - exact value masked, prefixed value untouched pass
Quotes, backslashes, newlines, unicode in values - matched in raw form, JSON escaping irrelevant pass
Blocked tool never executes - 400, execution counter stayed 0, zero occurrences of the block sentinel in the server receive log pass
Deeply nested blocked value - 400, counter 0 pass
Safe call executes - counter 0 -> 1 pass
Mixed mode: chat with planted mcp_tool_name/mcp_arguments mode not configurable guardrail skipped, request reached the provider, counter unchanged pass
Mixed mode: real MCP call - blocked pass
Schema keys not renamed - value masked, keys intact pass
Empty and non-string values - unchanged, no crash pass
Malformed MCP fields string arguments 500 (pre-existing pydantic validation, identical on baseline) same 500, no new 500s introduced pass
Guardrail chain - downstream probe blocked on the [EMAIL_REDACTED] tag, which only exists after upstream masking; masked value reached the tool pass
Full model-to-MCP lifecycle via /v1/responses (server_url litellm_proxy, real gpt-4.1-mini) - model called the tool with a raw email, tool received [EMAIL_REDACTED], final model answer quotes the sanitized result pass
Concurrency, 100 simultaneous requests (25 mask / 25 safe / 25 exec / 25 blocked) - 0 errors, no cross-request leakage, counter exactly 25 pass
Logs and spend logs raw tool args stored by the MCP logger (pre-existing) same pre-existing behavior; guardrail records are REDACTED_BY_LITELM and correctly attributed to pre_mcp_call pass

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)

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@yucheng-berri
yucheng-berri force-pushed the litellm_lit4226_content_filter_pre_mcp_call branch from 68fd181 to f9c3bf1 Compare July 11, 2026 22:44
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

…sfy strict-rule budget

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@yuneng-berri
yuneng-berri merged commit e7f4144 into litellm_internal_staging Jul 11, 2026
130 checks passed
@yuneng-berri
yuneng-berri deleted the litellm_lit4226_content_filter_pre_mcp_call branch July 11, 2026 23:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants