Skip to content

MCPClient tools_output_filters: non-string or empty-string filter results are discarded and the model is told the tool call failed #6111

Open

Description

@BlueX888

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

  1. Construct MCPClient(server_params=..., tools_output_filters={"get_time": <filter>}).
  2. 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).
  3. Return a dict (or list, None, or "") from the filter.
  4. 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/None should 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

  1. BlueX888 commented on Oct 11, 2026

    @BlueX888
    Author

    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 with error_msg or "Sorry, could not call the mcp tool" (error_msg is None on success)

    Minimal reproduction:

    python3 — the minimal reproducible example embedded in this issue: a faked MCP session whose call_tool always succeeds, MCPClient(..., tools_output_filters={"get_time": lambda text: {"iso": text}}), then await schema.handler(params) (the same path an LLM service takes) and read the value delivered to result_callback
    

    Observed output:

    --- 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'
    

    Fix is ready on BlueX888:fix/prep-mcp-output-filter-clobbers-result (commit 13a9d69a). 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 passed

    Happy to open the PR once you confirm the approach or assign this to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions