fix(mcp): apply post-call rewrites without stale structured output (#41530)

* fix(mcp): apply async_post_mcp_tool_call_hook content changes to the tool result

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): drop structuredContent when a post-call hook rewrites tool content

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): satisfy type discipline and result contract

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(mcp): document internal logging patch

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): avoid Final assignments inside callback loops

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): run every post-call hook and chain the rewritten content

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(mcp): credit the original fix from #33403

Co-authored-by: eric <mitrecx@163.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(mcp): cover post-call logging fallback paths

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(mcp): cover proxy hook logging context

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(mcp): preserve native structured guardrail replacements

* fix(mcp): invalidate stale structure after direct content edits

* fix(mcp): reconcile direct edits after callback exceptions

* fix(mcp): preserve successful in-place callback rewrites

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eric <mitrecx@163.com>
Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 13:44:25 -07:00 committed by GitHub
parent 1c602334ee
commit 7210403e23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 572 additions and 40 deletions

View file

@ -574,11 +574,10 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
Useful if you want to modify the standard logging payload after the MCP tool call is made.
To change what the caller sends back to the MCP client, mutate ``response_obj``
in place: every call site discards the returned object, because the
dispatcher unwraps it to ``mcp_tool_call_response`` (a raw content list, not
a ``CallToolResult``) which the tool-call paths cannot forward. Guardrails
that mask or reject tool output should use ``post_mcp_call`` instead.
Modify ``mcp_tool_call_response`` in place or return a replacement response
object to change what the caller sends back to the MCP client. Content rewrites
discard stale structured output and mark those results as tool errors.
Use ``post_mcp_call`` guardrails for schema-preserving structured redaction.
"""
return None

View file

@ -221,7 +221,7 @@ from .initialize_dynamic_callback_params import (
from .specialty_caches.dynamic_logging_cache import DynamicLoggingCache
if TYPE_CHECKING:
from mcp.types import EmbeddedResource, ImageContent, TextContent
from mcp.types import CallToolResult, EmbeddedResource, ImageContent, TextContent
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
@ -1634,15 +1634,11 @@ class Logging(LiteLLMLoggingBaseClass):
async def async_post_mcp_tool_call_hook(
self,
kwargs: dict,
response_obj: Any,
response_obj: "CallToolResult",
start_time: datetime.datetime,
end_time: datetime.datetime,
):
"""
Post MCP Tool Call Hook
Use this to modify the MCP tool call response before it is returned to the user.
"""
) -> "CallToolResult":
"""Apply ordered MCP content callbacks to the result returned to the caller."""
from litellm.types.llms.base import HiddenParams
from litellm.types.mcp import MCPPostCallResponseObject
@ -1650,24 +1646,51 @@ class Logging(LiteLLMLoggingBaseClass):
dynamic_success_callbacks=self.dynamic_success_callbacks,
global_callbacks=litellm.success_callback,
)
post_mcp_tool_call_response_obj: Final[MCPPostCallResponseObject] = MCPPostCallResponseObject(
mcp_tool_call_response=response_obj, hidden_params=HiddenParams()
)
hidden_params = HiddenParams()
for callback in callbacks:
try:
if isinstance(callback, CustomLogger):
response: MCPPostCallResponseObject | None = await callback.async_post_mcp_tool_call_hook(
kwargs=kwargs,
response_obj=post_mcp_tool_call_response_obj,
start_time=start_time,
end_time=end_time,
original_content = copy.deepcopy(response_obj.content)
original_structured_content = copy.deepcopy(response_obj.structured_content)
callback_response = MCPPostCallResponseObject(
mcp_tool_call_response=copy.deepcopy(original_content), hidden_params=hidden_params
)
######################################################################
# if any of the callbacks modify the response, use the modified response
# current implementation returns the first modified response
######################################################################
if response is not None:
response_obj = self._parse_post_mcp_call_hook_response(response=response)
try:
response = await callback.async_post_mcp_tool_call_hook(
kwargs=kwargs,
response_obj=callback_response,
start_time=start_time,
end_time=end_time,
)
hook_content = (
self._parse_post_mcp_call_hook_response(response=response)
if response is not None
else callback_response.mcp_tool_call_response
)
if response is not None:
hidden_params = response.hidden_params
except Exception as e:
verbose_logger.exception(
"LiteLLM.LoggingError: [Non-Blocking] Exception occurred while logging %s", e
)
hook_content = None
structured_replacement_matches = (
response_obj.structured_content != original_structured_content
and (
hook_content is None
or hook_content == original_content
or response_obj.content == hook_content
)
)
if hook_content is not None and hook_content != original_content:
response_obj.content[:] = hook_content
if (
response_obj.content != original_content
and response_obj.structured_content is not None
and not structured_replacement_matches
):
response_obj.structured_content = None
response_obj.is_error = True
except Exception as e:
verbose_logger.exception("LiteLLM.LoggingError: [Non-Blocking] Exception occurred while logging %s", e)
return response_obj

View file

@ -2198,7 +2198,7 @@ async def _fire_mcp_tool_call_logging(
from litellm.proxy.proxy_server import proxy_logging_obj
logging_obj.post_call(original_response=result)
await logging_obj.async_post_mcp_tool_call_hook(
result = await logging_obj.async_post_mcp_tool_call_hook(
kwargs=logging_obj.model_call_details,
response_obj=result,
start_time=start_time,

View file

@ -872,7 +872,7 @@ class LiteLLM_Proxy_MCP_Handler:
if litellm_logging_obj:
try:
litellm_logging_obj.post_call(original_response=result)
await litellm_logging_obj.async_post_mcp_tool_call_hook(
result = await litellm_logging_obj.async_post_mcp_tool_call_hook(
kwargs=litellm_logging_obj.model_call_details,
response_obj=result,
start_time=start_time,

View file

@ -2962,7 +2962,7 @@ async def test_call_mcp_tool_uses_manager_permission_lookup():
mcp_info={"server_name": "test_server"},
)
expected_response = [TextContent(type="text", text="ok")]
expected_response = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False)
with (
patch.object(
@ -3038,7 +3038,7 @@ async def test_call_mcp_tool_resolves_unprefixed_tool_name_and_checks_permission
mcp_info={"server_name": "test_server"},
)
expected_response = [TextContent(type="text", text="ok")]
expected_response = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False)
with (
patch.object(

View file

@ -5,16 +5,15 @@ import json
import logging
import os
import sys
import time
from collections.abc import Callable, Iterator, Mapping
from types import MappingProxyType
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import time
import httpx
import pytest
from mcp.types import AudioContent, CallToolResult, ImageContent, TextContent
from openai._legacy_response import HttpxBinaryResponseContent
import litellm
@ -51,6 +50,272 @@ def logging_obj():
)
@pytest.mark.asyncio
async def test_async_post_mcp_tool_call_hook_preserves_and_returns_content(logging_obj):
from litellm.types.mcp import MCPPostCallResponseObject
class RedactingLogger(CustomLogger):
async def async_post_mcp_tool_call_hook(
self,
kwargs: dict[str, object],
response_obj: MCPPostCallResponseObject,
start_time: datetime.datetime,
end_time: datetime.datetime,
) -> MCPPostCallResponseObject:
assert isinstance(response_obj.mcp_tool_call_response, list)
assert isinstance(response_obj.mcp_tool_call_response[0], TextContent)
response_obj.mcp_tool_call_response = [TextContent(type="text", text="[REDACTED]")]
return response_obj
logging_obj.dynamic_success_callbacks = [RedactingLogger()]
result = CallToolResult(content=[TextContent(type="text", text="SECRET-1234")], isError=False)
hooked_content = await logging_obj.async_post_mcp_tool_call_hook(
kwargs=logging_obj.model_call_details,
response_obj=result,
start_time=datetime.datetime.now(),
end_time=datetime.datetime.now(),
)
assert hooked_content.content == [TextContent(type="text", text="[REDACTED]")]
@pytest.mark.asyncio
async def test_async_post_mcp_tool_call_hook_chains_every_callback(logging_obj):
from litellm.types.mcp import MCPPostCallResponseObject
class ReplacingLogger(CustomLogger):
def __init__(self, old: str, new: str) -> None:
super().__init__()
self.old: Final = old
self.new: Final = new
self.seen: list[str] = [] # mutable-ok: test records what each callback observed
async def async_post_mcp_tool_call_hook(
self,
kwargs: dict[str, object],
response_obj: MCPPostCallResponseObject,
start_time: datetime.datetime,
end_time: datetime.datetime,
) -> MCPPostCallResponseObject:
first = response_obj.mcp_tool_call_response[0]
assert isinstance(first, TextContent)
self.seen.append(first.text)
return MCPPostCallResponseObject(
mcp_tool_call_response=[TextContent(type="text", text=first.text.replace(self.old, self.new))],
hidden_params=response_obj.hidden_params,
)
first_logger: Final = ReplacingLogger("SECRET", "[S]")
second_logger: Final = ReplacingLogger("1234", "[N]")
logging_obj.dynamic_success_callbacks = [first_logger, second_logger]
result = CallToolResult(content=[TextContent(type="text", text="SECRET-1234")], isError=False)
hooked_content = await logging_obj.async_post_mcp_tool_call_hook(
kwargs=logging_obj.model_call_details,
response_obj=result,
start_time=datetime.datetime.now(),
end_time=datetime.datetime.now(),
)
assert first_logger.seen == ["SECRET-1234"]
assert second_logger.seen == ["[S]-1234"]
assert hooked_content.content == [TextContent(type="text", text="[S]-[N]")]
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["replace", "inplace", "empty", "inplace_none", "replace_none"])
@pytest.mark.parametrize("structured", [False, True])
async def test_mcp_content_rewrite_never_returns_stale_structured_data(logging_obj, mode, structured):
from litellm.types.llms.base import HiddenParams
from litellm.types.mcp import MCPPostCallResponseObject
class Redactor(CustomLogger):
async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time):
block = response_obj.mcp_tool_call_response[0]
assert isinstance(block, TextContent)
if mode in ("inplace", "inplace_none"):
block.text = "[REDACTED]"
return None if mode == "inplace_none" else response_obj
if mode == "replace_none":
response_obj.mcp_tool_call_response = [TextContent(type="text", text="[REDACTED]")]
return None
return MCPPostCallResponseObject(
mcp_tool_call_response=[] if mode == "empty" else [TextContent(type="text", text="[REDACTED]")],
hidden_params=HiddenParams(response_cost=0.25),
)
logging_obj.dynamic_success_callbacks = [Redactor()]
result = CallToolResult(
content=[TextContent(type="text", text="SECRET-1234")],
structured_content={"nested": {"secret": "SECRET-1234"}} if structured else None,
meta={"request": "trace-1"},
)
logging_obj.model_call_details["original_response"] = result
returned = await logging_obj.async_post_mcp_tool_call_hook(
kwargs=logging_obj.model_call_details,
response_obj=result,
start_time=datetime.datetime.now(),
end_time=datetime.datetime.now(),
)
assert "SECRET-1234" not in result.model_dump_json(by_alias=True)
assert returned is result
assert result.content == ([] if mode == "empty" else [TextContent(type="text", text="[REDACTED]")])
assert result.structured_content is None
assert result.is_error is structured
assert result.meta == {"request": "trace-1"}
assert logging_obj.model_call_details["original_response"] is result
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["none", "cost", "direct", "block", "exception"])
async def test_mcp_callbacks_preserve_effective_result_and_cost(logging_obj, mode):
from litellm.types.llms.base import HiddenParams
from litellm.types.mcp import MCPPostCallResponseObject
class Callback(CustomLogger):
async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time):
if mode == "exception":
response_obj.mcp_tool_call_response[0].text = "discarded"
raise ValueError("non-blocking callback")
if mode in ("direct", "block"):
original = kwargs["original_response"]
original.content = [TextContent(type="text", text="safe")]
original.structured_content = {"result": "safe"}
original.is_error = mode == "block"
if mode == "none" or mode == "direct":
return None
return MCPPostCallResponseObject(
mcp_tool_call_response=response_obj.mcp_tool_call_response,
hidden_params=HiddenParams(response_cost=0.25),
)
logging_obj.dynamic_success_callbacks = [Callback()]
result = CallToolResult(
content=[TextContent(type="text", text="original")], structured_content={"result": "original"}
)
logging_obj.model_call_details["original_response"] = result
returned = await logging_obj.async_post_mcp_tool_call_hook(
kwargs=logging_obj.model_call_details,
response_obj=result,
start_time=datetime.datetime.now(),
end_time=datetime.datetime.now(),
)
expected = "safe" if mode in ("direct", "block") else "original"
assert result.content == [TextContent(type="text", text=expected)]
assert result.structured_content == {"result": expected}
assert result.is_error is (mode == "block")
assert returned is result
assert logging_obj.model_call_details.get("response_cost") == (0.25 if mode in ("cost", "block") else None)
@pytest.mark.asyncio
async def test_mcp_callback_cancellation_propagates_without_mutating_result(logging_obj):
class CancelledCallback(CustomLogger):
async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time):
response_obj.mcp_tool_call_response[0].text = "partial"
raise asyncio.CancelledError
logging_obj.dynamic_success_callbacks = [CancelledCallback()]
result = CallToolResult(content=[TextContent(type="text", text="original")])
with pytest.raises(asyncio.CancelledError):
await logging_obj.async_post_mcp_tool_call_hook(
kwargs={},
response_obj=result,
start_time=datetime.datetime.now(),
end_time=datetime.datetime.now(),
)
assert result.content == [TextContent(type="text", text="original")]
@pytest.mark.asyncio
@pytest.mark.parametrize("callbacks", [[], ["prometheus"]])
@pytest.mark.parametrize("is_error", [False, True])
async def test_mcp_without_custom_callbacks_preserves_mixed_content(logging_obj, callbacks, is_error):
logging_obj.dynamic_success_callbacks = callbacks
result = CallToolResult(
content=[
TextContent(type="text", text="ok"),
ImageContent(type="image", data="aW1n", mime_type="image/png"),
AudioContent(type="audio", data="c291bmQ=", mime_type="audio/wav"),
],
structured_content={"result": "ok"},
is_error=is_error,
)
before = result.model_dump()
returned = await logging_obj.async_post_mcp_tool_call_hook(
kwargs={},
response_obj=result,
start_time=datetime.datetime.now(),
end_time=datetime.datetime.now(),
)
assert returned is result
assert returned.model_dump() == before
@pytest.mark.asyncio
@pytest.mark.parametrize("replace_structured", [False, True])
@pytest.mark.parametrize("same_content", [False, True])
async def test_mcp_native_structured_replacement_must_match_returned_content(
logging_obj, replace_structured, same_content
):
from litellm.types.mcp import MCPPostCallResponseObject
class NativeReplacement(CustomLogger):
async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time):
original = kwargs["original_response"]
original.content[0].text = "native-safe"
if replace_structured:
original.structured_content["result"] = "native-safe"
return MCPPostCallResponseObject(
mcp_tool_call_response=[TextContent(type="text", text="native-safe" if same_content else "final-safe")],
hidden_params=response_obj.hidden_params,
)
result = CallToolResult(
content=[TextContent(type="text", text="SECRET-1234")],
structured_content={"result": "SECRET-1234"},
)
logging_obj.dynamic_success_callbacks = [NativeReplacement()]
returned = await logging_obj.async_post_mcp_tool_call_hook(
kwargs={"original_response": result}, response_obj=result,
start_time=datetime.datetime.now(), end_time=datetime.datetime.now(),
)
assert returned is result
assert result.content == [TextContent(type="text", text="native-safe" if same_content else "final-safe")]
assert result.structured_content == ({"result": "native-safe"} if replace_structured and same_content else None)
assert result.is_error is not (replace_structured and same_content)
assert "SECRET-1234" not in result.model_dump_json()
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["none", "wrapper", "exception"])
@pytest.mark.parametrize("structured", [False, True])
async def test_mcp_direct_content_edit_invalidates_stale_structured_data(logging_obj, mode, structured):
class DirectRedactor(CustomLogger):
async def async_post_mcp_tool_call_hook(self, kwargs, response_obj, start_time, end_time):
kwargs["original_response"].content[0].text = "[REDACTED]"
if mode == "exception":
raise ValueError("non-blocking callback after direct edit")
return response_obj if mode == "wrapper" else None
result = CallToolResult(
content=[TextContent(type="text", text="SECRET-1234")],
structured_content={"result": "SECRET-1234"} if structured else None,
)
logging_obj.dynamic_success_callbacks = [DirectRedactor()]
returned = await logging_obj.async_post_mcp_tool_call_hook(
kwargs={"original_response": result}, response_obj=result,
start_time=datetime.datetime.now(), end_time=datetime.datetime.now(),
)
assert returned is result
assert result.content == [TextContent(type="text", text="[REDACTED]")]
assert result.structured_content is None
assert result.is_error is structured
assert "SECRET-1234" not in result.model_dump_json()
def test_get_combined_callback_list_preserves_insertion_order(logging_obj):
assert logging_obj.get_combined_callback_list(
dynamic_success_callbacks=["prometheus", "langfuse", "datadog", "otel", "s3"],

View file

@ -8755,7 +8755,7 @@ def _call_tool_result(is_error: bool, text: str) -> CallToolResult:
def _mock_mcp_logging_obj() -> MagicMock:
logging_obj = MagicMock()
logging_obj.model_call_details = {}
logging_obj.async_post_mcp_tool_call_hook = AsyncMock()
logging_obj.async_post_mcp_tool_call_hook = AsyncMock(side_effect=lambda **kwargs: kwargs["response_obj"])
logging_obj.async_success_handler = AsyncMock()
logging_obj.async_failure_handler = AsyncMock()
return logging_obj
@ -8857,6 +8857,64 @@ async def test_fire_mcp_tool_call_logging_success_path_unchanged():
proxy_logging_mock.post_call_failure_hook.assert_not_awaited()
@pytest.mark.asyncio
async def test_fire_mcp_tool_call_logging_applies_hook_content():
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.proxy._experimental.mcp_server.server import (
_fire_mcp_tool_call_logging,
)
from litellm.types.mcp import MCPPostCallResponseObject
class RedactingLogger(CustomLogger):
async def async_post_mcp_tool_call_hook(
self,
kwargs: dict[str, object],
response_obj: MCPPostCallResponseObject,
start_time: datetime,
end_time: datetime,
) -> MCPPostCallResponseObject:
assert isinstance(response_obj.mcp_tool_call_response, list)
assert isinstance(response_obj.mcp_tool_call_response[0], TextContent)
response_obj.mcp_tool_call_response = [TextContent(type="text", text="[REDACTED]")]
return response_obj
logging_obj = Logging(
model="MCP: weather/get_forecast",
messages=[{"role": "user", "content": "tool call"}],
stream=False,
call_type="call_mcp_tool",
start_time=datetime.now(),
litellm_call_id="test-mcp-hook-content",
function_id="test-fn",
dynamic_success_callbacks=[RedactingLogger()],
)
proxy_logging_mock = _mock_mcp_proxy_logging()
result = CallToolResult(
content=[TextContent(type="text", text="SECRET-1234")],
structuredContent={"result": "SECRET-1234"},
isError=False,
)
with patch( # test-quality-ok: [TQ008] inject proxy logging collaborator
"litellm.proxy.proxy_server.proxy_logging_obj",
proxy_logging_mock,
):
hooked_result = await _fire_mcp_tool_call_logging(
logging_obj=logging_obj,
result=result,
start_time=datetime.now(),
end_time=datetime.now(),
user_api_key_auth=UserAPIKeyAuth(api_key="test-key", user_id="test-user"),
request_data={},
)
assert isinstance(hooked_result.content[0], TextContent)
assert hooked_result.content[0].text == "[REDACTED]"
assert hooked_result.structured_content is None
assert hooked_result.is_error is True
@pytest.mark.asyncio
async def test_fire_mcp_tool_call_logging_iserror_without_auth_skips_failure_hook():
"""Without a UserAPIKeyAuth the failure handlers still fire but the proxy
@ -8871,7 +8929,7 @@ async def test_fire_mcp_tool_call_logging_iserror_without_auth_skips_failure_hoo
with patch("litellm.proxy.proxy_server.proxy_logging_obj", proxy_logging_mock):
await _fire_mcp_tool_call_logging(
logging_obj=logging_obj,
result={"isError": True, "content": [{"type": "text", "text": "denied"}]},
result=_call_tool_result(True, "denied"),
start_time=datetime.now(),
end_time=datetime.now(),
)

