Skip to content

[Bug] openai_agents: tool guardrails on the MCP factory's server are silently skipped #303

Open

Description

@campoy

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

What happens

From openai-agents 0.22.3, an MCP server takes tool_input_guardrails and tool_output_guardrails, applied to every tool on that server. Natively, a guardrail that rejects a call stops the tool. Through the plugin, guardrails passed to temporal_mcp_server(...) work the same way. Guardrails set on the server that the Worker's factory returns are skipped on all three routes: the tool runs, and nothing fails or warns.

Cause: the agent builds its tools from the MCPServer it holds, which in a Workflow is the plugin's Workflow-side server. The factory's server is used only inside the MCP Activities, where its guardrails are never read.

Reproduction

One file, no network or API key: an MCP server with one wire tool, run over stdio with python -c, a guardrail that rejects every wire call, and a scripted model that calls wire. It exits 1 while a guardrail on the factory's server lets the tool run.

"""Tool guardrails on the MCP factory's server are silently skipped.

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

With openai-agents 0.22.3, an MCP server takes tool_input_guardrails. A guardrail
that rejects every `wire` call stops the tool in a native run, and
temporal_mcp_server(tool_input_guardrails=...) does the same in a Workflow. Set
on the server that the Worker's factory returns, the guardrail is skipped on all
three routes: the tool runs and nothing fails or warns. Exits 1 while a
guardrail on the factory's server lets the tool run.
"""

import asyncio
import sys
import uuid
from datetime import timedelta
from typing import Any

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

with workflow.unsafe.imports_passed_through():
    from agents import (
        Agent,
        Model,
        RunConfig,
        Runner,
        ToolGuardrailFunctionOutput,
        ToolInputGuardrailData,
        tool_input_guardrail,
    )
    from agents.mcp import MCPServerStdio
    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,
    )

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

app = MCPServer("bank")


@app.tool()
def wire(amount: int) -> str:
    return "wired"


app.run("stdio")
"""


@tool_input_guardrail
def block_wire(data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
    return ToolGuardrailFunctionOutput.reject_content("blocked by guardrail")


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


def guarded_server() -> MCPServerStdio:
    return bank_server(tool_input_guardrails=[block_wire])


class WireModel(Model):
    """Calls `wire`, then answers with what the tool returned."""

    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(str(outputs[-1]["output"]))
        return ResponseBuilders.tool_call('{"amount": 5}', "wire")

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


@workflow.defn
class WireWorkflow:
    @workflow.run
    async def run(self, case: str) -> str:
        if case == "temporal_mcp_server(tool_input_guardrails=)":
            server = temporal_mcp_server("bank", tool_input_guardrails=[block_wire])
        elif case == "temporal_mcp_server(), guardrail on factory":
            server = temporal_mcp_server("bank-guarded")
        elif case == "stateless_mcp_server(), guardrail on factory":
            server = stateless_mcp_server("bank-guarded")
        else:
            async with stateful_mcp_server("bank-guarded") as stateful:
                result = await Runner.run(Agent(name="bank", mcp_servers=[stateful]), "Wire 5.")
            return result.final_output
        result = await Runner.run(Agent(name="bank", mcp_servers=[server]), "Wire 5.")
        return result.final_output


async def main() -> int:
    async with guarded_server() as server:
        result = await Runner.run(Agent(name="bank", mcp_servers=[server]), "Wire 5.",
                                  run_config=RunConfig(model_provider=TestModelProvider(WireModel())))
    print(f"{'native MCPServerStdio(tool_input_guardrails=)':47} tool output: {result.final_output}")

    plugin = OpenAIAgentsPlugin(
        model_provider=TestModelProvider(WireModel()),
        mcp_servers={"bank": bank_server, "bank-guarded": guarded_server},
        mcp_server_providers=[
            StatelessMCPServerProvider("bank-guarded", guarded_server),
            StatefulMCPServerProvider("bank-guarded", lambda _: guarded_server()),
        ],
    )
    unguarded = 0
    async with await WorkflowEnvironment.start_local() as env:
        client = Client(**{**env.client.config(), "plugins": [plugin]})
        async with Worker(client, task_queue="bank", workflows=[WireWorkflow]):
            for case in ("temporal_mcp_server(tool_input_guardrails=)",
                         "temporal_mcp_server(), guardrail on factory",
                         "stateless_mcp_server(), guardrail on factory",
                         "stateful_mcp_server(), guardrail on factory"):
                output = await client.execute_workflow(
                    WireWorkflow.run, case, id=f"wire-{uuid.uuid4()}", task_queue="bank",
                    execution_timeout=timedelta(seconds=60))
                if "on factory" in case and "wired" in output:
                    unguarded += 1
                print(f"{case:47} tool output: {output}")
    return 1 if unguarded 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_input_guardrails=)   tool output: blocked by guardrail
temporal_mcp_server(tool_input_guardrails=)     tool output: blocked by guardrail
temporal_mcp_server(), guardrail on factory     tool output: [{'text': 'wired', 'type': 'input_text'}]
stateless_mcp_server(), guardrail on factory    tool output: [{'text': 'wired', 'type': 'input_text'}]
stateful_mcp_server(), guardrail on factory     tool output: [{'text': 'wired', 'type': 'input_text'}]
exit=1

The first two lines are the controls.

Expected

A run should not call a tool that its configuration says a guardrail must check. If the plugin cannot use the factory's guardrails, it should reject them, as #275 does for a require_approval policy there.

Impact

A guardrail meant to block unsafe tool input, such as a payment above a limit or personal data, is skipped, and the run looks normal.

Proposed fix

Reject non-empty tool_input_guardrails or tool_output_guardrails on the factory's server, next to #275's require_approval check, with an error that points to temporal_mcp_server(...). Runs that complete today with guardrails on the factory's server would fail, so this is a breaking change. The deprecated stateless_mcp_server() and stateful_mcp_server() take no guardrail option; adding one, as #220 did for require_approval, would give those routes a working place for them.

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