zagens_core/engine/hosts/mcp.rs
1//! [`McpHost`] — engine boundary for the MCP (Model Context Protocol)
2//! tool pool.
3//!
4//! M4 (Engine-struct strangler step) promotes the empty
5//! [`TurnLoopMcpPool`](crate::engine::turn_loop::TurnLoopMcpPool) marker
6//! into a named host trait that exposes the MCP **predicate / metadata
7//! surface** the live `Engine` and the core turn loop actually use:
8//! `is_mcp_tool`, `tool_is_parallel_safe`, `tool_is_read_only`,
9//! `tool_approval_description`. All four methods carry **default
10//! implementations** that delegate to the existing stateless free
11//! functions in [`crate::engine::dispatch`], so `impl McpHost for
12//! McpPool {}` in tui is a one-liner that produces zero behavior
13//! change.
14//!
15//! ## Why not include `execute_tool` / `ensure` / `shutdown` here?
16//!
17//! - **`execute_tool`** lives on
18//! [`McpPoolPort`](crate::engine::turn_loop::McpPoolPort) and is
19//! implemented on `McpPoolHandle = Arc<Mutex<McpPool>>` (the locked
20//! container, not the bare pool). The two traits have different
21//! `self` shapes; merging would require reworking the
22//! `mcp_pool_as_port` factory and rippling through every
23//! `Option<Arc<AsyncMutex<Self::McpPool>>>` parameter in the turn
24//! loop signature. M4 keeps them separate.
25//! - **`ensure_pool` / `shutdown_all`** mutate engine state
26//! (`self.mcp_pool = Some(...)`) and depend on
27//! `EngineConfig.network_policy` + `session.mcp_config_path`. They
28//! stay as inherent `Engine` methods (`tool_context.rs:112-124`,
29//! `op_loop.rs:86-89`) and will move into the core `Engine` struct
30//! alongside the field in M7.
31//!
32//! ## Call-graph (R1)
33//!
34//! Method surface derived from direct calls to `McpPool::is_mcp_tool`
35//! and the `mcp_tool_*` free functions across the Engine call tree:
36//!
37//! | Method | Call sites |
38//! |------------------------------|------------------------------------------------------------------------------------------------------------------|
39//! | `is_mcp_tool` | `tui::mcp.rs:1498` (inherent), `capacity_flow/interventions.rs:139,143`, `tool_execution/parallel.rs:15,33`, … |
40//! | `tool_is_parallel_safe` | `capacity_flow/interventions.rs:144`, `tool_execution/parallel.rs:34`, `host_impl/mod.rs:427` |
41//! | `tool_is_read_only` | `capacity_flow/replay.rs:35`, `host_impl/mod.rs:426,428` |
42//! | `tool_approval_description` | `host_impl/mod.rs:429` |
43//!
44//! Existing call sites keep using the inherent
45//! `McpPool::is_mcp_tool(name)` and the `mcp_tool_*` free fns
46//! unchanged (M4 Q5A: zero call-site churn). The trait methods are
47//! the parallel `&self` entry points future implementations (e.g.
48//! MCP server capability negotiation) can override.
49
50/// Engine-side MCP host.
51///
52/// Implemented by `crates/tui/src/core/engine/turn_loop/host_impl/mod.rs`
53/// (`impl McpHost for McpPool {}`) using only the default method
54/// bodies — the live `McpPool` type carries no extra state for
55/// these predicates beyond what the free functions in
56/// [`crate::engine::dispatch`] already encode.
57pub trait McpHost: Send + Sync {
58 /// Whether `name` is dispatched through the MCP pool (vs. the
59 /// native [`ToolRegistry`](crate::engine::turn_loop::TurnLoopToolRegistry)).
60 ///
61 /// Default delegates to
62 /// [`is_mcp_tool_name`](crate::engine::dispatch::is_mcp_tool_name).
63 /// Cross-verified against `tui::mcp::McpPool::is_mcp_tool` by the
64 /// `is_mcp_tool_name_matches_tui_mcp_pool` unit test in
65 /// `crates/core/src/engine/dispatch.rs::tests` (and a mirrored
66 /// `cross_verify_core_is_mcp_tool_name` test in `tui::mcp`).
67 fn is_mcp_tool(&self, name: &str) -> bool {
68 crate::engine::dispatch::is_mcp_tool_name(name)
69 }
70
71 /// Whether the MCP tool `name` may run inside the parallel batch
72 /// executor (read-only + side-effect-free).
73 ///
74 /// Default delegates to
75 /// [`mcp_tool_is_parallel_safe`](crate::engine::dispatch::mcp_tool_is_parallel_safe).
76 fn tool_is_parallel_safe(&self, name: &str) -> bool {
77 crate::engine::dispatch::mcp_tool_is_parallel_safe(name)
78 }
79
80 /// Whether the MCP tool `name` is read-only (skips the approval
81 /// prompt for trusted-by-default operations like
82 /// `read_mcp_resource`).
83 ///
84 /// Default delegates to
85 /// [`mcp_tool_is_read_only`](crate::engine::dispatch::mcp_tool_is_read_only).
86 fn tool_is_read_only(&self, name: &str) -> bool {
87 crate::engine::dispatch::mcp_tool_is_read_only(name)
88 }
89
90 /// Human-readable approval prompt text for the MCP tool `name`.
91 ///
92 /// Default delegates to
93 /// [`mcp_tool_approval_description`](crate::engine::dispatch::mcp_tool_approval_description).
94 fn tool_approval_description(&self, name: &str) -> String {
95 crate::engine::dispatch::mcp_tool_approval_description(name)
96 }
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 /// Stand-in MCP host with no state — exercises default-impl paths
104 /// without pulling the heavy `tui::mcp::McpPool` into core tests.
105 struct StubMcpHost;
106 impl McpHost for StubMcpHost {}
107
108 #[test]
109 fn default_impls_match_dispatch_module() {
110 let host = StubMcpHost;
111 assert!(host.is_mcp_tool("mcp_filesystem_read"));
112 assert!(host.is_mcp_tool("list_mcp_resources"));
113 assert!(!host.is_mcp_tool("read_file"));
114 assert!(host.tool_is_read_only("read_mcp_resource"));
115 assert!(host.tool_is_parallel_safe("list_mcp_resources"));
116 assert!(!host.tool_is_read_only("mcp_filesystem_write"));
117 let desc = host.tool_approval_description("read_mcp_resource");
118 assert!(desc.starts_with("Read-only"));
119 }
120
121 #[test]
122 fn dyn_dispatch_compiles() {
123 fn _accepts(_: &dyn McpHost) {}
124 _accepts(&StubMcpHost);
125 }
126}