Skip to main content

unlit3dx/mcp/
bridge.rs

1//! The MCP stdio transport, wired straight into a world.
2//!
3//! Serving the protocol blocks and runs on a thread of its own; the
4//! commands it receives are posted through a channel and drained by an
5//! event-loop behaviour, so a world — which is not sendable — is only
6//! ever touched on its own thread.
7
8use std::sync::{Arc, Mutex};
9
10use async_channel::{Receiver, unbounded};
11use unlit_ecs::mcp::{McpCommand, WorldEntry, WorldId};
12use unlit_ecs::sync;
13use unlit_ecs::{Bundle, Command, DynamicBundle, Entity, EntityAccessError, World, bundle};
14use unlit3d::winit::event::OnAboutToWait;
15
16use crate::world::{WorldName, WorldSender, drain_all_stepped_worlds};
17
18/// The commands the transport thread has posted for the host to run.
19///
20/// Spawned with the behaviour that drains it: an inbox nothing drains fills up
21/// and is never read, and a drain behaviour without one has nothing to read.
22#[derive(Clone)]
23struct McpInbox {
24    receiver: Arc<Receiver<McpCommand>>,
25}
26
27impl Bundle for McpInbox {
28    fn into_bundle(self) -> DynamicBundle {
29        bundle![self, drain_mcp()]
30    }
31}
32
33/// Serve the MCP stdio transport against `world`.
34///
35/// The transport thread lives as long as its peer stays connected; the
36/// inbox behaviour drains whatever arrives every turn of the event loop.
37pub fn serve_mcp(world: &mut World) {
38    let (sender, receiver) = unbounded::<McpCommand>();
39    let started = std::thread::Builder::new()
40        .name("unlit3dx-mcp-transport".to_owned())
41        .spawn(move || {
42            let sink = move |command| sender.try_send(command).is_ok();
43            if let Err(error) = unlit_ecs::mcp::serve_stdio_with(sink) {
44                log::error!("the MCP server stopped: {error}");
45            }
46        });
47    match started {
48        Ok(_) => {
49            world.root_scope_mut().spawn(McpInbox {
50                receiver: Arc::new(receiver),
51            });
52        }
53        Err(error) => log::error!("the MCP transport thread failed to start: {error}"),
54    }
55}
56
57/// The event-loop behaviour that runs the commands the transport posted.
58///
59/// Running a command needs a mutable world, so each one is queued and
60/// applied between two event callbacks. The host answers the world list
61/// itself: it is the only one that knows which entities carry a world —
62/// a nested one, or a sender to one that runs apart from it.
63fn drain_mcp() -> OnAboutToWait {
64    OnAboutToWait::new(move |world, _entity, ()| drain(world))
65}
66
67/// Drain the commands the transport posted.
68fn drain(world: &World) {
69    let mut mcp_inbox_rows = world.root_scope().query::<&McpInbox>();
70
71    let Some(inbox) = mcp_inbox_rows.iter().next() else {
72        return;
73    };
74    while let Ok(command) = inbox.receiver.try_recv() {
75        dispatch_command(world, command);
76    }
77}
78
79/// Run one MCP turn without a window event loop.
80///
81/// A headless host calls this between its own frames. Commands are applied
82/// before the next frame, just as they are between two window callbacks. A
83/// command for a world the host steps is queued as a job of that world, so the
84/// turn ends by draining those queues: the reply must not wait for a frame the
85/// client has not asked for yet.
86pub fn pump_mcp(world: &mut World) -> Result<(), EntityAccessError> {
87    drain(world);
88    world.apply()?;
89    drain_all_stepped_worlds(world);
90    Ok(())
91}
92
93/// Run one MCP command against the world it names.
94///
95/// A command for a world the host merely carries is queued as a command of
96/// the host's own, because only the host's thread may touch its world. The
97/// list names every world a tool can reach: the host's own, each nested
98/// world an entity carries, and each world an entity holds a
99/// [`WorldSender`] for — a world on a thread of its own or one the host
100/// steps, neither of which a nested-world lookup could find.
101fn dispatch_command(world: &World, command: McpCommand) {
102    if command.wants_worlds() {
103        command.answer_worlds(reachable_worlds(world));
104        return;
105    }
106    match command.world().entity() {
107        Some(entity) => world
108            .root_scope()
109            .queue()
110            .push(RunInWorld { entity, command }),
111        None => world.root_scope().queue().push(RunInHost { command }),
112    }
113}
114
115/// Every world a tool can name through the host, with its name.
116///
117/// An entity that carries a world without a [`WorldName`] is left out
118/// rather than reported under a made-up name: every world a host offers to
119/// the tools is one it can name.
120fn reachable_worlds(world: &World) -> Vec<WorldEntry> {
121    let mut worlds = vec![WorldEntry {
122        world: WorldId::Host,
123        name: "host",
124    }];
125    let push = |entity: Entity, worlds: &mut Vec<WorldEntry>| {
126        let id = WorldId::handle(entity);
127        let Ok(mut name) = world.root_scope().query_one::<&WorldName>(entity) else {
128            return;
129        };
130        if worlds.iter().any(|entry| entry.world == id) {
131            return;
132        }
133        worlds.push(WorldEntry {
134            world: id,
135            name: name.get().0,
136        });
137    };
138    for (_world, entity) in world.root_scope().query::<(&World, Entity)>().iter() {
139        push(entity, &mut worlds);
140    }
141    for (_world_sender, entity) in world.root_scope().query::<(&WorldSender, Entity)>().iter() {
142        push(entity, &mut worlds);
143    }
144    worlds
145}
146
147/// Run an MCP command against the host world itself.
148struct RunInHost {
149    command: McpCommand,
150}
151
152impl Command for RunInHost {
153    fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
154        self.command.dispatch(world);
155        Ok(())
156    }
157}
158
159/// Run an MCP command against the world one entity carries.
160struct RunInWorld {
161    entity: Entity,
162    command: McpCommand,
163}
164
165impl Command for RunInWorld {
166    /// A nested world is a component here, so its commands run on this
167    /// thread. A world the host only sends to — one on a thread of its own,
168    /// or one the host steps — is reached by posting the command as a job
169    /// instead: its queue is shared with the handle its spawn returned, so
170    /// the sender beside the proxy is enough. The reply rides the command's
171    /// own channel, so nothing here waits for the other world to answer.
172    fn apply(self: Box<Self>, world: &mut World) -> Result<(), EntityAccessError> {
173        let RunInWorld { entity, command } = *self;
174        if let Ok(mut nested) = world.root_scope().query_one::<&mut World>(entity) {
175            command.dispatch(&mut *nested.get());
176            return Ok(());
177        }
178        let Ok(sender) = world
179            .root_scope()
180            .query_one::<&WorldSender>(entity)
181            .map(|mut sender| {
182                let sender = sender.get();
183                sender.clone()
184            })
185        else {
186            command.fail(format!("entity {entity:?} carries no world"));
187            return Ok(());
188        };
189        if sender.is_finished() {
190            command.fail(format!("the world on {entity:?} has stopped"));
191            return Ok(());
192        }
193        // The command rides inside the job, so a slot keeps it here: a
194        // refused job hands it back, and can then be failed with the reason.
195        let pending = Arc::new(Mutex::new(Some(command)));
196        let job_slot = Arc::clone(&pending);
197        let job = Box::new(move |world: &mut World| {
198            if let Some(command) = sync::lock(&job_slot).take() {
199                command.dispatch(world);
200            }
201        });
202        if let Err(error) = sender.send(job)
203            && let Some(command) = sync::lock(&pending).take()
204        {
205            command.fail(format!(
206                "the world on {entity:?} refused the command: {error}"
207            ));
208        }
209        Ok(())
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use std::time::{Duration, Instant};
216
217    use unlit_ecs::mcp::Output;
218
219    use super::*;
220    use crate::FRAME_SECONDS;
221    use crate::thread::ThreadControl;
222    use crate::world::{spawn_stepped_world_with_init, spawn_threaded_world, step_stepped_world};
223
224    /// A component the host command counts.
225    struct Marker;
226
227    /// A host world wired like `serve_mcp` wires one, plus the channel
228    /// the fake transport posts commands through.
229    fn host_with_inbox() -> (World, async_channel::Sender<McpCommand>) {
230        let mut host = World::new();
231        let (sender, receiver) = unbounded::<McpCommand>();
232        host.root_scope_mut().spawn(McpInbox {
233            receiver: Arc::new(receiver),
234        });
235        (host, sender)
236    }
237
238    /// Run one turn of the host's event loop: the drain behaviour takes
239    /// whatever the transport posted and queues it, then the queued
240    /// commands apply — the order the real loop runs them in.
241    fn run_turn(host: &mut World) {
242        for on_about_to_wait in host.root_scope().query::<&mut OnAboutToWait>().iter() {
243            on_about_to_wait.run(host, Entity::from_bits(0), &());
244        }
245        host.apply().unwrap();
246    }
247
248    /// The host answers a world list with itself and every entity that
249    /// carries a world, nested or apart — the render sub-world among them.
250    #[test]
251    fn list_worlds_answers_with_the_host_and_its_nested_worlds() {
252        let (mut host, transport) = host_with_inbox();
253        let nested = host
254            .root_scope_mut()
255            .spawn(bundle![World::new(), WorldName("nested")]);
256
257        let (command, reply) = McpCommand::list_worlds();
258        transport.try_send(command).expect("the inbox takes it");
259        run_turn(&mut host);
260
261        let answer = reply.recv_blocking().expect("the command was answered");
262        let Ok(Output::Json(value)) = answer else {
263            panic!("a world list answers with JSON")
264        };
265        let text = value.to_string();
266        assert!(text.contains("host"), "the host names itself: {text}");
267        assert!(
268            text.contains("nested") && text.contains(&nested.to_bits().to_string()),
269            "the host names its nested world {nested:?}: {text}"
270        );
271    }
272
273    /// A world the host cannot name is left out of the list: every world the
274    /// tools are offered is one the host can name, so nothing is reported
275    /// under a made-up label.
276    #[test]
277    fn list_worlds_leaves_out_a_world_it_cannot_name() {
278        let (mut host, transport) = host_with_inbox();
279        let unnamed = host.root_scope_mut().spawn(bundle![World::new()]);
280
281        let (command, reply) = McpCommand::list_worlds();
282        transport.try_send(command).expect("the inbox takes it");
283        run_turn(&mut host);
284
285        let answer = reply.recv_blocking().expect("the command was answered");
286        let Ok(Output::Json(value)) = answer else {
287            panic!("a world list answers with JSON")
288        };
289        let text = value.to_string();
290        assert!(
291            !text.contains(&unnamed.to_bits().to_string()),
292            "the unnamed world is left out: {text}"
293        );
294    }
295
296    /// A command aimed at a host entity that carries no world fails with
297    /// a readable message instead of being dropped or panicking.
298    #[test]
299    fn a_command_for_a_worldless_entity_fails_readably() {
300        let (mut host, transport) = host_with_inbox();
301        let bare = host.root_scope_mut().spawn(bundle![Marker]);
302
303        let (command, reply) = McpCommand::new(|_| Ok(Output::Json(serde_json::Value::Null)));
304        let command = command.with_world(WorldId::handle(bare));
305        transport.try_send(command).expect("the inbox takes it");
306        run_turn(&mut host);
307
308        let answer = reply.recv_blocking().expect("the command was answered");
309        let Err(message) = answer else {
310            panic!("the command fails")
311        };
312        assert!(
313            message.contains("carries no world"),
314            "the failure says why: {message}"
315        );
316    }
317
318    /// A command aimed at a nested world runs against that world, not the
319    /// host: the marker only the nested world carries is the one counted.
320    #[test]
321    fn a_command_runs_against_the_nested_world_it_names() {
322        let (mut host, transport) = host_with_inbox();
323        let mut nested = World::new();
324        nested.root_scope_mut().spawn(bundle![Marker]);
325        let nested_world = host.root_scope_mut().spawn(bundle![nested]);
326
327        let (command, reply) = McpCommand::new(|world| {
328            let count = world.root_scope().query::<&Marker>().iter().count();
329            Ok(Output::Json(serde_json::Value::from(count as u64)))
330        });
331        let command = command.with_world(WorldId::handle(nested_world));
332        transport.try_send(command).expect("the inbox takes it");
333        run_turn(&mut host);
334
335        let answer = reply.recv_blocking().expect("the command was answered");
336        let Ok(Output::Json(value)) = answer else {
337            panic!("the job answers with JSON")
338        };
339        assert_eq!(value.as_u64(), Some(1), "the nested world was seen");
340    }
341
342    /// A command aimed at the host runs against the host world itself.
343    #[test]
344    fn a_host_command_runs_against_the_host_world() {
345        let (mut host, transport) = host_with_inbox();
346        host.root_scope_mut().spawn(bundle![Marker]);
347
348        let (command, reply) = McpCommand::new(|world| {
349            let count = world.root_scope().query::<&Marker>().iter().count();
350            Ok(Output::Json(serde_json::Value::from(count as u64)))
351        });
352        transport.try_send(command).expect("the inbox takes it");
353        run_turn(&mut host);
354
355        let answer = reply.recv_blocking().expect("the command was answered");
356        let Ok(Output::Json(value)) = answer else {
357            panic!("the job answers with JSON")
358        };
359        assert_eq!(value.as_u64(), Some(1), "the host world was seen");
360    }
361    /// A world the host merely sends to is named too, so a tool can reach a
362    /// world that runs apart from the host — an example's gameplay world, say.
363    #[test]
364    fn list_worlds_names_a_world_on_its_own_thread() {
365        let (mut host, transport) = host_with_inbox();
366        let threaded = spawn_threaded_world(&mut host, "listed", 8, |_, control, _| {
367            control.wait_timeout(Duration::from_millis(1));
368            true
369        });
370
371        let (command, reply) = McpCommand::list_worlds();
372        transport.try_send(command).expect("the inbox takes it");
373        run_turn(&mut host);
374
375        let answer = reply.recv_blocking().expect("the command was answered");
376        let Ok(Output::Json(value)) = answer else {
377            panic!("a world list answers with JSON")
378        };
379        let text = value.to_string();
380        assert!(
381            text.contains("listed") && text.contains(&threaded.entity.to_bits().to_string()),
382            "the host names the threaded world: {text}"
383        );
384    }
385
386    /// A command aimed at a world on its own thread runs there and answers
387    /// on the command's own channel: the host never waits for the thread.
388    #[test]
389    fn a_command_runs_against_a_world_on_its_own_thread() {
390        let (mut host, transport) = host_with_inbox();
391        let threaded = spawn_threaded_world(&mut host, "counted", 8, |_, control, _| {
392            control.wait_timeout(Duration::from_millis(1));
393            true
394        });
395        let seeded = crate::channel::Snapshot::new();
396        let reader = seeded.reader();
397        let publish = seeded.clone();
398        threaded
399            .sender
400            .send(Box::new(move |world: &mut World| {
401                world.root_scope_mut().spawn(bundle![Marker]);
402                publish.publish(());
403            }))
404            .expect("the thread accepts jobs");
405        let deadline = Instant::now() + Duration::from_secs(2);
406        while reader.latest().is_none() && Instant::now() < deadline {
407            std::thread::sleep(Duration::from_millis(1));
408        }
409        assert!(reader.latest().is_some(), "the thread ran the seeding job");
410
411        let (command, reply) = McpCommand::new(|world| {
412            let count = world.root_scope().query::<&Marker>().iter().count();
413            Ok(Output::Json(serde_json::Value::from(count as u64)))
414        });
415        let command = command.with_world(WorldId::handle(threaded.entity));
416        transport.try_send(command).expect("the inbox takes it");
417        run_turn(&mut host);
418
419        let answer = reply.recv_blocking().expect("the command was answered");
420        let Ok(Output::Json(value)) = answer else {
421            panic!("the job answers with JSON")
422        };
423        assert_eq!(value.as_u64(), Some(1), "the threaded world was seen");
424    }
425
426    /// A stepped world is named and reached the same way, and its command
427    /// runs on a step: nothing else drives it.
428    #[test]
429    fn a_command_runs_against_a_world_the_host_steps() {
430        let (mut host, transport) = host_with_inbox();
431        let stepped = spawn_stepped_world_with_init(
432            &mut host,
433            "stepped",
434            8,
435            |world: &mut World| {
436                world.root_scope_mut().spawn(bundle![Marker]);
437            },
438            |_, _: &ThreadControl, (), _: f32| true,
439        );
440
441        let (command, reply) = McpCommand::new(|world| {
442            let count = world.root_scope().query::<&Marker>().iter().count();
443            Ok(Output::Json(serde_json::Value::from(count as u64)))
444        });
445        let command = command.with_world(WorldId::handle(stepped.entity));
446        transport.try_send(command).expect("the inbox takes it");
447        run_turn(&mut host);
448        step_stepped_world(&host, stepped.entity, FRAME_SECONDS).expect("the stepped world starts");
449
450        let answer = reply.recv_blocking().expect("the command was answered");
451        let Ok(Output::Json(value)) = answer else {
452            panic!("the job answers with JSON")
453        };
454        assert_eq!(value.as_u64(), Some(1), "the stepped world was seen");
455    }
456
457    /// A world that has stopped answers nothing, so the host explains why
458    /// instead of leaving the job to be dropped without a word.
459    #[test]
460    fn a_command_for_a_stopped_world_fails_readably() {
461        let (mut host, transport) = host_with_inbox();
462        let stepped = spawn_stepped_world_with_init(
463            &mut host,
464            "ended",
465            8,
466            |_: &mut World| (),
467            |_, _: &ThreadControl, (), _: f32| false,
468        );
469        step_stepped_world(&host, stepped.entity, FRAME_SECONDS).expect("the stepped world starts");
470        assert!(stepped.is_finished(), "the tick ended the world");
471
472        let (command, reply) = McpCommand::new(|_| Ok(Output::Json(serde_json::Value::Null)));
473        let command = command.with_world(WorldId::handle(stepped.entity));
474        transport.try_send(command).expect("the inbox takes it");
475        run_turn(&mut host);
476
477        let answer = reply.recv_blocking().expect("the command was answered");
478        let Err(message) = answer else {
479            panic!("the command fails")
480        };
481        assert!(
482            message.contains("has stopped"),
483            "the failure says why: {message}"
484        );
485    }
486
487    /// A command whose job the queue refuses fails right here with the
488    /// reason, rather than closing the channel without a word.
489    #[test]
490    fn a_command_for_a_full_queue_fails_readably() {
491        let (mut host, transport) = host_with_inbox();
492        let stepped = spawn_stepped_world_with_init(
493            &mut host,
494            "busy",
495            1,
496            |_: &mut World| (),
497            |_, _: &ThreadControl, (), _: f32| true,
498        );
499        stepped
500            .sender
501            .send(Box::new(|_: &mut World| {}))
502            .expect("the first job fits");
503
504        let (command, reply) = McpCommand::new(|_| Ok(Output::Json(serde_json::Value::Null)));
505        let command = command.with_world(WorldId::handle(stepped.entity));
506        transport.try_send(command).expect("the inbox takes it");
507        run_turn(&mut host);
508
509        let answer = reply.recv_blocking().expect("the command was answered");
510        let Err(message) = answer else {
511            panic!("the command fails")
512        };
513        assert!(
514            message.contains("refused the command"),
515            "the failure says why: {message}"
516        );
517    }
518}