1use 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#[derive(Clone)]
33pub struct RoboticusMcpHandler {
34 tool_registry: Arc<ToolRegistry>,
35 allow_list: Option<Vec<String>>,
38 default_context: McpToolContext,
39}
40
41#[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
52impl 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 pub fn with_allow_list(mut self, allow_list: Vec<String>) -> Self {
67 self.allow_list = Some(allow_list);
68 self
69 }
70
71 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 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 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
110impl RoboticusMcpHandler {
113 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 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 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 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
207impl 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#[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 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}