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