1use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20use serde_json::{Value, json};
21
22use super::attached::Attached;
23use super::forward::{Forward, Forwarding, path_of_uri};
24use super::host::{Reply, Request, failed, no_session, one_off};
25use super::{StartInput, takes_project, tool, tools};
26use crate::contract::codes::{self, Code};
27use crate::contract::{Op, VERSION};
28use crate::project::Project;
29
30pub const PROTOCOL_VERSIONS: [&str; 4] = ["2025-06-18", "2025-11-25", "2025-03-26", "2024-11-05"];
33
34const INSTRUCTIONS: &str = "Shuyi Engine's tool contract on a game. Each operation is a tool; \
36 the tools that run the game take `project`, the game's folder. To play a game and look at \
37 it: `start` (with `project`), then `step`, `scene`, `frame_png` and `events` (`perf` for \
38 the frames' times), `reset` to start over, `stop` at the end; call `start` again after changing the game's code. `start` \
39 with `link` attaches the session to a debug build running in its window instead. A failed \
40 answer is an error result with a `code` (`SH0001`): branch on it, and `explain` says what \
41 it means.";
42
43pub const ROOTS: &str = "shuyi-roots";
45
46pub trait Link {
48 fn call(&mut self, request: &Request) -> Result<Reply, String>;
51}
52
53pub trait Launch<'a> {
55 fn launch(&self, project: &Project) -> Result<Box<dyn Link + 'a>, (Code, String)>;
58}
59
60pub struct Server<'a> {
62 cwd: PathBuf,
63 launch: Box<dyn Launch<'a> + 'a>,
64 game: Option<Box<dyn Link + 'a>>,
66 attached: Option<Attached>,
69 command: String,
72 forwarding: Option<Box<dyn Forwarding<'a> + 'a>>,
75 roots: bool,
77 folder: Option<PathBuf>,
79 workspaces: BTreeMap<PathBuf, Option<PathBuf>>,
81 forwarded: BTreeMap<PathBuf, Box<dyn Forward + 'a>>,
83 session_at: Option<PathBuf>,
86 follow: Option<PathBuf>,
89 listing: Option<PathBuf>,
91 tools: Vec<Value>,
93 restarted: bool,
96}
97
98const PARSE_ERROR: i64 = -32700;
100const INVALID_REQUEST: i64 = -32600;
102const METHOD_NOT_FOUND: i64 = -32601;
104const INVALID_PARAMS: i64 = -32602;
106const INTERNAL_ERROR: i64 = -32603;
108
109fn response(id: &Value, result: &Value) -> String {
111 json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string()
112}
113
114fn error(id: &Value, code: i64, message: &str) -> String {
116 json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }).to_string()
117}
118
119pub fn tool_result(answer: &Value, png: Option<String>) -> Value {
123 let text = serde_json::to_string_pretty(answer).unwrap_or_default();
124 let mut content = vec![json!({ "type": "text", "text": text })];
125 if let Some(png) = png {
126 content.push(json!({ "type": "image", "data": png, "mimeType": "image/png" }));
127 }
128 let ok = answer.get("ok").and_then(Value::as_bool).unwrap_or(false);
129 json!({ "content": content, "structuredContent": answer, "isError": !ok })
130}
131
132fn project_root(folder: &Path) -> PathBuf {
134 Project::find(folder).map_or_else(|_| folder.to_path_buf(), |project| project.root)
135}
136
137impl<'a> Server<'a> {
138 pub fn new(cwd: PathBuf, launch: Box<dyn Launch<'a> + 'a>) -> Self {
142 Self {
143 cwd,
144 launch,
145 game: None,
146 attached: None,
147 command: "shuyi".to_owned(),
148 forwarding: None,
149 roots: false,
150 folder: None,
151 workspaces: BTreeMap::new(),
152 forwarded: BTreeMap::new(),
153 session_at: None,
154 follow: None,
155 listing: None,
156 tools: tools(),
157 restarted: false,
158 }
159 }
160
161 #[must_use]
164 pub fn forwarding(mut self, forwarding: Box<dyn Forwarding<'a> + 'a>) -> Self {
165 self.forwarding = Some(forwarding);
166 self
167 }
168
169 pub fn has_game(&self) -> bool {
171 self.game.is_some() || self.attached.is_some() || self.session_at.is_some()
172 }
173
174 pub fn folder(&self) -> &Path {
177 self.folder.as_deref().unwrap_or(&self.cwd)
178 }
179
180 pub fn handle(&mut self, message: &str) -> Vec<String> {
184 if message.trim().is_empty() {
185 return Vec::new();
186 }
187 let Ok(message) = serde_json::from_str::<Value>(message) else {
188 return vec![error(&Value::Null, PARSE_ERROR, "Parse error")];
189 };
190 let Some(method) = message.get("method").and_then(Value::as_str) else {
191 if message.get("result").is_some() || message.get("error").is_some() {
192 if message.get("id") == Some(&json!(ROOTS)) {
194 return self.rooted(&message);
195 }
196 return Vec::new();
197 }
198 let id = message.get("id").cloned().unwrap_or(Value::Null);
199 return vec![error(&id, INVALID_REQUEST, "Invalid Request")];
200 };
201 let params = message.get("params").cloned().unwrap_or(Value::Null);
202 let Some(id) = message.get("id") else {
206 let asks = matches!(
207 method,
208 "notifications/initialized" | "notifications/roots/list_changed"
209 );
210 if asks && self.roots {
211 let ask = json!({ "jsonrpc": "2.0", "id": ROOTS, "method": "roots/list" });
212 return vec![ask.to_string()];
213 }
214 return Vec::new();
215 };
216 vec![match method {
217 "initialize" => {
218 self.roots = params["capabilities"]["roots"].is_object();
219 response(id, &initialize(¶ms))
220 }
221 "ping" => response(id, &json!({})),
222 "tools/list" => response(id, &json!({ "tools": self.tools })),
223 "tools/call" => self.tool_call(id, ¶ms),
224 _ => error(id, METHOD_NOT_FOUND, &format!("Method not found: {method}")),
225 }]
226 }
227
228 fn rooted(&mut self, answer: &Value) -> Vec<String> {
233 let Some(folder) = answer["result"]["roots"][0]["uri"]
234 .as_str()
235 .and_then(path_of_uri)
236 else {
237 return Vec::new();
238 };
239 let root = project_root(&folder);
240 self.folder = Some(folder);
241 if let Some(workspace) = self.forward_to(&root) {
242 self.follow = Some(workspace);
243 return self.poll();
244 }
245 self.follow = None;
248 self.listing = None;
249 if self.tools == tools() {
250 return Vec::new();
251 }
252 self.tools = tools();
253 vec![json!({ "jsonrpc": "2.0", "method": "notifications/tools/list_changed" }).to_string()]
254 }
255
256 pub fn poll(&mut self) -> Vec<String> {
261 let Some(workspace) = self.follow.clone() else {
262 return Vec::new();
263 };
264 let Some(server) = self.forwarded.get_mut(&workspace) else {
265 self.follow = None;
266 return Vec::new();
267 };
268 match server.ready(false) {
269 None => return Vec::new(),
270 Some(Err(_)) => {
271 self.follow = None;
273 return Vec::new();
274 }
275 Some(Ok(())) => self.follow = None,
276 }
277 let Ok(listed) = server.request("tools/list", &json!({})) else {
278 return Vec::new();
279 };
280 match listed["result"]["tools"].as_array() {
281 Some(list) if *list != self.tools => {
282 self.tools.clone_from(list);
283 self.listing = Some(workspace);
284 let changed =
285 json!({ "jsonrpc": "2.0", "method": "notifications/tools/list_changed" });
286 vec![changed.to_string()]
287 }
288 Some(_) => {
289 if self.listing.is_some() {
292 self.listing = Some(workspace);
293 }
294 Vec::new()
295 }
296 None => Vec::new(),
297 }
298 }
299
300 fn workspace_of(&mut self, root: &Path) -> Option<PathBuf> {
302 let forwarding = self.forwarding.as_ref()?;
303 self.workspaces
304 .entry(root.to_path_buf())
305 .or_insert_with(|| forwarding.workspace(root))
306 .clone()
307 }
308
309 fn forward_to(&mut self, root: &Path) -> Option<PathBuf> {
314 let workspace = self.workspace_of(root)?;
315 let own = self.workspace_of(&self.cwd.clone());
316 if own.as_ref() == Some(&workspace) && !self.restarted {
317 return None;
318 }
319 if !self.forwarded.contains_key(&workspace) {
320 let (words, run_in) = super::forward::words(root, &workspace)?;
321 let server = self.forwarding.as_ref()?.spawn(&words, &run_in);
322 self.forwarded.insert(workspace.clone(), server);
323 }
324 Some(workspace)
325 }
326
327 fn forwarded_call(
331 &mut self,
332 workspace: &Path,
333 id: &Value,
334 name: &str,
335 arguments: &Value,
336 ) -> Result<String, String> {
337 let Some(server) = self.forwarded.get_mut(workspace) else {
338 return Err("the project's own server isn't running".to_owned());
339 };
340 let params = json!({ "name": name, "arguments": arguments });
341 match server.request("tools/call", ¶ms) {
342 Ok(mut answer) => {
343 answer["id"] = id.clone();
344 Ok(answer.to_string())
345 }
346 Err(why) => {
347 self.forwarded.remove(workspace);
348 if self.session_at.as_deref() == Some(workspace) {
349 self.session_at = None;
350 }
351 Err(why)
352 }
353 }
354 }
355
356 fn tool_call(&mut self, id: &Value, params: &Value) -> String {
358 let name = params.get("name").and_then(Value::as_str).unwrap_or("");
359 let arguments = params.get("arguments").cloned().unwrap_or(Value::Null);
360 let Some((op, session)) = tool(name) else {
361 let listed = self.tools.iter().any(|tool| tool["name"] == name);
363 if let Some(workspace) = self.listing.clone().filter(|_| listed) {
364 let mut arguments = arguments;
365 if let Some(Value::String(project)) = arguments.get("project") {
366 let project = self.folder().join(project).display().to_string();
367 arguments["project"] = json!(project);
368 }
369 return self
370 .forwarded_call(&workspace, id, name, &arguments)
371 .unwrap_or_else(|why| error(id, INTERNAL_ERROR, &why));
372 }
373 return error(id, INVALID_PARAMS, &format!("Unknown tool: {name}"));
374 };
375 let gone = |op: Op, why: String| {
376 response(
377 id,
378 &tool_result(&failed(op, codes::GAME_PROGRAM, why), None),
379 )
380 };
381 if session && op != Op::Start {
382 if let Some(workspace) = self.session_at.clone() {
383 return self
384 .forwarded_call(&workspace, id, name, &arguments)
385 .unwrap_or_else(|why| gone(op, why));
386 }
387 let (answer, png) = self.call(op, session, arguments);
388 return response(id, &tool_result(&answer, png));
389 }
390 if op == Op::Start && !arguments["link"].is_null() {
391 self.end_session_elsewhere(None);
394 let (answer, png) = self.call(op, session, arguments);
395 return response(id, &tool_result(&answer, png));
396 }
397 let (arguments, root) = self.absolute(op, session, arguments);
398 if let Some(workspace) = self.forward_to(&root) {
399 return self.forwarded_tool(&workspace, id, op, &arguments);
400 }
401 if op == Op::Start {
402 self.end_session_elsewhere(None);
403 }
404 let (answer, png) = self.call(op, session, arguments.clone());
405 if self.forwarding.is_some()
406 && !self.restarted
407 && stale(&answer, VERSION, "").is_some()
408 && self
409 .workspace_of(&root)
410 .is_some_and(|ws| Some(ws) == self.workspace_of(&self.cwd.clone()))
411 {
412 self.restarted = true;
415 if op == Op::Start {
416 self.game = None;
417 }
418 if let Some(workspace) = self.forward_to(&root) {
419 let name = op.name();
420 match self.forwarded_call(&workspace, id, name, &arguments) {
421 Ok(answered) => {
422 if self.follow.is_none() && self.listing.is_none() {
423 self.follow = Some(workspace.clone());
424 }
425 if op == Op::Start && is_ok(&answered) {
426 self.session_at = Some(workspace);
427 }
428 return answered;
429 }
430 Err(why) => {
431 self.restarted = false;
432 let mut answer = answer;
433 let diagnosis = stale_diagnosis(&answer, VERSION);
436 if let Some(limits) = answer["limits"].as_array_mut() {
437 if let Some(diagnosis) = diagnosis {
438 limits.retain(|l| {
439 l.as_str().is_none_or(|l| !l.starts_with(&diagnosis))
440 });
441 limits.push(Value::String(diagnosis));
442 }
443 limits.push(Value::String(format!(
444 "a fresh server of this checkout didn't start: {why}"
445 )));
446 }
447 return response(id, &tool_result(&answer, png));
448 }
449 }
450 }
451 self.restarted = false;
452 }
453 response(id, &tool_result(&answer, png))
454 }
455
456 fn forwarded_tool(
460 &mut self,
461 workspace: &Path,
462 id: &Value,
463 op: Op,
464 arguments: &Value,
465 ) -> String {
466 if op == Op::Start {
467 self.end_session_elsewhere(Some(workspace));
468 }
469 match self.forwarded_call(workspace, id, op.name(), arguments) {
470 Ok(answered) => {
471 if op == Op::Start && is_ok(&answered) {
472 self.session_at = Some(workspace.to_path_buf());
473 }
474 answered
475 }
476 Err(why) => response(
477 id,
478 &tool_result(&failed(op, codes::GAME_PROGRAM, why), None),
479 ),
480 }
481 }
482
483 fn end_session_elsewhere(&mut self, at: Option<&Path>) {
486 if let Some(workspace) = self.session_at.take_if(|w| Some(w.as_path()) != at)
487 && let Some(server) = self.forwarded.get_mut(&workspace)
488 {
489 let _ = server.request("tools/call", &json!({ "name": "stop", "arguments": {} }));
490 }
491 if at.is_some() {
492 self.attached = None;
493 }
494 if at.is_some()
495 && let Some(mut game) = self.game.take()
496 {
497 let _ = game.call(&Request {
498 op: Op::Stop,
499 input: json!({}),
500 });
501 }
502 }
503
504 fn absolute(&self, op: Op, session: bool, arguments: Value) -> (Value, PathBuf) {
509 let mut arguments = if arguments.is_null() {
510 json!({})
511 } else {
512 arguments
513 };
514 let folder = self.folder().to_path_buf();
515 let mut at = folder.clone();
516 if let Some(arguments) = arguments.as_object_mut() {
517 if takes_project(op, session) || op == Op::Start {
518 match arguments.get("project") {
519 Some(Value::String(project)) => at = folder.join(project),
520 None | Some(Value::Null) => {}
521 Some(_) => return (Value::Object(arguments.clone()), project_root(&at)),
523 }
524 arguments.insert("project".to_owned(), json!(at.display().to_string()));
525 }
526 if op == Op::New
527 && let Some(Value::String(dir)) = arguments.get("dir")
528 {
529 let dir = folder.join(dir).display().to_string();
530 arguments.insert("dir".to_owned(), json!(dir));
531 }
532 }
533 let root = project_root(&at);
534 (arguments, root)
535 }
536
537 pub fn call(&mut self, op: Op, session: bool, arguments: Value) -> (Value, Option<String>) {
541 if op == Op::Start {
542 return (self.start(arguments), None);
543 }
544 if session {
545 return self.in_session(op, arguments);
546 }
547 let mut arguments = if arguments.is_null() {
548 json!({})
549 } else {
550 arguments
551 };
552 let folder = match arguments.as_object_mut().map(|a| a.remove("project")) {
553 Some(Some(Value::String(folder))) => Some(folder),
554 Some(None | Some(Value::Null)) | None => None,
555 Some(Some(other)) => {
556 let error = format!("project: a folder's path, not {other}");
557 return (failed(op, codes::WRONG_ARGUMENTS, error), None);
558 }
559 };
560 if folder.is_some() && !takes_project(op, false) {
561 let error = format!("{} takes no project", op.name());
562 return (failed(op, codes::WRONG_ARGUMENTS, error), None);
563 }
564 let project = self.project(folder.as_deref());
565 let runs_in_game = crate::contract::cli::runs_the_game(op.name())
566 && project
567 .as_ref()
568 .is_ok_and(|project| project.config.game.is_some());
569 let cwd = self.folder().to_path_buf();
570 if !runs_in_game {
571 return (one_off(op, arguments, &cwd, &project, None), None);
572 }
573 let Ok(project) = project else {
574 return (one_off(op, arguments, &cwd, &project, None), None);
575 };
576 let mut host = match self.launch.launch(&project) {
577 Ok(host) => host,
578 Err((code, e)) => return (failed(op, code, e), None),
579 };
580 let request = Request {
581 op,
582 input: arguments,
583 };
584 match host.call(&request) {
585 Ok(reply) => answer_of(op, reply, project.config.command()),
586 Err(e) => (failed(op, codes::GAME_PROGRAM, e), None),
587 }
588 }
589
590 fn project(&self, folder: Option<&str>) -> Result<Project, (Code, String)> {
593 let at = self.folder();
594 Project::find(&folder.map_or_else(|| at.to_path_buf(), |f| at.join(f)))
595 }
596
597 fn start(&mut self, arguments: Value) -> Value {
600 let op = Op::Start;
601 let input: StartInput = match serde_json::from_value(if arguments.is_null() {
602 json!({})
603 } else {
604 arguments
605 }) {
606 Ok(input) => input,
607 Err(e) => return failed(op, codes::WRONG_ARGUMENTS, format!("input: {e}")),
608 };
609 let mut limits = Vec::new();
610 if let Some(mut old) = self.game.take() {
611 let stop = Request {
612 op: Op::Stop,
613 input: json!({}),
614 };
615 let frames = old
616 .call(&stop)
617 .ok()
618 .and_then(|reply| reply.answer)
619 .and_then(|answer| answer["result"]["frames"].as_u64());
620 limits.push(match frames {
621 Some(frames) => format!("the game that was running was stopped on frame {frames}"),
622 None => "the game that was running was stopped".to_owned(),
623 });
624 }
625 if self.attached.take().is_some() {
626 limits.push("the build that was attached was left running".to_owned());
627 }
628 if let Some(to) = &input.link {
629 if !input.start.is_empty() || input.session.is_some() {
630 let error = "start: a build attached over the link runs with its own options";
631 return failed(op, codes::WRONG_ARGUMENTS, error);
632 }
633 let (attached, mut answer) = Attached::start(to);
634 self.attached = attached;
635 if let Some(list) = answer["limits"].as_array_mut() {
636 list.extend(limits.into_iter().map(Value::String));
637 }
638 return answer;
639 }
640 let project = match self.project(input.project.as_deref()) {
641 Ok(project) => project,
642 Err((code, e)) => return failed(op, code, e),
643 };
644 if project.config.game.is_none() {
645 let error = format!(
646 "{} names no game tool program (`game`), and start runs the game",
647 crate::project::FILE
648 );
649 return failed(op, codes::PROJECT_FILE, error);
650 }
651 let mut host = match self.launch.launch(&project) {
652 Ok(host) => host,
653 Err((code, e)) => return failed(op, code, e),
654 };
655 let request = Request {
656 op,
657 input: json!({ "start": input.start, "session": input.session }),
658 };
659 let (mut answer, _) = match host.call(&request) {
660 Ok(reply) => answer_of(op, reply, project.config.command()),
661 Err(e) => return failed(op, codes::GAME_PROGRAM, e),
662 };
663 if answer["ok"] == true || !answer["result"].is_null() {
666 self.game = Some(host);
667 project.config.command().clone_into(&mut self.command);
668 }
669 if let Some(list) = answer["limits"].as_array_mut() {
670 list.extend(limits.into_iter().map(Value::String));
671 }
672 answer
673 }
674
675 fn in_session(&mut self, op: Op, arguments: Value) -> (Value, Option<String>) {
677 if let Some(attached) = self.attached.as_mut() {
678 let answered = attached.call(op, arguments);
679 if attached.ended() {
680 self.attached = None;
681 }
682 return answered;
683 }
684 let Some(game) = self.game.as_mut() else {
685 return (no_session(op), None);
686 };
687 let request = Request {
688 op,
689 input: arguments,
690 };
691 match game.call(&request) {
692 Ok(reply) => {
693 let (answer, png) = answer_of(op, reply, &self.command);
694 if op == Op::Stop && answer["ok"] == true {
695 self.game = None;
696 }
697 (answer, png)
698 }
699 Err(e) => {
700 self.game = None;
701 (failed(op, codes::GAME_PROGRAM, e), None)
702 }
703 }
704 }
705}
706
707fn is_ok(answered: &str) -> bool {
710 serde_json::from_str::<Value>(answered).is_ok_and(|answer| answer["result"]["isError"] == false)
711}
712
713fn answer_of(op: Op, reply: Reply, command: &str) -> (Value, Option<String>) {
718 match (reply.answer, reply.error) {
719 (Some(mut answer), _) => {
720 if let Some(limit) = stale(&answer, VERSION, command)
721 && let Some(limits) = answer["limits"].as_array_mut()
722 {
723 limits.push(Value::String(limit));
724 }
725 (answer, reply.png)
726 }
727 (None, error) => {
728 let error = error.unwrap_or_else(|| "the game's tool program answered nothing".into());
729 (failed(op, codes::GAME_PROGRAM, error), None)
730 }
731 }
732}
733
734fn stale_diagnosis(answer: &Value, version: u32) -> Option<String> {
739 let theirs = answer.get("contract")?.as_u64()?;
740 (theirs != u64::from(version)).then(|| {
741 let newer = if theirs > u64::from(version) {
742 "the server was built before the engine changed"
743 } else {
744 "the server is newer than the game's engine (a checkout with an older pin?)"
745 };
746 format!("the game's engine speaks contract {theirs} and this server {version}: {newer}")
747 })
748}
749
750pub fn stale(answer: &Value, version: u32, command: &str) -> Option<String> {
760 let diagnosis = stale_diagnosis(answer, version)?;
761 let theirs = answer.get("contract")?.as_u64()?;
762 Some(if theirs > u64::from(version) {
763 format!("{diagnosis}; reconnect it (in Claude Code: /mcp) to start a fresh one")
764 } else {
765 format!(
766 "{diagnosis}; if the MCP entry runs a `shuyi` installed for every project, register \
767 the project's own (`{command} mcp install`)"
768 )
769 })
770}
771
772fn initialize(params: &Value) -> Value {
774 let asked = params.get("protocolVersion").and_then(Value::as_str);
775 let version = asked
776 .filter(|asked| PROTOCOL_VERSIONS.contains(asked))
777 .unwrap_or(PROTOCOL_VERSIONS[0]);
778 json!({
779 "protocolVersion": version,
780 "capabilities": { "tools": { "listChanged": true } },
781 "serverInfo": { "name": "shuyi", "version": env!("CARGO_PKG_VERSION") },
782 "instructions": INSTRUCTIONS,
783 })
784}
785
786#[cfg(test)]
787mod tests;