Skip to main content

shuyi_cli/mcp/
server.rs

1//! The MCP server: JSON-RPC 2.0 messages, one per line ([`Server::handle`]),
2//! the methods `initialize`, `ping`, `tools/list` and `tools/call`, and
3//! the session.
4//!
5//! A tool that runs the game is answered by a host of the game's tools
6//! ([`super::host`]) that a [`Launch`] starts: the game's tool program,
7//! run by cargo ([`super::child::Cargo`]), or one in this process
8//! ([`super::host::InProcess`]). A one-off operation gets a host of its
9//! own; the session keeps one from `start` to `stop`.
10//!
11//! The session's folder is the client's first root (`roots/list`), or the
12//! working folder: a relative `project` is from it. A call whose project
13//! is in another cargo workspace than the server's goes to that
14//! workspace's own server ([`super::forward`]), and the tools the server
15//! lists follow the session folder's.
16
17use 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
30/// The MCP protocol versions the server speaks, newest first: it answers
31/// with the client's if it is one of them, else with the first.
32pub const PROTOCOL_VERSIONS: [&str; 4] = ["2025-06-18", "2025-11-25", "2025-03-26", "2024-11-05"];
33
34/// What the server tells a client it is for.
35const 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
43/// The id of the server's `roots/list` request.
44pub const ROOTS: &str = "shuyi-roots";
45
46/// A host of the game's tools to send requests to.
47pub trait Link {
48    /// The host's reply to `request`, or why there is none: the host
49    /// stopped (with its last output).
50    fn call(&mut self, request: &Request) -> Result<Reply, String>;
51}
52
53/// Starts hosts of a game's tools.
54pub trait Launch<'a> {
55    /// A host of `project`'s game, or the problem code and why there is
56    /// none.
57    fn launch(&self, project: &Project) -> Result<Box<dyn Link + 'a>, (Code, String)>;
58}
59
60/// The server: where it runs, how it starts hosts, and its session.
61pub struct Server<'a> {
62    cwd: PathBuf,
63    launch: Box<dyn Launch<'a> + 'a>,
64    /// The session's game: the host it runs in.
65    game: Option<Box<dyn Link + 'a>>,
66    /// The session's build, when it is attached to one over the live link
67    /// instead.
68    attached: Option<Attached>,
69    /// The session's project's `command`, for what its answers say to
70    /// run.
71    command: String,
72    /// How it starts the projects' own servers: none in one of them, which
73    /// answers every call itself.
74    forwarding: Option<Box<dyn Forwarding<'a> + 'a>>,
75    /// Whether the client lists its roots.
76    roots: bool,
77    /// The session's folder: the client's first root, once it said.
78    folder: Option<PathBuf>,
79    /// The workspace of each project root looked up, none outside one.
80    workspaces: BTreeMap<PathBuf, Option<PathBuf>>,
81    /// The projects' own servers, by workspace.
82    forwarded: BTreeMap<PathBuf, Box<dyn Forward + 'a>>,
83    /// The workspace whose server runs the session's game, if not this
84    /// server.
85    session_at: Option<PathBuf>,
86    /// The workspace whose server's tools the server will list, once it
87    /// has answered `initialize`.
88    follow: Option<PathBuf>,
89    /// The workspace whose server's tools it lists, if not its own.
90    listing: Option<PathBuf>,
91    /// The tools it lists.
92    tools: Vec<Value>,
93    /// Whether its own workspace's calls go to a fresh server of it: the
94    /// engine moved under it.
95    restarted: bool,
96}
97
98/// A JSON-RPC error's code: the message isn't JSON.
99const PARSE_ERROR: i64 = -32700;
100/// The message isn't a request.
101const INVALID_REQUEST: i64 = -32600;
102/// No such method.
103const METHOD_NOT_FOUND: i64 = -32601;
104/// Its parameters are wrong.
105const INVALID_PARAMS: i64 = -32602;
106/// The server couldn't answer.
107const INTERNAL_ERROR: i64 = -32603;
108
109/// A JSON-RPC response to `id` with `result`.
110fn response(id: &Value, result: &Value) -> String {
111    json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string()
112}
113
114/// A JSON-RPC error response to `id`.
115fn error(id: &Value, code: i64, message: &str) -> String {
116    json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }).to_string()
117}
118
119/// A tool's MCP result: the answer as text and as structured content,
120/// the picture if there is one, and an error result unless the answer is
121/// `ok`.
122pub 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
132/// The root of the project at `folder`, or `folder` outside one.
133fn 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    /// A server in the working folder `cwd`, starting hosts with
139    /// `launch`. It answers every call itself until given
140    /// [`Self::forwarding`].
141    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    /// The server, handing each call whose project is in another workspace
162    /// to that workspace's own server, which `forwarding` starts.
163    #[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    /// Whether a game is running in the session.
170    pub fn has_game(&self) -> bool {
171        self.game.is_some() || self.attached.is_some() || self.session_at.is_some()
172    }
173
174    /// The session's folder: the client's first root, or the working
175    /// folder.
176    pub fn folder(&self) -> &Path {
177        self.folder.as_deref().unwrap_or(&self.cwd)
178    }
179
180    /// The messages to write for `message`, one JSON-RPC message: its
181    /// response if it wants one (a notification and a response want none),
182    /// and the server's own request or notification it led to.
183    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                // A response to a request of ours: taken, not answered.
193                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        // A notification (`notifications/initialized`, `…/cancelled`):
203        // nothing to answer, but the roots to ask for, again when they
204        // change.
205        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(&params))
220            }
221            "ping" => response(id, &json!({})),
222            "tools/list" => response(id, &json!({ "tools": self.tools })),
223            "tools/call" => self.tool_call(id, &params),
224            _ => error(id, METHOD_NOT_FOUND, &format!("Method not found: {method}")),
225        }]
226    }
227
228    /// The client's roots, `answer`: the first is the session's folder. If
229    /// it is in another workspace than the server's, that workspace's
230    /// server starts now, so its build runs while the agent works, and the
231    /// server will list its tools.
232    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        // The server's own workspace: its own list, again if the folder
246        // moved back from another one.
247        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    /// The notification the client gets when the tools it should list
257    /// changed: once the followed server has answered `initialize`, its
258    /// list, if it isn't the server's. Asks nothing of a server still
259    /// building.
260    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                // Its next call says why.
272                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                // The same list from another folder's server: its tools
290                // go there now.
291                if self.listing.is_some() {
292                    self.listing = Some(workspace);
293                }
294                Vec::new()
295            }
296            None => Vec::new(),
297        }
298    }
299
300    /// The workspace of the project root `root`, looked up once.
301    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    /// The workspace of the server whose project root is `root`, when
310    /// another server answers for it (one of another workspace, or a fresh
311    /// one of its own once restarted), started now if it isn't running.
312    /// None when this server answers.
313    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    /// The response of the server of `workspace` to the tool `name` with
328    /// `arguments`, under the client's `id`, or why it has none: it
329    /// stopped, and is forgotten, so the next call starts it again.
330    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", &params) {
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    /// The response to `tools/call` with `params`, under `id`.
357    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            // A tool of the list it follows that it doesn't know itself.
362            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            // Attached here, whatever the folder's workspace: the link is
392            // the same wire in every engine of its version.
393            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            // The engine moved under the server: a fresh server of its own
413            // workspace answers this call and every later one.
414            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                        // What stopped the fresh server is the fix, not a
434                        // reconnect: the limit keeps the diagnosis alone.
435                        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    /// The response of the server of `workspace` to the tool of `op` (not
457    /// a session tool, or `start`) with `arguments`, under `id`; a `start`
458    /// there ends the session's game anywhere else.
459    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    /// Stops the session's game unless it runs in the server of `at`
484    /// (this server's own if none): `start` is starting one there.
485    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    /// `arguments` of the tool of `op` (not a session tool, or `start`)
505    /// with `project` made absolute from the session's folder, the folder
506    /// itself if left out, and `new`'s `dir` absolute too; and the root of
507    /// that project (the folder itself outside one).
508    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                    // Wrong: the call says so.
522                    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    /// The answer of the tool of `op` (a session tool if `session`) to
538    /// `arguments`, as JSON, and its picture, if it has one: this server's
539    /// own.
540    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    /// The project of `folder` (from the session's folder), or the
591    /// session folder's own.
592    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    /// `start`: a host of the project's game launched, the game started
598    /// in it, and kept as the session's, in place of one running.
599    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        // A session record that played differently has `ok` false and a
664        // result: the game stands on that frame, to look at.
665        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    /// Session tool `op` with `arguments`, on the session's game.
676    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
707/// Whether the response `answered` holds a tool result that isn't an
708/// error.
709fn is_ok(answered: &str) -> bool {
710    serde_json::from_str::<Value>(answered).is_ok_and(|answer| answer["result"]["isError"] == false)
711}
712
713/// The answer and the picture of `reply` to the tool of `op`, in a
714/// project whose `command` is `command`: a host that answered nothing is a
715/// [`codes::GAME_PROGRAM`] answer, and one of another contract than the
716/// server's gets the limit that says so ([`stale`]).
717fn 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
734/// What a host's `answer` says when its engine speaks another contract
735/// than `version`, the server's: both contracts and which side is newer.
736/// None when they speak the same one: a change of the engine that changes
737/// no schema leaves the server's tools right.
738fn 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
750/// The limit a host's `answer` gets, where no fresh server is started for
751/// it, when its engine speaks another contract than `version`, the
752/// server's (its two contracts and the newer side), and what fixes it: for an older
753/// server, a reconnect (in Claude Code: /mcp) starts a fresh one; for a
754/// newer one, a reconnect doesn't, and the project's own `shuyi` must be
755/// the MCP entry (`command mcp install`) if the entry runs one installed
756/// for every project. A server that forwards first hands the call to a
757/// fresh server of its own workspace, and writes the diagnosis alone,
758/// beside why that one didn't start.
759pub 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
772/// `initialize`'s result: the protocol version, the tools, the server.
773fn 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;