Skip to content

Commit 28de365

Browse files
authored
fix: #3168 validate MCP require_approval policies (#3179)
1 parent a67d95f commit 28de365

2 files changed

Lines changed: 80 additions & 11 deletions

File tree

src/agents/mcp/server.py

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -391,8 +391,43 @@ def _normalize_needs_approval(
391391
if require_approval is None:
392392
return False
393393

394-
def _to_bool(value: str) -> bool:
395-
return value == "always"
394+
def _to_bool(value: object, *, location: str) -> bool:
395+
if value == "always":
396+
return True
397+
if value == "never":
398+
return False
399+
raise UserError(
400+
f"Invalid require_approval value at {location}: "
401+
f"expected 'always' or 'never', got {value!r}."
402+
)
403+
404+
def _validate_tool_names(value: object, *, location: str) -> list[str]:
405+
if not isinstance(value, list):
406+
raise UserError(
407+
f"Invalid require_approval tool_names at {location}: "
408+
f"expected a list of strings, got {type(value).__name__}."
409+
)
410+
411+
tool_names: list[str] = []
412+
for index, tool_name in enumerate(value):
413+
if not isinstance(tool_name, str):
414+
raise UserError(
415+
f"Invalid require_approval tool name at {location}[{index}]: "
416+
f"expected a string, got {type(tool_name).__name__}."
417+
)
418+
tool_names.append(tool_name)
419+
return tool_names
420+
421+
def _get_tool_names_entry(value: object, *, policy: str) -> list[str]:
422+
if not isinstance(value, dict):
423+
raise UserError(
424+
f"Invalid require_approval.{policy}: "
425+
f"expected an object with tool_names, got {type(value).__name__}."
426+
)
427+
return _validate_tool_names(
428+
value.get("tool_names", []),
429+
location=f"require_approval.{policy}.tool_names",
430+
)
396431

397432
def _is_tool_list_schema(value: object) -> bool:
398433
if not isinstance(value, dict):
@@ -408,24 +443,36 @@ def _is_tool_list_schema(value: object) -> bool:
408443
if isinstance(require_approval, dict) and _is_tool_list_schema(require_approval):
409444
always_entry: RequireApprovalToolList | Any = require_approval.get("always", {})
410445
never_entry: RequireApprovalToolList | Any = require_approval.get("never", {})
411-
always_names = (
412-
always_entry.get("tool_names", []) if isinstance(always_entry, dict) else []
413-
)
414-
never_names = never_entry.get("tool_names", []) if isinstance(never_entry, dict) else []
446+
invalid_keys = sorted(set(require_approval) - {"always", "never"})
447+
if invalid_keys:
448+
raise UserError(
449+
"Invalid require_approval tool list policy: "
450+
f"unexpected keys {invalid_keys!r}; expected only 'always' and 'never'."
451+
)
452+
always_names = _get_tool_names_entry(always_entry, policy="always")
453+
never_names = _get_tool_names_entry(never_entry, policy="never")
454+
overlapping_names = sorted(set(always_names) & set(never_names))
455+
if overlapping_names:
456+
raise UserError(
457+
"Invalid require_approval tool list policy: "
458+
f"tool names cannot appear in both always and never: {overlapping_names!r}."
459+
)
415460
tool_list_mapping: dict[str, bool] = {}
416461
for name in always_names:
417-
tool_list_mapping[str(name)] = True
462+
tool_list_mapping[name] = True
418463
for name in never_names:
419-
tool_list_mapping[str(name)] = False
464+
tool_list_mapping[name] = False
420465
return tool_list_mapping
421466

422467
if isinstance(require_approval, dict):
423468
tool_mapping: dict[str, bool] = {}
424469
for name, value in require_approval.items():
425470
if isinstance(value, bool):
426471
tool_mapping[str(name)] = value
427-
elif isinstance(value, str) and value in ("always", "never"):
428-
tool_mapping[str(name)] = _to_bool(value)
472+
else:
473+
tool_mapping[str(name)] = _to_bool(
474+
value, location=f"require_approval[{name!r}]"
475+
)
429476
return tool_mapping
430477

431478
if callable(require_approval):
@@ -434,7 +481,7 @@ def _is_tool_list_schema(value: object) -> bool:
434481
if isinstance(require_approval, bool):
435482
return require_approval
436483

437-
return _to_bool(require_approval)
484+
return _to_bool(require_approval, location="require_approval")
438485

439486
def _get_needs_approval_for_tool(
440487
self,

tests/mcp/test_mcp_approval.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from mcp.types import Tool as MCPTool
55

66
from agents import Agent, RunContextWrapper, Runner
7+
from agents.exceptions import UserError
78

89
from ..fake_model import FakeModel
910
from ..test_responses import get_function_tool_call, get_text_message
@@ -127,6 +128,27 @@ async def test_mcp_require_approval_mapping_allows_policy_keyword_tool_names():
127128
assert not second.interruptions, "tool named 'never' should not require approval"
128129

129130

131+
@pytest.mark.parametrize(
132+
("require_approval", "message"),
133+
[
134+
("alwyas", "expected 'always' or 'never'"),
135+
({"delete": "alwyas"}, "delete"),
136+
(
137+
{
138+
"always": {"tool_names": ["delete"]},
139+
"never": {"tool_names": ["delete"]},
140+
},
141+
"both always and never",
142+
),
143+
],
144+
)
145+
def test_mcp_require_approval_rejects_invalid_fail_open_policies(require_approval, message):
146+
"""Invalid MCP approval policies should not silently disable approvals."""
147+
148+
with pytest.raises(UserError, match=message):
149+
FakeMCPServer(require_approval=require_approval)
150+
151+
130152
@pytest.mark.asyncio
131153
async def test_mcp_require_approval_callable_can_allow_and_block_by_tool_name():
132154
"""Callable policies should decide approval dynamically for each MCP tool."""

0 commit comments

Comments
 (0)