Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8898a20
fix(router): stop retries and fallbacks after MCP tools executed
Atharva-Kanherkar Sep 25, 2026
f89d031
fix(mcp): tag a chat follow-up failure after MCP tools executed
Atharva-Kanherkar Sep 25, 2026
dcaeff7
fix(mcp): tag a Responses follow-up failure after MCP tools executed
Atharva-Kanherkar Sep 25, 2026
7ccee38
fix(mcp): tag a /v1/messages follow-up failure after MCP tools executed
Atharva-Kanherkar Sep 25, 2026
33a4363
fix(mcp): only block router replays after a tool actually executed
Atharva-Kanherkar Sep 25, 2026
de4ba11
test(router): inject the attempt instead of patching router methods
Atharva-Kanherkar Sep 25, 2026
c483f99
refactor(mcp): follow one link per step when looking for the replay tag
Atharva-Kanherkar Sep 25, 2026
e9e95d5
fix(mcp): walk the exception chain without recursion
Atharva-Kanherkar Sep 25, 2026
2a8f588
fix(mcp): count a tool call once it is sent to the MCP server
Atharva-Kanherkar Sep 25, 2026
b1e4442
fix(mcp): stop fallbacks from replaying a streamed chat tool call
Atharva-Kanherkar Sep 25, 2026
d4caa30
fix(mcp): count a tool call when the MCP session sends it
Atharva-Kanherkar Sep 25, 2026
f78e072
test(router): keep the MCP replay tests in the router retry test file
Atharva-Kanherkar Sep 25, 2026
4ad4a4d
Merge upstream main into fix-mcp-retry-tool-reexecution
Atharva-Kanherkar Sep 25, 2026
99b3c06
style(mcp): drop routine comments from the replay change
Atharva-Kanherkar Sep 25, 2026
8b1c0a2
Merge remote-tracking branch 'upstream/main' into fix-mcp-retry-tool-…
Atharva-Kanherkar Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions litellm/experimental_mcp_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -881,6 +881,7 @@ async def call_tool(
host_progress_callback: Callable | None = None,
raise_on_error: bool = False,
allow_input_required: bool = False,
on_dispatch: Callable[[], None] | None = None,
) -> MCPCallToolResult | InputRequiredResult:
"""
Call an MCP Tool.
Expand All @@ -893,6 +894,8 @@ async def call_tool(
allow_input_required: When True, a 2026-07-28 upstream may answer with an interim
``InputRequiredResult`` and it is returned as is. The SDK rejects it otherwise, so
callers only opt in when the downstream side can carry it.
on_dispatch: Called once the session is ready, right before the tool call is sent,
so a caller can tell a call the server may have run from one that never left.
"""
verbose_logger.info("MCP client calling tool '%s'", call_tool_request_params.name)

Expand All @@ -911,6 +914,8 @@ async def on_progress(progress: float, total: float | None, message: str | None)

