1use 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#[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
33pub 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
57fn drain_mcp() -> OnAboutToWait {
64 OnAboutToWait::new(move |world, _entity, ()| drain(world))
65}
66
67fn 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
79pub fn pump_mcp(world: &mut World) -> Result<(), EntityAccessError> {
87 drain(world);
88 world.apply()?;
89 drain_all_stepped_worlds(world);
90 Ok(())
91}
92
93fn 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
115fn 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
147struct 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
159struct RunInWorld {
161 entity: Entity,
162 command: McpCommand,
163}
164
165impl Command for RunInWorld {
166 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 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 struct Marker;
226
227 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}