litellm - ✅(Solved) Fix [Bug]: async_pre_call_hook callbacks never fire for /mcp/ tool calls — local registry dispatch bypasses all hooks [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
BerriAI/litellm#25011Fetched 2026-04-08 02:35:07
View on GitHub
Comments
1
Participants
2
Timeline
7
Reactions
0
Author
Timeline (top)
referenced ×3labeled ×2commented ×1cross-referenced ×1

CustomLogger.async_pre_call_hook (and async_pre_mcp_tool_call_hook) callbacks registered via litellm_settings.callbacks do not fire for MCP tool calls on the /mcp/ endpoint. Tool calls execute on the backend MCP server with zero callback invocation.

This is a security issue for production deployments using LiteLLM as an MCP gateway with custom guardrails for tool-level access control.

Error Message

  • If the callback raises an exception, call_mcp_tool() is never called — tool is blocked pre-execution

Root Cause

mcp_server_tool_call() in server.py (line ~306) delegates to call_mcp_tool() (line ~1924), which has two dispatch paths:

Path 1 — Local tool registry (NO hook):

local_tool = global_mcp_tool_registry.get_tool(name)
if local_tool:
    await _handle_local_mcp_tool(name, arguments)  # ← no pre_call_hook

Path 2 — Managed MCP server (HAS hook, but never reached):

elif mcp_server:
    await _handle_managed_mcp_tool(...)
        → global_mcp_server_manager.call_tool(...)
            → pre_call_tool_check(...)  # ← has pre_call_hook

Why Path 2 is never reached: During startup, LiteLLM discovers MCP tools and registers them in global_mcp_tool_registry. At call time, the local registry is checked first (Path 1). Since discovered tools are always in the local registry, Path 1 always matches, and _handle_local_mcp_tool() executes without any hook invocation.

Additionally, tool_name_to_mcp_server_name_mapping in MCPServerManager is often empty even when tools are discoverable and callable (see #24089). This means _get_mcp_server_from_tool_name() returns None, and the elif mcp_server: condition for Path 2 always fails — even if a tool somehow doesn't match the local registry.

Fix Action

Fix / Workaround

[Bug]: async_pre_call_hook callbacks never fire for /mcp/ tool calls — local registry dispatch bypasses all hooks

mcp_server_tool_call() in server.py (line ~306) delegates to call_mcp_tool() (line ~1924), which has two dispatch paths:

TestCallback fires?Tool blocked?
/mcp/ direct (unpatched)NONO
/v1/chat/completions LLM proxyYESYES
/mcp/ with allowed_tools configN/AYES (name-level only)

PR fix notes

PR #25012: fix(mcp): invoke pre_call_hook before local

registry dispatch

Description (problem / solution / changelog)

Summary

  • Fixes #25011CustomLogger.async_pre_call_hook callbacks never fire for /mcp/ tool calls
  • The local registry dispatch path (_handle_local_mcp_tool) always matches first, bypassing the managed-server path that contains the hook invocation
  • Inserts pre_call_hook invocation in mcp_server_tool_call() before call_mcp_tool() so callbacks fire for ALL tool calls regardless of dispatch path
  • Uses the same _create_mcp_request_object_from_kwargs_convert_mcp_to_llm_formatpre_call_hook chain as MCPServerManager.pre_call_tool_check for consistency

Changes

  • 1 file modified: litellm/proxy/_experimental/mcp_server/server.py (~35 lines inserted, 0 modified/deleted)
  • 1 test file added: tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_pre_call_hook_local_registry.py (5 tests)

Test plan

  • Hook invoked with correct kwargs (tool name, arguments, user context)
  • Hook exception blocks tool execution (ValueError → tool NOT called)
  • Hook receives call_type="call_mcp_tool" matching managed-server path
  • Safely skipped when proxy_logging_obj is None
  • Safely skipped when user_api_key_auth is None
  • All 5 unit tests pass locally
  • Verified in production (AKS, 5 MCP backends, 11 tool-call scenarios — see #25011 for details)

Changed files

  • litellm/proxy/_experimental/mcp_server/server.py (modified, +80/-0)
  • tests/mcp_tests/test_mcp_pre_call_hook_local_registry.py (added, +278/-0)
  • tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_pre_call_hook_local_registry.py (added, +342/-0)

Code Example

local_tool = global_mcp_tool_registry.get_tool(name)
if local_tool:
    await _handle_local_mcp_tool(name, arguments)  # ← no pre_call_hook

---

elif mcp_server:
    await _handle_managed_mcp_tool(...)
        → global_mcp_server_manager.call_tool(...)
pre_call_tool_check(...)  # ← has pre_call_hook

---

from litellm.integrations.custom_logger import CustomLogger
from litellm._logging import verbose_proxy_logger
import litellm

class McpGuardrail(CustomLogger):
    async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
        verbose_proxy_logger.warning(
            "PRE_CALL_HOOK FIRED: call_type=%s tool=%s",
            call_type,
            data.get("mcp_tool_name", "N/A"),
        )
        # Block a specific tool to prove enforcement works
        if data.get("mcp_tool_name") == "blocked_tool":
            raise ValueError("Tool 'blocked_tool' is not allowed")
        return data

proxy_handler_instance = McpGuardrail()

---

model_list:
  - model_name: gpt-4
    litellm_params:
      model: openai/gpt-4

litellm_settings:
  callbacks:
    - my_guardrail.proxy_handler_instance

mcp_servers:
  my_server:
    url: "http://localhost:8001/mcp"

---

litellm --config config.yaml --detailed_debug

---

# First, list tools to confirm discovery works
curl -X POST http://localhost:4000/mcp/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk-..." \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'

# Then call a tool — callback should fire but does NOT
curl -X POST http://localhost:4000/mcp/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk-..." \
  -d '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"some_tool","arguments":{}}}'

---

# Callback loads successfully:
LiteLLM:DEBUG: Initialized Callbacks - [<my_guardrail.McpGuardrail object at 0x...>]

# Tool call executes — but zero output from the callback:
LiteLLM Proxy:DEBUG: mcp_server_tool_call: name=some_tool
# ... no PRE_CALL_HOOK FIRED line ...
# Tool result returned to client

---

# --- Pre MCP Tool Call Hook ---
from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_log_obj
if _proxy_log_obj and user_api_key_auth:
    _synth = _proxy_log_obj._convert_mcp_to_llm_format(
        type("R", (), {"tool_name": name, "arguments": arguments or {}})(),
        {
            "user_api_key_user_id": getattr(user_api_key_auth, "user_id", None),
            "user_api_key_team_id": getattr(user_api_key_auth, "team_id", None),
            "user_api_key_end_user_id": getattr(user_api_key_auth, "end_user_id", None),
            "user_api_key_hash": getattr(user_api_key_auth, "token", None),
            "user_api_key_request_route": "/mcp/",
            "incoming_bearer_token": None,
        },
    )
    await _proxy_log_obj.pre_call_hook(
        user_api_key_dict=user_api_key_auth,
        data=_synth,
        call_type="call_mcp_tool",
    )
# --- End fix ---
RAW_BUFFERClick to expand / collapse

[Bug]: async_pre_call_hook callbacks never fire for /mcp/ tool calls — local registry dispatch bypasses all hooks

Check for existing issues

  • I have searched the existing issues and checked that my issue is not a duplicate.

Related but distinct issues:

  • #15594 — async_pre_call_hook modifications not reflected in async_moderation_hook (same code area, different bug — closed as stale)
  • #20303 — Need litellm_call_id in MCP custom guardrail (user's hook fires, suggesting managed-server path — different setup)
  • #23528 — OpenAPI-to-MCP tool registry never populated (same global_mcp_tool_registry, opposite problem)
  • #24089 — tool_name_to_mcp_server_name_mapping emptied by cancelled list_tools (confirms mapping instability that contributes to this bug)

What happened?

Description

CustomLogger.async_pre_call_hook (and async_pre_mcp_tool_call_hook) callbacks registered via litellm_settings.callbacks do not fire for MCP tool calls on the /mcp/ endpoint. Tool calls execute on the backend MCP server with zero callback invocation.

This is a security issue for production deployments using LiteLLM as an MCP gateway with custom guardrails for tool-level access control.

Expected behavior

async_pre_call_hook should fire for every /mcp/ tool call, allowing the callback to allow, block, or modify the call before execution — the same behavior as /v1/chat/completions.

Actual behavior

The callback loads successfully (confirmed via LITELLM_LOG=DEBUG — "Initialized Callbacks" shows the callback), but it is never invoked for /mcp/ tool calls. Tools execute unconditionally on the backend.

Root cause

mcp_server_tool_call() in server.py (line ~306) delegates to call_mcp_tool() (line ~1924), which has two dispatch paths:

Path 1 — Local tool registry (NO hook):

local_tool = global_mcp_tool_registry.get_tool(name)
if local_tool:
    await _handle_local_mcp_tool(name, arguments)  # ← no pre_call_hook

Path 2 — Managed MCP server (HAS hook, but never reached):

elif mcp_server:
    await _handle_managed_mcp_tool(...)
        → global_mcp_server_manager.call_tool(...)
            → pre_call_tool_check(...)  # ← has pre_call_hook

Why Path 2 is never reached: During startup, LiteLLM discovers MCP tools and registers them in global_mcp_tool_registry. At call time, the local registry is checked first (Path 1). Since discovered tools are always in the local registry, Path 1 always matches, and _handle_local_mcp_tool() executes without any hook invocation.

Additionally, tool_name_to_mcp_server_name_mapping in MCPServerManager is often empty even when tools are discoverable and callable (see #24089). This means _get_mcp_server_from_tool_name() returns None, and the elif mcp_server: condition for Path 2 always fails — even if a tool somehow doesn't match the local registry.

Verification matrix

TestCallback fires?Tool blocked?
/mcp/ direct (unpatched)NONO
/v1/chat/completions LLM proxyYESYES
/mcp/ with allowed_tools configN/AYES (name-level only)

Steps to Reproduce

1. Callback (my_guardrail.py)

from litellm.integrations.custom_logger import CustomLogger
from litellm._logging import verbose_proxy_logger
import litellm

class McpGuardrail(CustomLogger):
    async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
        verbose_proxy_logger.warning(
            "PRE_CALL_HOOK FIRED: call_type=%s tool=%s",
            call_type,
            data.get("mcp_tool_name", "N/A"),
        )
        # Block a specific tool to prove enforcement works
        if data.get("mcp_tool_name") == "blocked_tool":
            raise ValueError("Tool 'blocked_tool' is not allowed")
        return data

proxy_handler_instance = McpGuardrail()

2. Config (config.yaml)

model_list:
  - model_name: gpt-4
    litellm_params:
      model: openai/gpt-4

litellm_settings:
  callbacks:
    - my_guardrail.proxy_handler_instance

mcp_servers:
  my_server:
    url: "http://localhost:8001/mcp"

3. Start proxy

litellm --config config.yaml --detailed_debug

4. Make a tool call via /mcp/

# First, list tools to confirm discovery works
curl -X POST http://localhost:4000/mcp/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk-..." \
  -d '{"jsonrpc":"2.0","method":"tools/list","id":1}'

# Then call a tool — callback should fire but does NOT
curl -X POST http://localhost:4000/mcp/ \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk-..." \
  -d '{"jsonrpc":"2.0","method":"tools/call","id":2,"params":{"name":"some_tool","arguments":{}}}'

5. Observe

  • No PRE_CALL_HOOK FIRED log line appears
  • Tool executes on backend unconditionally
  • Compare with /v1/chat/completions where the same callback fires correctly

Relevant log output

# Callback loads successfully:
LiteLLM:DEBUG: Initialized Callbacks - [<my_guardrail.McpGuardrail object at 0x...>]

# Tool call executes — but zero output from the callback:
LiteLLM Proxy:DEBUG: mcp_server_tool_call: name=some_tool
# ... no PRE_CALL_HOOK FIRED line ...
# Tool result returned to client

Suggested fix

Insert a pre_call_hook invocation in mcp_server_tool_call() before call_mcp_tool() is called. This ensures the callback fires for ALL tool calls regardless of which dispatch path handles them.

Insertion point: In server.py, inside mcp_server_tool_call(), after authentication and add_litellm_data_to_request, but before call_mcp_tool():

# --- Pre MCP Tool Call Hook ---
from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_log_obj
if _proxy_log_obj and user_api_key_auth:
    _synth = _proxy_log_obj._convert_mcp_to_llm_format(
        type("R", (), {"tool_name": name, "arguments": arguments or {}})(),
        {
            "user_api_key_user_id": getattr(user_api_key_auth, "user_id", None),
            "user_api_key_team_id": getattr(user_api_key_auth, "team_id", None),
            "user_api_key_end_user_id": getattr(user_api_key_auth, "end_user_id", None),
            "user_api_key_hash": getattr(user_api_key_auth, "token", None),
            "user_api_key_request_route": "/mcp/",
            "incoming_bearer_token": None,
        },
    )
    await _proxy_log_obj.pre_call_hook(
        user_api_key_dict=user_api_key_auth,
        data=_synth,
        call_type="call_mcp_tool",
    )
# --- End fix ---

Why this location:

  • Runs after authentication (user_api_key_auth is set)
  • Runs after add_litellm_data_to_request (logging metadata attached)
  • Runs before call_mcp_tool() — both dispatch paths are covered
  • If the callback raises an exception, call_mcp_tool() is never called — tool is blocked pre-execution
  • Uses existing _convert_mcp_to_llm_format() to create the synthetic LLM data the hook expects

Scope: ~15 lines inserted, 0 lines modified/deleted, 1 file changed. No breaking changes for users without custom callbacks.

We have this fix running in production (applied at Docker build time) and can contribute a PR with tests if there's interest.

What part of LiteLLM is this about?

Proxy

What LiteLLM version are you on?

v1.82.6

Twitter / LinkedIn details

No response

extent analysis

TL;DR

To fix the issue where async_pre_call_hook callbacks never fire for /mcp/ tool calls, insert a pre_call_hook invocation in mcp_server_tool_call() before call_mcp_tool() is called.

Guidance

  • Identify the mcp_server_tool_call() function in server.py and locate the appropriate insertion point for the pre_call_hook invocation.
  • Insert the suggested fix code, which includes converting the MCP tool call to an LLM format and then calling the pre_call_hook with the required parameters.
  • Verify that the pre_call_hook is being called by checking for the PRE_CALL_HOOK FIRED log line after making a tool call via /mcp/.
  • Test the fix by attempting to block a specific tool using the async_pre_call_hook callback and verifying that the tool is indeed blocked.

Example

The suggested fix code provides an example of how to insert the pre_call_hook invocation:

# --- Pre MCP Tool Call Hook ---
from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_log_obj
if _proxy_log_obj and user_api_key_auth:
    _synth = _proxy_log_obj._convert_mcp_to_llm_format(
        type("R", (), {"tool_name": name, "arguments": arguments or {}})(),
        {
            "user_api_key_user_id": getattr(user_api_key_auth, "user_id", None),
            "user_api_key_team_id": getattr(user_api_key_auth, "team_id", None),
            "user_api_key_end_user_id": getattr(user_api_key_auth, "end_user_id", None),
            "user_api_key_hash": getattr(user_api_key_auth, "token", None),
            "user_api_key_request_route": "/mcp/",
            "incoming_bearer_token": None,
        },
    )
    await _proxy_log_obj.pre_call_hook(
        user_api_key_dict=user_api_key_auth,
        data=_synth,
        call_type="call_mcp_tool",
    )
# --- End fix ---

Notes

The suggested fix assumes that the pre_call_hook invocation is inserted at the correct location in mcp_server_tool_call(). It is also important to verify that the pre_call_hook is being called correctly and that the tool is being blocked as expected.

Recommendation

Apply the suggested workaround by inserting the pre_call_hook invocation in mcp_server_tool_call() to ensure that the async_pre_call_hook callbacks fire for /mcp/ tool calls. This fix provides a solution to the security issue and allows for custom guardrails to be enforced.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

async_pre_call_hook should fire for every /mcp/ tool call, allowing the callback to allow, block, or modify the call before execution — the same behavior as /v1/chat/completions.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING