Skip to main content

everruns_core/capabilities/
mcp.rs

1// MCP Virtual Capability
2//
3// Spec: specs/mcp.md (umbrella), specs/mcp-servers.md (capabilities integration)
4//
5// This module provides a capability wrapper for MCP servers.
6// Each active MCP server becomes a virtual capability that contributes
7// its tools to the agent's tool set.
8//
9// Design decisions:
10// - Capability ID format: "mcp:{server_id}" using the MCP server's UUID
11// - Tool names are prefixed: "mcp_{sanitized_server_name}_{tool_name}"
12// - Tool execution is delegated to the MCP server via HTTP
13// - Tools are cached and refreshed periodically
14
15use crate::capability_types::{CapabilityId, CapabilityStatus};
16use crate::mcp_server::{McpToolDefinition, mcp_tool_name};
17use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolDefinition, ToolHints, ToolPolicy};
18use crate::tools::Tool;
19
20use super::Capability;
21use uuid::Uuid;
22
23/// MCP Virtual Capability ID prefix
24pub const MCP_CAPABILITY_PREFIX: &str = "mcp:";
25
26/// Generate capability ID for an MCP server
27pub fn mcp_capability_id(server_id: Uuid) -> String {
28    format!("{}{}", MCP_CAPABILITY_PREFIX, server_id)
29}
30
31/// Check if a capability ID is an MCP capability
32pub fn is_mcp_capability(capability_id: &str) -> bool {
33    capability_id.starts_with(MCP_CAPABILITY_PREFIX)
34}
35
36/// Parse MCP server ID from capability ID
37pub fn parse_mcp_capability_id(capability_id: &str) -> Option<Uuid> {
38    if !capability_id.starts_with(MCP_CAPABILITY_PREFIX) {
39        return None;
40    }
41    let uuid_str = &capability_id[MCP_CAPABILITY_PREFIX.len()..];
42    Uuid::parse_str(uuid_str).ok()
43}
44
45/// MCP Virtual Capability wrapping an MCP server.
46///
47/// This capability provides tools from a remote MCP server.
48/// Tool names are prefixed with "mcp_{server_name}_" to avoid collisions.
49#[derive(Debug, Clone)]
50pub struct McpCapability {
51    /// MCP server UUID
52    pub server_id: Uuid,
53    /// Server name (used for tool name prefix)
54    pub server_name: String,
55    /// Server description
56    pub description: Option<String>,
57    /// Cached tool definitions from the MCP server
58    pub tools: Vec<McpToolDefinition>,
59}
60
61impl McpCapability {
62    /// Create a new MCP capability from server info and cached tools
63    pub fn new(
64        server_id: Uuid,
65        server_name: String,
66        description: Option<String>,
67        tools: Vec<McpToolDefinition>,
68    ) -> Self {
69        Self {
70            server_id,
71            server_name,
72            description,
73            tools,
74        }
75    }
76
77    /// Get the capability ID for this MCP server
78    pub fn capability_id(&self) -> String {
79        mcp_capability_id(self.server_id)
80    }
81
82    /// Convert MCP tool definition to our ToolDefinition with prefixed name.
83    /// Maps MCP annotations to ToolHints when available.
84    fn mcp_tool_to_definition(&self, mcp_tool: &McpToolDefinition) -> ToolDefinition {
85        let prefixed_name = mcp_tool_name(&self.server_name, &mcp_tool.name);
86
87        // Map MCP annotations to ToolHints
88        let hints = match &mcp_tool.annotations {
89            Some(ann) => ToolHints {
90                readonly: ann.read_only_hint,
91                destructive: ann.destructive_hint,
92                idempotent: ann.idempotent_hint,
93                // Default open_world to true for MCP tools unless explicitly set to false
94                open_world: Some(ann.open_world_hint.unwrap_or(true)),
95                // MCP doesn't define requires_secrets or long_running — leave as None
96                ..ToolHints::default()
97            },
98            None => {
99                // MCP tools are external by nature
100                ToolHints::default().with_open_world(true)
101            }
102        };
103
104        ToolDefinition::Builtin(BuiltinTool {
105            name: prefixed_name,
106            display_name: None,
107            description: mcp_tool
108                .description
109                .clone()
110                .unwrap_or_else(|| format!("Tool from MCP server: {}", self.server_name)),
111            parameters: mcp_tool.input_schema.clone(),
112            policy: ToolPolicy::Auto,
113            category: self.category().map(|s| s.to_string()),
114            deferrable: DeferrablePolicy::default(),
115            hints,
116            full_parameters: None,
117        })
118        .with_capability_attribution(self.capability_id(), Some(self.server_name.clone()))
119    }
120}
121
122impl Capability for McpCapability {
123    fn id(&self) -> &str {
124        // Return a static reference by leaking the capability ID
125        // This is acceptable since capabilities are long-lived
126        Box::leak(self.capability_id().into_boxed_str())
127    }
128
129    fn name(&self) -> &str {
130        Box::leak(self.server_name.clone().into_boxed_str())
131    }
132
133    fn description(&self) -> &str {
134        let desc = self
135            .description
136            .clone()
137            .unwrap_or_else(|| format!("MCP Server providing {} tool(s)", self.tools.len()));
138        Box::leak(desc.into_boxed_str())
139    }
140
141    fn status(&self) -> CapabilityStatus {
142        CapabilityStatus::Available
143    }
144
145    fn icon(&self) -> Option<&str> {
146        Some("mcp") // Official MCP logo
147    }
148
149    fn category(&self) -> Option<&str> {
150        Some("MCP Servers")
151    }
152
153    fn system_prompt_addition(&self) -> Option<&str> {
154        None // MCP tools are self-documenting
155    }
156
157    fn narrate(
158        &self,
159        _tool_def: Option<&crate::tool_types::ToolDefinition>,
160        tool_call: &crate::tool_types::ToolCall,
161        phase: crate::tool_narration::ToolNarrationPhase,
162        locale: Option<&str>,
163        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
164    ) -> Option<String> {
165        // Generic search narration for provider/MCP search tools (`*__search`).
166        if !tool_call.name.ends_with("__search") {
167            return None;
168        }
169        Some(crate::tool_narration::narrate_provider_search(
170            &tool_call.arguments,
171            phase,
172            locale,
173        ))
174    }
175
176    fn tools(&self) -> Vec<Box<dyn Tool>> {
177        // MCP tools are executed via HTTP, not directly
178        // Return empty vec - tool execution is handled specially
179        vec![]
180    }
181
182    fn tool_definitions(&self) -> Vec<ToolDefinition> {
183        self.tools
184            .iter()
185            .map(|t| self.mcp_tool_to_definition(t))
186            .collect()
187    }
188}
189
190/// Capability ID constant for MCP capabilities
191impl CapabilityId {
192    /// Check if this capability ID is for an MCP server
193    pub fn is_mcp(&self) -> bool {
194        is_mcp_capability(self.as_str())
195    }
196
197    /// Create a capability ID for an MCP server
198    pub fn mcp(server_id: Uuid) -> Self {
199        Self::new(mcp_capability_id(server_id))
200    }
201
202    /// Parse MCP server UUID from this capability ID
203    pub fn mcp_server_id(&self) -> Option<Uuid> {
204        parse_mcp_capability_id(self.as_str())
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use serde_json::json;
212
213    #[test]
214    fn test_mcp_capability_id() {
215        let server_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
216        let cap_id = mcp_capability_id(server_id);
217        assert_eq!(cap_id, "mcp:550e8400-e29b-41d4-a716-446655440000");
218    }
219
220    #[test]
221    fn test_is_mcp_capability() {
222        assert!(is_mcp_capability(
223            "mcp:550e8400-e29b-41d4-a716-446655440000"
224        ));
225        assert!(!is_mcp_capability("current_time"));
226        assert!(!is_mcp_capability("mcp_something")); // Wrong format
227    }
228
229    #[test]
230    fn test_parse_mcp_capability_id() {
231        let server_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
232        let cap_id = mcp_capability_id(server_id);
233        let parsed = parse_mcp_capability_id(&cap_id);
234        assert_eq!(parsed, Some(server_id));
235
236        assert_eq!(parse_mcp_capability_id("current_time"), None);
237        assert_eq!(parse_mcp_capability_id("mcp:invalid"), None);
238    }
239
240    #[test]
241    fn test_mcp_capability_tool_definitions() {
242        let server_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
243        let tools = vec![McpToolDefinition {
244            name: "search".to_string(),
245            description: Some("Search documentation".to_string()),
246            input_schema: json!({
247                "type": "object",
248                "properties": {
249                    "query": { "type": "string" }
250                }
251            }),
252            annotations: None,
253        }];
254
255        let capability = McpCapability::new(
256            server_id,
257            "microsoft-learn".to_string(),
258            Some("Microsoft Learn MCP".to_string()),
259            tools,
260        );
261
262        let defs = capability.tool_definitions();
263        assert_eq!(defs.len(), 1);
264
265        let ToolDefinition::Builtin(builtin) = &defs[0] else {
266            panic!("expected Builtin variant");
267        };
268        assert_eq!(builtin.name, "mcp_microsoft_learn__search");
269        assert_eq!(builtin.description, "Search documentation");
270        assert_eq!(
271            defs[0].capability_attribution(),
272            Some((
273                "mcp:550e8400-e29b-41d4-a716-446655440000",
274                Some("microsoft-learn")
275            ))
276        );
277    }
278
279    #[test]
280    fn test_capability_id_mcp_methods() {
281        let server_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
282        let cap_id = CapabilityId::mcp(server_id);
283
284        assert!(cap_id.is_mcp());
285        assert_eq!(cap_id.mcp_server_id(), Some(server_id));
286
287        let regular_cap = CapabilityId::new("current_time");
288        assert!(!regular_cap.is_mcp());
289        assert_eq!(regular_cap.mcp_server_id(), None);
290    }
291}