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            let version = env!("CARGO_PKG_VERSION");
132            format!(
133                r#"{{"jsonrpc":"2.0","result":{{"protocolVersion":"2025-03-26","capabilities":{{"tools":{{}}}},"serverInfo":{{"name":"arcane-mcp","version":"{version}"}}}},"id":{rpc_id}}}"#,
134            )
135        }
136        "notifications/initialized" => {
137            // Client acknowledgment, no response needed for notifications
138            // But since we got it via HTTP POST, respond with empty result
139            format!(r#"{{"jsonrpc":"2.0","result":null,"id":{rpc_id}}}"#)
140        }
141        "tools/list" => {
142            let tools_json = build_tools_list();
143            format!(
144                r#"{{"jsonrpc":"2.0","result":{{"tools":{tools_json}}},"id":{rpc_id}}}"#,
145            )
146        }
147        "tools/call" => {
148            let tool_name = extract_json_string(&params, "name").unwrap_or_default();
149            let arguments =
150                extract_json_value(&params, "arguments").unwrap_or_else(|| "{}".to_string());
151
152            let result = call_tool(&tool_name, &arguments, request_tx);
153            format!(
154                r#"{{"jsonrpc":"2.0","result":{{"content":[{{"type":"text","text":{result}}}]}},"id":{rpc_id}}}"#,
155            )
156        }
157        "ping" => {
158            format!(r#"{{"jsonrpc":"2.0","result":{{}},"id":{rpc_id}}}"#)
159        }
160        _ => {
161            format!(
162                r#"{{"jsonrpc":"2.0","error":{{"code":-32601,"message":"Method not found: {rpc_method}"}},"id":{rpc_id}}}"#,
163            )
164        }
165    }
166}
167
168/// Call an MCP tool by dispatching to the game loop via the inspector channel.
169fn call_tool(name: &str, arguments: &str, request_tx: &RequestSender) -> String {
170    let inspector_req = match name {
171        "get_state" => {
172            let path = extract_json_string(arguments, "path");
173            InspectorRequest::GetState { path }
174        }
175        "describe_state" => {
176            let verbosity = extract_json_string(arguments, "verbosity");
177            InspectorRequest::Describe { verbosity }
178        }
179        "list_actions" => InspectorRequest::ListActions,
180        "execute_action" => {
181            let action_name = extract_json_string(arguments, "name").unwrap_or_default();
182            let args = extract_json_value(arguments, "args").unwrap_or_else(|| "{}".to_string());
183            InspectorRequest::ExecuteAction {
184                name: action_name,
185                payload: args,
186            }
187        }
188        "inspect_scene" => {
189            let path = extract_json_string(arguments, "path");
190            InspectorRequest::GetState { path }
191        }
192        "capture_snapshot" => InspectorRequest::GetHistory,
193        "hot_reload" => {
194            // Signal a reload via a special simulate action
195            InspectorRequest::Simulate {
196                action: "__hot_reload__".to_string(),
197            }
198        }
199        "run_tests" => InspectorRequest::Simulate {
200            action: "__run_tests__".to_string(),
201        },
202        "rewind" => InspectorRequest::Rewind { steps: 0 },
203        "simulate_action" => {
204            let action_name = extract_json_string(arguments, "name").unwrap_or_default();
205            let args = extract_json_value(arguments, "args").unwrap_or_else(|| "{}".to_string());
206            InspectorRequest::Simulate {
207                action: format!("{{\"name\":\"{action_name}\",\"args\":{args}}}"),
208            }
209        }
210        _ => {
211            return json_encode(&format!("Unknown tool: {name}"));
212        }
213    };
214
215    // Send request to game loop and wait for response
216    let (resp_tx, resp_rx) = mpsc::channel();
217
218    if request_tx.send((inspector_req, resp_tx)).is_err() {
219        return json_encode("Game loop disconnected");
220    }
221
222    match resp_rx.recv_timeout(Duration::from_secs(10)) {
223        Ok(resp) => json_encode(&resp.body),
224        Err(_) => json_encode("Game loop timeout"),
225    }
226}
227
228/// Build the JSON array of tool definitions.
229fn build_tools_list() -> String {
230    let tools: Vec<String> = MCP_TOOLS
231        .iter()
232        .map(|t| {
233            format!(
234                r#"{{"name":"{}","description":"{}","inputSchema":{}}}"#,
235                t.name, t.description, t.input_schema
236            )
237        })
238        .collect();
239    format!("[{}]", tools.join(","))
240}
241
242/// Encode a string as a JSON string value (with escaping).
243fn json_encode(s: &str) -> String {
244    let escaped = s
245        .replace('\\', "\\\\")
246        .replace('"', "\\\"")
247        .replace('\n', "\\n")
248        .replace('\r', "\\r")
249        .replace('\t', "\\t");
250    format!("\"{escaped}\"")
251}
252
253/// Build an HTTP response with JSON-RPC content type.
254fn build_json_response(
255    status: u16,
256    body: &str,
257) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
258    let data = body.as_bytes().to_vec();
259    let data_len = data.len();
260
261    let status = tiny_http::StatusCode(status);
262    let content_type =
263        tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();
264    let cors =
265        tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap();
266    let cors_headers = tiny_http::Header::from_bytes(
267        &b"Access-Control-Allow-Headers"[..],
268        &b"Content-Type"[..],
269    )
270    .unwrap();
271    let cors_methods = tiny_http::Header::from_bytes(
272        &b"Access-Control-Allow-Methods"[..],
273        &b"GET, POST, OPTIONS"[..],
274    )
275    .unwrap();
276
277    tiny_http::Response::new(
278        status,
279        vec![content_type, cors, cors_headers, cors_methods],
280        std::io::Cursor::new(data),
281        Some(data_len),
282        None,
283    )
284}
285
286/// Build a CORS preflight response.
287fn build_cors_response() -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
288    build_json_response(204, "")
289}
290
291// --- Simple JSON extraction (reuse inspector pattern) ---
292
293fn extract_json_string(json: &str, key: &str) -> Option<String> {
294    let pattern = format!("\"{}\"", key);
295    let start = json.find(&pattern)?;
296    let rest = &json[start + pattern.len()..];
297    let rest = rest.trim_start();
298    let rest = rest.strip_prefix(':')?;
299    let rest = rest.trim_start();
300
301    if rest.starts_with('"') {
302        let rest = &rest[1..];
303        let end = rest.find('"')?;
304        Some(rest[..end].to_string())
305    } else {
306        let end = rest
307            .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
308            .unwrap_or(rest.len());
309        let val = rest[..end].to_string();
310        if val == "null" {
311            None
312        } else {
313            Some(val)
314        }
315    }
316}
317
318fn extract_json_value(json: &str, key: &str) -> Option<String> {
319    let pattern = format!("\"{}\"", key);
320    let start = json.find(&pattern)?;
321    let rest = &json[start + pattern.len()..];
322    let rest = rest.trim_start();
323    let rest = rest.strip_prefix(':')?;
324    let rest = rest.trim_start();
325
326    if rest.starts_with('{') {
327        let mut depth = 0;
328        for (i, c) in rest.char_indices() {
329            match c {
330                '{' => depth += 1,
331                '}' => {
332                    depth -= 1;
333                    if depth == 0 {
334                        return Some(rest[..=i].to_string());
335                    }
336                }
337                _ => {}
338            }
339        }
340        None
341    } else if rest.starts_with('[') {
342        let mut depth = 0;
343        for (i, c) in rest.char_indices() {
344            match c {
345                '[' => depth += 1,
346                ']' => {
347                    depth -= 1;
348                    if depth == 0 {
349                        return Some(rest[..=i].to_string());
350                    }
351                }
352                _ => {}
353            }
354        }
355        None
356    } else if rest.starts_with('"') {
357        let inner = &rest[1..];
358        let end = inner.find('"')?;
359        Some(format!("\"{}\"", &inner[..end]))
360    } else {
361        let end = rest
362            .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
363            .unwrap_or(rest.len());
364        Some(rest[..end].to_string())
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn build_tools_list_is_valid_json_array() {
374        let list = build_tools_list();
375        assert!(list.starts_with('['));
376        assert!(list.ends_with(']'));
377        assert!(list.contains("get_state"));
378        assert!(list.contains("execute_action"));
379        assert!(list.contains("describe_state"));
380    }
381
382    #[test]
383    fn all_tools_have_required_fields() {
384        for tool in MCP_TOOLS {
385            assert!(!tool.name.is_empty());
386            assert!(!tool.description.is_empty());
387            assert!(tool.input_schema.starts_with('{'));
388        }
389    }
390
391    #[test]
392    fn json_encode_escapes_special_chars() {
393        assert_eq!(json_encode("hello"), r#""hello""#);
394        assert_eq!(json_encode(r#"a"b"#), r#""a\"b""#);
395        assert_eq!(json_encode("a\nb"), r#""a\nb""#);
396        assert_eq!(json_encode("a\\b"), r#""a\\b""#);
397    }
398
399    #[test]
400    fn extract_json_string_basic() {
401        let json = r#"{"name": "test", "value": 42}"#;
402        assert_eq!(extract_json_string(json, "name"), Some("test".to_string()));
403    }
404
405    #[test]
406    fn extract_json_string_null() {
407        let json = r#"{"path": null}"#;
408        assert_eq!(extract_json_string(json, "path"), None);
409    }
410
411    #[test]
412    fn extract_json_value_object() {
413        let json = r#"{"args": {"x": 1, "y": 2}}"#;
414        let val = extract_json_value(json, "args");
415        assert_eq!(val, Some(r#"{"x": 1, "y": 2}"#.to_string()));
416    }
417
418    #[test]
419    fn extract_json_value_array() {
420        let json = r#"{"items": [1, 2, 3]}"#;
421        let val = extract_json_value(json, "items");
422        assert_eq!(val, Some("[1, 2, 3]".to_string()));
423    }
424
425    #[test]
426    fn handle_initialize() {
427        let (tx, _rx) = mpsc::channel();
428        let body = r#"{"jsonrpc":"2.0","method":"initialize","id":1}"#;
429        let resp = handle_jsonrpc(body, &tx);
430        assert!(resp.contains("protocolVersion"));
431        assert!(resp.contains("arcane-mcp"));
432        assert!(resp.contains(r#""id":1"#));
433    }
434
435    #[test]
436    fn handle_tools_list() {
437        let (tx, _rx) = mpsc::channel();
438        let body = r#"{"jsonrpc":"2.0","method":"tools/list","id":2}"#;
439        let resp = handle_jsonrpc(body, &tx);
440        assert!(resp.contains("get_state"));
441        assert!(resp.contains("execute_action"));
442        assert!(resp.contains(r#""id":2"#));
443    }
444
445    #[test]
446    fn handle_ping() {
447        let (tx, _rx) = mpsc::channel();
448        let body = r#"{"jsonrpc":"2.0","method":"ping","id":3}"#;
449        let resp = handle_jsonrpc(body, &tx);
450        assert!(resp.contains(r#""result":{}"#));
451        assert!(resp.contains(r#""id":3"#));
452    }
453
454    #[test]
455    fn handle_unknown_method() {
456        let (tx, _rx) = mpsc::channel();
457        let body = r#"{"jsonrpc":"2.0","method":"foo/bar","id":4}"#;
458        let resp = handle_jsonrpc(body, &tx);
459        assert!(resp.contains("error"));
460        assert!(resp.contains("-32601"));
461        assert!(resp.contains("foo/bar"));
462    }
463
464    #[test]
465    fn tool_count() {
466        assert_eq!(MCP_TOOLS.len(), 10);
467    }
468}