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 let client_version = extract_json_string(¶ms, "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 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(¶ms, "name").unwrap_or_default();
152 let arguments =
153 extract_json_value(¶ms, "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
171fn 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 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 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
231fn 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
245fn 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
256fn 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
289fn build_cors_response() -> tiny_http::Response<std::io::Cursor<Vec<u8>>> {
291 build_json_response(204, "")
292}
293
294fn 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}