1use std::sync::mpsc;
2use std::thread::{self, JoinHandle};
3use std::time::Duration;
4
5use super::{InspectorRequest, RequestSender};
6
7#[derive(Debug)]
9struct McpTool {
10 name: &'static str,
11 description: &'static str,
12 input_schema: &'static str,
14}
15
16static 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
70pub 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 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 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
122fn handle_jsonrpc(body: &str, request_tx: &RequestSender) -> String {
124 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 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(¶ms, "name").unwrap_or_default();
148 let arguments =
149 extract_json_value(¶ms, "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
167fn 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 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 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
227fn 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
241fn 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
252fn 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
285fn build_cors_response() -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
287 build_json_response(204, "")
288}
289
290fn 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}