async def _call_tool_operation(session: ClientSession):
verbose_logger.debug("MCP client sending tool call to session")
if on_dispatch is not None:
on_dispatch()
return await session.call_tool(
name=call_tool_request_params.name,
arguments=call_tool_request_params.arguments,
Expand Down
10 changes: 7 additions & 3 deletions litellm/llms/anthropic/pass_through/messages/mcp_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from litellm._logging import verbose_logger
from litellm.responses.mcp.request_context import MCPRequestContext
from litellm.router_utils.mcp_tool_execution import MCPToolReplayGuard
from litellm.types.llms.anthropic import (
AnthropicMessagesTool,
AnthropicMessagesToolResultParam,
Expand Down Expand Up @@ -137,6 +138,7 @@ async def anthropic_messages_with_mcp(
messages=list(working_messages), stream=False, **base_call_args
)

replay_guard: Final = MCPToolReplayGuard()
for _ in range(MAX_MCP_TOOL_USE_ITERATIONS):
if _get_stop_reason(response) != "tool_use":
break
Expand All @@ -157,6 +159,7 @@ async def anthropic_messages_with_mcp(
litellm_trace_id=context.litellm_trace_id,
request_tags=list(context.request_tags) if context.request_tags else None,
guardrail_context=context.guardrail_context,
on_tool_dispatched=replay_guard.record_tool_dispatch,
)

# Every tool call was skipped, so there is nothing to feed back; a
Expand All @@ -169,9 +172,10 @@ async def anthropic_messages_with_mcp(
{"role": "assistant", "content": list(_get_response_content(response))},
_build_tool_result_message(tool_results),
)
response = await _AnthropicMessagesCall(fn=litellm.anthropic_messages).fn(
messages=list(working_messages), stream=False, **base_call_args
)
with replay_guard:
response = await _AnthropicMessagesCall(fn=litellm.anthropic_messages).fn(
messages=list(working_messages), stream=False, **base_call_args
)
else:
verbose_logger.warning(
"MCP tool loop hit its %s iteration cap for model %s; returning the last response",
Expand Down
13 changes: 13 additions & 0 deletions litellm/proxy/_experimental/mcp_server/mcp_server_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5954,6 +5954,7 @@ async def _obo_call_tool_with_retry(
raw_headers: Mapping[str, str] | None = None,
client_ip: str | None = None,
allow_input_required: bool = False,
on_dispatch: Callable[[], None] | None = None,
) -> CallToolResult | InputRequiredResult:
"""Call a token_exchange (OBO) tool; on an upstream 401/403 re-mint the token once and retry.

Expand All @@ -5968,6 +5969,7 @@ async def _obo_call_tool_with_retry(
host_progress_callback=host_progress_callback,
raise_on_error=True,
allow_input_required=allow_input_required,
on_dispatch=on_dispatch,
)
except Exception as exc:
if _extract_upstream_auth_failure(exc) is None:
Expand All @@ -5989,6 +5991,7 @@ async def _obo_call_tool_with_retry(
call_tool_params,
host_progress_callback=host_progress_callback,
allow_input_required=allow_input_required,
on_dispatch=on_dispatch,
)

async def _call_regular_mcp_tool(
Expand All @@ -6007,6 +6010,7 @@ async def _call_regular_mcp_tool(
user_api_key_auth: UserAPIKeyAuth | None = None,
client_ip: str | None = None,
allow_input_required: bool = False,
on_dispatch: Callable[[], None] | None = None,
) -> CallToolResult | InputRequiredResult:
"""
Call a regular MCP tool using the MCP client.
Expand Down Expand Up @@ -6170,6 +6174,7 @@ async def _obo_call_tool_limited():
client=client,
call_tool_params=call_tool_params,
host_progress_callback=host_progress_callback,
on_dispatch=on_dispatch,
mcp_server=mcp_server,
server_auth_header=server_auth_header,
extra_headers=extra_headers,
Expand All @@ -6196,6 +6201,7 @@ async def _call_tool_via_client(client, params):
params,
host_progress_callback=host_progress_callback,
allow_input_required=allow_input_required,
on_dispatch=on_dispatch,
)
# The client-forwarded modes carry the caller's own upstream token, so an upstream
# 401 (expired/invalid token) is the caller's to resolve: relay it as
Expand All @@ -6212,6 +6218,7 @@ async def _call_tool_via_client(client, params):
host_progress_callback=host_progress_callback,
raise_on_error=True,
allow_input_required=allow_input_required,
on_dispatch=on_dispatch,
)
except Exception as e:
auth_info: Final = _extract_upstream_auth_failure(e)
Expand Down Expand Up @@ -6469,6 +6476,7 @@ async def call_tool(
guardrail_context: Mapping[str, object] | None = None,
client_ip: str | None = None,
wire_compat: WireCompat = WireCompat.LEGACY,
on_dispatch: Callable[[], None] | None = None,
) -> CallToolResult | InputRequiredResult:
"""
Call a tool with the given name and arguments
Expand All @@ -6484,6 +6492,8 @@ async def call_tool(
litellm_logging_obj: Optional request logger the guardrail hooks record
their evaluations onto, so MCP guardrail activity reaches the
Guardrails Monitor. See ``pre_call_tool_check``
on_dispatch: Optional callback run right before the call is sent to the server,
after its headers and client are ready, even if the server never returns a result


Returns:
Expand Down Expand Up @@ -6578,6 +6588,8 @@ async def _call_openapi_via_handler():
resolved_token: Final = _request_resolved_auth_headers.set(resolved_auth_headers)
try:
async with self._limit_outbound_concurrency(mcp_server):
if on_dispatch is not None:
on_dispatch()
return await self._call_openapi_tool_handler(mcp_server, name, arguments, wire_compat)
finally:
_request_auth_header.reset(auth_token)
Expand All @@ -6601,6 +6613,7 @@ async def _call_openapi_via_handler():
hook_extra_headers=hook_result.get("extra_headers"),
user_api_key_auth=user_api_key_auth,
allow_input_required=wire_compat is WireCompat.MODERN,
on_dispatch=on_dispatch,
)

return await self._gather_openapi_tool_tasks(tasks, proxy_logging_obj)
Expand Down
41 changes: 23 additions & 18 deletions litellm/responses/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
)
from litellm.responses.mcp.request_context import MCPRequestContext
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.router_utils.mcp_tool_execution import MCPToolReplayGuard
from litellm.types.llms.openai import (
PromptObject,
Reasoning,
Expand Down Expand Up @@ -337,6 +338,7 @@ async def aresponses_api_with_mcp(
tools=tools,
)

replay_guard: Final = MCPToolReplayGuard()
tool_results: Final = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
tool_server_map=tool_server_map,
tool_calls=tool_calls,
Expand All @@ -351,6 +353,7 @@ async def aresponses_api_with_mcp(
guardrail_context=MCPRequestContext.resolve_guardrail_context(
MappingProxyType({**kwargs, "metadata": metadata, "model": model})
),
on_tool_dispatched=replay_guard.record_tool_dispatch,
)

if tool_results:
Expand All @@ -375,13 +378,14 @@ async def aresponses_api_with_mcp(
tool_calls=tool_calls, tool_results=tool_results
)

final_response = await LiteLLM_Proxy_MCP_Handler._make_follow_up_call(
follow_up_input=follow_up_input,
model=model,
all_tools=all_tools,
response_id=previous_response_id if persistence_disabled else response.id,
**follow_up_call_params,
)
with replay_guard:
Comment thread
veria-ai[bot] marked this conversation as resolved.
final_response = await LiteLLM_Proxy_MCP_Handler._make_follow_up_call(
follow_up_input=follow_up_input,
model=model,
all_tools=all_tools,
response_id=previous_response_id if persistence_disabled else response.id,
**follow_up_call_params,
)

# If streaming and we have tool execution events, wrap the response
if (
Expand All @@ -403,17 +407,18 @@ async def aresponses_api_with_mcp(
# Add custom output elements to the final response (for non-streaming)
elif isinstance(final_response, ResponsesAPIResponse):
# Fetch MCP tools again for output elements (without OpenAI transformation)
(
mcp_tools_for_output,
_,
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
request_tags=LiteLLM_Proxy_MCP_Handler._get_parent_request_tags(kwargs),
raw_headers=discovery_raw_headers,
)
with replay_guard:
(
mcp_tools_for_output,
_,
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
request_tags=LiteLLM_Proxy_MCP_Handler._get_parent_request_tags(kwargs),
raw_headers=discovery_raw_headers,
)
final_response = LiteLLM_Proxy_MCP_Handler._add_mcp_output_elements_to_response(
response=final_response,
mcp_tools_fetched=mcp_tools_for_output,
Expand Down
11 changes: 9 additions & 2 deletions litellm/responses/mcp/chat_completions_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
LiteLLM_Proxy_MCP_Handler,
)
from litellm.responses.mcp.request_context import MCPRequestContext
from litellm.router_utils.mcp_tool_execution import MCPToolReplayGuard
from litellm.types.utils import Message, ModelResponse
from litellm.utils import CustomStreamWrapper

Expand Down Expand Up @@ -244,6 +245,7 @@ def __init__(
self.follow_up_stream = None
self.follow_up_iterator = None
self.follow_up_exhausted = False
self.replay_guard = MCPToolReplayGuard()

def __aiter__(self):
return self
Expand Down Expand Up @@ -382,7 +384,8 @@ async def __anext__(self):
verbose_logger.debug("Follow-up stream iterator created")

try:
chunk = await self.follow_up_iterator.__anext__()
with self.replay_guard:
chunk = await self.follow_up_iterator.__anext__()
from litellm._logging import verbose_logger

verbose_logger.debug("Follow-up chunk yielded: %s", chunk)
Expand Down Expand Up @@ -445,6 +448,7 @@ async def _process_tool_calls(self):
litellm_trace_id=self.litellm_trace_id,
request_tags=self.request_tags,
guardrail_context=context.guardrail_context,
on_tool_dispatched=self.replay_guard.record_tool_dispatch,
)

async def _prepare_follow_up_call(self):
Expand Down Expand Up @@ -606,6 +610,7 @@ def __next__(self):
return initial_response

# Execute tool calls
replay_guard: Final = MCPToolReplayGuard()
Comment thread
veria-ai[bot] marked this conversation as resolved.
tool_results: Final = await LiteLLM_Proxy_MCP_Handler._execute_tool_calls(
tool_server_map=tool_server_map,
tool_calls=tool_calls,
Expand All @@ -618,6 +623,7 @@ def __next__(self):
litellm_trace_id=context.litellm_trace_id,
request_tags=request_tags,
guardrail_context=context.guardrail_context,
on_tool_dispatched=replay_guard.record_tool_dispatch,
)

if not tool_results:
Expand All @@ -640,7 +646,8 @@ def __next__(self):
follow_up_call_args["messages"] = follow_up_messages
follow_up_call_args["stream"] = stream

response = await litellm_acompletion(**follow_up_call_args)
with replay_guard:
response = await litellm_acompletion(**follow_up_call_args)
if isinstance(response, (ModelResponse, CustomStreamWrapper)):
_add_mcp_metadata_to_response(
response=response,
Expand Down
9 changes: 8 additions & 1 deletion litellm/responses/mcp/litellm_proxy_mcp_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -696,8 +696,14 @@ async def _execute_tool_calls(
litellm_trace_id: str | None = None,
request_tags: list[str] | None = None,
guardrail_context: Mapping[str, object] | None = None,
on_tool_dispatched: Callable[[], None] | None = None,
) -> list[MCPToolResult]:
"""Execute tool calls and return results."""
"""Execute tool calls and return results.

``on_tool_dispatched`` runs for every call that is sent to its MCP server, even one that then
times out. Calls rejected or failing before that, for example on a pre-call guardrail or a
credential the caller has not set, do not trigger it.
"""
from fastapi import HTTPException

from litellm._uuid import uuid
Expand Down Expand Up @@ -860,6 +866,7 @@ async def _execute_tool_calls(
proxy_logging_obj=proxy_logging_obj,
litellm_logging_obj=litellm_logging_obj,
guardrail_context=guardrail_context,
on_dispatch=on_tool_dispatched,
)

if proxy_logging_obj:
Expand Down
12 changes: 10 additions & 2 deletions litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@
send_llm_exception_alert,
)
from litellm.router_utils.health_state_cache import DeploymentHealthCache
from litellm.router_utils.mcp_tool_execution import mcp_tools_executed
from litellm.router_utils.pre_call_checks.deployment_affinity_check import (
DeploymentAffinityCheck,
warn_on_unknown_model_group_affinity_flags,
Expand Down Expand Up @@ -7249,7 +7250,12 @@ async def async_function_with_fallbacks_common_utils(
hop_depth: Final = kwargs.get("fallback_depth")
nested_fallback_hop: Final = isinstance(hop_depth, int) and hop_depth > 0

if disable_fallbacks is True or original_model_group is None or is_guardrail_intervention(e):
if (
disable_fallbacks is True
or original_model_group is None
or is_guardrail_intervention(e)
or mcp_tools_executed(e)
):
raise e

input_kwargs: Final = {
Expand Down Expand Up @@ -7663,7 +7669,7 @@ async def async_function_with_retries(self, *args, **kwargs):
response = add_retry_headers_to_response(response=response, attempted_retries=0, max_retries=None)
return response
except Exception as e:
if is_guardrail_intervention(e):
if is_guardrail_intervention(e) or mcp_tools_executed(e):
raise
current_attempt = None
original_exception = e
Expand Down Expand Up @@ -7764,6 +7770,8 @@ async def async_function_with_retries(self, *args, **kwargs):
# Always track the latest error so we raise the most
# recent exception instead of the first one.
original_exception = e
if mcp_tools_executed(e):
raise

## LOGGING
kwargs = self.log_retry(kwargs=kwargs, e=e)
Expand Down
Loading
Loading