Skip to main content

arcane_engine/agent/
mcp.rs

1use std::sync::mpsc;
2use std::thread::{self, JoinHandle};
3use std::time::Duration;
4
5use super::{InspectorRequest, RequestSender};
6
7/// MCP tool definition sent to clients in the tools/list response.
8#[derive(Debug)]
9struct McpTool {
10    name: &'static str,
11    description: &'static str,
12    /// JSON Schema for parameters (as a static string).
13    input_schema: &'static str,
14}
15
16/// All available MCP tools.
17static MCP_TOOLS: &[McpTool] = &[
18    McpTool {
19        name: "get_state",
20        description: "Get the full game state or a specific path within it",
21        input_schema: r#"{"type":"object","properties":{"path":{"type":"string","description":"Optional dot-separated path (e.g. 'player.hp')"}}}"#,
22    },
23    McpTool {
24        name: "describe_state",
25        description: "Get a human-readable text description of the game state",
26        input_schema: r#"{"type":"object","properties":{"verbosity":{"type":"string","enum":["minimal","normal","detailed"],"description":"Detail level"}}}"#,
27    },
28    McpTool {
29        name: "list_actions",
30        description: "List all available agent actions with descriptions and argument schemas",
31        input_schema: r#"{"type":"object","properties":{}}"#,
32    },
33    McpTool {
34        name: "execute_action",
35        description: "Execute a named agent action with optional arguments",
36        input_schema: r#"{"type":"object","properties":{"name":{"type":"string","description":"Action name"},"args":{"type":"object","description":"Optional action arguments"}},"required":["name"]}"#,
37    },
38    McpTool {
39        name: "inspect_scene",
40        description: "Query a specific value in the game state by dot-path",
41        input_schema: r#"{"type":"object","properties":{"path":{"type":"string","description":"Dot-separated state path (e.g. 'player.inventory')"}},"required":["path"]}"#,
42    },
43    McpTool {
44        name: "capture_snapshot",
45        description: "Capture a snapshot of the current game state",
46        input_schema: r#"{"type":"object","properties":{}}"#,
47    },
48    McpTool {
49        name: "hot_reload",
50        description: "Trigger a hot reload of the game entry file",
51        input_schema: r#"{"type":"object","properties":{}}"#,
52    },
53    McpTool {
54        name: "run_tests",
55        description: "Run the game's test suite and return results",
56        input_schema: r#"{"type":"object","properties":{}}"#,
57    },
58    McpTool {
59        name: "rewind",
60        description: "Reset game state to initial state (captured at registerAgent time)",
61        input_schema: r#"{"type":"object","properties":{}}"#,
62    },
63    McpTool {
64        name: "simulate_action",
65        description: "Simulate an action without committing state changes",
66        input_schema: r#"{"type":"object","properties":{"name":{"type":"string","description":"Action name"},"args":{"type":"object","description":"Optional action arguments"}},"required":["name"]}"#,
67    },
68];
69
70/// Start the MCP server on a background thread.
71/// The MCP server uses JSON-RPC 2.0 over HTTP (Streamable HTTP transport).
72/// Returns a join handle for the server thread.
73pub fn start_mcp_server(port: u16, request_tx: RequestSender) -> JoinHandle<()> {
74    thread::spawn(move || {
75        let addr = format!("0.0.0.0:{port}");
76        let server = match tiny_http::Server::http(&addr) {
77            Ok(s) => s,
78            Err(e) => {
79                eprintln!("[mcp] Failed to start on {addr}: {e}");
80                return;
81            }
82        };
83
84        eprintln!("[mcp] MCP server listening on http://localhost:{port}");
85
86        for mut request in server.incoming_requests() {
87            let method = request.method().as_str().to_uppercase();
88
89            // Handle CORS preflight
90            if method == "OPTIONS" {
91                let _ = request.respond(build_cors_response());
92                continue;
93            }
94
95            if method != "POST" {
96                let resp = build_json_response(
97                    405,
98                    r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Method not allowed. Use POST."},"id":null}"#,
99                );
100                let _ = request.respond(resp);
101                continue;
102            }
103
104            // Read the request body
105            let mut body = String::new();
106            if request.as_reader().read_to_string(&mut body).is_err() {
107                let resp = build_json_response(
108                    400,
109                    r#"{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null}"#,
110                );
111                let _ = request.respond(resp);
112                continue;
113            }
114
115            let response_body = handle_jsonrpc(&body, &request_tx);
116            let resp = build_json_response(200, &response_body);
117            let _ = request.respond(resp);
118        }
119    })
120}
121
122/// Handle a JSON-RPC 2.0 request and return the response body.
123fn handle_jsonrpc(body: &str, request_tx: &RequestSender) -> String {
124    // Parse the JSON-RPC method and params
125    let rpc_method = extract_json_string(body, "method").unwrap_or_default();
126    let rpc_id = extract_json_value(body, "id").unwrap_or_else(|| "null".to_string());
127    let params = extract_json_value(body, "params").unwrap_or_else(|| "{}".to_string());
128
129    match rpc_method.as_str() {
130        "initialize" => {
131            format!(
132                r#"{{"jsonrpc":"2.0","result":{{"protocolVersion":"2025-03-26","capabilities":{{"tools":{{}}}},"serverInfo":{{"name":"arcane-mcp","version":"0.7.0"}}}},"id":{rpc_id}}}"#,
133            )
134        }
135        "notifications/initialized" => {
136            // Client acknowledgment, no response needed for notifications
137            // But since we got it via HTTP POST, respond with empty result
138            format!(r#"{{"jsonrpc":"2.0","result":null,"id":{rpc_id}}}"#)
139        }
140        "tools/list" => {
141            let tools_json = build_tools_list();
142            format!(
143                r#"{{"jsonrpc":"2.0","result":{{"tools":{tools_json}}},"id":{rpc_id}}}"#,
144            )
145        }
146        "tools/call" => {
147            let tool_name = extract_json_string(&params, "name").unwrap_or_default();
148            let arguments =
149                extract_json_value(&params, "arguments").unwrap_or_else(|| "{}".to_string());
150
151            let result = call_tool(&tool_name, &arguments, request_tx);
152            format!(
153                r#"{{"jsonrpc":"2.0","result":{{"content":[{{"type":"text","text":{result}}}]}},"id":{rpc_id}}}"#,
154            )
155        }
156        "ping" => {
157            format!(r#"{{"jsonrpc":"2.0","result":{{}},"id":{rpc_id}}}"#)
158        }
159        _ => {
160            format!(
161                r#"{{"jsonrpc":"2.0","error":{{"code":-32601,"message":"Method not found: {rpc_method}"}},"id":{rpc_id}}}"#,
162            )
163        }
164    }
165}
166
167/// Call an MCP tool by dispatching to the game loop via the inspector channel.
168fn call_tool(name: &str, arguments: &str, request_tx: &RequestSender) -> String {
169    let inspector_req = match name {
170        "get_state" => {
171            let path = extract_json_string(arguments, "path");
172            InspectorRequest::GetState { path }
173        }
174        "describe_state" => {
175            let verbosity = extract_json_string(arguments, "verbosity");
176            InspectorRequest::Describe { verbosity }
177        }
178        "list_actions" => InspectorRequest::ListActions,
179        "execute_action" => {
180            let action_name = extract_json_string(arguments, "name").unwrap_or_default();
181            let args = extract_json_value(arguments, "args").unwrap_or_else(|| "{}".to_string());
182            InspectorRequest::ExecuteAction {
183                name: action_name,
184                payload: args,
185            }
186        }
187        "inspect_scene" => {
188            let path = extract_json_string(arguments, "path");
189            InspectorRequest::GetState { path }
190        }
191        "capture_snapshot" => InspectorRequest::GetHistory,
192        "hot_reload" => {
193            // Signal a reload via a special simulate action
194            InspectorRequest::Simulate {
195                action: "__hot_reload__".to_string(),
196            }
197        }
198        "run_tests" => InspectorRequest::Simulate {
199            action: "__run_tests__".to_string(),
200        },
201        "rewind" => InspectorRequest::Rewind { steps: 0 },
202        "simulate_action" => {
203            let action_name = extract_json_string(arguments, "name").unwrap_or_default();
204            let args = extract_json_value(arguments, "args").unwrap_or_else(|| "{}".to_string());
205            InspectorRequest::Simulate {
206                action: format!("{{\"name\":\"{action_name}\",\"args\":{args}}}"),
207            }
208        }
209        _ => {
210            return json_encode(&format!("Unknown tool: {name}"));
211        }
212    };
213
214    // Send request to game loop and wait for response
215    let (resp_tx, resp_rx) = mpsc::channel();
216
217    if request_tx.send((inspector_req, resp_tx)).is_err() {
218        return json_encode("Game loop disconnected");
219    }
220
221    match resp_rx.recv_timeout(Duration::from_secs(10)) {
222        Ok(resp) => json_encode(&resp.body),
223        Err(_) => json_encode("Game loop timeout"),
224    }
225}
226
227/// Build the JSON array of tool definitions.
228fn build_tools_list() -> String {
229    let tools: Vec<String> = MCP_TOOLS
230        .iter()
231        .map(|t| {
232            format!(
233                r#"{{"name":"{}","description":"{}","inputSchema":{}}}"#,
234                t.name, t.description, t.input_schema
235            )
236        })
237        .collect();
238    format!("[{}]", tools.join(","))
239}
240
241/// Encode a string as a JSON string value (with escaping).
242fn json_encode(s: &str) -> String {
243    let escaped = s
244        .replace('\\', "\\\\")
245        .replace('"', "\\\"")
246        .replace('\n', "\\n")
247        .replace('\r', "\\r")
248        .replace('\t', "\\t");
249    format!("\"{escaped}\"")
250}
251
252/// Build an HTTP response with JSON-RPC content type.
253fn build_json_response(
254    status: u16,
255    body: &str,
256) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
257    let data = body.as_bytes().to_vec();
258    let data_len = data.len();
259
260    let status = tiny_http::StatusCode(status);
261    let content_type =
262        tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();
263    let cors =
264        tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap();
265    let cors_headers = tiny_http::Header::from_bytes(
266        &b"Access-Control-Allow-Headers"[..],
267        &b"Content-Type"[..],
268    )
269    .unwrap();
270    let cors_methods = tiny_http::Header::from_bytes(
271        &b"Access-Control-Allow-Methods"[..],
272        &b"GET, POST, OPTIONS"[..],
273    )
274    .unwrap();
275
276    tiny_http::Response::new(
277        status,
278        vec![content_type, cors, cors_headers, cors_methods],
279        std::io::Cursor::new(data),
280        Some(data_len),
281        None,
282    )
283}
284
285/// Build a CORS preflight response.
286fn build_cors_response() -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
287    build_json_response(204, "")
288}
289
290// --- Simple JSON extraction (reuse inspector pattern) ---
291
292fn extract_json_string(json: &str, key: &str) -> Option<String> {
293    let pattern = format!("\"{}\"", key);
294    let start = json.find(&pattern)?;
295    let rest = &json[start + pattern.len()..];
296    let rest = rest.trim_start();
297    let rest = rest.strip_prefix(':')?;
298    let rest = rest.trim_start();
299
300    if rest.starts_with('"') {
301        let rest = &rest[1..];
302        let end = rest.find('"')?;
303        Some(rest[..end].to_string())
304    } else {
305        let end = rest
306            .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
307            .unwrap_or(rest.len());
308        let val = rest[..end].to_string();
309        if val == "null" {
310            None
311        } else {
312            Some(val)
313        }
314    }
315}
316
317fn extract_json_value(json: &str, key: &str) -> Option<String> {
318    let pattern = format!("\"{}\"", key);
319    let start = json.find(&pattern)?;
320    let rest = &json[start + pattern.len()..];
321    let rest = rest.trim_start();
322    let rest = rest.strip_prefix(':')?;
323    let rest = rest.trim_start();
324
325    if rest.starts_with('{') {
326        let mut depth = 0;
327        for (i, c) in rest.char_indices() {
328            match c {
329                '{' => depth += 1,
330                '}' => {
331                    depth -= 1;
332                    if depth == 0 {
333                        return Some(rest[..=i].to_string());
334                    }
335                }
336                _ => {}
337            }
338        }
339        None
340    } else if rest.starts_with('[') {
341        let mut depth = 0;
342        for (i, c) in rest.char_indices() {
343            match c {
344                '[' => depth += 1,
345                ']' => {
346                    depth -= 1;
347                    if depth == 0 {
348                        return Some(rest[..=i].to_string());
349                    }
350                }
351                _ => {}
352            }
353        }
354        None
355    } else if rest.starts_with('"') {
356        let inner = &rest[1..];
357        let end = inner.find('"')?;
358        Some(format!("\"{}\"", &inner[..end]))
359    } else {
360        let end = rest
361            .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
362            .unwrap_or(rest.len());
363        Some(rest[..end].to_string())
364    }
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    #[test]
372    fn build_tools_list_is_valid_json_array() {
373        let list = build_tools_list();
374        assert!(list.starts_with('['));
375        assert!(list.ends_with(']'));
376        assert!(list.contains("get_state"));
377        assert!(list.contains("execute_action"));
378        assert!(list.contains("describe_state"));
379    }
380
381    #[test]
382    fn all_tools_have_required_fields() {
383        for tool in MCP_TOOLS {
384            assert!(!tool.name.is_empty());
385            assert!(!tool.description.is_empty());
386            assert!(tool.input_schema.starts_with('{'));
387        }
388    }
389
390    #[test]
391    fn json_encode_escapes_special_chars() {
392        assert_eq!(json_encode("hello"), r#""hello""#);
393        assert_eq!(json_encode(r#"a"b"#), r#""a\"b""#);
394        assert_eq!(json_encode("a\nb"), r#""a\nb""#);
395        assert_eq!(json_encode("a\\b"), r#""a\\b""#);
396    }
397
398    #[test]
399    fn extract_json_string_basic() {
400        let json = r#"{"name": "test", "value": 42}"#;
401        assert_eq!(extract_json_string(json, "name"), Some("test".to_string()));
402    }
403
404    #[test]
405    fn extract_json_string_null() {
406        let json = r#"{"path": null}"#;
407        assert_eq!(extract_json_string(json, "path"), None);
408    }
409
410    #[test]
411    fn extract_json_value_object() {
412        let json = r#"{"args": {"x": 1, "y": 2}}"#;
413        let val = extract_json_value(json, "args");
414        assert_eq!(val, Some(r#"{"x": 1, "y": 2}"#.to_string()));
415    }
416
417    #[test]
418    fn extract_json_value_array() {
419        let json = r#"{"items": [1, 2, 3]}"#;
420        let val = extract_json_value(json, "items");
421        assert_eq!(val, Some("[1, 2, 3]".to_string()));
422    }
423
424    #[test]
425    fn handle_initialize() {
426        let (tx, _rx) = mpsc::channel();
427        let body = r#"{"jsonrpc":"2.0","method":"initialize","id":1}"#;
428        let resp = handle_jsonrpc(body, &tx);
429        assert!(resp.contains("protocolVersion"));
430        assert!(resp.contains("arcane-mcp"));
431        assert!(resp.contains(r#""id":1"#));
432    }
433
434    #[test]
435    fn handle_tools_list() {
436        let (tx, _rx) = mpsc::channel();
437        let body = r#"{"jsonrpc":"2.0","method":"tools/list","id":2}"#;
438        let resp = handle_jsonrpc(body, &tx);
439        assert!(resp.contains("get_state"));
440        assert!(resp.contains("execute_action"));
441        assert!(resp.contains(r#""id":2"#));
442    }
443
444    #[test]
445    fn handle_ping() {
446        let (tx, _rx) = mpsc::channel();
447        let body = r#"{"jsonrpc":"2.0","method":"ping","id":3}"#;
448        let resp = handle_jsonrpc(body, &tx);
449        assert!(resp.contains(r#""result":{}"#));
450        assert!(resp.contains(r#""id":3"#));
451    }
452
453    #[test]
454    fn handle_unknown_method() {
455        let (tx, _rx) = mpsc::channel();
456        let body = r#"{"jsonrpc":"2.0","method":"foo/bar","id":4}"#;
457        let resp = handle_jsonrpc(body, &tx);
458        assert!(resp.contains("error"));
459        assert!(resp.contains("-32601"));
460        assert!(resp.contains("foo/bar"));
461    }
462
463    #[test]
464    fn tool_count() {
465        assert_eq!(MCP_TOOLS.len(), 10);
466    }
467}