View file

@ -827,3 +827,38 @@ class TestCiscoAIDefenseJsonRpcSuccessEnvelope:
assert unwrapped is verdict
else:
assert unwrapped == expected
@pytest.mark.asyncio
@pytest.mark.parametrize("action", ["block", "redact"])
async def test_cisco_native_hook_through_logging_preserves_sanitized_result(action):
from mcp.types import CallToolResult, TextContent
from litellm.litellm_core_utils.litellm_logging import Logging
guardrail = _make_guardrail(
inspection_type="mcp", event_hook=["pre_mcp_call", "during_mcp_call"]
)
verdict = (
_violation_response(url=MCP_URL) if action == "block"
else _redact_response(sanitized_text="[REDACTED]", url=MCP_URL)
)
result = CallToolResult(
content=[TextContent(type="text", text="SECRET-1234")],
structured_content={"result": "SECRET-1234"},
)
logging_obj = Logging(
model="MCP: probe/search", messages=[], stream=False, call_type="call_mcp_tool",
start_time=datetime.now(), litellm_call_id="cisco-hook", function_id="cisco-hook",
dynamic_success_callbacks=[guardrail],
)
logging_obj.model_call_details.update({"name": "search", "arguments": {}, "original_response": result})
with _patch_inspection_post(guardrail, AsyncMock(return_value=verdict)):
returned = await logging_obj.async_post_mcp_tool_call_hook(
kwargs=logging_obj.model_call_details, response_obj=result,
start_time=datetime.now(), end_time=datetime.now(),
)
assert returned is result
assert "SECRET-1234" not in returned.model_dump_json()
assert returned.is_error is (action == "block")
assert ("Blocked by Cisco AI Defense" if action == "block" else "[REDACTED]") in returned.content[0].text
assert returned.structured_content == {"result": returned.content[0].text}

