Skip to content

Commit 7399c82

Browse files
committed
feat(mcp): preserve typed results and policy boundaries
1 parent 88fba0a commit 7399c82

14 files changed

Lines changed: 1137 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Preserved standard MCP tool metadata and call results end to end, including
13+
output schemas, annotations, icons, `_meta`, `structuredContent`, decoded
14+
images, embedded resources, and bounded content-addressed artifacts.
15+
16+
### Security
17+
18+
- Made MCP confirmation annotations escalation-only: tool metadata can require
19+
HITL but cannot weaken a host Allow/Ask/Deny decision.
20+
- Allowed an explicitly scoped delegated worker to see a parent-hidden tool
21+
while keeping both parent and worker execution policies authoritative.
22+
1023
## [5.3.5] - 2026-07-17
1124

1225
### Added

core/src/agent/tool_invoker.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ impl ScopedToolInvoker {
8383
tool_name: &invocation.name,
8484
args: &invocation.args,
8585
pre_tool_block,
86+
tool_requires_confirmation: self
87+
.agent
88+
.tool_executor
89+
.registry()
90+
.requires_confirmation(&invocation.name, &invocation.args),
8691
})
8792
.await
8893
}
@@ -395,7 +400,11 @@ impl ToolInvoker for ScopedToolInvoker {
395400
}
396401

397402
fn available_tools(&self) -> Vec<String> {
398-
self.agent.tool_executor.registry().list()
403+
let mut tools = self.agent.tool_executor.registry().list();
404+
if let Some(permission_checker) = &self.agent.config.permission_checker {
405+
tools.retain(|tool| permission_checker.expose_to_model(tool));
406+
}
407+
tools
399408
}
400409

