Honor read-only MCP tool hints in automatic approval (#60638)

## Why

MCP tools marked with `readOnlyHint: true` could still require approval in automatic mode when `destructiveHint` was also true.

## What changed

Give `readOnlyHint` precedence over `destructiveHint` and `openWorldHint` when deciding whether annotations require approval. Explicit prompt mode and strict automatic review continue to require their respective approval checks.

## Testing

Update unit tests for conflicting annotations, prompt mode, and strict automatic review. Extend app integration coverage to verify that read-only calls proceed while writes prompt in both automatic and writes approval modes.

GitOrigin-RevId: 11a930b857886bf41d09fd54c3a38607ae3d1b4e
This commit is contained in:
cazhang-oai
2026-11-10 18:33:19 +00:00
committed by copyberry
parent 4a784f63e8
commit 057d9140a8
3 changed files with 41 additions and 20 deletions
+1 -5
View File
@@ -2744,11 +2744,6 @@ fn project_mcp_tool_approval_config_folder(
}
fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool {
let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint);
if destructive_hint == Some(true) {
return true;
}
let read_only_hint = annotations
.and_then(|annotations| annotations.read_only_hint)
.unwrap_or(false);
@@ -2756,6 +2751,7 @@ fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool {
return false;
}
let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint);
destructive_hint.unwrap_or(true)
|| annotations
.and_then(|annotations| annotations.open_world_hint)
+11 -10
View File
@@ -402,9 +402,9 @@ fn approval_required_when_read_only_false_and_open_world() {
}
#[test]
fn approval_required_when_destructive_even_if_read_only_true() {
fn approval_not_required_when_read_only_even_if_destructive() {
let annotations = annotations(Some(true), Some(true), Some(true));
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false);
}
#[test]
@@ -3259,8 +3259,13 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
);
}
#[test_case::test_case(false, AppToolApproval::Approve; "approved tool")]
#[test_case::test_case(true, AppToolApproval::Auto; "read only tool")]
#[tokio::test]
async fn strict_auto_review_forces_guardian_for_mcp_policy_skip() {
async fn strict_auto_review_forces_guardian_for_mcp_policy_skip(
read_only: bool,
approval_mode: AppToolApproval,
) {
let server = start_mock_server().await;
let guardian_request_log = mount_sse_once(
&server,
@@ -3311,7 +3316,7 @@ async fn strict_auto_review_forces_guardian_for_mcp_policy_skip() {
arguments: Some(serde_json::json!({ "calendar_id": "primary" })),
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(Some(false), Some(true), Some(true))),
annotations: Some(annotations(Some(read_only), Some(true), Some(true))),
connector_id: None,
link_id: None,
connector_name: None,
@@ -3352,7 +3357,7 @@ async fn strict_auto_review_forces_guardian_for_mcp_policy_skip() {
host_cwd: &captured_mcp_config.host_cwd,
},
test_environment_id(&turn_context),
McpToolApprovalPolicy::for_server(AppToolApproval::Approve),
McpToolApprovalPolicy::for_server(approval_mode),
)
.await;
@@ -3494,11 +3499,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
arguments: None,
};
let metadata = McpToolApprovalMetadata {
annotations: Some(annotations(
Some(true),
/*destructive*/ None,
/*open_world*/ None,
)),
annotations: Some(annotations(Some(true), Some(true), Some(true))),
connector_id: None,
link_id: None,
connector_name: None,