mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-15 10:24:33 +00:00
fix(mcp): run pre_call_tool_check on OpenAPI/local-registry path (VERIA-7)
`execute_mcp_tool` dispatches in two ways: managed MCP servers go through `_handle_managed_mcp_tool`, which calls `MCPServerManager.pre_call_tool_check` to enforce allowed/banned tool lists, key/team `object_permission` tool grants, and parameter validation. OpenAPI-backed tools, however, were resolved via `global_mcp_tool_registry` and dispatched directly to `_handle_local_mcp_tool` — entirely skipping `pre_call_tool_check`. A caller could invoke any registered OpenAPI tool regardless of their key/team permissions, including administrative or destructive operations on the upstream API. Run `pre_call_tool_check` before the local-registry dispatch whenever the resolved server is set (the same condition used to surface server context to the managed path). Honor any guardrail-modified arguments the hook returns. Errors raised by the hook propagate up before `_handle_local_mcp_tool` runs. Tests cover both directions: the pre-call hook fires when the local tool resolves alongside a server, and a hook-raised HTTPException prevents the local handler from being invoked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2104,6 +2104,27 @@ if MCP_AVAILABLE:
|
||||
#########################################################
|
||||
local_tool = global_mcp_tool_registry.get_tool(name)
|
||||
if local_tool:
|
||||
# OpenAPI-backed tools used to bypass `pre_call_tool_check` —
|
||||
# only the managed path ran allowed/banned-tool checks, key/team
|
||||
# tool permissions, and parameter validation. Run the same checks
|
||||
# before dispatching to the local registry whenever we have a
|
||||
# resolved server, so OpenAPI tools enforce the same allowlist
|
||||
# the proxy applies to managed MCP tools.
|
||||
if mcp_server is not None:
|
||||
hook_result = await global_mcp_server_manager.pre_call_tool_check(
|
||||
name=original_tool_name,
|
||||
arguments=arguments or {},
|
||||
server_name=server_name or mcp_server.name,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
proxy_logging_obj=kwargs.get("proxy_logging_obj"),
|
||||
server=mcp_server,
|
||||
raw_headers=raw_headers,
|
||||
)
|
||||
# `pre_call_tool_check` may return guardrail-modified
|
||||
# arguments; honor them on the local path too.
|
||||
if isinstance(hook_result, dict) and "arguments" in hook_result:
|
||||
arguments = hook_result["arguments"]
|
||||
|
||||
verbose_logger.debug(f"Executing local registry tool: {name}")
|
||||
# For BYOK servers the credential must be injected via a ContextVar
|
||||
# because the tool function has headers baked into its closure.
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
VERIA-7 regression: OpenAPI-backed (local-registry) MCP tools must run
|
||||
through `pre_call_tool_check` before dispatch, the same as managed
|
||||
MCP server tools.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openapi_local_tool_runs_pre_call_tool_check():
|
||||
"""When `execute_mcp_tool` resolves a local-registry (OpenAPI) tool
|
||||
AND a server, the pre-call hook must fire before the local handler
|
||||
runs. Pre-fix this path skipped the hook entirely."""
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
user = UserAPIKeyAuth(
|
||||
api_key="sk-user",
|
||||
user_id="alice",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
)
|
||||
|
||||
fake_server = MagicMock()
|
||||
fake_server.name = "openapi-petstore"
|
||||
fake_server.is_byok = False
|
||||
fake_server.auth_type = None
|
||||
fake_server.mcp_info = None
|
||||
fake_server.server_id = "srv-1"
|
||||
fake_server.server_name = "openapi-petstore"
|
||||
|
||||
fake_tool = MagicMock()
|
||||
fake_tool.name = "list_pets"
|
||||
|
||||
pre_call = AsyncMock(return_value={})
|
||||
handle_local = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
mcp_module.global_mcp_server_manager,
|
||||
"_get_mcp_server_from_tool_name",
|
||||
return_value=fake_server,
|
||||
),
|
||||
patch.object(
|
||||
mcp_module.global_mcp_server_manager,
|
||||
"pre_call_tool_check",
|
||||
new=pre_call,
|
||||
),
|
||||
patch.object(
|
||||
mcp_module.global_mcp_tool_registry,
|
||||
"get_tool",
|
||||
return_value=fake_tool,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._handle_local_mcp_tool",
|
||||
new=handle_local,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server.MCPRequestHandler.is_tool_allowed",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
await mcp_module.execute_mcp_tool(
|
||||
name="list_pets",
|
||||
arguments={"limit": 10},
|
||||
allowed_mcp_servers=[fake_server],
|
||||
start_time=datetime.now(timezone.utc),
|
||||
user_api_key_auth=user,
|
||||
)
|
||||
|
||||
pre_call.assert_awaited_once()
|
||||
handle_local.assert_awaited_once()
|
||||
|
||||
# The pre-call hook must run before _handle_local_mcp_tool so an
|
||||
# unauthorized tool is blocked before any work runs. AsyncMock
|
||||
# records call order indirectly — we already asserted both were
|
||||
# called; the relative ordering is enforced by the source change.
|
||||
pre_call_kwargs = pre_call.await_args.kwargs
|
||||
assert pre_call_kwargs["name"] == "list_pets"
|
||||
assert pre_call_kwargs["server"] is fake_server
|
||||
assert pre_call_kwargs["user_api_key_auth"] is user
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openapi_local_tool_blocked_when_pre_call_check_raises():
|
||||
"""If the pre-call check raises (caller not authorized for this
|
||||
tool), the local handler must NOT be invoked."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server as mcp_module
|
||||
|
||||
user = UserAPIKeyAuth(
|
||||
api_key="sk-user",
|
||||
user_id="alice",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
)
|
||||
|
||||
fake_server = MagicMock()
|
||||
fake_server.name = "openapi-petstore"
|
||||
fake_server.is_byok = False
|
||||
fake_server.auth_type = None
|
||||
fake_server.mcp_info = None
|
||||
fake_server.server_id = "srv-1"
|
||||
fake_server.server_name = "openapi-petstore"
|
||||
|
||||
fake_tool = MagicMock()
|
||||
fake_tool.name = "delete_pet"
|
||||
|
||||
pre_call = AsyncMock(
|
||||
side_effect=HTTPException(status_code=403, detail="not allowed")
|
||||
)
|
||||
handle_local = AsyncMock(return_value=[])
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
mcp_module.global_mcp_server_manager,
|
||||
"_get_mcp_server_from_tool_name",
|
||||
return_value=fake_server,
|
||||
),
|
||||
patch.object(
|
||||
mcp_module.global_mcp_server_manager,
|
||||
"pre_call_tool_check",
|
||||
new=pre_call,
|
||||
),
|
||||
patch.object(
|
||||
mcp_module.global_mcp_tool_registry,
|
||||
"get_tool",
|
||||
return_value=fake_tool,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server._handle_local_mcp_tool",
|
||||
new=handle_local,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy._experimental.mcp_server.server.MCPRequestHandler.is_tool_allowed",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await mcp_module.execute_mcp_tool(
|
||||
name="delete_pet",
|
||||
arguments={},
|
||||
allowed_mcp_servers=[fake_server],
|
||||
start_time=datetime.now(timezone.utc),
|
||||
user_api_key_auth=user,
|
||||
)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
pre_call.assert_awaited_once()
|
||||
handle_local.assert_not_awaited()
|
||||
Reference in New Issue
Block a user