401410
fn capabilities(&self, name: &str, args: &serde_json::Value) -> Option<ToolCapabilities> {

core/src/child_run.rs

Lines changed: 145 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
use crate::agent::AgentConfig;
3131
use crate::hitl::ConfirmationProvider;
3232
use crate::hooks::HookExecutor;
33-
use crate::permissions::{PermissionChecker, PermissionPolicy};
33+
use crate::permissions::{PermissionChecker, PermissionDecision, PermissionPolicy};
3434
use crate::security::SecurityProvider;
3535
use crate::skills::SkillRegistry;
3636
use std::sync::Arc;
@@ -60,6 +60,56 @@ pub struct ChildRunContext {
6060
pub budget_guard: Option<Arc<dyn crate::budget::BudgetGuard>>,
6161
}
6262

63+
struct DelegatedPermissionChecker {
64+
child: Arc<dyn PermissionChecker>,
65+
child_policy: Option<PermissionPolicy>,
66+
parent: Arc<dyn PermissionChecker>,
67+
parent_policy: Option<PermissionPolicy>,
68+
}
69+
70+
impl PermissionChecker for DelegatedPermissionChecker {
71+
fn expose_to_model(&self, tool_name: &str) -> bool {
72+
if !self.child.expose_to_model(tool_name) {
73+
return false;
74+
}
75+
76+
if self
77+
.child_policy
78+
.as_ref()
79+
.is_some_and(|policy| policy.declares_tool_access(tool_name))
80+
{
81+
// A worker's explicitly declared capability may cross a host's
82+
// ordinary parent-only visibility filter. A serializable parent
83+
// deny remains authoritative and keeps the tool hidden.
84+
return self
85+
.parent_policy
86+
.as_ref()
87+
.map(|policy| policy.expose_to_model(tool_name))
88+
.unwrap_or_else(|| self.parent.expose_to_model(tool_name));
89+
}
90+
91+
self.parent.expose_to_model(tool_name)
92+
}
93+
94+
fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
95+
stricter_decision(
96+
self.child.check(tool_name, args),
97+
self.parent.check(tool_name, args),
98+
)
99+
}
100+
}
101+
102+
const fn stricter_decision(
103+
left: PermissionDecision,
104+
right: PermissionDecision,
105+
) -> PermissionDecision {
106+
match (left, right) {
107+
(PermissionDecision::Deny, _) | (_, PermissionDecision::Deny) => PermissionDecision::Deny,
108+
(PermissionDecision::Ask, _) | (_, PermissionDecision::Ask) => PermissionDecision::Ask,
109+
(PermissionDecision::Allow, PermissionDecision::Allow) => PermissionDecision::Allow,
110+
}
111+
}
112+
63113
impl ChildRunContext {
64114
/// Apply inherited capabilities to a child AgentConfig.
65115
///
@@ -75,10 +125,26 @@ impl ChildRunContext {
75125
if config.skill_registry.is_none() {
76126
config.skill_registry = self.skill_registry.clone();
77127
}
78-
if config.permission_checker.is_none() {
79-
config.permission_checker = self.permission_checker.clone();
80-
config.permission_policy = self.permission_policy.clone();
81-
} else if config.permission_policy.is_none() {
128+
match (
129+
config.permission_checker.take(),
130+
self.permission_checker.clone(),
131+
) {
132+
(Some(child), Some(parent)) => {
133+
config.permission_checker = Some(Arc::new(DelegatedPermissionChecker {
134+
child,
135+
child_policy: config.permission_policy.clone(),
136+
parent,
137+
parent_policy: self.permission_policy.clone(),
138+
}));
139+
}
140+
(Some(child), None) => config.permission_checker = Some(child),
141+
(None, Some(parent)) => {
142+
config.permission_checker = Some(parent);
143+
config.permission_policy = self.permission_policy.clone();
144+
}
145+
(None, None) => {}
146+
}
147+
if config.permission_policy.is_none() {
82148
config.permission_policy = self.permission_policy.clone();
83149
}
84150
if config.tool_timeout_ms.is_none() {
@@ -110,3 +176,77 @@ impl ChildRunContext {
110176
}
111177
}
112178
}
179+
180+
#[cfg(test)]
181+
mod tests {
182+
use super::*;
183+
184+
#[derive(Clone)]
185+
struct ParentVisibility {
186+
policy: PermissionPolicy,
187+
hide_use_from_primary: bool,
188+
}
189+
190+
impl PermissionChecker for ParentVisibility {
191+
fn expose_to_model(&self, tool_name: &str) -> bool {
192+
!(self.hide_use_from_primary && tool_name.starts_with("mcp__use_"))
193+
&& self.policy.expose_to_model(tool_name)
194+
}
195+
196+
fn check(&self, tool_name: &str, args: &serde_json::Value) -> PermissionDecision {
197+
self.policy.check(tool_name, args)
198+
}
199+
}
200+
201+
fn delegated(
202+
child_policy: PermissionPolicy,
203+
parent_policy: PermissionPolicy,
204+
) -> DelegatedPermissionChecker {
205+
DelegatedPermissionChecker {
206+
child: Arc::new(child_policy.clone()),
207+
child_policy: Some(child_policy),
208+
parent: Arc::new(ParentVisibility {
209+
policy: parent_policy.clone(),
210+
hide_use_from_primary: true,
211+
}),
212+
parent_policy: Some(parent_policy),
213+
}
214+
}
215+
216+
#[test]
217+
fn explicitly_scoped_worker_can_see_parent_hidden_tool() {
218+
let mut child = PermissionPolicy::new().allow("mcp__use_*");
219+
child.default_decision = PermissionDecision::Deny;
220+
let parent = PermissionPolicy::new().allow("mcp__use_*");
221+
let checker = delegated(child, parent);
222+
223+
assert!(checker.expose_to_model("mcp__use_browser__browser_snapshot"));
224+
assert_eq!(
225+
checker.check("mcp__use_browser__browser_snapshot", &serde_json::json!({})),
226+
PermissionDecision::Allow
227+
);
228+
}
229+
230+
#[test]
231+
fn unrelated_worker_does_not_inherit_parent_hidden_use_tools() {
232+
let child = PermissionPolicy::new().allow("read(*)");
233+
let parent = PermissionPolicy::new().allow("mcp__use_*");
234+
let checker = delegated(child, parent);
235+
236+
assert!(!checker.expose_to_model("mcp__use_browser__browser_snapshot"));
237+
}
238+
239+
#[test]
240+
fn parent_deny_remains_authoritative_for_explicit_worker_capability() {
241+
let mut child = PermissionPolicy::new().allow("mcp__use_*");
242+
child.default_decision = PermissionDecision::Deny;
243+
let parent = PermissionPolicy::new().deny("mcp__use_ocr__ocr_extract");
244+
let checker = delegated(child, parent);
245+
246+
assert!(!checker.expose_to_model("mcp__use_ocr__ocr_extract"));
247+
assert_eq!(
248+
checker.check("mcp__use_ocr__ocr_extract", &serde_json::json!({})),
249+
PermissionDecision::Deny
250+
);
251+
}
252+
}

core/src/mcp/manager.rs

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
use crate::mcp::client::McpClient;
66
use crate::mcp::oauth;
77
use crate::mcp::protocol::{
8-
CallToolResult, McpServerConfig, McpTool, McpTransportConfig, OAuthConfig, ToolContent,
8+
CallToolResult, McpServerConfig, McpTool, McpTransportConfig, OAuthConfig,
99
};
1010
use crate::mcp::transport::http_sse::HttpSseTransport;
1111
use crate::mcp::transport::stdio::StdioTransport;
@@ -16,6 +16,8 @@ use std::collections::HashMap;
1616
use std::sync::Arc;
1717
use tokio::sync::RwLock;
1818

19+
pub use crate::mcp::result::tool_result_to_string;
20+
1921
/// MCP server status
2022
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
2123
pub struct McpServerStatus {
@@ -489,33 +491,6 @@ fn now_epoch_ms() -> u64 {
489491
.unwrap_or(0)
490492
}
491493

492-
/// Convert MCP tool result to string output
493-
pub fn tool_result_to_string(result: &CallToolResult) -> String {
494-
let mut output = String::new();
495-
496-
for content in &result.content {
497-
match content {
498-
ToolContent::Text { text } => {
499-
output.push_str(text);
500-
output.push('\n');
501-
}
502-
ToolContent::Image { data: _, mime_type } => {
503-
output.push_str(&format!("[Image: {}]\n", mime_type));
504-
}
505-
ToolContent::Resource { resource } => {
506-
if let Some(text) = &resource.text {
507-
output.push_str(text);
508-
output.push('\n');
509-
} else {
510-
output.push_str(&format!("[Resource: {}]\n", resource.uri));
511-
}
512-
}
513-
}
514-
}
515-
516-
output.trim_end().to_string()
517-
}
518-
519494
#[cfg(test)]
520495
#[path = "manager/tests.rs"]
521496
mod tests;

core/src/mcp/manager/tests.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use super::*;
2+
use crate::mcp::protocol::ToolContent;
23

34
#[test]
45
fn test_parse_tool_name() {
@@ -32,6 +33,7 @@ fn test_tool_result_to_string() {
3233
},
3334
],
3435
is_error: false,
36+
..CallToolResult::default()
3537
};
3638

3739
let output = tool_result_to_string(&result);
@@ -136,6 +138,7 @@ fn test_tool_result_to_string_single_text() {
136138
text: "Hello World".to_string(),
137139
}],
138140
is_error: false,
141+
..CallToolResult::default()
139142
};
140143
let output = tool_result_to_string(&result);
141144
assert_eq!(output, "Hello World");
@@ -153,6 +156,7 @@ fn test_tool_result_to_string_multiple_text() {
153156
},
154157
],
155158
is_error: false,
159+
..CallToolResult::default()
156160
};
157161
let output = tool_result_to_string(&result);
158162
assert!(output.contains("First line"));
@@ -164,6 +168,7 @@ fn test_tool_result_to_string_empty() {
164168
let result = CallToolResult {
165169
content: vec![],
166170
is_error: false,
171+
..CallToolResult::default()
167172
};
168173
let output = tool_result_to_string(&result);
169174
assert_eq!(output, "");
@@ -177,6 +182,7 @@ fn test_tool_result_to_string_image() {
177182
mime_type: "image/png".to_string(),
178183
}],
179184
is_error: false,
185+
..CallToolResult::default()
180186
};
181187
let output = tool_result_to_string(&result);
182188
assert!(output.contains("[Image: image/png]"));
@@ -195,6 +201,7 @@ fn test_tool_result_to_string_resource() {
195201
},
196202
}],
197203
is_error: false,
204+
..CallToolResult::default()
198205
};
199206
let output = tool_result_to_string(&result);
200207
assert!(output.contains("Resource content"));
@@ -222,6 +229,7 @@ fn test_tool_result_to_string_mixed_content() {
222229
},
223230
],
224231
is_error: false,
232+
..CallToolResult::default()
225233
};
226234
let output = tool_result_to_string(&result);
227235
assert!(output.contains("Text content"));

core/src/mcp/mod.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,15 @@ pub mod client;
6161
pub mod manager;
6262
pub mod oauth;
6363
pub mod protocol;
64+
mod result;
6465
pub mod tools;
6566
pub mod transport;
6667

6768
pub use client::McpClient;
68-
pub use manager::{tool_result_to_string, McpManager, McpServerStatus};
69+
pub use manager::{McpManager, McpServerStatus};
6970
pub use protocol::{
70-
CallToolResult, McpNotification, McpResource, McpServerConfig, McpTool, McpTransportConfig,
71-
OAuthConfig, ServerCapabilities, ToolContent,
71+
CallToolResult, McpNotification, McpResource, McpServerConfig, McpTool, McpToolAnnotations,
72+
McpTransportConfig, OAuthConfig, ServerCapabilities, ToolContent,
7273
};
74+
pub use result::tool_result_to_string;
7375
pub use tools::{create_mcp_tools, McpToolWrapper};

0 commit comments

Comments
 (0)