import asyncio import pytest from mcp.types import Tool as MCPTool from agents import Agent, RunContextWrapper, Runner from agents.exceptions import UserError from ..fake_model import FakeModel from ..test_responses import get_function_tool_call, get_text_message from ..utils.hitl import queue_function_call_and_text, resume_after_first_approval from .helpers import FakeMCPServer @pytest.mark.asyncio async def test_mcp_require_approval_pauses_and_resumes(): """MCP servers should honor require_approval for non-hosted tools.""" server = FakeMCPServer(require_approval="always") server.add_tool("add", {"type": "object", "properties": {}}) model = FakeModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( model, get_function_tool_call("add", "{}"), followup=[get_text_message("done")], ) first = await Runner.run(agent, "call add") assert first.interruptions, "MCP tool should request approval" assert first.interruptions[0].tool_name == "add" resumed = await resume_after_first_approval(agent, first, always_approve=True) assert not resumed.interruptions assert server.tool_calls == ["add"] assert resumed.final_output == "done" @pytest.mark.asyncio async def test_mcp_require_approval_tool_lists(): """TS-style requireApproval toolNames should map to needs_approval.""" require_approval: dict[str, object] = { "always": {"tool_names": ["add"]}, "never": {"tool_names": ["noop"]}, } server = FakeMCPServer(require_approval=require_approval) server.add_tool("add", {"type": "object", "properties": {}}) model = FakeModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( model, get_function_tool_call("add", "{}"), followup=[get_text_message("done")], ) first = await Runner.run(agent, "call add") assert first.interruptions, "add should require approval via require_approval toolNames" resumed = await resume_after_first_approval(agent, first, always_approve=True) assert resumed.final_output == "done" assert server.tool_calls == ["add"] @pytest.mark.asyncio async def test_mcp_require_approval_tool_mapping(): """Tool-name require_approval mappings should map to needs_approval.""" require_approval = {"add": "always", "noop": "never"} server = FakeMCPServer(require_approval=require_approval) server.add_tool("add", {"type": "object", "properties": {}}) model = FakeModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( model, get_function_tool_call("add", "{}"), followup=[get_text_message("done")], ) first = await Runner.run(agent, "call add") assert first.interruptions, "add should require approval via require_approval mapping" resumed = await resume_after_first_approval(agent, first, always_approve=True) assert resumed.final_output == "done" assert server.tool_calls == ["add"] @pytest.mark.asyncio async def test_mcp_require_approval_mapping_allows_policy_keyword_tool_names(): """Tool-name mappings should treat literal 'always'/'never' as tool names.""" require_approval = {"always": "always", "never": "never"} server = FakeMCPServer(require_approval=require_approval) server.add_tool("always", {"type": "object", "properties": {}}) server.add_tool("never", {"type": "object", "properties": {}}) model = FakeModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( model, get_function_tool_call("always", "{}"), followup=[get_text_message("done")], ) first = await Runner.run(agent, "call always") assert first.interruptions, "tool named 'always' should require approval" assert first.interruptions[0].tool_name == "always" resumed = await resume_after_first_approval(agent, first, always_approve=True) assert resumed.final_output == "done" queue_function_call_and_text( model, get_function_tool_call("never", "{}"), followup=[get_text_message("done")], ) second = await Runner.run(agent, "call never") assert not second.interruptions, "tool named 'never' should not require approval" @pytest.mark.parametrize( ("require_approval", "message"), [ ("alwyas", "expected 'always' or 'never'"), ({"delete": "alwyas"}, "delete"), ( { "always": {"tool_names": ["delete"]}, "never": {"tool_names": ["delete"]}, }, "both always and never", ), ], ) def test_mcp_require_approval_rejects_invalid_fail_open_policies(require_approval, message): """Invalid MCP approval policies should not silently disable approvals.""" with pytest.raises(UserError, match=message): FakeMCPServer(require_approval=require_approval) @pytest.mark.asyncio async def test_mcp_require_approval_callable_can_allow_and_block_by_tool_name(): """Callable policies should decide approval dynamically for each MCP tool.""" seen: list[str] = [] def require_approval( _run_context: RunContextWrapper[object | None], _agent: Agent, tool: MCPTool, ) -> bool: seen.append(tool.name) return tool.name == "guarded" server = FakeMCPServer(require_approval=require_approval) server.add_tool("guarded", {"type": "object", "properties": {}}) server.add_tool("safe", {"type": "object", "properties": {}}) model = FakeModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( model, get_function_tool_call("guarded", "{}"), followup=[get_text_message("guarded done")], ) first = await Runner.run(agent, "call guarded") assert first.interruptions, "guarded should require approval via callable policy" assert first.interruptions[0].tool_name == "guarded" resumed = await resume_after_first_approval(agent, first, always_approve=True) assert resumed.final_output == "guarded done" queue_function_call_and_text( model, get_function_tool_call("safe", "{}"), followup=[get_text_message("safe done")], ) second = await Runner.run(agent, "call safe") assert not second.interruptions, "safe should bypass approval via callable policy" assert second.final_output == "safe done" assert seen == ["guarded", "guarded", "safe"] @pytest.mark.asyncio async def test_mcp_require_approval_async_callable_uses_run_context(): """Async callable policies should receive the run context and be awaited.""" seen_contexts: list[object | None] = [] async def require_approval( run_context: RunContextWrapper[dict[str, bool] | None], _agent: Agent, _tool, ) -> bool: seen_contexts.append(run_context.context) await asyncio.sleep(0) return bool(run_context.context and run_context.context.get("needs_approval")) server = FakeMCPServer(require_approval=require_approval) server.add_tool("conditional", {"type": "object", "properties": {}}) model = FakeModel() agent = Agent(name="TestAgent", model=model, mcp_servers=[server]) queue_function_call_and_text( model, get_function_tool_call("conditional", "{}"), followup=[get_text_message("approved path")], ) first = await Runner.run(agent, "call conditional", context={"needs_approval": True}) assert first.interruptions, "run context should be able to trigger approval" resumed = await resume_after_first_approval(agent, first, always_approve=True) assert resumed.final_output == "approved path" queue_function_call_and_text( model, get_function_tool_call("conditional", "{}"), followup=[get_text_message("no approval path")], ) second = await Runner.run(agent, "call conditional", context={"needs_approval": False}) assert not second.interruptions, "run context should be able to skip approval" assert second.final_output == "no approval path" assert seen_contexts == [ {"needs_approval": True}, {"needs_approval": True}, {"needs_approval": False}, ]