Skip to main content

shuyi_cli/
mcp.rs

1//! `shuyi mcp`: the tool contract as an MCP server over stdio (ADR-0005
2//! ยง2, ADR-0031), and `shuyi mcp install`, which registers it with the
3//! agents it finds ([`install`]).
4//!
5//! Each operation of the contract is a tool, its input the operation's
6//! input ([`crate::contract::op_schemas`]), its answer the operation's
7//! [`Answer`](crate::contract::Answer). The tools that run the game take
8//! `project`, the game's folder, from the session's folder (the client's
9//! first root, or the server's working folder); the session folder's own
10//! project if it is left out. A project in another cargo workspace than
11//! the server's is answered by that workspace's own server ([`forward`]).
12//!
13//! A **session** keeps one game running headless between calls, so an
14//! agent can play it, look at it and play on:
15//!
16//! | Tool | Does | Answers as |
17//! | ---- | ---- | ---------- |
18//! | `start` | builds the game's tool program and starts its game as its options say | [`StartOutput`] |
19//! | `step` | plays a script, then runs idle frames | `run`'s answer |
20//! | `scene` | the scene on screen now; with `path` one value of its data, with `shape` its shape | `scene`'s answer |
21//! | `frame_png` | a picture of the frame now, as an image | [`FramePngOutput`] and the PNG |
22//! | `events` | what happened since the start | [`EventsOutput`] |
23//! | `perf` | the frames' records since a number, and their summary; a heap snapshot and a diff between two | [`PerfOutput`] |
24//! | `reset` | the game started again as `start` started it | [`StartOutput`] |
25//! | `stop` | the game stopped | [`StopOutput`] |
26//!
27//! `start` with `session` starts from a session record instead, played to
28//! its end, and `reset` plays it again.
29//!
30//! `start` with `link` attaches the session to a debug build already
31//! running in its window instead, over the live link ([`attached`]).
32//!
33//! The one-off `scene` operation is the session's `scene` here: `run`
34//! answers with the scene of a one-off run.
35//!
36//! The server runs no game itself. A tool that runs the game runs it in
37//! the game's tool program (`shuyi.ron`'s `game`), started with `cargo
38//! run` as `shuyi mcp-host` ([`host`]); the session keeps that program
39//! running until `stop`, so `start` builds the game's latest code. A
40//! failed answer is an MCP error result with the answer as it is, its
41//! `code` and its `help`.
42
43pub mod aside;
44pub mod attached;
45pub mod child;
46pub mod forward;
47pub mod host;
48pub mod install;
49pub mod server;
50
51use std::io::{BufRead, BufReader, Write};
52use std::path::Path;
53use std::process::Command;
54use std::sync::mpsc::{RecvTimeoutError, channel};
55use std::time::Duration;
56
57use schemars::JsonSchema;
58use serde::{Deserialize, Serialize};
59use serde_json::{Map, Value, json};
60
61use crate::contract::perf::PerfSummary;
62use crate::contract::session::{AddedSound, Event, SessionInput, SessionPlayed, Sound};
63use crate::contract::{Op, VERSION, op_schemas};
64use crate::frame_png::Script;
65use crate::game::{GameArg, GameOption};
66use crate::project::Project;
67use shuyi_ui::perf::{FrameRecord, HeapDiff, HeapSnapshot, PERF_FRAMES};
68
69/// A session tool's input that takes nothing.
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
71#[serde(deny_unknown_fields)]
72pub struct NoInput {}
73
74/// What the session's `scene` answers beyond the scene: one value of its
75/// data, or its data's shape, either in place of the scene.
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields, default)]
78// An input: built with `default()` and its fields assigned, it may grow
79// (ADR-0039).
80#[non_exhaustive]
81pub struct SceneInput {
82    /// Answer only the value at this JSON Pointer (RFC 6901) in the
83    /// scene's data, `/hero/x`.
84    pub path: Option<String>,
85    /// Answer the JSON Schema of the scene's data, as the game derived it.
86    pub shape: bool,
87}
88
89impl SceneInput {
90    /// What it asks of `scene`.
91    pub(crate) fn ask(&self) -> crate::contract::run::SceneAsk<'_> {
92        crate::contract::run::SceneAsk {
93            path: self.path.as_deref(),
94            shape: self.shape,
95        }
96    }
97}
98
99/// What `start` starts.
100#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
101#[serde(deny_unknown_fields, default)]
102// An input: built with `default()` and its fields assigned, it may grow
103// (ADR-0039).
104#[non_exhaustive]
105pub struct StartInput {
106    /// The game's folder (the one holding its `shuyi.ron`, or one below
107    /// it), from the session's folder; the session folder's own project
108    /// if none. Not needed with `link`.
109    pub project: Option<String>,
110    /// The game's own start options (`start`'s answer lists them), in the
111    /// order given. None with `link`: the build runs with its own.
112    pub start: Vec<GameArg>,
113    /// A debug build already running in its window, to attach the session
114    /// to over the live link instead of starting the game headless.
115    pub link: Option<StartLink>,
116    /// A session record to start from, as `run`'s: played to its end, or
117    /// the first frame this build plays differently (`ok` false, the game
118    /// kept on that frame), and `step`, `scene` and `frame_png` go on from
119    /// there; `reset` plays it again. No start options with it.
120    pub session: Option<SessionInput>,
121}
122
123/// The running build `start` attaches to: its log line `shuyi link:
124/// listening on <address>, token <token>` gives both.
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
126#[serde(deny_unknown_fields, default)]
127// An input: built with `default()` and its fields assigned, it may grow
128// (ADR-0039).
129#[non_exhaustive]
130pub struct StartLink {
131    /// The build's address, `host:port` (`127.0.0.1:7357`).
132    pub address: String,
133    /// The token the build logged when it started listening.
134    pub token: String,
135}
136
137/// A start option the game offers.
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
139#[serde(deny_unknown_fields)]
140pub struct StartOption {
141    /// Its flag (`--stage`).
142    pub flag: String,
143    /// What its value is called (`N`), if it takes one.
144    pub value: Option<String>,
145    /// What it does.
146    pub help: String,
147}
148
149impl From<&GameOption> for StartOption {
150    fn from(option: &GameOption) -> Self {
151        Self {
152            flag: option.flag.to_owned(),
153            value: option.value.map(str::to_owned),
154            help: option.help.to_owned(),
155        }
156    }
157}
158
159/// What `start` and `reset` started.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
161#[serde(deny_unknown_fields)]
162// An answer, read field by field: it may grow (ADR-0039).
163#[non_exhaustive]
164pub struct StartOutput {
165    /// The game's folder; empty when attached over the link.
166    pub project: String,
167    /// The running build's game, its window's title, when attached over
168    /// the link; empty when started headless.
169    pub game: String,
170    /// The start options it was started with.
171    pub start: Vec<GameArg>,
172    /// The start options the game offers.
173    pub options: Vec<StartOption>,
174    /// The frames run (none yet).
175    pub frames: u32,
176    /// The top screen's name.
177    pub top_screen: String,
178    /// The open screens, bottom first.
179    pub screens: Vec<String>,
180    /// How far the session record played, if it started from one.
181    pub session: Option<SessionPlayed>,
182}
183
184/// What `step` plays.
185#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
186#[serde(deny_unknown_fields, default)]
187pub struct StepInput {
188    /// The steps to play, as `run`'s script.
189    pub script: Script,
190    /// Frames to run after the script with no input.
191    pub frames: u32,
192}
193
194/// How `frame_png` paints the frame.
195#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
196#[serde(deny_unknown_fields, default)]
197pub struct FramePngInput {
198    /// Pixels per console pixel, 1 to 8; the game's choice if none.
199    pub scale: Option<u32>,
200}
201
202/// What `frame_png` painted (the picture itself is the tool's image).
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
204#[serde(deny_unknown_fields)]
205pub struct FramePngOutput {
206    /// The frames run in all.
207    pub frames: u32,
208    /// The top screen's name.
209    pub top_screen: String,
210    /// The picture's width in pixels.
211    pub width: u32,
212    /// Its height in pixels.
213    pub height: u32,
214}
215
216/// What `events` saw.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
218#[serde(deny_unknown_fields)]
219pub struct EventsOutput {
220    /// The frames run in all.
221    pub frames: u32,
222    /// What happened since the start, in frame order, then the game's own
223    /// events.
224    pub events: Vec<Event>,
225    /// The sound cues asked for since the start, in order.
226    pub sounds: Vec<Sound>,
227    /// The sounds the game added at run time and hasn't removed, in cue
228    /// order; left out when none.
229    #[serde(default, skip_serializing_if = "Vec::is_empty")]
230    pub sounds_added: Vec<AddedSound>,
231}
232
233/// How many records `perf` answers when its input doesn't say.
234pub const PERF_MAX: u32 = 120;
235
236/// How many heap snapshots a session keeps for `perf`'s `diff_from`: the
237/// last, by frame.
238pub const KEPT_SNAPSHOTS: usize = 8;
239
240/// Which of the session's frames' records `perf` reads.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
242#[serde(deny_unknown_fields, default)]
243// An input: built with `default()` and its fields assigned, it may grow
244// (ADR-0039).
245#[non_exhaustive]
246pub struct PerfInput {
247    /// The first record's number: 0, or the last answer's `next`.
248    pub since: u64,
249    /// The most records to answer, 1 to 600.
250    #[schemars(range(min = 1, max = 600))]
251    pub max: u32,
252    /// Take a heap snapshot now: what holds the heap by memory tag. The
253    /// session keeps its last 8, by frame.
254    pub snapshot: bool,
255    /// The frame of a kept snapshot: answer the diff from it to the
256    /// snapshot taken now, or, without `snapshot`, to the newest kept.
257    pub diff_from: Option<u32>,
258}
259
260impl Default for PerfInput {
261    fn default() -> Self {
262        Self {
263            since: 0,
264            max: PERF_MAX,
265            snapshot: false,
266            diff_from: None,
267        }
268    }
269}
270
271impl PerfInput {
272    /// Whether the session can answer it: `max` from 1 to 600.
273    pub fn check(&self) -> Result<(), String> {
274        let most = u32::try_from(PERF_FRAMES).unwrap_or(u32::MAX);
275        if (1..=most).contains(&self.max) {
276            Ok(())
277        } else {
278            Err(format!("max: 1 to {most}, not {}", self.max))
279        }
280    }
281}
282
283/// What `perf` read of the session's frames.
284#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
285#[serde(deny_unknown_fields)]
286// An answer, read field by field: it may grow (ADR-0039).
287#[non_exhaustive]
288pub struct PerfOutput {
289    /// The frames' records numbered `since` or more, oldest first.
290    pub records: Vec<FrameRecord>,
291    /// The number to pass as `since` next time.
292    pub next: u64,
293    /// How many records numbered `since` or more were no longer kept.
294    pub dropped: u64,
295    /// What the records answered add up to; none when there are none.
296    pub summary: Option<PerfSummary>,
297    /// The heap snapshot taken now, with `snapshot`: null without it, or
298    /// where the heap isn't counted by tag.
299    pub snapshot: Option<HeapSnapshot>,
300    /// What changed from the snapshot of `diff_from`, with it.
301    pub diff: Option<HeapDiff>,
302    /// The frames of the snapshots the session keeps, oldest first: the
303    /// `diff_from` it takes.
304    pub kept: Vec<u32>,
305}
306
307/// What `stop` stopped.
308#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
309#[serde(deny_unknown_fields)]
310pub struct StopOutput {
311    /// The frames the game had run.
312    pub frames: u32,
313}
314
315/// The tool named `name`: its operation, and whether it is a session
316/// tool ([`Op::SESSION`]).
317pub fn tool(name: &str) -> Option<(Op, bool)> {
318    if let Some(op) = Op::SESSION.into_iter().find(|op| op.name() == name) {
319        return Some((op, true));
320    }
321    Op::ALL
322        .into_iter()
323        .find(|op| op.name() == name)
324        .map(|op| (op, false))
325}
326
327/// What `attach`'s tool does.
328const ATTACH: &str = "Reach a game's debug build already running in its window, over the live link \
329     (`address` and `token`: its log line `shuyi link: listening on ..., token ...`; the \
330     build runs with its `link` feature and SHUYI_LINK set). Play `steps` as a player's \
331     input (keys, pad buttons, an action by name with `act`, waits in its frames), let \
332     `frames` pass, then read its screens, frame as text, log from `log_since`, and, \
333     with `screenshot` (a PNG's path), the window's picture; with `perf_since`, its \
334     frames' records and their summary; with `perf_follow` (a file), each new frame's \
335     record written to the file for `follow_for` seconds, then their summary.";
336
337/// What `start`'s tool does.
338const START: &str = "Build the game's tool program and start its game headless in the session, as \
339     its start options say (the answer lists them); a game already running is stopped. \
340     `project`: the game's folder. Call it again after changing the game's code. With `link` \
341     (`address` and `token`, from a debug build's log line `shuyi link: listening on ..., token \
342     ...`), attach the session to that build running in its window instead: the session tools \
343     play a player's input on it and read its frame, picture and log (no scene, events or \
344     sounds), `reset` is refused, and `stop` leaves it running.";
345
346/// What `export`'s tool does.
347const EXPORT: &str = "Check the game against `target` (`windows`, `linux`, `macos`, `web`), then \
348     build and package it there: a zip, a tar.gz, a signed .app or a web folder and its zip, \
349     each with NOTICES.txt, and export-report.json and exposure-report.json beside it, in `out` \
350     (dist/export/<target> by default). A package that breaks shuyi.ron's `disclosure` part is \
351     removed. `release` remaps the build's paths and keeps the debug file beside the package; \
352     `run` runs the package after; `without` leaves features of shuyi.ron's `export` part off.";
353
354/// `lint`'s description.
355const LINT: &str = "Check the game's code: keys named in code or text (check-keys), screen text as literals \
356     (check-text), and the rules crate's fence (clippy's bans on hash maps, floats, clocks, \
357     files and outside luck; no screen or device crate under it). Each finding with its \
358     file, line and code.";
359
360/// What the tool of `op` does, for its description.
361fn description(op: Op, session: bool) -> &'static str {
362    match (op, session) {
363        (Op::New, _) => {
364            "Make a new game in `dir` (from the server's working folder) from the engine's \
365             template, its parts picked by the answers: `template`, a short name (`grid`, a \
366             title and a turn-based grid stage with a bot and records; `blank`, one screen), \
367             then `answers` (an answers file) and `set` (answers by question). `force` writes \
368             into a folder that isn't empty."
369        }
370        (Op::Check, _) => {
371            "Load the game's content through every validator: each error with its file, line and \
372             code. With `baked`, its pack too; with `target`, whether its frame, text and \
373             controls fit a target (or `all`)."
374        }
375        (Op::Lint, _) => LINT,
376        (Op::Modules, _) => {
377            "The engine's catalog of modules by concern (perspective, bodies and motion, online \
378             play, ...): each answer, who gives it (the default engine or a module) and its \
379             status, the modules the game's engine builds with what they answer and serve, and \
380             the ones the game has on."
381        }
382        (Op::Add, _) => {
383            "Turn a module of the game's engine on (`module`: `physics` or `shuyi-physics`): its \
384             feature of `shuyi` in the game's Cargo.toml (it changes the project; a second call \
385             changes nothing), its skill copied into the game's skills, what it is for, an \
386             alternative already on, and any line the game's code still needs."
387        }
388        (Op::Run, _) => {
389            "Start the game headless as its start options say, play a script of keys, pad \
390             buttons and waits, run idle frames: the screens, the scene, the frame as text, what \
391             happened, the sounds and the log. A fresh game each call; the session tools keep \
392             one running."
393        }
394        (Op::Scene, false) => "The scene on screen after a one-off run.",
395        (Op::Scene, true) => {
396            "The scene on screen in the session now (a stage's map and state), and the log. Not \
397             ok when no stage is open. `path` (a JSON Pointer, `/hero/x`) answers one value of \
398             its data instead, and `shape` its data's JSON Schema."
399        }
400        (Op::Replay, _) => {
401            "Re-run a record (a file in the game's folder) by the game's rules: a state hash per \
402             step; with `other`, the first step where two runs differ."
403        }
404        (Op::Playtest, _) => {
405            "Each of the game's named bots (its `entrants`, or the game's choice) plays `tries` \
406             tries of a `stage` (or `every_stage`) on every core; the game's checks say per bot \
407             whether it succeeded as often as its band wants (with no checks, a plain bot run, \
408             nothing is judged), and each bot's time per try, outcomes counted and the spread \
409             of its turns and measures. Each try has its commands and, if the game keeps one, \
410             its `record` for `replay`."
411        }
412        (Op::Autobalance, _) => {
413            "Change a `stage` with the game's knobs, never its bots, until each of its named \
414             bots (`entrants`) succeeds as often as its checks' bands want: the smallest change \
415             the search finds, in the knobs' words, and the reports before and after. Each \
416             setting's tries run on every core; it stops at `budget` evaluations or after \
417             `time` seconds. With `apply`, the game writes the changed stage to its files: it \
418             changes the project."
419        }
420        (Op::Import, _) => {
421            "Read a file (from the game's folder) through the format registry as every kind \
422             that knows it, or give its errors."
423        }
424        (Op::Bake, _) => {
425            "Bake the game's sources into one pack: what went in, the sizes, how long baking and \
426             loading took."
427        }
428        (Op::Doctor, _) => {
429            "Check the toolchain (Rust, Cargo, targets, the linker) and say how to fix each \
430             missing piece."
431        }
432        (Op::Explain, _) => {
433            "Every problem code, or one code's page (`SH2003` or `missing-file`): what it means \
434             and how to fix it. Every failed answer has a `code`."
435        }
436        (Op::Attach, _) => ATTACH,
437        (Op::Export, _) => EXPORT,
438        (Op::Start, _) => START,
439        (Op::Step, _) => {
440            "Play on in the session: a script of keys, pad buttons and waits, then `frames` idle \
441             frames. Answers as `run` does, for everything since the start."
442        }
443        (Op::FramePng, _) => {
444            "A picture of the session's frame now, as the game paints it, as an image."
445        }
446        (Op::Events, _) => {
447            "What happened in the session since the start: screens opened and closed, music, \
448             voices, the quit, the game's own events, and the sounds."
449        }
450        (Op::Perf, _) => {
451            "The session's frames' records (times in microseconds, the game's spans) numbered \
452             `since` or more, at most `max`, and their summary: a stream, `step` then `perf` with \
453             the last `next`. `snapshot` takes a heap snapshot (live bytes by memory tag; the \
454             last 8 are `kept`); `diff_from`, a kept one's frame, answers what grew since."
455        }
456        (Op::Reset, _) => {
457            "Start the session's game again as `start` started it, without building again."
458        }
459        (Op::Stop, _) => "Stop the session's game.",
460    }
461}
462
463/// Whether the tool of `op` takes `project`: the operations that run the
464/// game, and `lint`, `modules` and `add`.
465pub fn takes_project(op: Op, session: bool) -> bool {
466    !session
467        && (crate::contract::cli::runs_the_game(op.name())
468            || matches!(op, Op::Lint | Op::Modules | Op::Add))
469}
470
471/// The schema of `project`, which every tool that runs the game takes.
472fn project_schema() -> Value {
473    json!({
474        "description": "The game's folder (the one holding its `shuyi.ron`, or one below it), \
475                        from the session's folder (the agent's working folder); the session \
476                        folder's own project if none.",
477        "type": ["string", "null"],
478    })
479}
480
481/// A schema file's JSON, with `project` added to its properties if
482/// `project`.
483fn schema_value(text: &str, project: bool) -> Value {
484    let mut schema: Value = serde_json::from_str(text).unwrap_or_else(|_| json!({}));
485    if project
486        && let Some(properties) = schema.as_object_mut().and_then(|schema| {
487            schema
488                .entry("properties")
489                .or_insert_with(|| json!({}))
490                .as_object_mut()
491        })
492    {
493        properties.insert("project".to_owned(), project_schema());
494    }
495    schema
496}
497
498/// The tool of `op`, as `tools/list` lists it.
499fn tool_listing(op: Op, session: bool) -> Value {
500    let [input, output] = op_schemas(op, session);
501    let mut tool = Map::new();
502    tool.insert("name".to_owned(), json!(op.name()));
503    tool.insert("description".to_owned(), json!(description(op, session)));
504    tool.insert(
505        "inputSchema".to_owned(),
506        schema_value(&input.text, takes_project(op, session)),
507    );
508    tool.insert("outputSchema".to_owned(), schema_value(&output.text, false));
509    Value::Object(tool)
510}
511
512/// Every tool, as `tools/list` lists them: the operations ([`Op::ALL`],
513/// the one-off `scene` left out), then the session's ([`Op::SESSION`]).
514pub fn tools() -> Vec<Value> {
515    let ops = Op::ALL
516        .into_iter()
517        .filter(|op| *op != Op::Scene)
518        .map(|op| tool_listing(op, false));
519    let session = Op::SESSION.into_iter().map(|op| tool_listing(op, true));
520    ops.chain(session).collect()
521}
522
523/// `bytes` in standard base64, with padding: how MCP carries an image.
524pub fn base64(bytes: &[u8]) -> String {
525    shuyi_platform::link::base64::encode(bytes)
526}
527
528/// The usage of `shuyi mcp`, with `command` as the tool's name.
529pub fn usage(command: &str) -> String {
530    format!(
531        "\
532usage: {command} mcp                    serve the tool contract over MCP on stdin and stdout
533       {command} mcp --ready            build what the server needs, then exit
534       {command} mcp install [--dry-run] [--yes] [--command \"<how to run shuyi>\"]
535                                     register the server with the agents found here
536
537--ready builds the server (it runs through the same command) and the
538game's tool program, if shuyi.ron names one, so an agent's first
539connection doesn't wait for a compile. A running server follows the
540agent's folder and the engine by itself: a project in another workspace
541goes to that workspace's own server, and a server whose engine moved
542under it hands its calls to a fresh one. install shows what it will
543change and asks before writing, keeping a backup of each file it
544changes, then runs the server with --ready; --yes doesn't ask, and
545--dry-run writes nothing and prints each file as it would write it. The server is run as --command's
546words and `mcp` (the project's `command` in shuyi.ron, or `shuyi`, if not
547given)."
548    )
549}
550
551/// `shuyi mcp` with `args`, in the working folder `cwd`: the server on
552/// this process's stdin and stdout, or `install`. Returns the exit code.
553pub fn main(args: &[String], cwd: &Path) -> u8 {
554    match args.split_first() {
555        None => {
556            if cfg!(windows) {
557                // So a build while it runs can write a fresh program.
558                aside::this_program();
559            }
560            let mut server = server::Server::new(cwd.to_path_buf(), Box::new(child::Cargo));
561            if std::env::var_os(forward::FORWARDED).is_none() {
562                server = server.forwarding(Box::new(forward::Processes));
563            }
564            let stdout = std::io::stdout();
565            match serve(&mut server, BufReader::new(std::io::stdin()), stdout.lock()) {
566                Ok(()) => 0,
567                Err(e) => {
568                    eprintln!("mcp: {e}");
569                    1
570                }
571            }
572        }
573        Some((first, rest)) if first == "install" => install::main(rest, cwd),
574        Some((first, [])) if first == "--ready" => ready(cwd),
575        Some(_) => {
576            eprintln!("{}", usage("shuyi"));
577            2
578        }
579    }
580}
581
582/// `shuyi mcp --ready` in `cwd`: the server is built (this process is
583/// it), and so is the game's tool program if the project names one, with
584/// cargo's output passed through. Returns the exit code: 1 if the program
585/// didn't build.
586fn ready(cwd: &Path) -> u8 {
587    if let Ok(project) = Project::find(cwd)
588        && let Some(program) = &project.config.game
589    {
590        let cargo = crate::cargo();
591        let built = Command::new(cargo)
592            .args(program.cargo_build_args())
593            .current_dir(&project.root)
594            .status();
595        match built {
596            Ok(status) if status.success() => {}
597            Ok(status) => {
598                let code = status
599                    .code()
600                    .map_or_else(String::new, |c| format!(" (exit code {c})"));
601                eprintln!("mcp: the game's tool program didn't build{code}");
602                return 1;
603            }
604            Err(e) => {
605                eprintln!("mcp: cannot run cargo: {e}");
606                return 1;
607            }
608        }
609    }
610    println!("mcp: ready (contract {VERSION})");
611    0
612}
613
614/// How long the server waits for a message before it looks whether a
615/// project's own server it follows is ready.
616const POLL: Duration = Duration::from_millis(200);
617
618/// Serves MCP messages from `input`, one per line, writing each message
619/// the server has for one on a line of its own, and the notification that
620/// its tools changed when they do ([`server::Server::poll`]), until `input`
621/// ends.
622pub fn serve(
623    server: &mut server::Server<'_>,
624    input: impl BufRead + Send + 'static,
625    mut output: impl Write,
626) -> std::io::Result<()> {
627    let (sent, lines) = channel();
628    // It reads on a thread of its own, whose allocations the heap counter
629    // counts with the game's.
630    crate::contract::perf::note_other_thread();
631    std::thread::spawn(move || {
632        for line in input.lines() {
633            if sent.send(line).is_err() {
634                break;
635            }
636        }
637    });
638    loop {
639        let messages = match lines.recv_timeout(POLL) {
640            Ok(line) => {
641                let mut messages = server.handle(&line?);
642                messages.extend(server.poll());
643                messages
644            }
645            Err(RecvTimeoutError::Timeout) => server.poll(),
646            Err(RecvTimeoutError::Disconnected) => return Ok(()),
647        };
648        for message in &messages {
649            writeln!(output, "{message}")?;
650        }
651        output.flush()?;
652    }
653}
654
655#[cfg(test)]
656mod tests;