Skip to main content

roboticus_agent/
mcp_handler.rs

1//! MCP Server Handler — bridges rmcp's `ServerHandler` to Roboticus's `ToolRegistry`.
2//!
3//! This module implements the server half of the MCP gateway. External MCP clients
4//! (Claude Desktop, Cursor, VS Code, etc.) connect via SSE/HTTP, discover Roboticus's
5//! tools through `tools/list`, and invoke them through `tools/call`.
6//!
7//! All tool calls from MCP clients run with `InputAuthority::External` and pass
8//! through the policy engine — Forbidden tools are never exposed, and Dangerous
9//! tools require explicit configuration.
10
11use std::borrow::Cow;
12use std::sync::Arc;
13
14use rmcp::handler::server::ServerHandler;
15use rmcp::model::{
16    CallToolRequestParams, CallToolResult, Content, JsonObject, ListToolsResult,
17    PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool as McpTool,
18};
19use rmcp::service::RequestContext;
20use rmcp::{ErrorData as McpError, RoleServer};
21use serde_json::Value;
22use tracing::{debug, info, warn};
23
24use roboticus_core::{InputAuthority, RiskLevel};
25
26use crate::tools::{ToolContext, ToolRegistry, ToolSandboxSnapshot};
27
28// ─── Public types ──────────────────────────────────────────────────────────
29
30/// Bridges Roboticus's `ToolRegistry` to the MCP protocol so external clients
31/// can discover and invoke tools over SSE/HTTP.
32#[derive(Clone)]
33pub struct RoboticusMcpHandler {
34    tool_registry: Arc<ToolRegistry>,
35    /// Optional allow-list. When `Some`, only these tool names are exposed.
36    /// When `None`, all non-Forbidden tools are exposed.
37    allow_list: Option<Vec<String>>,
38    default_context: McpToolContext,
39}
40
41/// Minimal context needed to construct a `ToolContext` for MCP-originated calls.
42#[derive(Clone)]
43pub struct McpToolContext {
44    pub agent_id: String,
45    pub agent_name: String,
46    pub workspace_root: std::path::PathBuf,
47    pub tool_allowed_paths: Vec<std::path::PathBuf>,
48    pub sandbox: ToolSandboxSnapshot,
49    pub db: Option<roboticus_db::Database>,
50}
51
52// ─── Construction ──────────────────────────────────────────────────────────
53
54impl RoboticusMcpHandler {
55    pub fn new(tool_registry: Arc<ToolRegistry>, default_context: McpToolContext) -> Self {
56        Self {
57            tool_registry,
58            allow_list: None,
59            default_context,
60        }
61    }
62
63    /// Restrict which tools are visible over MCP. Only names in this list
64    /// will appear in `tools/list` (Forbidden tools are *always* hidden
65    /// regardless of this list).
66    pub fn with_allow_list(mut self, allow_list: Vec<String>) -> Self {
67        self.allow_list = Some(allow_list);
68        self
69    }
70
71    /// Returns true if the tool should be visible to MCP clients.
72    fn is_tool_exposed(&self, name: &str, risk: RiskLevel) -> bool {
73        if risk == RiskLevel::Forbidden {
74            return false;
75        }
76        if let Some(ref allow_list) = self.allow_list {
77            return allow_list.iter().any(|n| n == name);
78        }
79        true
80    }
81
82    /// Convert a serde_json::Value (expected to be an object) into rmcp's JsonObject.
83    fn to_json_object(schema: &Value) -> JsonObject {
84        match schema.as_object() {
85            Some(obj) => obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
86            None => {
87                let mut obj = JsonObject::new();
88                obj.insert("type".to_string(), Value::String("object".to_string()));
89                obj
90            }
91        }
92    }
93
94    /// Build a `ToolContext` for an MCP-originated tool call.
95    fn build_tool_context(&self, session_id: String) -> ToolContext {
96        ToolContext {
97            session_id,
98            agent_id: self.default_context.agent_id.clone(),
99            agent_name: self.default_context.agent_name.clone(),
100            authority: InputAuthority::External,
101            workspace_root: self.default_context.workspace_root.clone(),
102            tool_allowed_paths: self.default_context.tool_allowed_paths.clone(),
103            channel: Some("mcp".to_string()),
104            db: self.default_context.db.clone(),
105            sandbox: self.default_context.sandbox.clone(),
106        }
107    }
108}
109
110// ─── Core logic (testable without RequestContext) ──────────────────────────
111
112impl RoboticusMcpHandler {
113    /// List all tools that should be visible to MCP clients.
114    pub async fn list_exposed_tools(&self) -> Vec<McpTool> {
115        let registry = &self.tool_registry;
116        registry
117            .list()
118            .into_iter()
119            .filter(|t| self.is_tool_exposed(t.name(), t.risk_level()))
120            .map(|t| {
121                let schema = Self::to_json_object(&t.parameters_schema());
122                McpTool {
123                    name: Cow::Owned(t.name().to_string()),
124                    title: None,
125                    description: Some(Cow::Owned(t.description().to_string())),
126                    input_schema: Arc::new(schema),
127                    output_schema: None,
128                    annotations: None,
129                    execution: None,
130                    icons: None,
131                    meta: None,
132                }
133            })
134            .collect()
135    }
136
137    /// Execute a tool call and return an MCP-formatted result.
138    pub async fn execute_tool_call(
139        &self,
140        tool_name: &str,
141        arguments: JsonObject,
142    ) -> CallToolResult {
143        let registry = &self.tool_registry;
144
145        let tool = match registry.get(tool_name) {
146            Some(t) => t,
147            None => {
148                warn!(tool = tool_name, "MCP client requested unknown tool");
149                return CallToolResult::error(vec![Content::text(format!(
150                    "Unknown tool: {tool_name}"
151                ))]);
152            }
153        };
154
155        // Never execute Forbidden tools, even if somehow requested directly.
156        if tool.risk_level() == RiskLevel::Forbidden {
157            warn!(
158                tool = tool_name,
159                "MCP client attempted to call Forbidden tool"
160            );
161            return CallToolResult::error(vec![Content::text(
162                "This tool is not available via MCP".to_string(),
163            )]);
164        }
165
166        // Check allow-list
167        if !self.is_tool_exposed(tool_name, tool.risk_level()) {
168            warn!(
169                tool = tool_name,
170                "MCP client attempted to call tool not in allow-list"
171            );
172            return CallToolResult::error(vec![Content::text(
173                "This tool is not available via MCP".to_string(),
174            )]);
175        }
176
177        let params: Value = Value::Object(
178            arguments
179                .into_iter()
180                .collect::<serde_json::Map<String, Value>>(),
181        );
182
183        let session_id = uuid::Uuid::new_v4().to_string();
184        let ctx = self.build_tool_context(session_id);
185
186        debug!(tool = tool_name, "Executing MCP tool call");
187
188        match tool.execute(params, &ctx).await {
189            Ok(result) => {
190                let mut content = vec![Content::text(result.output)];
191                if let Some(meta) = result.metadata {
192                    content.push(Content::text(format!(
193                        "\n---\nMetadata: {}",
194                        serde_json::to_string_pretty(&meta).unwrap_or_default()
195                    )));
196                }
197                CallToolResult::success(content)
198            }
199            Err(e) => {
200                warn!(tool = tool_name, error = %e, "MCP tool call failed");
201                CallToolResult::error(vec![Content::text(e.message)])
202            }
203        }
204    }
205}
206
207// ─── ServerHandler trait impl (thin delegation) ────────────────────────────
208
209impl ServerHandler for RoboticusMcpHandler {
210    fn get_info(&self) -> ServerInfo {
211        ServerInfo {
212            instructions: Some(
213                "Roboticus agent runtime — tools are filtered by policy engine".into(),
214            ),
215            capabilities: ServerCapabilities::builder().enable_tools().build(),
216            ..Default::default()
217        }
218    }
219
220    #[allow(unused_variables)]
221    async fn list_tools(
222        &self,
223        request: Option<PaginatedRequestParams>,
224        context: RequestContext<RoleServer>,
225    ) -> Result<ListToolsResult, McpError> {
226        let tools = self.list_exposed_tools().await;
227        info!(count = tools.len(), "MCP tools/list");
228        Ok(ListToolsResult {
229            tools,
230            next_cursor: None,
231            meta: None,
232        })
233    }
234
235    #[allow(unused_variables)]
236    async fn call_tool(
237        &self,
238        request: CallToolRequestParams,
239        context: RequestContext<RoleServer>,
240    ) -> Result<CallToolResult, McpError> {
241        info!(tool = %request.name, "MCP tools/call");
242        Ok(self
243            .execute_tool_call(&request.name, request.arguments.unwrap_or_default())
244            .await)
245    }
246}
247
248// ─── Tests ─────────────────────────────────────────────────────────────────
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::tools::{Tool, ToolError, ToolResult};
254    use async_trait::async_trait;
255    use serde_json::json;
256
257    // --- Dummy tools for testing ---
258
259    struct EchoTool;
260
261    #[async_trait]
262    impl Tool for EchoTool {
263        fn name(&self) -> &str {
264            "echo"
265        }
266        fn description(&self) -> &str {
267            "Echoes input back"
268        }
269        fn risk_level(&self) -> RiskLevel {
270            RiskLevel::Safe
271        }
272        fn parameters_schema(&self) -> Value {
273            json!({"type": "object", "properties": {"message": {"type": "string"}}})
274        }
275        async fn execute(
276            &self,
277            params: Value,
278            _ctx: &ToolContext,
279        ) -> Result<ToolResult, ToolError> {
280            let msg = params
281                .get("message")
282                .and_then(|v| v.as_str())
283                .unwrap_or("(empty)");
284            Ok(ToolResult {
285                output: format!("Echo: {msg}"),
286                metadata: None,
287            })
288        }
289    }
290
291    struct ForbiddenTool;
292
293    #[async_trait]
294    impl Tool for ForbiddenTool {
295        fn name(&self) -> &str {
296            "nuke"
297        }
298        fn description(&self) -> &str {
299            "Forbidden operation"
300        }
301        fn risk_level(&self) -> RiskLevel {
302            RiskLevel::Forbidden
303        }
304        fn parameters_schema(&self) -> Value {
305            json!({"type": "object"})
306        }
307        async fn execute(
308            &self,
309            _params: Value,
310            _ctx: &ToolContext,
311        ) -> Result<ToolResult, ToolError> {
312            panic!("should never execute");
313        }
314    }
315
316    struct FailingTool;
317
318    #[async_trait]
319    impl Tool for FailingTool {
320        fn name(&self) -> &str {
321            "fail"
322        }
323        fn description(&self) -> &str {
324            "Always fails"
325        }
326        fn risk_level(&self) -> RiskLevel {
327            RiskLevel::Safe
328        }
329        fn parameters_schema(&self) -> Value {
330            json!({"type": "object"})
331        }
332        async fn execute(
333            &self,
334            _params: Value,
335            _ctx: &ToolContext,
336        ) -> Result<ToolResult, ToolError> {
337            Err(ToolError {
338                message: "Intentional failure".to_string(),
339            })
340        }
341    }
342
343    fn make_handler(tools: Vec<Box<dyn Tool>>) -> RoboticusMcpHandler {
344        let mut registry = ToolRegistry::new();
345        for tool in tools {
346            registry.register(tool);
347        }
348        let ctx = McpToolContext {
349            agent_id: "test-agent".to_string(),
350            agent_name: "test-agent".to_string(),
351            workspace_root: std::path::PathBuf::from("/tmp"),
352            tool_allowed_paths: vec![],
353            sandbox: ToolSandboxSnapshot::default(),
354            db: None,
355        };
356        RoboticusMcpHandler::new(Arc::new(registry), ctx)
357    }
358
359    #[tokio::test]
360    async fn list_tools_excludes_forbidden() {
361        let handler = make_handler(vec![Box::new(EchoTool), Box::new(ForbiddenTool)]);
362        let tools = handler.list_exposed_tools().await;
363        assert_eq!(tools.len(), 1);
364        assert_eq!(tools[0].name.as_ref(), "echo");
365    }
366
367    #[tokio::test]
368    async fn list_tools_respects_allow_list() {
369        let handler = make_handler(vec![Box::new(EchoTool), Box::new(FailingTool)])
370            .with_allow_list(vec!["echo".to_string()]);
371        let tools = handler.list_exposed_tools().await;
372        assert_eq!(tools.len(), 1);
373        assert_eq!(tools[0].name.as_ref(), "echo");
374    }
375
376    #[tokio::test]
377    async fn execute_tool_success() {
378        let handler = make_handler(vec![Box::new(EchoTool)]);
379        let mut args = JsonObject::new();
380        args.insert("message".to_string(), Value::String("hello".to_string()));
381        let result = handler.execute_tool_call("echo", args).await;
382        assert!(!result.is_error.unwrap_or(false));
383        let text = result
384            .content
385            .first()
386            .and_then(|c| c.as_text())
387            .map(|t| t.text.as_str())
388            .unwrap_or("");
389        assert_eq!(text, "Echo: hello");
390    }
391
392    #[tokio::test]
393    async fn execute_unknown_tool_returns_error() {
394        let handler = make_handler(vec![Box::new(EchoTool)]);
395        let result = handler
396            .execute_tool_call("nonexistent", JsonObject::new())
397            .await;
398        assert!(result.is_error.unwrap_or(false));
399    }
400
401    #[tokio::test]
402    async fn execute_forbidden_tool_returns_error() {
403        let handler = make_handler(vec![Box::new(ForbiddenTool)]);
404        let result = handler.execute_tool_call("nuke", JsonObject::new()).await;
405        assert!(result.is_error.unwrap_or(false));
406    }
407
408    #[tokio::test]
409    async fn execute_failing_tool_returns_error() {
410        let handler = make_handler(vec![Box::new(FailingTool)]);
411        let result = handler.execute_tool_call("fail", JsonObject::new()).await;
412        assert!(result.is_error.unwrap_or(false));
413        let text = result
414            .content
415            .first()
416            .and_then(|c| c.as_text())
417            .map(|t| t.text.as_str())
418            .unwrap_or("");
419        assert!(text.contains("Intentional failure"));
420    }
421
422    #[tokio::test]
423    async fn tool_context_uses_external_authority() {
424        let handler = make_handler(vec![]);
425        let ctx = handler.build_tool_context("test-session".to_string());
426        assert_eq!(ctx.authority, InputAuthority::External);
427        assert_eq!(ctx.channel.as_deref(), Some("mcp"));
428    }
429}