243 lines
8.2 KiB
Python
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},
|
|
]
|