Skip to content

[Bug] openai_agents: a callable tool_filter on a deprecated MCP provider's server makes tool listing retry forever #290

Closed

Description

@campoy

Package: temporalio-openai-agents 1.1.0 and main (3af00d9).

What happens

The OpenAI Agents SDK lets an MCP server take a callable tool_filter. The filter gets a
ToolFilterContext with the run context and the agent, so it can hide tools per agent or per run.
The SDK applies it when the agent lists the server's tools.

Through the plugin, a callable filter works when Workflow code sets it on
temporal_mcp_server(tool_filter=...). When it is set on the server that the Worker's factory
returns, temporal_mcp_server() rejects it with a clear, non-retryable error that points to
temporal_mcp_server().

The deprecated StatelessMCPServerProvider and StatefulMCPServerProvider have no such check. Their
list-tools Activity calls list_tools() on the factory's server with no run context or agent, so
the SDK raises UserError: run_context and agent are required for dynamic tool filtering on every
attempt. The error is retryable, so under the default config the Activity retries forever and the
run never reaches the model. With a bounded retry policy, the run fails with the SDK's UserError.

Reproduction

One file, no network or API key: an MCP server with two tools, safe and danger, run over stdio
with python -c, and a scripted model that calls safe. The filter hides danger from the agent.
It runs the agent natively, then in Workflows on a local dev server started by
WorkflowEnvironment.start_local(). It exits 1 while a run with the filter on the factory's server
is still retrying after 30 seconds.

"""A callable tool_filter on a deprecated MCP provider's server makes every tool listing fail.

python issue_repro.py    (starts a local Temporal dev server; no API key needed)

A callable tool_filter hides the `danger` tool from a native run, and
temporal_mcp_server(tool_filter=...) does the same in a Workflow. Set on the
server that the Worker's factory returns, it gets the run context and agent only
in the Workflow. temporal_mcp_server() rejects it at once with a clear error.
StatelessMCPServerProvider and StatefulMCPServerProvider call list_tools() with
no context, the Agents SDK raises UserError, and the list Activity retries it
forever: the run never reaches the model. Exits 1 while such a run is still
retrying after 30 seconds.
"""

import asyncio
import sys
import uuid
from typing import Any

from temporalio import workflow
from temporalio.client import Client, WorkflowFailureError
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker

with workflow.unsafe.imports_passed_through():
    from agents import Agent, Model, RunConfig, Runner
    from agents.mcp import MCPServerStdio, ToolFilterContext
    from mcp import Tool as MCPTool
    from temporalio.openai_agents import (
        OpenAIAgentsPlugin,
        StatefulMCPServerProvider,
        StatelessMCPServerProvider,
    )
    from temporalio.openai_agents.testing import ResponseBuilders, TestModelProvider
    from temporalio.openai_agents.workflow import (
        stateful_mcp_server,
        stateless_mcp_server,
        temporal_mcp_server,
    )

OFFERED: list[list[str]] = []  # the tool names the model is offered, per model call

# The MCP server, run over stdio with `python -c`: two tools.
SERVER = """
from mcp.server.mcpserver import MCPServer

app = MCPServer("tools")


@app.tool()
def safe() -> str:
    return "safe ran"


@app.tool()
def danger() -> str:
    return "danger ran"


app.run("stdio")
"""


def drop_danger(context: ToolFilterContext, tool: MCPTool) -> bool:
    """The documented dynamic filter shape: it reads the agent from the context."""
    return not (context.agent.name == "helper" and tool.name == "danger")


def stdio_server(**options: Any) -> MCPServerStdio:
    return MCPServerStdio(
        params={"command": sys.executable, "args": ["-c", SERVER]}, name="tools", **options
    )


def filtered_server() -> MCPServerStdio:
    return stdio_server(tool_filter=drop_danger)


class SafeModel(Model):
    """Calls `safe` if it is offered, then answers with the tool's output."""

    async def get_response(self, system_instructions, input, model_settings, tools, *args, **kwargs):
        items = [] if isinstance(input, str) else input
        outputs = [i for i in items if isinstance(i, dict) and i.get("type") == "function_call_output"]
        if outputs:
            return ResponseBuilders.output_message(f"done: {outputs[-1]['output']}")
        OFFERED.append(sorted(t.name for t in tools))
        return ResponseBuilders.tool_call("{}", "safe")

    def stream_response(self, *args, **kwargs):
        raise NotImplementedError


@workflow.defn
class ToolsWorkflow:
    @workflow.run
    async def run(self, case: str) -> str:
        if case == "temporal_mcp_server(tool_filter=)":
            server = temporal_mcp_server("tools", tool_filter=drop_danger)
        elif case == "temporal_mcp_server(), filter on factory":
            server = temporal_mcp_server("tools-filtered")
        elif case == "stateless_mcp_server(), filter on factory":
            server = stateless_mcp_server("tools-filtered")
        else:
            async with stateful_mcp_server("tools-filtered") as stateful:
                result = await Runner.run(Agent(name="helper", mcp_servers=[stateful]), "Go.")
            return result.final_output
        result = await Runner.run(Agent(name="helper", mcp_servers=[server]), "Go.")
        return result.final_output


