Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/agents/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from ..run_context import RunContextWrapper
from ..tool import ToolErrorFunction
from ..tool_guardrails import ToolInputGuardrail, ToolOutputGuardrail
from ..util._approvals import evaluate_needs_approval_setting
from ..util._types import MaybeAwaitable
from . import _compat as mcp_compat
from ._compat import (
Expand Down Expand Up @@ -843,10 +844,7 @@ def _get_needs_approval_for_tool(
async def _needs_approval(
run_context: RunContextWrapper[Any], _args: dict[str, Any], _call_id: str
) -> bool:
result = policy(run_context, agent, tool)
if inspect.isawaitable(result):
result = await result
return bool(result)
return await evaluate_needs_approval_setting(policy, run_context, agent, tool)

return _needs_approval

Expand Down
2 changes: 1 addition & 1 deletion src/agents/util/_approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ async def evaluate_needs_approval_setting(
maybe_result = needs_approval_setting(*args)
if inspect.isawaitable(maybe_result):
maybe_result = await maybe_result
return bool(maybe_result)
return maybe_result if isinstance(maybe_result, bool) else True
if strict:
raise UserError(
f"Invalid needs_approval value: expected a bool or callable, "
Expand Down
29 changes: 29 additions & 0 deletions tests/mcp/test_mcp_approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,32 @@ async def require_approval(
{"needs_approval": True},
{"needs_approval": False},
]


@pytest.mark.asyncio
@pytest.mark.parametrize("async_policy", [False, True], ids=["sync", "async"])
async def test_mcp_require_approval_callable_non_bool_fails_closed(async_policy: bool):
"""Callable policies that do not return bool must still require approval."""

def require_approval(_run_context, _agent, _tool):
return None

async def async_require_approval(_run_context, _agent, _tool):
return None

policy = async_require_approval if async_policy else require_approval
server = FakeMCPServer(require_approval=policy)
server.add_tool("guarded", {"type": "object", "properties": {}})
model = ScriptedModel()
agent = Agent(name="TestAgent", model=model, mcp_servers=[server])

queue_function_call_and_text(
model,
get_function_tool_call("guarded", "{}"),
followup=[get_text_message("done")],
)

first = await Runner.run(agent, "call guarded")

assert first.interruptions, "non-bool MCP policy result must fail closed"
assert server.tool_calls == []
27 changes: 27 additions & 0 deletions tests/test_run_step_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -3254,6 +3254,33 @@ async def test_execute_tools_handles_tool_approval_items(
assert_single_approval_interruption(result, tool_name=scenario.expected_tool_name)


@pytest.mark.parametrize("policy_result", [None, "approve"], ids=["none", "truthy-non-bool"])
@pytest.mark.asyncio
async def test_callable_needs_approval_non_bool_result_fails_closed(policy_result: Any) -> None:
tool_calls: list[str] = []

async def async_needs_approval(
_context: RunContextWrapper[Any], _args: dict[str, Any], _call_id: str
) -> Any:
return policy_result

@function_tool(name_override="sensitive_tool", needs_approval=async_needs_approval)
def sensitive_tool() -> str:
tool_calls.append("ran")
return "sensitive result"

agent = make_agent(tools=[sensitive_tool])
tool_call = make_function_tool_call("sensitive_tool", arguments="{}")
processed_response = make_processed_response(
functions=[ToolRunFunction(function_tool=sensitive_tool, tool_call=tool_call)]
)

result = await run_execute_with_processed_response(agent, processed_response)

assert_single_approval_interruption(result, tool_name="sensitive_tool")
assert tool_calls == []


@pytest.mark.asyncio
async def test_execute_tools_preserves_synthetic_namespace_for_deferred_top_level_approval() -> (
None
Expand Down