Files
2026-07-13 12:39:17 +08:00

243 lines
8.2 KiB
Python

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},
]