Repository navigation
Description
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