1use std::panic::AssertUnwindSafe;
17use std::path::Path;
18
19use schemars::JsonSchema;
20use serde::Deserialize;
21use serde_json::Value;
22
23use crate::{Entity, World};
24
25pub enum Output {
34 Json(Value),
36 Image(EncodedImage),
38 Audio(EncodedAudio),
40}
41
42impl From<Value> for Output {
43 fn from(value: Value) -> Self {
44 Self::Json(value)
45 }
46}
47
48pub struct EncodedImage {
50 pub data: String,
52 pub mime_type: String,
54 pub structured: Value,
56}
57
58pub struct EncodedAudio {
60 pub data: String,
62 pub mime_type: String,
64 pub structured: Value,
67}
68
69pub type Job = Box<dyn FnOnce(&mut World) -> Result<Output, String> + Send + 'static>;
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, JsonSchema, facet::Facet)]
84#[repr(u8)]
85pub enum WorldId {
86 #[default]
88 Host,
89 Handle(u64),
91}
92
93impl WorldId {
94 #[must_use]
96 pub const fn handle(entity: Entity) -> Self {
97 Self::Handle(entity.to_bits())
98 }
99
100 #[must_use]
102 pub const fn entity(self) -> Option<Entity> {
103 match self {
104 Self::Host => None,
105 Self::Handle(bits) => Some(Entity::from_bits(bits)),
106 }
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, facet::Facet)]
116pub struct WorldEntry {
117 pub world: WorldId,
119 pub name: &'static str,
121}
122
123enum Request {
125 Job(Job),
127 Worlds,
131}
132
133pub struct McpCommand {
135 request: Request,
136 reply: async_channel::Sender<Result<Output, String>>,
137 world: WorldId,
139}
140
141impl McpCommand {
142 pub fn new(
148 job: impl FnOnce(&mut World) -> Result<Output, String> + Send + 'static,
149 ) -> (Self, async_channel::Receiver<Result<Output, String>>) {
150 Self::build(Request::Job(Box::new(job)))
151 }
152
153 #[must_use]
158 pub fn list_worlds() -> (Self, async_channel::Receiver<Result<Output, String>>) {
159 Self::build(Request::Worlds)
160 }
161
162 fn build(request: Request) -> (Self, async_channel::Receiver<Result<Output, String>>) {
163 let (reply, receiver) = async_channel::bounded(1);
164 (
165 Self {
166 request,
167 reply,
168 world: WorldId::Host,
169 },
170 receiver,
171 )
172 }
173
174 #[must_use]
176 pub fn with_world(mut self, world: WorldId) -> Self {
177 self.world = world;
178 self
179 }
180
181 #[must_use]
183 pub const fn world(&self) -> WorldId {
184 self.world
185 }
186
187 #[must_use]
190 pub const fn wants_worlds(&self) -> bool {
191 matches!(self.request, Request::Worlds)
192 }
193
194 pub fn answer_worlds(self, worlds: Vec<WorldEntry>) {
200 let result = to_value(&worlds).map(Output::from);
201 let _ = self.reply.send_blocking(result);
202 }
203
204 pub fn fail(self, message: String) {
209 let _ = self.reply.send_blocking(Err(message));
210 }
211
212 pub fn dispatch(self, world: &mut World) {
226 let result = match self.request {
227 Request::Job(job) => catch_job(world, job),
228 Request::Worlds => Err("the host did not answer list_worlds".to_owned()),
229 };
230 let _ = self.reply.send_blocking(result);
231 }
232}
233
234fn catch_job(world: &mut World, job: Job) -> Result<Output, String> {
240 match std::panic::catch_unwind(AssertUnwindSafe(move || job(world))) {
241 Ok(result) => result,
242 Err(payload) => Err(format!(
243 "the tool panicked and the world it ran in may be inconsistent: {}",
244 panic_message(payload)
245 )),
246 }
247}
248
249fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
251 payload
252 .downcast_ref::<&'static str>()
253 .map(|message| (*message).to_owned())
254 .or_else(|| payload.downcast_ref::<String>().cloned())
255 .unwrap_or_else(|| "an opaque panic payload".to_owned())
256}
257
258pub fn to_value<T: facet::Facet<'static>>(value: &T) -> Result<Value, String> {
264 let text = facet_json::to_string(value).map_err(|error| error.to_string())?;
265 serde_json::from_str(&text).map_err(|error| error.to_string())
266}
267
268#[derive(facet::Facet)]
270pub struct SpawnedEntity {
271 #[facet(opaque, proxy = crate::EntityProxy)]
273 pub entity: Entity,
274 pub name: &'static str,
276 #[facet(opaque, proxy = crate::EntityProxy)]
278 pub scope: Entity,
279}
280
281#[derive(facet::Facet)]
283pub struct Despawned {
284 pub removed: usize,
286 pub name: &'static str,
288}
289
290#[derive(facet::Facet)]
292pub struct SavedFile {
293 pub path: String,
295 pub size: usize,
297}
298
299pub use crate::naming::{extension_of, with_extension};
300
301fn generated_name(stem: &str, extension: &str) -> String {
304 format!("{stem}-{}.{extension}", uuid::Uuid::new_v4().simple())
305}
306
307pub fn saved_name(file_name: Option<&str>, stem: &str, extension: &str) -> String {
310 with_extension(file_name, extension).unwrap_or_else(|| generated_name(stem, extension))
311}
312
313pub fn write_file(directory: &str, file_name: &str, bytes: &[u8]) -> Result<SavedFile, String> {
315 let path = Path::new(directory).join(file_name);
316 if let Some(parent) = path.parent() {
317 std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
318 }
319 std::fs::write(&path, bytes).map_err(|error| error.to_string())?;
320 let path = std::fs::canonicalize(&path).unwrap_or(path);
323 Ok(SavedFile {
324 path: path.display().to_string(),
325 size: bytes.len(),
326 })
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use crate::bundle;
333
334 #[derive(Debug, Deserialize)]
336 struct Arg {
337 #[serde(default)]
338 world: WorldId,
339 }
340
341 #[test]
342 fn a_world_id_parses_from_json() {
343 let host: Arg = serde_json::from_str("{}").unwrap();
344 assert_eq!(host.world, WorldId::Host);
345 let host: Arg = serde_json::from_str(r#"{"world": "Host"}"#).unwrap();
346 assert_eq!(host.world, WorldId::Host);
347 let handle: Arg = serde_json::from_str(r#"{"world": {"Handle": 7}}"#).unwrap();
348 assert_eq!(handle.world, WorldId::Handle(7));
349 }
350
351 #[test]
352 fn a_world_id_maps_to_and_from_an_entity() {
353 let entity = Entity::from_bits(0x0000_0002_0000_0001);
354 assert_eq!(
355 WorldId::handle(entity),
356 WorldId::Handle(0x0000_0002_0000_0001)
357 );
358 assert_eq!(WorldId::handle(entity).entity(), Some(entity));
359 assert_eq!(WorldId::Host.entity(), None);
360 assert_eq!(WorldId::default(), WorldId::Host);
361 }
362
363 #[test]
364 fn a_list_worlds_command_carries_no_job() {
365 let (command, receiver) = McpCommand::list_worlds();
366 assert!(command.wants_worlds());
367 assert_eq!(command.world(), WorldId::Host);
368 command.answer_worlds(vec![
369 WorldEntry {
370 world: WorldId::Host,
371 name: "host",
372 },
373 WorldEntry {
374 world: WorldId::Handle(3),
375 name: "render",
376 },
377 ]);
378 let answer = receiver.recv_blocking().unwrap().unwrap();
379 let Output::Json(value) = answer else {
380 panic!("list_worlds answers with JSON");
381 };
382 assert_eq!(
383 value,
384 serde_json::json!([
385 {"world": "Host", "name": "host"},
386 {"world": {"Handle": 3}, "name": "render"}
387 ])
388 );
389 }
390
391 #[test]
392 fn a_job_command_dispatches_against_a_world() {
393 let (command, receiver) =
394 McpCommand::new(|world| Ok(Output::Json(serde_json::json!(world.root_scope().len()))));
395 assert!(!command.wants_worlds());
396 let mut world = World::new();
397 world.root_scope_mut().spawn(bundle![1u32]);
398 command.dispatch(&mut world);
399 let answer = receiver.recv_blocking().unwrap().unwrap();
400 let Output::Json(value) = answer else {
401 panic!("a job answers with JSON");
402 };
403 assert_eq!(value, serde_json::json!(1));
404 }
405
406 #[test]
407 fn a_panicking_job_answers_an_error_and_the_thread_survives() {
408 let (command, receiver) = McpCommand::new(|_| {
409 panic!("a tool blew up");
410 });
411 let mut world = World::new();
412 command.dispatch(&mut world);
413 let answer = receiver.recv_blocking().unwrap();
414 let Err(message) = answer else {
415 panic!("a panicking job answers with an error");
416 };
417 assert_eq!(
418 message,
419 "the tool panicked and the world it ran in may be inconsistent: a tool blew up"
420 );
421
422 let (command, receiver) =
425 McpCommand::new(|world| Ok(Output::Json(serde_json::json!(world.root_scope().len()))));
426 command.dispatch(&mut world);
427 let answer = receiver.recv_blocking().unwrap().unwrap();
428 let Output::Json(value) = answer else {
429 panic!("a job answers with JSON");
430 };
431 assert_eq!(value, serde_json::json!(0));
432 }
433
434 #[test]
435 fn a_generated_name_carries_a_fresh_id() {
436 let name = generated_name("texture", "webp");
437 assert!(name.starts_with("texture-"), "{name}");
438 assert!(name.ends_with(".webp"), "{name}");
439 assert_ne!(name, generated_name("texture", "webp"));
440 }
441
442 #[test]
443 fn a_saved_name_prefers_the_callers_own() {
444 assert_eq!(saved_name(Some("a"), "buffer", "bin"), "a.bin");
445 assert_eq!(saved_name(Some("a.png"), "buffer", "bin"), "a.png");
446 let generated = saved_name(None, "screenshot", "webp");
447 assert!(generated.starts_with("screenshot-"), "{generated}");
448 assert!(generated.ends_with(".webp"), "{generated}");
449 }
450}