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 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 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(¶ms, "name").unwrap_or_default();
149 let arguments =
150 extract_json_value(¶ms, "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
168fn 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 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 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
228fn 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
242fn 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
253fn 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
286fn build_cors_response() -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
288 build_json_response(204, "")
289}
290
291fn 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}