fix(guardrails): allow litellm_content_filter to run on post_mcp_call - #35980
Conversation
ContentFilterGuardrail implements apply_guardrail, which is everything the generic post_mcp_call_hook machinery needs to scan an MCP tool result before it reaches the model, but post_mcp_call was missing from get_supported_event_hooks. _validate_event_hook rejects any mode outside that list, so a config with `mode: post_mcp_call` failed proxy startup with "Event hook GuardrailEventHooks.post_mcp_call is not in the supported event hooks" instead of scanning tool output. Declaring the hook makes the indirect-prompt-injection case enforceable: an MCP fetch tool returns a page whose body carries "IGNORE ALL PREVIOUS INSTRUCTIONS ...", and the gateway blocks the result rather than handing it to the model.
Greptile SummaryThis PR allows the existing LiteLLM content filter to run after MCP tool calls and adds integration coverage for blocking, masking, and unchanged text responses.
Confidence Score: 4/5The PR should not merge until the previously reported non-text MCP result bypass is addressed or explicitly accepted as an outstanding limitation. The new hook correctly filters textual and structured MCP output, but image and embedded-resource items are omitted from guardrail inputs and remain unchanged in the returned tool result. Files Needing Attention: litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py; litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py
|
| Filename | Overview |
|---|---|
| litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py | Advertises the existing unified content-filter implementation for post-MCP-call dispatch; text and structured-result handling is covered, while the previously reported non-text gap remains. |
| tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py | Adds integration tests for supported-hook validation and block, mask, and clean text-result paths through ProxyLogging. |
Reviews (3): Last reviewed commit: "fix(guardrails): allow litellm_content_f..." | Re-trigger Greptile
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
The text-only gap predates this PR and lives in the shared translation handler all post_mcp_call guardrails use. Merging strictly adds scanning where none existed @greptileai given this, update your score and summary as needed |
TLDR
Problem this solves:
litellm_content_filterrejectedmode: post_mcp_callat bootHow it solves it:
post_mcp_calltoget_supported_event_hooksapply_guardrailpath, no new logicRelevant issues
Linear ticket
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 used for both legs, an MCP fetch server behind the gateway plus two guardrails, one on
pre_mcp_callfor egress and one onpost_mcp_callfor what comes back:A local static server on
127.0.0.1:8899hosts two pages./is ordinary internal documentation,/onboarding.htmlis the same shape of page with a paragraph of injected instructions buried in the middle telling the model it is now unrestricted and should exfiltrate a local.envfile.Before, at
0659738b3e(litellm_internal_staging)The proxy will not start at all with that config:
After, at
83aca91ddeSame config, proxy starts. Three calls against the live MCP gateway on
localhost:4000:Clean page, allowed through:
{"content":[{"type":"text","text":"Contents of http://127.0.0.1:8899/:\nWelcome to the internal platform documentation server.\n\n## Deploying a service\n\nServices are deployed with the standard pipeline. Push to the release branch,\nwait for the build to go green, then approve the rollout in the deploy dashboard.\n..."}],"isError":false}Poisoned page, blocked on the way back by the new hook:
{"detail":{"error":"Content blocked: prompt_injection_jailbreak conditional match 'you are now + no restrictions' detected (severity: high)","category":"prompt_injection_jailbreak","matched_phrase":"you are now + no restrictions","severity":"high","guardrail_name":"scan-fetched-content","guardrail_mode":"post_mcp_call"}}Off-allowlist URL, blocked before the tool runs by the pre-existing
pre_mcp_callhook, included to show the two hooks composing:{"detail":{"error":"Content blocked: url_not_on_allowlist pattern detected","pattern":"url_not_on_allowlist","guardrail_name":"egress-allowlist","guardrail_mode":"pre_mcp_call"}}End to end through a real agent
Same proxy, an interactive Claude Code session pointed at it with
ANTHROPIC_BASE_URL=http://127.0.0.1:4000, the gateway registered as its only MCP server, and its ownWebFetch/Bashtools turned off so every fetch has to go through the gateway. Real/v1/messagestraffic to Anthropic, real spend:The model never sees the injected instructions in the second case. The gateway holds the tool result back and returns the block, and the agent reports that instead
Type
🐛 Bug Fix
Changes
One line in
ContentFilterGuardrail.get_supported_event_hooks. The class already implementsapply_guardrail, which is the whole contractProxyLogging.post_mcp_call_hookneeds to scan aCallToolResultand either raise or return masked content, so nothing else had to change._validate_event_hookchecks the configured mode against that list at construction time, which is why the omission surfaced as a startup failure rather than a silently skipped guardrailFour regression tests in
tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py, all driving the realProxyLogging.post_mcp_call_hookrather than calling the guardrail directly:post_mcp_callis advertised as supported, an injected tool result raises with the pattern name,MASKrewrites the result in place instead of raising, and a clean tool result comes back byte for byte unchangedFinal Attestation