Repository navigation
Description
pipecat version
main @ a3b149f (editable install, reports as 1.11.1.dev26)
Python version
3.13.15
Operating System
macOS 26.6.2 (arm64)
Issue description
MCPClient.tools_output_filters is documented to accept any type in and any type out — the public signature is tools_output_filters: dict[str, Callable[[Any], Any]] | None (src/pipecat/services/mcp_service.py:216) and the __init__ docstring says: "Each filter function receives the raw tool output (any type) and returns the processed output (any type)." (src/pipecat/services/mcp_service.py:225-226).
But when a filter returns a non-string value (e.g. a parsed dict/list) or the empty string, the processed output is thrown away and the model receives "Sorry, could not call the mcp tool", even though session.call_tool() succeeded and no error occurred. The LLM is told that a successful tool call failed.
Root cause: after the filter is applied at src/pipecat/services/mcp_service.py:604, the result is gated by if isinstance(response, str) and response: (src/pipecat/services/mcp_service.py:611). Any non-string value and the empty string fall into the else at src/pipecat/services/mcp_service.py:614-615, which replaces the response with error_msg or "Sorry, could not call the mcp tool" — and error_msg is None for a successful call. The adjacent except-handler at src/pipecat/services/mcp_service.py:607-609 deliberately sets response = "" when a filter itself raises, which indicates this check was meant to catch filter failures, not successful calls whose legitimate output is empty or non-string.
Reproduction steps
- Construct
MCPClient(server_params=..., tools_output_filters={"get_time": <filter>}). - Have the model call the tool (or invoke the tool schema's handler directly, as the script below does — the same path an LLM service takes:
schema.handler→_ToolWrapperWithCleanup.__call__→_tool_wrapper→_call_tool→_call_tool_text). - Return a
dict(orlist,None, or"") from the filter. - Observe the value delivered to the model via
result_callback/FunctionCallResultFrame.
Minimal reproducible example
Only the MCP transport and session are faked, and every call_tool succeeds, so the script isolates the post-filter logic in _call_tool_text:
"""tools_output_filters: is a non-string / empty-string filter result delivered to the model?"""
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from mcp.client.session_group import StreamableHttpParameters
from mcp.types import CallToolResult, TextContent
from pipecat.services import mcp_service
from pipecat.services.mcp_service import MCPClient
def make_tool(name):
return SimpleNamespace(
name=name, description="A tool.", input_schema={"properties": {}, "required": []}
)
class FakeSession:
"""MCP session whose tool calls always succeed with a text result."""
def __init__(self, result_text):
self._result_text = result_text
self.calls = [] # (name, arguments)
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def initialize(self):
pass
async def list_tools(self):
return SimpleNamespace(tools=[make_tool("get_time")])
async def call_tool(self, name, arguments=None):
self.calls.append((name, arguments))
return CallToolResult(
content=[TextContent(type="text", text=self._result_text)]
)
class FakeTransport:
def __init__(self, session):
self._session = session
async def __aenter__(self):
return (MagicMock(), MagicMock())
async def __aexit__(self, *exc):
return False
async def run_case(label, output_filter=None):
session = FakeSession("2024-01-01T12:00:00Z")
ctx = patch.multiple(
mcp_service,
_streamable_http_transport=lambda params: FakeTransport(session),
ClientSession=lambda read, write: session,
)
ctx.start()
try:
kwargs = {}
if output_filter is not None:
kwargs["tools_output_filters"] = {"get_time": output_filter}
client = MCPClient(
server_params=StreamableHttpParameters(url="http://test/mcp"), **kwargs
)
try:
tools_schema = await client.tools()
schema = next(s for s in tools_schema.standard_tools if s.name == "get_time")
captured = []
async def result_callback(value):
captured.append(value)
params = SimpleNamespace(
function_name="get_time",
tool_call_id="call-1",
arguments={},
result_callback=result_callback,
)
await schema.handler(params) # the exact path an LLM service takes
print(f"--- {label} ---")
print(f"session.call_tool calls (all succeeded): {session.calls}")
print(f"tool result returned to the model: {captured[0]!r}")
print()
finally:
await client.close()
finally:
ctx.stop()
def raising_filter(text):
raise RuntimeError("filter exploded")
async def main():
await run_case("no filter (baseline)")
await run_case("filter returns a dict (documented 'any type')", lambda text: {"iso": text})
await run_case("filter returns a list", lambda text: [text])
await run_case("filter returns empty string (fully redacted output)", lambda text: "")
await run_case("filter returns None", lambda text: None)
await run_case("filter raises (control: genuine filter failure)", raising_filter)
asyncio.run(main())Expected behavior
Per the documented contract (src/pipecat/services/mcp_service.py:216, :225-226), a filter may return any type, and the framework supports any-type tool results end-to-end: the result callback takes result: Any (src/pipecat/services/llm_service.py:105-106) and the LLM context aggregator serializes non-string results with json.dumps(frame.result, ensure_ascii=False) (src/pipecat/processors/aggregators/llm_response_universal.py:2119).
So:
- a successful call whose filter returns a
dict/list/Noneshould be delivered to the model (serialized downstream), not replaced with a failure message; - a successful call whose filter returns
""(e.g. the filter redacts the whole output) should deliver"", not a failure message; "Sorry, could not call the mcp tool"should be reserved for actual errors — the call failing or the filter raising.
Actual behavior
Verbatim output of the script above on main @ a3b149f:
--- no filter (baseline) ---
session.call_tool calls (all succeeded): [('get_time', {})]
tool result returned to the model: '2024-01-01T12:00:00Z'
--- filter returns a dict (documented 'any type') ---
session.call_tool calls (all succeeded): [('get_time', {})]
tool result returned to the model: 'Sorry, could not call the mcp tool'
--- filter returns a list ---
session.call_tool calls (all succeeded): [('get_time', {})]
tool result returned to the model: 'Sorry, could not call the mcp tool'
--- filter returns empty string (fully redacted output) ---
session.call_tool calls (all succeeded): [('get_time', {})]
tool result returned to the model: 'Sorry, could not call the mcp tool'
--- filter returns None ---
session.call_tool calls (all succeeded): [('get_time', {})]
tool result returned to the model: 'Sorry, could not call the mcp tool'
--- filter raises (control: genuine filter failure) ---
session.call_tool calls (all succeeded): [('get_time', {})]
tool result returned to the model: 'Sorry, could not call the mcp tool'
The dict case is indistinguishable from a genuinely failing filter — the filter's output is computed, logged, then discarded and replaced.
Logs
Log trail for the dict case — note the filter output is computed at :605 and then silently replaced by the :611 check:
DEBUG | pipecat.services.mcp_service:_call_tool_text:592 - Tool response chunk 0: 2024-01-01T12:00:00Z
DEBUG | pipecat.services.mcp_service:_call_tool_text:605 - Final response (after filter): {'iso': '2024-01-01T12:00:00Z'}
# ...and the model still receives: 'Sorry, could not call the mcp tool'Before you submit
- I have personally reproduced this issue on the latest Pipecat release (or
main). - I have searched existing issues for this bug.
- If AI tools helped write this report, I have verified the behavior myself and will personally answer follow-up questions.
AI tools helped draft this report; I ran the reproduction above myself on main @ a3b149f and verified the cited lines in that checkout.
Related: tools_output_filters was introduced in #3152 (merged 2025-12-05); the only existing test for filters is string-to-string (tests/test_mcp_service.py:271, test_handler_applies_output_filter), so this case is untested. I found no existing issue or PR covering it. Happy to open a PR — after applying the filter, only substitute the failure message when a real error occurred, and let a successful call's filter output stand.
Activity
BlueX888 commentedon Oct 11, 2026
Reproduced this. Root cause: src/pipecat/services/mcp_service.py:611 —
if isinstance(response, str) and response:gates the just-filtered response, so on a successful call any non-string or empty-string filter result falls into the else at :614-615 and is replaced witherror_msg or "Sorry, could not call the mcp tool"(error_msg is None on success)Minimal reproduction:
Observed output:
Fix is ready on
BlueX888:fix/prep-mcp-output-filter-clobbers-result(commit13a9d69a). Track whether a filter ran without error and, on a successful call, deliver its output as-is whatever the type, reserving the failure line for real errors (the call failing or the filter raising).Verified with:
uv run pytest tests/test_mcp_service.py -q — 57 passedHappy to open the PR once you confirm the approach or assign this to me.