Skip to content

fix(guardrails): allow litellm_content_filter to run on post_mcp_call - #35980

Merged
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_content_filter_post_mcp_call
Aug 6, 2026
Merged

fix(guardrails): allow litellm_content_filter to run on post_mcp_call#35980
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_content_filter_post_mcp_call

Conversation

@mateo-berri

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • litellm_content_filter rejected mode: post_mcp_call at boot
  • No way to scan MCP tool results for prompt injection
  • Proxy exited 3 instead of starting

How it solves it:

  • Adds post_mcp_call to get_supported_event_hooks
  • Reuses the existing apply_guardrail path, no new logic

Relevant issues

Linear ticket

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 used for both legs, an MCP fetch server behind the gateway plus two guardrails, one on pre_mcp_call for egress and one on post_mcp_call for what comes back:

mcp_servers:
  egress:
    transport: "stdio"
    command: "uvx"
    args: ["--with", "mcp==1.15.0", "mcp-server-fetch", "--ignore-robots-txt"]

guardrails:
  - guardrail_name: egress-allowlist
    litellm_params:
      guardrail: litellm_content_filter
      mode: pre_mcp_call
      default_on: true
      patterns:
        - pattern_type: regex
          name: url_not_on_allowlist
          pattern: 'https?://(?!(?:127\.0\.0\.1|localhost)(?::\d+)?(?:[/?#]|$))\S*'
          action: BLOCK

  - guardrail_name: scan-fetched-content
    litellm_params:
      guardrail: litellm_content_filter
      mode: post_mcp_call
      default_on: true
      categories:
        - category: prompt_injection_jailbreak
          enabled: true
          action: BLOCK

A local static server on 127.0.0.1:8899 hosts two pages. / is ordinary internal documentation, /onboarding.html is 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 .env file.

Before, at 0659738b3e (litellm_internal_staging)

The proxy will not start at all with that config:

$ python litellm/proxy/proxy_cli.py --config config.yaml --port 4173
  File "litellm/integrations/custom_guardrail.py", line 485, in _validate_event_hook
    raise ValueError(f"Event hook {event_hook} is not in the supported event hooks {supported_event_hooks}")
ValueError: Event hook GuardrailEventHooks.post_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'>, <GuardrailEventHooks.pre_mcp_call: 'pre_mcp_call'>]

ERROR:    Application startup failed. Exiting.
EXIT=3

After, at 83aca91dde

Same config, proxy starts. Three calls against the live MCP gateway on localhost:4000:

$ SID=$(curl -s http://127.0.0.1:4000/v1/mcp/server -H "Authorization: Bearer sk-1234" \
    | python3 -c 'import sys,json; print(json.load(sys.stdin)[0]["server_id"])')

$ for URL in "http://127.0.0.1:8899/" "http://127.0.0.1:8899/onboarding.html" "http://evil.com/install.sh"; do
    curl -s -X POST http://127.0.0.1:4000/mcp-rest/tools/call \
      -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" \
      -d "{\"name\":\"egress-fetch\",\"arguments\":{\"url\":\"$URL\"},\"server_id\":\"$SID\"}"
  done

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_call hook, 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 own WebFetch / Bash tools turned off so every fetch has to go through the gateway. Real /v1/messages traffic to Anthropic, real spend:

❯ Fetch https://example.com and summarize the page
  Called gateway
⏺ A guardrail blocked the fetch since example.com isn't on the egress allowlist, so no summary is possible right now.

❯ Fetch http://127.0.0.1:8899/onboarding.html and summarize the onboarding steps
  Called gateway
⏺ That page contains a high-severity prompt-injection/jailbreak attempt ("you are now + no restrictions"), so it was blocked and I won't summarize or act on its content.

❯ Fetch http://127.0.0.1:8899/ and summarize the page
  Called gateway
⏺ The homepage is internal platform docs covering the staged deployment pipeline (push, build, staged regional rollout) and a 90-day-expiring production access request process.

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 implements apply_guardrail, which is the whole contract ProxyLogging.post_mcp_call_hook needs to scan a CallToolResult and either raise or return masked content, so nothing else had to change. _validate_event_hook checks the configured mode against that list at construction time, which is why the omission surfaced as a startup failure rather than a silently skipped guardrail

Four regression tests in tests/test_litellm/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py, all driving the real ProxyLogging.post_mcp_call_hook rather than calling the guardrail directly: post_mcp_call is advertised as supported, an injected tool result raises with the pattern name, MASK rewrites the result in place instead of raising, and a clean tool result comes back byte for byte unchanged

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

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-apps

greptile-apps Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR allows the existing LiteLLM content filter to run after MCP tool calls and adds integration coverage for blocking, masking, and unchanged text responses.

  • Adds post_mcp_call to the content filter’s supported event hooks.
  • Exercises the real ProxyLogging.post_mcp_call_hook callback path.
  • Verifies blocking, masking, and clean text-result behavior.

Confidence Score: 4/5

The 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

Important Files Changed

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

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_content_filter_post_mcp_call (83aca91) with litellm_internal_staging (2792887)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (0b8c587) during the generation of this report, so 2792887 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@mateo-berri

Copy link
Copy Markdown
Contributor Author

@greptileai

@mateo-berri

Copy link
Copy Markdown
Contributor Author

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

@mateo-berri
mateo-berri merged commit b9b239b into litellm_internal_staging Aug 6, 2026
80 checks passed
@mateo-berri
mateo-berri deleted the litellm_content_filter_post_mcp_call branch August 6, 2026 01:08
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