View file

@ -1,13 +1,15 @@
import importlib
import subprocess
import sys
import textwrap
import types
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import HTTPException
from mcp.types import CallToolResult, TextContent
from openai.types.responses.tool_param import Mcp
import importlib
from litellm.proxy._experimental.mcp_server.faults.list_outcomes import AggregateToolListing
from litellm.responses import main as responses_main
@ -15,10 +17,9 @@ from litellm.responses.mcp import litellm_proxy_mcp_handler as mcp_handler_modul
from litellm.responses.mcp.litellm_proxy_mcp_handler import (
LiteLLM_Proxy_MCP_Handler,
)
from typing import Any, cast
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.utils import ModelResponse
from litellm.types.responses.main import OutputFunctionToolCall
from litellm.types.utils import ModelResponse
class _DummyMCPResult:
@ -492,6 +493,157 @@ async def test_execute_tool_calls_threads_logging_obj_into_call_tool(monkeypatch
assert call_tool_mock.await_args.kwargs["litellm_logging_obj"] is sentinel_logging_obj
@pytest.mark.asyncio
async def test_execute_tool_calls_applies_post_call_hook_content(monkeypatch):
proxy_module = types.SimpleNamespace(proxy_logging_obj=None)
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", proxy_module)
result = CallToolResult(
content=[TextContent(type="text", text="SECRET-1234")],
structuredContent={"result": "SECRET-1234"},
isError=False,
)
fake_manager = types.SimpleNamespace(
get_registry=MagicMock(return_value={}),
call_tool=AsyncMock(return_value=result),
_get_mcp_server_from_tool_name=MagicMock(return_value=None),
get_mcp_server_by_name=MagicMock(return_value=None),
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
fake_manager,
)
logging_obj = MagicMock()
logging_obj.model_call_details = {}
logging_obj.async_post_mcp_tool_call_hook = AsyncMock(return_value=CallToolResult(content=[TextContent(type="text", text="[REDACTED]")], is_error=True))
logging_obj.async_success_handler = AsyncMock()
handler_module = importlib.import_module("litellm.responses.mcp.litellm_proxy_mcp_handler")
monkeypatch.setattr(handler_module, "function_setup", lambda *_args, **_kwargs: (logging_obj, None))
tool_name = "deepwiki-read_wiki_structure"
results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
tool_server_map={tool_name: "deepwiki"},
tool_calls=[{"id": "call-1", "function": {"name": tool_name, "arguments": "{}"}}],
user_api_key_auth=None,
)
assert results == [{"tool_call_id": "call-1", "result": "[REDACTED]", "name": tool_name}]
assert logging_obj.async_success_handler.await_args.kwargs["result"].content[0].text == "[REDACTED]"
assert logging_obj.async_success_handler.await_args.kwargs["result"].structured_content is None
@pytest.mark.asyncio
async def test_execute_tool_calls_returns_proxy_result_without_logging(monkeypatch):
result = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False)
proxy_logging_obj = MagicMock()
proxy_logging_obj.post_mcp_call_hook = AsyncMock(side_effect=lambda response, **_: response)
monkeypatch.setitem(
sys.modules, "litellm.proxy.proxy_server", types.SimpleNamespace(proxy_logging_obj=proxy_logging_obj)
)
fake_manager = types.SimpleNamespace(
get_registry=MagicMock(return_value={}),
call_tool=AsyncMock(return_value=result),
_get_mcp_server_from_tool_name=MagicMock(return_value=None),
get_mcp_server_by_name=MagicMock(return_value=None),
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
fake_manager,
)
handler_module = importlib.import_module("litellm.responses.mcp.litellm_proxy_mcp_handler")
monkeypatch.setattr(handler_module, "function_setup", lambda *_args, **_kwargs: (None, None))
tool_name = "deepwiki-read_wiki_structure"
results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
tool_server_map={tool_name: "deepwiki"},
tool_calls=[{"id": "call-1", "function": {"name": tool_name, "arguments": "{}"}}],
user_api_key_auth=None,
)
assert results == [{"tool_call_id": "call-1", "result": "ok", "name": tool_name}]
proxy_logging_obj.post_mcp_call_hook.assert_awaited_once()
@pytest.mark.asyncio
async def test_execute_tool_calls_passes_logging_details_to_proxy_hook(monkeypatch):
result = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False)
proxy_logging_obj = MagicMock()
proxy_logging_obj.post_mcp_call_hook = AsyncMock(side_effect=lambda response, **_: response)
monkeypatch.setitem(
sys.modules, "litellm.proxy.proxy_server", types.SimpleNamespace(proxy_logging_obj=proxy_logging_obj)
)
fake_manager = types.SimpleNamespace(
get_registry=MagicMock(return_value={}),
call_tool=AsyncMock(return_value=result),
_get_mcp_server_from_tool_name=MagicMock(return_value=None),
get_mcp_server_by_name=MagicMock(return_value=None),
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
fake_manager,
)
logging_obj = MagicMock()
logging_obj.model_call_details = {"request_id": "request-1"}
logging_obj.async_post_mcp_tool_call_hook = AsyncMock(return_value=result)
logging_obj.async_success_handler = AsyncMock()
handler_module = importlib.import_module("litellm.responses.mcp.litellm_proxy_mcp_handler")
monkeypatch.setattr(handler_module, "function_setup", lambda *_args, **_kwargs: (logging_obj, None))
tool_name = "deepwiki-read_wiki_structure"
results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
tool_server_map={tool_name: "deepwiki"},
tool_calls=[{"id": "call-1", "function": {"name": tool_name, "arguments": "{}"}}],
user_api_key_auth=None,
)
assert results == [{"tool_call_id": "call-1", "result": "ok", "name": tool_name}]
assert proxy_logging_obj.post_mcp_call_hook.await_args.kwargs["request_data"] == logging_obj.model_call_details
@pytest.mark.asyncio
@pytest.mark.parametrize("failure_stage", ["post_call_hook", "success_handler"])
async def test_execute_tool_calls_continues_when_post_call_logging_fails(monkeypatch, failure_stage: str):
proxy_module = types.SimpleNamespace(proxy_logging_obj=None)
monkeypatch.setitem(sys.modules, "litellm.proxy.proxy_server", proxy_module)
result = CallToolResult(content=[TextContent(type="text", text="ok")], isError=False)
fake_manager = types.SimpleNamespace(
get_registry=MagicMock(return_value={}),
call_tool=AsyncMock(return_value=result),
_get_mcp_server_from_tool_name=MagicMock(return_value=None),
get_mcp_server_by_name=MagicMock(return_value=None),
)
monkeypatch.setattr(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager",
fake_manager,
)
logging_obj = MagicMock()
logging_obj.model_call_details = {}
logging_obj.post_call = MagicMock()
logging_obj.async_post_mcp_tool_call_hook = AsyncMock(
side_effect=RuntimeError("hook failed") if failure_stage == "post_call_hook" else None,
return_value=result,
)
logging_obj.async_success_handler = AsyncMock(
side_effect=RuntimeError("success logging failed") if failure_stage == "success_handler" else None
)
handler_module = importlib.import_module("litellm.responses.mcp.litellm_proxy_mcp_handler")
monkeypatch.setattr(handler_module, "function_setup", lambda *_args, **_kwargs: (logging_obj, None))
tool_name = "deepwiki-read_wiki_structure"
results = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
tool_server_map={tool_name: "deepwiki"},
tool_calls=[{"id": "call-1", "function": {"name": tool_name, "arguments": "{}"}}],
user_api_key_auth=None,
)
assert results == [{"tool_call_id": "call-1", "result": "ok", "name": tool_name}]
@pytest.mark.asyncio
async def test_get_mcp_tools_from_manager_enables_list_tools_logging(monkeypatch):
"""