Skip to main content

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}