Skip to main content

unlit3dx_examples/
mcp.rs

1//! The interactive headless host: MCP drives the same scene without a window.
2//!
3//! The host waits for commands rather than pacing itself. Only the frame-stepping
4//! tool advances simulation time, so a client can reproduce a complete sequence
5//! of input, rendering and recording without sleeping between commands.
6//!
7//! The scene itself is the one [`crate::interactive`] builds: the host adds the
8//! exit request and serves MCP over it, so a client drives exactly the scene a
9//! test drives in process.
10
11use schemars::JsonSchema;
12use serde::Deserialize;
13use unlit_ecs::mcp::serde_json::{self, Value, json};
14use unlit_ecs::mcp::{
15    CommandSender, McpServer, ToolResult, ToolRouter, ToolSource, WorldArg, command, tool,
16};
17use unlit_ecs::{World, bundle};
18use unlit3d::winit::builtin::ExitRequest;
19use unlit3dx::mcp::{pump_mcp, serve_mcp};
20use unlit3dx::prelude::*;
21
22use crate::interactive::{Interactive, InteractiveConfig, scene_status};
23
24/// Run the interactive headless host until MCP asks it to exit.
25pub fn run(soundfont: std::sync::Arc<[u8]>) {
26    let gpu = pollster::block_on(Gpu::headless()).expect("the headless run opens a GPU");
27    let (mut host, _input, interactive) = crate::interactive::run(InteractiveConfig {
28        device: gpu.device,
29        queue: gpu.queue,
30        capabilities: gpu.capabilities,
31        soundfont,
32    });
33    host.root_scope_mut().spawn(bundle![ExitRequest(false)]);
34    host.root_scope_mut().spawn(bundle![interactive]);
35    serve_mcp(&mut host);
36    log::info!("the interactive headless MCP server is ready");
37    loop {
38        pump_mcp(&mut host).expect("the MCP command queue applies");
39        let exit = host
40            .root_scope()
41            .query::<&ExitRequest>()
42            .iter()
43            .any(|request| request.0);
44        if exit {
45            break;
46        }
47        std::thread::park_timeout(std::time::Duration::from_millis(1));
48    }
49}
50
51/// Report the scene as the last step left it, plus the ids a client reads its
52/// frame and its recording through.
53fn status(host: &World, interactive: &Interactive) -> Value {
54    let worlds = interactive.worlds_handle();
55    let scene = scene_status(host, worlds);
56    let buttons = Interactive::panel_buttons(host, worlds)
57        .into_iter()
58        .map(|(label, rect)| (label.to_owned(), json!([rect.center().x, rect.center().y])))
59        .collect::<serde_json::Map<_, _>>();
60    json!({
61        "ready": true,
62        "frame": interactive.frame(),
63        "balls": scene.balls,
64        "sequence_paused": scene.sequence.map(|state| state.0),
65        "sequence_time": scene.sequence.map(|state| state.1),
66        "sequence_finished": scene.sequence.map(|state| state.2),
67        "buttons": buttons,
68        "render_world": { "Handle": interactive.render_world.to_bits() },
69        "audio_world": { "Handle": worlds.audio.entity.to_bits() },
70        "target": interactive.target.id().to_bits(),
71    })
72}
73
74#[derive(Debug, Deserialize, JsonSchema)]
75struct StepFrames {
76    #[serde(default)]
77    world: unlit_ecs::mcp::WorldId,
78    /// How many fixed-length frames to advance.
79    frames: u32,
80}
81
82unlit_ecs::mcp::inventory::submit! {
83    ToolSource { extend: register }
84}
85
86fn register(router: &mut ToolRouter<McpServer>) {
87    router.add_route(tool(
88        "step_frames",
89        "Advance the interactive headless scene by an exact number of fixed frames.",
90        step_frames,
91    ));
92    router.add_route(tool(
93        "scene_status",
94        "Report the interactive scene's frame, balls, MIDI sequence, button centres and MCP world ids.",
95        scene_status_tool,
96    ));
97}
98
99async fn step_frames(commands: CommandSender, args: StepFrames) -> ToolResult {
100    command(&commands, args.world, move |host| {
101        if args.frames == 0 {
102            return Err("frames must be at least 1".to_owned());
103        }
104        let mut interactive = host.root_scope().query::<&mut Interactive>();
105        let Some(interactive) = interactive.iter().next() else {
106            return Err("the host is not an interactive headless scene".to_owned());
107        };
108        interactive.step(host, args.frames);
109        // The frame is drawn as the steps leave it, so the frame a client reads
110        // back afterwards is the one the steps produced. Drawing is cheap here
111        // and readback does not draw, so the picture has to be brought up to
112        // date before a client asks for it.
113        interactive.draw(host);
114        Ok(json!({ "frame": interactive.frame() }))
115    })
116    .await
117}
118
119async fn scene_status_tool(commands: CommandSender, args: WorldArg) -> ToolResult {
120    command(&commands, args.world, |host| {
121        let mut interactive = host.root_scope().query::<&Interactive>();
122        let Some(interactive) = interactive.iter().next() else {
123            return Err("the host is not an interactive headless scene".to_owned());
124        };
125        Ok(status(host, interactive))
126    })
127    .await
128}