microsandbox_server/
mcp.rs

1//! Model Context Protocol (MCP) implementation for microsandbox server.
2//!
3//! This module implements MCP endpoints served at the `/mcp` endpoint.
4//! MCP is essentially JSON-RPC with specific method names and schemas.
5//!
6//! The module provides:
7//! - MCP server initialization and capabilities
8//! - Tool definitions for sandbox operations
9//! - Prompt templates for common sandbox tasks
10//! - Integration with existing sandbox management functions
11
12use serde_json::json;
13use tracing::{debug, error};
14
15use crate::{
16    error::ServerError,
17    handler::{
18        forward_rpc_to_portal, sandbox_get_metrics_impl, sandbox_start_impl, sandbox_stop_impl,
19    },
20    payload::{
21        JsonRpcError, JsonRpcRequest, JsonRpcResponse, JsonRpcResponseOrNotification,
22        ProcessedNotification, SandboxMetricsGetParams, SandboxStartParams, SandboxStopParams,
23        JSONRPC_VERSION,
24    },
25    state::AppState,
26    ServerResult,
27};
28
29//--------------------------------------------------------------------------------------------------
30// Constants
31//--------------------------------------------------------------------------------------------------
32
33/// MCP protocol version
34const MCP_PROTOCOL_VERSION: &str = "2025-03-26";
35
36/// Server information
37const SERVER_NAME: &str = "microsandbox-server";
38const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
39
40//--------------------------------------------------------------------------------------------------
41// Functions: Handlers
42//--------------------------------------------------------------------------------------------------
43
44/// Handle MCP initialize request
45pub async fn handle_mcp_initialize(
46    _state: AppState,
47    request: JsonRpcRequest,
48) -> ServerResult<JsonRpcResponse> {
49    debug!("Handling MCP initialize request");
50
51    let result = json!({
52        "protocolVersion": MCP_PROTOCOL_VERSION,
53        "capabilities": {
54            "tools": {
55                "listChanged": false
56            },
57            "prompts": {
58                "listChanged": false
59            }
60        },
61        "serverInfo": {
62            "name": SERVER_NAME,
63            "version": SERVER_VERSION
64        }
65    });
66
67    Ok(JsonRpcResponse::success(result, request.id))
68}
69
70/// Handle MCP list tools request
71pub async fn handle_mcp_list_tools(
72    _state: AppState,
73    request: JsonRpcRequest,
74) -> ServerResult<JsonRpcResponse> {
75    debug!("Handling MCP list tools request");
76
77    let tools = json!({
78        "tools": [
79            {
80                "name": "sandbox_start",
81                "description": "Start a new sandbox with specified configuration. This creates an isolated environment for code execution. IMPORTANT: Always stop the sandbox when done to prevent it from running indefinitely and consuming resources. SUPPORTED IMAGES: Only 'microsandbox/python' (for Python code) and 'microsandbox/node' (for Node.js code) are currently supported.",
82                "inputSchema": {
83                    "type": "object",
84                    "properties": {
85                        "sandbox": {
86                            "type": "string",
87                            "description": "Name of the sandbox to start"
88                        },
89                        "namespace": {
90                            "type": "string",
91                            "description": "Namespace for the sandbox"
92                        },
93                        "config": {
94                            "type": "object",
95                            "description": "Sandbox configuration",
96                            "properties": {
97                                "image": {
98                                    "type": "string",
99                                    "description": "Docker image to use. Only 'microsandbox/python' and 'microsandbox/node' are supported.",
100                                    "enum": ["microsandbox/python", "microsandbox/node"]
101                                },
102                                "memory": {
103                                    "type": "integer",
104                                    "description": "Memory limit in MiB"
105                                },
106                                "cpus": {
107                                    "type": "integer",
108                                    "description": "Number of CPUs"
109                                },
110                                "volumes": {
111                                    "type": "array",
112                                    "items": {"type": "string"},
113                                    "description": "Volume mounts"
114                                },
115                                "ports": {
116                                    "type": "array",
117                                    "items": {"type": "string"},
118                                    "description": "Port mappings"
119                                },
120                                "envs": {
121                                    "type": "array",
122                                    "items": {"type": "string"},
123                                    "description": "Environment variables"
124                                }
125                            }
126                        }
127                    },
128                    "required": ["sandbox", "namespace"]
129                }
130            },
131            {
132                "name": "sandbox_stop",
133                "description": "Stop a running sandbox and clean up its resources. CRITICAL: Always call this when you're finished with a sandbox to prevent resource leaks and indefinite running. Failing to stop sandboxes will cause them to consume system resources unnecessarily.",
134                "inputSchema": {
135                    "type": "object",
136                    "properties": {
137                        "sandbox": {
138                            "type": "string",
139                            "description": "Name of the sandbox to stop"
140                        },
141                        "namespace": {
142                            "type": "string",
143                            "description": "Namespace of the sandbox"
144                        }
145                    },
146                    "required": ["sandbox", "namespace"]
147                }
148            },
149            {
150                "name": "sandbox_run_code",
151                "description": "Execute code in a running sandbox. PREREQUISITES: The target sandbox must be started first using sandbox_start - this will fail if the sandbox is not running. TIMING: Code execution is synchronous and may take time depending on complexity. Long-running code will block until completion or timeout.",
152                "inputSchema": {
153                    "type": "object",
154                    "properties": {
155                        "sandbox": {
156                            "type": "string",
157                            "description": "Name of the sandbox (must be already started)"
158                        },
159                        "namespace": {
160                            "type": "string",
161                            "description": "Namespace of the sandbox"
162                        },
163                        "code": {
164                            "type": "string",
165                            "description": "Code to execute"
166                        },
167                        "language": {
168                            "type": "string",
169                            "description": "Programming language (e.g., 'python', 'nodejs')"
170                        }
171                    },
172                    "required": ["sandbox", "namespace", "code", "language"]
173                }
174            },
175            {
176                "name": "sandbox_run_command",
177                "description": "Execute a command in a running sandbox. PREREQUISITES: The target sandbox must be started first using sandbox_start - this will fail if the sandbox is not running. TIMING: Command execution is synchronous and may take time depending on the command complexity. Long-running commands will block until completion or timeout.",
178                "inputSchema": {
179                    "type": "object",
180                    "properties": {
181                        "sandbox": {
182                            "type": "string",
183                            "description": "Name of the sandbox (must be already started)"
184                        },
185                        "namespace": {
186                            "type": "string",
187                            "description": "Namespace of the sandbox"
188                        },
189                        "command": {
190                            "type": "string",
191                            "description": "Command to execute"
192                        },
193                        "args": {
194                            "type": "array",
195                            "items": {"type": "string"},
196                            "description": "Command arguments"
197                        }
198                    },
199                    "required": ["sandbox", "namespace", "command"]
200                }
201            },
202            {
203                "name": "sandbox_get_metrics",
204                "description": "Get metrics and status for sandboxes including CPU usage, memory consumption, and running state. This tool can check the status of any sandbox regardless of whether it's running or not",
205                "inputSchema": {
206                    "type": "object",
207                    "properties": {
208                        "sandbox": {
209                            "type": "string",
210                            "description": "Optional specific sandbox name to get metrics for"
211                        },
212                        "namespace": {
213                            "type": "string",
214                            "description": "Namespace to query (use '*' for all namespaces)"
215                        }
216                    },
217                    "required": ["namespace"]
218                }
219            }
220        ]
221    });
222
223    Ok(JsonRpcResponse::success(tools, request.id))
224}
225
226/// Handle MCP list prompts request
227pub async fn handle_mcp_list_prompts(
228    _state: AppState,
229    request: JsonRpcRequest,
230) -> ServerResult<JsonRpcResponse> {
231    debug!("Handling MCP list prompts request");
232
233    let prompts = json!({
234        "prompts": [
235            {
236                "name": "create_python_sandbox",
237                "description": "Create a Python development sandbox",
238                "arguments": [
239                    {
240                        "name": "sandbox_name",
241                        "description": "Name for the new sandbox",
242                        "required": true
243                    },
244                    {
245                        "name": "namespace",
246                        "description": "Namespace for the sandbox",
247                        "required": true
248                    }
249                ]
250            },
251            {
252                "name": "create_node_sandbox",
253                "description": "Create a Node.js development sandbox",
254                "arguments": [
255                    {
256                        "name": "sandbox_name",
257                        "description": "Name for the new sandbox",
258                        "required": true
259                    },
260                    {
261                        "name": "namespace",
262                        "description": "Namespace for the sandbox",
263                        "required": true
264                    }
265                ]
266            }
267        ]
268    });
269
270    Ok(JsonRpcResponse::success(prompts, request.id))
271}
272
273/// Handle MCP get prompt request
274pub async fn handle_mcp_get_prompt(
275    _state: AppState,
276    request: JsonRpcRequest,
277) -> ServerResult<JsonRpcResponse> {
278    debug!("Handling MCP get prompt request");
279
280    let params = request.params.as_object().ok_or_else(|| {
281        ServerError::ValidationError(crate::error::ValidationError::InvalidInput(
282            "Request parameters must be an object".to_string(),
283        ))
284    })?;
285
286    let prompt_name = params.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
287        ServerError::ValidationError(crate::error::ValidationError::InvalidInput(
288            "Missing required 'name' parameter".to_string(),
289        ))
290    })?;
291
292    let arguments = params.get("arguments").and_then(|v| v.as_object());
293
294    let result = match prompt_name {
295        "create_python_sandbox" => {
296            let sandbox_name = arguments
297                .and_then(|args| args.get("sandbox_name"))
298                .and_then(|v| v.as_str())
299                .unwrap_or("python-sandbox");
300            let namespace = arguments
301                .and_then(|args| args.get("namespace"))
302                .and_then(|v| v.as_str())
303                .unwrap_or("default");
304
305            json!({
306                "description": "Create a Python development sandbox",
307                "messages": [
308                    {
309                        "role": "user",
310                        "content": {
311                            "type": "text",
312                            "text": format!(
313                                "Create a Python sandbox named '{}' in namespace '{}' using the sandbox_start tool with the following configuration:\n\n\
314                                - Image: microsandbox/python\n\
315                                - Memory: 512 MiB\n\
316                                - CPUs: 1\n\
317                                - Working directory: /workspace\n\n\
318                                This will set up a Python development environment ready for code execution.",
319                                sandbox_name, namespace
320                            )
321                        }
322                    }
323                ]
324            })
325        }
326        "create_node_sandbox" => {
327            let sandbox_name = arguments
328                .and_then(|args| args.get("sandbox_name"))
329                .and_then(|v| v.as_str())
330                .unwrap_or("node-sandbox");
331            let namespace = arguments
332                .and_then(|args| args.get("namespace"))
333                .and_then(|v| v.as_str())
334                .unwrap_or("default");
335
336            json!({
337                "description": "Create a Node.js development sandbox",
338                "messages": [
339                    {
340                        "role": "user",
341                        "content": {
342                            "type": "text",
343                            "text": format!(
344                                "Create a Node.js sandbox named '{}' in namespace '{}' using the sandbox_start tool with the following configuration:\n\n\
345                                - Image: microsandbox/node\n\
346                                - Memory: 512 MiB\n\
347                                - CPUs: 1\n\
348                                - Working directory: /workspace\n\n\
349                                This will set up a Node.js development environment ready for JavaScript execution.",
350                                sandbox_name, namespace
351                            )
352                        }
353                    }
354                ]
355            })
356        }
357        _ => {
358            return Err(ServerError::NotFound(format!(
359                "Prompt '{}' not found",
360                prompt_name
361            )));
362        }
363    };
364
365    Ok(JsonRpcResponse::success(result, request.id))
366}
367
368/// Handle MCP call tool request
369pub async fn handle_mcp_call_tool(
370    state: AppState,
371    request: JsonRpcRequest,
372) -> ServerResult<JsonRpcResponse> {
373    debug!("Handling MCP call tool request");
374
375    let params = request.params.as_object().ok_or_else(|| {
376        ServerError::ValidationError(crate::error::ValidationError::InvalidInput(
377            "Request parameters must be an object".to_string(),
378        ))
379    })?;
380
381    let tool_name = params.get("name").and_then(|v| v.as_str()).ok_or_else(|| {
382        ServerError::ValidationError(crate::error::ValidationError::InvalidInput(
383            "Missing required 'name' parameter".to_string(),
384        ))
385    })?;
386
387    let arguments = params.get("arguments").ok_or_else(|| {
388        ServerError::ValidationError(crate::error::ValidationError::InvalidInput(
389            "Missing required 'arguments' parameter".to_string(),
390        ))
391    })?;
392
393    // Convert MCP tool calls to our internal JSON-RPC calls
394    let internal_method = match tool_name {
395        "sandbox_start" => "sandbox.start",
396        "sandbox_stop" => "sandbox.stop",
397        "sandbox_run_code" => "sandbox.repl.run",
398        "sandbox_run_command" => "sandbox.command.run",
399        "sandbox_get_metrics" => "sandbox.metrics.get",
400        _ => {
401            return Err(ServerError::NotFound(format!(
402                "Tool '{}' not found",
403                tool_name
404            )));
405        }
406    };
407
408    // Create internal JSON-RPC request
409    let internal_request = JsonRpcRequest {
410        jsonrpc: JSONRPC_VERSION.to_string(),
411        method: internal_method.to_string(),
412        params: arguments.clone(),
413        id: request.id.clone(),
414    };
415
416    // Handle the request using our existing infrastructure
417    let internal_response = if matches!(internal_method, "sandbox.repl.run" | "sandbox.command.run")
418    {
419        // These need to be forwarded to the portal
420        match forward_rpc_to_portal(state, internal_request).await {
421            Ok((_, json_response)) => json_response.0,
422            Err(e) => {
423                error!("Failed to forward request to portal: {}", e);
424                return Ok(JsonRpcResponse::error(
425                    JsonRpcError {
426                        code: -32603,
427                        message: format!("Internal error: {}", e),
428                        data: None,
429                    },
430                    request.id,
431                ));
432            }
433        }
434    } else {
435        // These are handled locally - call the handler functions directly
436        match internal_method {
437            "sandbox.start" => {
438                let params: SandboxStartParams = serde_json::from_value(arguments.clone())
439                    .map_err(|e| {
440                        return JsonRpcResponse::error(
441                            JsonRpcError {
442                                code: -32602,
443                                message: format!("Invalid parameters: {}", e),
444                                data: None,
445                            },
446                            request.id.clone(),
447                        );
448                    })
449                    .unwrap();
450
451                match sandbox_start_impl(state, params).await {
452                    Ok(result) => JsonRpcResponse::success(json!(result), request.id.clone()),
453                    Err(e) => JsonRpcResponse::error(
454                        JsonRpcError {
455                            code: -32603,
456                            message: format!("Sandbox start failed: {}", e),
457                            data: None,
458                        },
459                        request.id.clone(),
460                    ),
461                }
462            }
463            "sandbox.stop" => {
464                let params: SandboxStopParams = serde_json::from_value(arguments.clone())
465                    .map_err(|e| {
466                        return JsonRpcResponse::error(
467                            JsonRpcError {
468                                code: -32602,
469                                message: format!("Invalid parameters: {}", e),
470                                data: None,
471                            },
472                            request.id.clone(),
473                        );
474                    })
475                    .unwrap();
476
477                match sandbox_stop_impl(state, params).await {
478                    Ok(result) => JsonRpcResponse::success(json!(result), request.id.clone()),
479                    Err(e) => JsonRpcResponse::error(
480                        JsonRpcError {
481                            code: -32603,
482                            message: format!("Sandbox stop failed: {}", e),
483                            data: None,
484                        },
485                        request.id.clone(),
486                    ),
487                }
488            }
489            "sandbox.metrics.get" => {
490                let params: SandboxMetricsGetParams = serde_json::from_value(arguments.clone())
491                    .map_err(|e| {
492                        return JsonRpcResponse::error(
493                            JsonRpcError {
494                                code: -32602,
495                                message: format!("Invalid parameters: {}", e),
496                                data: None,
497                            },
498                            request.id.clone(),
499                        );
500                    })
501                    .unwrap();
502
503                match sandbox_get_metrics_impl(state, params).await {
504                    Ok(result) => JsonRpcResponse::success(json!(result), request.id.clone()),
505                    Err(e) => JsonRpcResponse::error(
506                        JsonRpcError {
507                            code: -32603,
508                            message: format!("Get metrics failed: {}", e),
509                            data: None,
510                        },
511                        request.id.clone(),
512                    ),
513                }
514            }
515            _ => JsonRpcResponse::error(
516                JsonRpcError {
517                    code: -32601,
518                    message: format!("Method not found: {}", internal_method),
519                    data: None,
520                },
521                request.id.clone(),
522            ),
523        }
524    };
525
526    // Convert the response to MCP format
527    let mcp_result = if let Some(result) = internal_response.result {
528        json!({
529            "content": [
530                {
531                    "type": "text",
532                    "text": serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string())
533                }
534            ]
535        })
536    } else if let Some(error) = internal_response.error {
537        json!({
538            "content": [
539                {
540                    "type": "text",
541                    "text": format!("Error: {}", error.message)
542                }
543            ],
544            "isError": true
545        })
546    } else {
547        json!({
548            "content": [
549                {
550                    "type": "text",
551                    "text": "No result returned"
552                }
553            ]
554        })
555    };
556
557    Ok(JsonRpcResponse::success(mcp_result, request.id))
558}
559
560/// Handle MCP notifications/initialized request
561pub async fn handle_mcp_notifications_initialized(
562    _state: AppState,
563    _request: JsonRpcRequest,
564) -> ServerResult<ProcessedNotification> {
565    debug!("Handling MCP notifications/initialized");
566
567    // This is a notification - no response is expected
568    // The client is indicating it has finished initialization
569    Ok(ProcessedNotification::processed())
570}
571
572/// Handle MCP methods
573pub async fn handle_mcp_method(
574    state: AppState,
575    request: JsonRpcRequest,
576) -> ServerResult<JsonRpcResponseOrNotification> {
577    match request.method.as_str() {
578        "initialize" => {
579            let response = handle_mcp_initialize(state, request).await?;
580            Ok(JsonRpcResponseOrNotification::response(response))
581        }
582        "tools/list" => {
583            let response = handle_mcp_list_tools(state, request).await?;
584            Ok(JsonRpcResponseOrNotification::response(response))
585        }
586        "tools/call" => {
587            let response = handle_mcp_call_tool(state, request).await?;
588            Ok(JsonRpcResponseOrNotification::response(response))
589        }
590        "prompts/list" => {
591            let response = handle_mcp_list_prompts(state, request).await?;
592            Ok(JsonRpcResponseOrNotification::response(response))
593        }
594        "prompts/get" => {
595            let response = handle_mcp_get_prompt(state, request).await?;
596            Ok(JsonRpcResponseOrNotification::response(response))
597        }
598        "notifications/initialized" => {
599            let notification = handle_mcp_notifications_initialized(state, request).await?;
600            Ok(JsonRpcResponseOrNotification::notification(notification))
601        }
602        _ => Err(ServerError::NotFound(format!(
603            "MCP method '{}' not found",
604            request.method
605        ))),
606    }
607}