async def main() -> int:
    async with filtered_server() as server:
        result = await Runner.run(Agent(name="helper", mcp_servers=[server]), "Go.",
                                  run_config=RunConfig(model_provider=TestModelProvider(SafeModel())))
    print(f"{'native MCPServerStdio(tool_filter=)':45} offered={OFFERED[-1]} output={result.final_output!r}")

    plugin = OpenAIAgentsPlugin(
        model_provider=TestModelProvider(SafeModel()),
        mcp_servers={"tools": stdio_server, "tools-filtered": filtered_server},
        mcp_server_providers=[
            StatelessMCPServerProvider("tools-filtered", filtered_server),
            StatefulMCPServerProvider("tools-filtered", lambda _: filtered_server()),
        ],
    )
    retrying = 0
    async with await WorkflowEnvironment.start_local() as env:
        client = Client(**{**env.client.config(), "plugins": [plugin]})
        async with Worker(client, task_queue="tools", workflows=[ToolsWorkflow]):
            for case in ("temporal_mcp_server(tool_filter=)",
                         "temporal_mcp_server(), filter on factory",
                         "stateless_mcp_server(), filter on factory",
                         "stateful_mcp_server(), filter on factory"):
                calls = len(OFFERED)
                handle = await client.start_workflow(
                    ToolsWorkflow.run, case, id=f"tools-{uuid.uuid4()}", task_queue="tools")
                try:
                    output = await asyncio.wait_for(handle.result(), timeout=30)
                    outcome = f"offered={OFFERED[-1]} output={output!r}"
                except WorkflowFailureError as err:
                    cause: BaseException = err
                    while getattr(cause, "cause", None) is not None:
                        cause = cause.cause  # type: ignore[attr-defined]
                    outcome = f"failed: {cause}"
                except asyncio.TimeoutError:
                    pending = (await handle.describe()).raw_description.pending_activities
                    await handle.terminate("still retrying after 30 seconds")
                    retrying += 1
                    outcome = "still running after 30 s: " + "; ".join(
                        f"{p.activity_type.name} attempt {p.attempt}, last failure "
                        f"{p.last_failure.application_failure_info.type}: {p.last_failure.message}"
                        for p in pending)
                print(f"{case:45} {outcome} model_calls={len(OFFERED) - calls}")
    return 1 if retrying else 0


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))

Output on main at 3af00d9 and on temporalio-openai-agents 1.1.0 (temporalio 1.35.0,
openai-agents 0.22.3, mcp 2.3.0, Python 3.13.16); both give the same lines:

native MCPServerStdio(tool_filter=)           offered=['safe'] output="done: [{'type': 'input_text', 'text': 'safe ran'}]"
temporal_mcp_server(tool_filter=)             offered=['safe'] output="done: [{'text': 'safe ran', 'type': 'input_text'}]" model_calls=1
temporal_mcp_server(), filter on factory      failed: MCP server 'tools-filtered' sets a callable tool_filter on its worker-side MCPServer, which cannot be applied there because the run context and agent it receives exist only in the workflow; pass the filter to temporal_mcp_server() instead model_calls=0
stateless_mcp_server(), filter on factory     still running after 30 s: tools-filtered-stateless-list-tools attempt 6, last failure UserError: run_context and agent are required for dynamic tool filtering model_calls=0
stateful_mcp_server(), filter on factory      still running after 30 s: tools-filtered-stateful-server-session attempt 1, last failure : ; tools-filtered-stateful-list-tools attempt 6, last failure UserError: run_context and agent are required for dynamic tool filtering model_calls=0
exit=1

The first three lines are the controls: the native run and temporal_mcp_server(tool_filter=...)
hide danger and complete, and temporal_mcp_server() rejects the filter on the factory's server
at once. The last two set the same filter on the factory's server of the deprecated providers: the
list Activity is at its sixth attempt with the same UserError, and the model was never called.

Expected

The same as temporal_mcp_server(): a clear, non-retryable error that says where to put the filter,
instead of a run that retries forever.

Impact

A run whose MCP factory sets a callable filter never makes progress under the default config. Each
attempt connects to the MCP server and lists its tools before the filter fails. Nothing in the error
says that the filter is the problem or where to move it.

Proposed fix

Run the existing tool_filter check where the deprecated providers create the factory's server, so
every MCP Activity for that server fails with the same non-retryable error. That is what
temporal_mcp_server() does, and what #275 does for a require_approval policy on these routes.
Static filters keep working. Moving the check into _mcp.py keeps it importable with MCP v1, which
the deprecated providers still support.

This is a breaking change. A Workflow that uses such a server only for direct tool calls or prompts
never lists tools, so it completes today; natively, too, the filter applies only to tool listing.
With this change it fails at its first MCP call. Runs where an agent lists the tools already fail
today.

Making the callable filter work on these routes would need a new tool_filter option on
stateless_mcp_server() and stateful_mcp_server(); temporal_mcp_server(tool_filter=...) already
provides it for new code. A PR follows.

Activity

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