1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::mpsc;
3use std::sync::Arc;
4use std::thread::{self, JoinHandle};
5use std::time::Duration;
6
7use super::{InspectorRequest, RequestSender};
8
9#[derive(Debug)]
11struct McpTool {
12 name: &'static str,
13 description: &'static str,
14 input_schema: &'static str,
16}
17
18static MCP_TOOLS: &[McpTool] = &[
20 McpTool {
21 name: "get_state",
22 description: "Get the full game state or a specific path within it",
23 input_schema: r#"{"type":"object","properties":{"path":{"type":"string","description":"Optional dot-separated path (e.g. 'player.hp')"}}}"#,
24 },
25 McpTool {
26 name: "describe_state",
27 description: "Get a human-readable text description of the game state",
28 input_schema: r#"{"type":"object","properties":{"verbosity":{"type":"string","enum":["minimal","normal","detailed"],"description":"Detail level"}}}"#,
29 },
30 McpTool {
31 name: "list_actions",
32 description: "List all available agent actions with descriptions and argument schemas",
33 input_schema: r#"{"type":"object","properties":{}}"#,
34 },
35 McpTool {
36 name: "execute_action",
37 description: "Execute a named agent action with optional arguments",
38 input_schema: r#"{"type":"object","properties":{"name":{"type":"string","description":"Action name"},"args":{"type":"object","description":"Optional action arguments"}},"required":["name"]}"#,
39 },
40 McpTool {
41 name: "inspect_scene",
42 description: "Query a specific value in the game state by dot-path",
43 input_schema: r#"{"type":"object","properties":{"path":{"type":"string","description":"Dot-separated state path (e.g. 'player.inventory')"}},"required":["path"]}"#,
44 },
45 McpTool {
46 name: "capture_snapshot",
47 description: "Capture a snapshot of the current game state",
48 input_schema: r#"{"type":"object","properties":{}}"#,
49 },
50 McpTool {
51 name: "hot_reload",
52 description: "Trigger a hot reload of the game entry file",
53 input_schema: r#"{"type":"object","properties":{}}"#,
54 },
55 McpTool {
56 name: "run_tests",
57 description: "Run the game's test suite and return results",
58 input_schema: r#"{"type":"object","properties":{}}"#,
59 },
60 McpTool {
61 name: "rewind",
62 description: "Reset game state to initial state (captured at registerAgent time)",
63 input_schema: r#"{"type":"object","properties":{}}"#,
64 },
65 McpTool {
66 name: "simulate_action",
67 description: "Simulate an action without committing state changes",
68 input_schema: r#"{"type":"object","properties":{"name":{"type":"string","description":"Action name"},"args":{"type":"object","description":"Optional action arguments"}},"required":["name"]}"#,
69 },
70 McpTool {
71 name: "get_frame_stats",
72 description: "Get frame timing statistics (frame time, draw calls, FPS)",
73 input_schema: r#"{"type":"object","properties":{}}"#,
74 },
75 McpTool {
76 name: "capture_frame",
77 description: "Capture the current rendered frame as a PNG image (with optional scaling to reduce file size)",
78 input_schema: r#"{"type":"object","properties":{"scale":{"type":"number","description":"Scale factor (0.1-1.0). Default 1.0. Use 0.5 to reduce file size to ~25%.","default":1.0},"regionX":{"type":"integer","description":"Region crop X coordinate (optional)"},"regionY":{"type":"integer","description":"Region crop Y coordinate (optional)"},"regionWidth":{"type":"integer","description":"Region crop width (optional)"},"regionHeight":{"type":"integer","description":"Region crop height (optional)"}}}"#,
79 },
80];
81
82pub fn start_mcp_server(
88 port: u16,
89 request_tx: RequestSender,
90 reload_flag: Arc<AtomicBool>,
91) -> (JoinHandle<()>, mpsc::Receiver<u16>) {
92 let (port_tx, port_rx) = mpsc::channel();
93 let handle = thread::spawn(move || {
94 let addr = format!("0.0.0.0:{port}");
95 let server = match tiny_http::Server::http(&addr) {
96 Ok(s) => s,
97 Err(e) => {
98 eprintln!("[mcp] Failed to start on {addr}: {e}");
99 return;
100 }
101 };
102
103 let actual_port = match server.server_addr() {
105 tiny_http::ListenAddr::IP(addr) => addr.port(),
106 _ => port,
107 };
108 let _ = port_tx.send(actual_port);
109
110 eprintln!("[mcp] MCP server listening on http://localhost:{actual_port}");
111
112 for mut request in server.incoming_requests() {
113 let method = request.method().as_str().to_uppercase();
114
115 if method == "OPTIONS" {
117 let _ = request.respond(build_cors_response());
118 continue;
119 }
120
121 if method != "POST" {
122 let resp = build_json_response(
123 405,
124 r#"{"jsonrpc":"2.0","error":{"code":-32600,"message":"Method not allowed. Use POST."},"id":null}"#,
125 );
126 let _ = request.respond(resp);
127 continue;
128 }
129
130 let mut body = String::new();
132 if request.as_reader().read_to_string(&mut body).is_err() {
133 let resp = build_json_response(
134 400,
135 r#"{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"},"id":null}"#,
136 );
137 let _ = request.respond(resp);
138 continue;
139 }
140
141 let response_body = handle_jsonrpc(&body, &request_tx, &reload_flag);
142 let resp = build_json_response(200, &response_body);
143 let _ = request.respond(resp);
144 }
145 });
146 (handle, port_rx)
147}
148
149fn handle_jsonrpc(body: &str, request_tx: &RequestSender, reload_flag: &Arc<AtomicBool>) -> String {
151 let rpc_method = extract_json_string(body, "method").unwrap_or_default();
153 let rpc_id = extract_json_value(body, "id").unwrap_or_else(|| "null".to_string());
154 let params = extract_json_value(body, "params").unwrap_or_else(|| "{}".to_string());
155
156 match rpc_method.as_str() {
157 "initialize" => {
158 let version = env!("CARGO_PKG_VERSION");
159 let client_version = extract_json_string(¶ms, "protocolVersion")
161 .unwrap_or_else(|| "2024-11-05".to_string());
162 format!(
163 r#"{{"jsonrpc":"2.0","result":{{"protocolVersion":"{client_version}","capabilities":{{"tools":{{}}}},"serverInfo":{{"name":"arcane-mcp","version":"{version}"}}}},"id":{rpc_id}}}"#,
164 )
165 }
166 "notifications/initialized" => {
167 format!(r#"{{"jsonrpc":"2.0","result":null,"id":{rpc_id}}}"#)
170 }
171 "tools/list" => {
172 let tools_json = build_tools_list();
173 format!(
174 r#"{{"jsonrpc":"2.0","result":{{"tools":{tools_json}}},"id":{rpc_id}}}"#,
175 )
176 }
177 "tools/call" => {
178 let tool_name = extract_json_string(¶ms, "name").unwrap_or_default();
179 let arguments =
180 extract_json_value(¶ms, "arguments").unwrap_or_else(|| "{}".to_string());
181
182 let result = call_tool(&tool_name, &arguments, request_tx, reload_flag);
183 let content = match result {
184 ToolResult::Text(text) => {
185 format!(r#"{{"type":"text","text":{text}}}"#)
186 }
187 ToolResult::Image { base64, mime_type } => {
188 format!(r#"{{"type":"image","data":"{base64}","mimeType":"{mime_type}"}}"#)
189 }
190 };
191 format!(
192 r#"{{"jsonrpc":"2.0","result":{{"content":[{content}]}},"id":{rpc_id}}}"#,
193 )
194 }
195 "ping" => {
196 format!(r#"{{"jsonrpc":"2.0","result":{{}},"id":{rpc_id}}}"#)
197 }
198 _ => {
199 format!(
200 r#"{{"jsonrpc":"2.0","error":{{"code":-32601,"message":"Method not found: {rpc_method}"}},"id":{rpc_id}}}"#,
201 )
202 }
203 }
204}
205
206enum ToolResult {
208 Text(String),
209 Image { base64: String, mime_type: String },
210}
211
212fn call_tool(name: &str, arguments: &str, request_tx: &RequestSender, reload_flag: &Arc<AtomicBool>) -> ToolResult {
214 let inspector_req = match name {
215 "get_state" => {
216 let path = extract_json_string(arguments, "path");
217 InspectorRequest::GetState { path }
218 }
219 "describe_state" => {
220 let verbosity = extract_json_string(arguments, "verbosity");
221 InspectorRequest::Describe { verbosity }
222 }
223 "list_actions" => InspectorRequest::ListActions,
224 "execute_action" => {
225 let action_name = extract_json_string(arguments, "name").unwrap_or_default();
226 let args = extract_json_value(arguments, "args").unwrap_or_else(|| "{}".to_string());
227 InspectorRequest::ExecuteAction {
228 name: action_name,
229 payload: args,
230 }
231 }
232 "inspect_scene" => {
233 let path = extract_json_string(arguments, "path");
234 InspectorRequest::GetState { path }
235 }
236 "capture_snapshot" => InspectorRequest::GetHistory,
237 "hot_reload" => {
238 let (probe_tx, _probe_rx) = mpsc::channel();
242 if request_tx.send((InspectorRequest::Health, probe_tx)).is_err() {
243 return ToolResult::Text(json_encode(
244 "{\"ok\":false,\"error\":\"Game window is not running. Start it with: arcane dev src/visual.ts\"}",
245 ));
246 }
247 reload_flag.store(true, Ordering::SeqCst);
252 return ToolResult::Text(json_encode("{\"ok\":true,\"reloading\":true}"));
253 }
254 "run_tests" => {
255 let exe = std::env::current_exe().unwrap_or_else(|_| "arcane".into());
259 match std::process::Command::new(&exe)
260 .arg("test")
261 .output()
262 {
263 Ok(output) => {
264 let stdout = String::from_utf8_lossy(&output.stdout);
265 let stderr = String::from_utf8_lossy(&output.stderr);
266 let combined = if stderr.is_empty() {
267 stdout.to_string()
268 } else {
269 format!("{stdout}\n{stderr}")
270 };
271 let summary = if output.status.success() {
272 format!("Tests passed.\n\n{combined}")
273 } else {
274 format!("Tests failed.\n\n{combined}")
275 };
276 return ToolResult::Text(json_encode(&summary));
277 }
278 Err(e) => {
279 return ToolResult::Text(json_encode(&format!("Failed to run arcane test: {e}")));
280 }
281 }
282 }
283 "rewind" => InspectorRequest::Rewind { steps: 0 },
284 "simulate_action" => {
285 let action_name = extract_json_string(arguments, "name").unwrap_or_default();
286 let args = extract_json_value(arguments, "args").unwrap_or_else(|| "{}".to_string());
287 InspectorRequest::Simulate {
288 action: format!("{{\"name\":\"{action_name}\",\"args\":{args}}}"),
289 }
290 }
291 "get_frame_stats" => InspectorRequest::GetFrameStats,
292 "capture_frame" => {
293 let scale = extract_json_number(arguments, "scale").unwrap_or(1.0) as f32;
294 let scale = scale.clamp(0.1, 1.0);
295 let region_x = extract_json_number(arguments, "regionX").and_then(|n| Some(n as u32));
296 let region_y = extract_json_number(arguments, "regionY").and_then(|n| Some(n as u32));
297 let region_w = extract_json_number(arguments, "regionWidth").and_then(|n| Some(n as u32));
298 let region_h = extract_json_number(arguments, "regionHeight").and_then(|n| Some(n as u32));
299
300 let region = match (region_x, region_y, region_w, region_h) {
301 (Some(x), Some(y), Some(w), Some(h)) if w > 0 && h > 0 => Some((x, y, w, h)),
302 _ => None,
303 };
304
305 InspectorRequest::CaptureFrame {
306 options: crate::agent::CaptureFrameOptions { scale, region },
307 }
308 }
309 _ => {
310 return ToolResult::Text(json_encode(&format!("Unknown tool: {name}")));
311 }
312 };
313
314 let (resp_tx, resp_rx) = mpsc::channel();
316
317 if request_tx.send((inspector_req, resp_tx)).is_err() {
318 return ToolResult::Text(json_encode("Game loop disconnected"));
319 }
320
321 match resp_rx.recv_timeout(Duration::from_secs(10)) {
322 Ok(resp) => {
323 if resp.content_type == "image/png" {
325 ToolResult::Image {
326 base64: resp.body,
327 mime_type: "image/png".into(),
328 }
329 } else {
330 ToolResult::Text(json_encode(&resp.body))
331 }
332 }
333 Err(_) => ToolResult::Text(json_encode("Game loop timeout")),
334 }
335}
336
337fn build_tools_list() -> String {
339 let tools: Vec<String> = MCP_TOOLS
340 .iter()
341 .map(|t| {
342 format!(
343 r#"{{"name":"{}","description":"{}","inputSchema":{}}}"#,
344 t.name, t.description, t.input_schema
345 )
346 })
347 .collect();
348 format!("[{}]", tools.join(","))
349}
350
351fn json_encode(s: &str) -> String {
353 let escaped = s
354 .replace('\\', "\\\\")
355 .replace('"', "\\\"")
356 .replace('\n', "\\n")
357 .replace('\r', "\\r")
358 .replace('\t', "\\t");
359 format!("\"{escaped}\"")
360}
361
362pub fn base64_encode(data: &[u8]) -> String {
364 const ALPHABET: &[u8; 64] =
365 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
366
367 let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
368 for chunk in data.chunks(3) {
369 let b0 = chunk[0] as u32;
370 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
371 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
372 let triple = (b0 << 16) | (b1 << 8) | b2;
373
374 out.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char);
375 out.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char);
376 if chunk.len() > 1 {
377 out.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char);
378 } else {
379 out.push('=');
380 }
381 if chunk.len() > 2 {
382 out.push(ALPHABET[(triple & 0x3F) as usize] as char);
383 } else {
384 out.push('=');
385 }
386 }
387 out
388}
389
390fn build_json_response(
392 status: u16,
393 body: &str,
394) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
395 let data = body.as_bytes().to_vec();
396 let data_len = data.len();
397
398 let status = tiny_http::StatusCode(status);
399 let content_type =
400 tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap();
401 let cors =
402 tiny_http::Header::from_bytes(&b"Access-Control-Allow-Origin"[..], &b"*"[..]).unwrap();
403 let cors_headers = tiny_http::Header::from_bytes(
404 &b"Access-Control-Allow-Headers"[..],
405 &b"Content-Type"[..],
406 )
407 .unwrap();
408 let cors_methods = tiny_http::Header::from_bytes(
409 &b"Access-Control-Allow-Methods"[..],
410 &b"GET, POST, OPTIONS"[..],
411 )
412 .unwrap();
413
414 tiny_http::Response::new(
415 status,
416 vec![content_type, cors, cors_headers, cors_methods],
417 std::io::Cursor::new(data),
418 Some(data_len),
419 None,
420 )
421}
422
423fn build_cors_response() -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
425 build_json_response(204, "")
426}
427
428fn extract_json_string(json: &str, key: &str) -> Option<String> {
431 let pattern = format!("\"{}\"", key);
432 let start = json.find(&pattern)?;
433 let rest = &json[start + pattern.len()..];
434 let rest = rest.trim_start();
435 let rest = rest.strip_prefix(':')?;
436 let rest = rest.trim_start();
437
438 if rest.starts_with('"') {
439 let rest = &rest[1..];
440 let end = rest.find('"')?;
441 Some(rest[..end].to_string())
442 } else {
443 let end = rest
444 .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
445 .unwrap_or(rest.len());
446 let val = rest[..end].to_string();
447 if val == "null" {
448 None
449 } else {
450 Some(val)
451 }
452 }
453}
454
455fn extract_json_number(json: &str, key: &str) -> Option<f64> {
456 let pattern = format!("\"{}\"", key);
457 let start = json.find(&pattern)?;
458 let rest = &json[start + pattern.len()..];
459 let rest = rest.trim_start();
460 let rest = rest.strip_prefix(':')?;
461 let rest = rest.trim_start();
462
463 let end = rest
464 .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
465 .unwrap_or(rest.len());
466 rest[..end].parse::<f64>().ok()
467}
468
469fn extract_json_value(json: &str, key: &str) -> Option<String> {
470 let pattern = format!("\"{}\"", key);
471 let start = json.find(&pattern)?;
472 let rest = &json[start + pattern.len()..];
473 let rest = rest.trim_start();
474 let rest = rest.strip_prefix(':')?;
475 let rest = rest.trim_start();
476
477 if rest.starts_with('{') {
478 let mut depth = 0;
479 for (i, c) in rest.char_indices() {
480 match c {
481 '{' => depth += 1,
482 '}' => {
483 depth -= 1;
484 if depth == 0 {
485 return Some(rest[..=i].to_string());
486 }
487 }
488 _ => {}
489 }
490 }
491 None
492 } else if rest.starts_with('[') {
493 let mut depth = 0;
494 for (i, c) in rest.char_indices() {
495 match c {
496 '[' => depth += 1,
497 ']' => {
498 depth -= 1;
499 if depth == 0 {
500 return Some(rest[..=i].to_string());
501 }
502 }
503 _ => {}
504 }
505 }
506 None
507 } else if rest.starts_with('"') {
508 let inner = &rest[1..];
509 let end = inner.find('"')?;
510 Some(format!("\"{}\"", &inner[..end]))
511 } else {
512 let end = rest
513 .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace())
514 .unwrap_or(rest.len());
515 Some(rest[..end].to_string())
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn build_tools_list_is_valid_json_array() {
525 let list = build_tools_list();
526 assert!(list.starts_with('['));
527 assert!(list.ends_with(']'));
528 assert!(list.contains("get_state"));
529 assert!(list.contains("execute_action"));
530 assert!(list.contains("describe_state"));
531 }
532
533 #[test]
534 fn all_tools_have_required_fields() {
535 for tool in MCP_TOOLS {
536 assert!(!tool.name.is_empty());
537 assert!(!tool.description.is_empty());
538 assert!(tool.input_schema.starts_with('{'));
539 }
540 }
541
542 #[test]
543 fn json_encode_escapes_special_chars() {
544 assert_eq!(json_encode("hello"), r#""hello""#);
545 assert_eq!(json_encode(r#"a"b"#), r#""a\"b""#);
546 assert_eq!(json_encode("a\nb"), r#""a\nb""#);
547 assert_eq!(json_encode("a\\b"), r#""a\\b""#);
548 }
549
550 #[test]
551 fn extract_json_string_basic() {
552 let json = r#"{"name": "test", "value": 42}"#;
553 assert_eq!(extract_json_string(json, "name"), Some("test".to_string()));
554 }
555
556 #[test]
557 fn extract_json_string_null() {
558 let json = r#"{"path": null}"#;
559 assert_eq!(extract_json_string(json, "path"), None);
560 }
561
562 #[test]
563 fn extract_json_value_object() {
564 let json = r#"{"args": {"x": 1, "y": 2}}"#;
565 let val = extract_json_value(json, "args");
566 assert_eq!(val, Some(r#"{"x": 1, "y": 2}"#.to_string()));
567 }
568
569 #[test]
570 fn extract_json_value_array() {
571 let json = r#"{"items": [1, 2, 3]}"#;
572 let val = extract_json_value(json, "items");
573 assert_eq!(val, Some("[1, 2, 3]".to_string()));
574 }
575
576 fn test_reload_flag() -> Arc<AtomicBool> {
577 Arc::new(AtomicBool::new(false))
578 }
579
580 #[test]
581 fn handle_initialize() {
582 let (tx, _rx) = mpsc::channel();
583 let flag = test_reload_flag();
584 let body = r#"{"jsonrpc":"2.0","method":"initialize","id":1}"#;
585 let resp = handle_jsonrpc(body, &tx, &flag);
586 assert!(resp.contains("protocolVersion"));
587 assert!(resp.contains("arcane-mcp"));
588 assert!(resp.contains(r#""id":1"#));
589 }
590
591 #[test]
592 fn handle_tools_list() {
593 let (tx, _rx) = mpsc::channel();
594 let flag = test_reload_flag();
595 let body = r#"{"jsonrpc":"2.0","method":"tools/list","id":2}"#;
596 let resp = handle_jsonrpc(body, &tx, &flag);
597 assert!(resp.contains("get_state"));
598 assert!(resp.contains("execute_action"));
599 assert!(resp.contains(r#""id":2"#));
600 }
601
602 #[test]
603 fn handle_ping() {
604 let (tx, _rx) = mpsc::channel();
605 let flag = test_reload_flag();
606 let body = r#"{"jsonrpc":"2.0","method":"ping","id":3}"#;
607 let resp = handle_jsonrpc(body, &tx, &flag);
608 assert!(resp.contains(r#""result":{}"#));
609 assert!(resp.contains(r#""id":3"#));
610 }
611
612 #[test]
613 fn handle_unknown_method() {
614 let (tx, _rx) = mpsc::channel();
615 let flag = test_reload_flag();
616 let body = r#"{"jsonrpc":"2.0","method":"foo/bar","id":4}"#;
617 let resp = handle_jsonrpc(body, &tx, &flag);
618 assert!(resp.contains("error"));
619 assert!(resp.contains("-32601"));
620 assert!(resp.contains("foo/bar"));
621 }
622
623 #[test]
624 fn tool_count() {
625 assert_eq!(MCP_TOOLS.len(), 12);
626 }
627
628 #[test]
629 fn base64_encode_basic() {
630 assert_eq!(base64_encode(b"Hello"), "SGVsbG8=");
631 assert_eq!(base64_encode(b""), "");
632 assert_eq!(base64_encode(b"f"), "Zg==");
633 assert_eq!(base64_encode(b"fo"), "Zm8=");
634 assert_eq!(base64_encode(b"foo"), "Zm9v");
635 }
636}