Skip to main content

unlit_ecs/mcp/
tools.rs

1//! The ECS tools: reading and writing a world's entities and components.
2//!
3//! These tools need nothing but the world itself, so they live next to it and
4//! register themselves with the MCP server through [ToolSource].
5
6use rmcp::ErrorData;
7use rmcp::model::{CallToolResult, ContentBlock};
8use serde_json::Value;
9
10use crate::mcp::host::{Despawned, McpCommand, Output, SpawnedEntity, to_value};
11use crate::mcp::server::{
12    CommandSender, EntityArg, GetComponent, ListEntities, McpServer, NoArgs, SetComponent,
13    SpawnEntity, ToolResult, ToolRouter, ToolSource, WorldArg, command, entity, tool,
14};
15use crate::reflect;
16use crate::{DynamicBundle, Entity, Scope, ScopeMut, World};
17
18/// A high-level description of a scope.
19fn summarize_world(scope: &Scope<'_>) -> Result<Value, String> {
20    to_value(&scope.info())
21}
22
23/// The component names the reflection table holds.
24fn component_names(_world: &World) -> Result<Value, String> {
25    to_value(&reflect::names().collect::<Vec<_>>())
26}
27
28/// Every archetype with the component names it stores.
29fn summarize_archetypes(scope: &Scope<'_>) -> Result<Value, String> {
30    to_value(&scope.archetype_infos())
31}
32
33/// Every spawned entity with its component names.
34fn summarize_entities(scope: &Scope<'_>, limit: usize) -> Result<Value, String> {
35    to_value(&scope.entity_infos(limit))
36}
37
38/// One entity's component names and location.
39fn summarize_entity(scope: &Scope<'_>, entity: Entity) -> Result<Value, String> {
40    let info = scope
41        .entity_info(entity)
42        .map_err(|_| format!("no entity {entity:?}"))?;
43    to_value(&info)
44}
45
46/// One component of one entity, encoded.
47fn read_component(scope: &Scope<'_>, entity: Entity, name: &str) -> Result<Value, String> {
48    if !scope.contains(entity) {
49        return Err(format!("no entity {entity:?}"));
50    }
51    let text = reflect::encode_json(scope, entity, name).map_err(|error| error.to_string())?;
52    serde_json::from_str(&text).map_err(|error| error.to_string())
53}
54
55/// Overwrite one component of one entity.
56fn write_component(
57    scope: &Scope<'_>,
58    entity: Entity,
59    name: &str,
60    value: &Value,
61) -> Result<Value, String> {
62    if !scope.contains(entity) {
63        return Err(format!("no entity {entity:?}"));
64    }
65    let patch = serde_json::to_string(value).map_err(|error| error.to_string())?;
66    reflect::set_json(scope, entity, name, &patch).map_err(|error| error.to_string())?;
67    Ok(Value::Null)
68}
69
70/// Spawn an entity from a map of component name to value.
71fn spawn_from_value(scope: &mut ScopeMut<'_>, components: &Value) -> Result<Value, String> {
72    let object = components
73        .as_object()
74        .ok_or_else(|| "components must be an object".to_string())?;
75    let mut bundle = DynamicBundle::new();
76    for (name, value) in object {
77        let patch = serde_json::to_string(value).map_err(|error| error.to_string())?;
78        reflect::push_json(&mut bundle, name, &patch).map_err(|error| error.to_string())?;
79    }
80    let spawned = scope.entity();
81    let name = "mcp spawned";
82    let entity = scope.spawn_named(name, bundle);
83    to_value(&SpawnedEntity {
84        entity,
85        name,
86        scope: spawned,
87    })
88}
89
90/// Despawn one entity and its subtree.
91///
92/// An entity the scope cannot remove is an error the caller reads, not a
93/// `false` beside a name: what to do about it depends on why.
94fn despawn_one(scope: &mut ScopeMut<'_>, entity: Entity) -> Result<Value, String> {
95    let name = scope.as_scope().entity_name(entity).unwrap_or("");
96    let removed = scope
97        .despawn(entity)
98        .map_err(|error| format!("cannot despawn {entity:?}: {error}"))?;
99    to_value(&Despawned { removed, name })
100}
101
102/// Every world the host carries.
103async fn list_worlds(commands: CommandSender, _args: NoArgs) -> ToolResult {
104    let (request, receiver) = McpCommand::list_worlds();
105    commands
106        .send(request)
107        .await
108        .map_err(|_| ErrorData::internal_error("the world thread is gone", None))?;
109    match receiver.recv().await {
110        Ok(Ok(Output::Json(value))) => Ok(CallToolResult::structured(value)),
111        Ok(Ok(Output::Image(_))) => Err(ErrorData::internal_error(
112            "list_worlds was answered with an image",
113            None,
114        )),
115        Ok(Ok(Output::Audio(_))) => Err(ErrorData::internal_error(
116            "list_worlds was answered with a sound",
117            None,
118        )),
119        Ok(Err(message)) => Ok(CallToolResult::error(vec![ContentBlock::text(message)])),
120        Err(_) => Err(ErrorData::internal_error(
121            "the world thread dropped the command",
122            None,
123        )),
124    }
125}
126
127/// A high-level description of the world.
128async fn world_summary(commands: CommandSender, args: WorldArg) -> ToolResult {
129    command(&commands, args.world, move |world| {
130        summarize_world(&scoped(world, args.scope)?)
131    })
132    .await
133}
134
135/// The component names the server can read and write.
136async fn list_components(commands: CommandSender, args: WorldArg) -> ToolResult {
137    command(&commands, args.world, move |world| component_names(world)).await
138}
139
140/// Every archetype with its component names and length.
141async fn list_archetypes(commands: CommandSender, args: WorldArg) -> ToolResult {
142    command(&commands, args.world, move |world| {
143        summarize_archetypes(&scoped(world, args.scope)?)
144    })
145    .await
146}
147
148/// Every spawned entity with its component names.
149async fn list_entities(commands: CommandSender, args: ListEntities) -> ToolResult {
150    command(&commands, args.world, move |world| {
151        summarize_entities(&scoped(world, args.scope)?, args.limit)
152    })
153    .await
154}
155
156/// One entity's component names and location.
157async fn get_entity(commands: CommandSender, args: EntityArg) -> ToolResult {
158    command(&commands, args.world, move |world| {
159        summarize_entity(&scoped(world, args.scope)?, entity(args.entity))
160    })
161    .await
162}
163
164/// One component of one entity.
165async fn get_component(commands: CommandSender, args: GetComponent) -> ToolResult {
166    command(&commands, args.world, move |world| {
167        read_component(
168            &scoped(world, args.scope)?,
169            entity(args.entity),
170            &args.component,
171        )
172    })
173    .await
174}
175
176/// Overwrite one component of one entity.
177async fn set_component(commands: CommandSender, args: SetComponent) -> ToolResult {
178    command(&commands, args.world, move |world| {
179        write_component(
180            &scoped(world, args.scope)?,
181            entity(args.entity),
182            &args.component,
183            &args.value,
184        )
185    })
186    .await
187}
188
189/// Spawn an entity from a map of component name to value.
190async fn spawn_entity(commands: CommandSender, args: SpawnEntity) -> ToolResult {
191    command(&commands, args.world, move |world| {
192        let mut scope = scoped_mut(world, args.scope)?;
193        spawn_from_value(&mut scope, &args.components)
194    })
195    .await
196}
197
198/// Despawn one entity.
199async fn despawn_entity(commands: CommandSender, args: EntityArg) -> ToolResult {
200    command(&commands, args.world, move |world| {
201        let mut scope = scoped_mut(world, args.scope)?;
202        despawn_one(&mut scope, entity(args.entity))
203    })
204    .await
205}
206
207/// The scope an ECS tool runs in: the scope the arguments name, or the root.
208fn scoped(world: &World, scope: Option<u64>) -> Result<Scope<'_>, String> {
209    match scope {
210        None => Ok(world.root_scope()),
211        Some(bits) => {
212            let scope = Entity::from_bits(bits);
213            world
214                .scope(scope)
215                .map_err(|_| format!("no scope {scope:?}"))
216        }
217    }
218}
219
220/// The mutable scope an ECS tool runs in.
221fn scoped_mut(world: &mut World, scope: Option<u64>) -> Result<ScopeMut<'_>, String> {
222    match scope {
223        None => Ok(world.root_scope_mut()),
224        Some(bits) => {
225            let scope = Entity::from_bits(bits);
226
227            world
228                .scope_mut(scope)
229                .map_err(|_| format!("no scope {scope:?}"))
230        }
231    }
232}
233
234inventory::submit! {
235    ToolSource { extend: register }
236}
237
238/// Register every ECS tool on `router`.
239fn register(router: &mut ToolRouter<McpServer>) {
240    router.add_route(tool(
241        "world_summary",
242        "Count a world's entities and archetypes and list the component names it has seen. Pass the host entity whose world to inspect, or omit for the host world.",
243        world_summary,
244    ));
245    router.add_route(tool(
246        "list_worlds",
247        "List the worlds the host carries: its own world and every world it routes commands to, including ones on other threads. Ask this before naming a world in another tool.",
248        list_worlds,
249    ));
250    router.add_route(tool(
251        "list_components",
252        "List the component names the server knows how to encode and decode.",
253        list_components,
254    ));
255    router.add_route(tool(
256        "list_archetypes",
257        "List every archetype of a world with the component names it stores and how many entities it holds.",
258        list_archetypes,
259    ));
260    router.add_route(tool(
261        "list_entities",
262        "List a world's spawned entities and their component names, up to a limit.",
263        list_entities,
264    ));
265    router.add_route(tool(
266        "get_entity",
267        "Describe one entity of a world: its components, archetype and row.",
268        get_entity,
269    ));
270    router.add_route(tool(
271        "get_component",
272        "Read one component of one entity of a world as JSON.",
273        get_component,
274    ));
275    router.add_route(tool(
276        "set_component",
277        "Overwrite one component of one entity of a world from a JSON value.",
278        set_component,
279    ));
280    router.add_route(tool(
281        "spawn_entity",
282        "Spawn an entity in a world from an object mapping component names to JSON values.",
283        spawn_entity,
284    ));
285    router.add_route(tool(
286        "despawn_entity",
287        "Despawn one entity of a world.",
288        despawn_entity,
289    ));
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::bundle;
296
297    /// A component the tests read and write through the tools.
298    #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, facet::Facet)]
299    struct Marker(u32);
300
301    inventory::submit! { crate::reflect::ComponentEntry::new::<Marker>() }
302
303    /// A world with a scene holding one child, and a sibling scene of its own.
304    fn fixture() -> (World, Entity, Entity, Entity) {
305        let mut world = World::new();
306        let scene = world.root_scope_mut().spawn(bundle![0u32]);
307        let child = world.scope_mut(scene).unwrap().spawn(bundle![Marker(1)]);
308        let sibling = world.root_scope_mut().spawn(bundle![Marker(2)]);
309        (world, scene, child, sibling)
310    }
311
312    #[test]
313    fn an_absent_scope_means_the_root() {
314        let (world, _, child, sibling) = fixture();
315        let Ok(scope) = scoped(&world, None) else {
316            panic!("the root needs no handle");
317        };
318        assert_eq!(scope.entity(), world.root());
319        assert!(scope.contains(child));
320        assert!(scope.contains(sibling));
321    }
322
323    #[test]
324    fn a_scope_handle_narrows_the_view() {
325        let (world, scene, child, sibling) = fixture();
326        let Ok(scope) = scoped(&world, Some(scene.to_bits())) else {
327            panic!("a live scope");
328        };
329        assert_eq!(scope.entity(), scene);
330        assert!(scope.contains(child));
331        assert!(!scope.contains(sibling), "the sibling is beside it");
332    }
333
334    #[test]
335    fn a_dead_scope_handle_is_reported_by_its_bits() {
336        let (mut world, scene, _, _) = fixture();
337        let bits = scene.to_bits();
338        let restored = Entity::from_bits(bits);
339        world.root_scope_mut().despawn(scene).unwrap();
340        let error = scoped(&world, Some(bits)).expect_err("the scope is gone");
341        assert_eq!(
342            error,
343            format!("no scope {restored:?}"),
344            "the bits it came as"
345        );
346    }
347
348    #[test]
349    fn an_unknown_scope_reads_the_same_as_a_dead_one() {
350        // A handle that never named anything must not be told apart from one
351        // that used to: the difference is a probing oracle.
352        let world = World::new();
353        let bits = 0x1234_5678_9abc_def0u64;
354        let restored = Entity::from_bits(bits);
355        let error = scoped(&world, Some(bits)).expect_err("no such scope");
356        assert_eq!(
357            error,
358            format!("no scope {restored:?}"),
359            "the bits it came as"
360        );
361    }
362
363    #[test]
364    fn a_scoped_write_refuses_an_entity_the_scope_cannot_see() {
365        let (world, scene, _, sibling) = fixture();
366        let scope = scoped(&world, Some(scene.to_bits())).unwrap();
367        let error = write_component(&scope, sibling, "Marker", &serde_json::json!({ "0": 9 }))
368            .expect_err("the sibling is out of reach");
369        assert!(error.contains(&sibling.to_bits().to_string()), "{error}");
370        assert_eq!(
371            world
372                .root_scope()
373                .query_one::<&Marker>(sibling)
374                .unwrap()
375                .get()
376                .0,
377            2
378        );
379    }
380
381    #[test]
382    fn a_scoped_read_refuses_an_entity_the_scope_cannot_see() {
383        let (world, scene, _, sibling) = fixture();
384        let scope = scoped(&world, Some(scene.to_bits())).unwrap();
385        let error = read_component(&scope, sibling, "Marker").expect_err("out of reach");
386        assert!(error.contains(&sibling.to_bits().to_string()), "{error}");
387    }
388
389    #[test]
390    fn a_scoped_spawn_lands_in_that_scope_and_reports_it() {
391        let (mut world, scene, _, _) = fixture();
392        let mut scope = scoped_mut(&mut world, Some(scene.to_bits())).unwrap();
393        let spawned = spawn_from_value(&mut scope, &serde_json::json!({ "Marker": { "0": 7 } }))
394            .expect("a marker is spawnable");
395        let entity = spawned["entity"].clone();
396        let reported = spawned["scope"].clone();
397        let entity = Entity::from_bits(entity.as_u64().expect("bits"));
398        assert_eq!(world.parent_scope(entity).unwrap(), scene);
399        assert_eq!(
400            Entity::from_bits(reported.as_u64().expect("bits")),
401            scene,
402            "the reply says which scope it went in"
403        );
404    }
405
406    #[test]
407    fn a_scoped_despawn_refuses_what_the_scope_does_not_own() {
408        let (mut world, scene, _, sibling) = fixture();
409        let mut scope = scoped_mut(&mut world, Some(scene.to_bits())).unwrap();
410        let error = despawn_one(&mut scope, sibling).expect_err("it is not its to remove");
411        assert!(error.contains("is not its to move or remove"), "{error}");
412        assert!(error.contains(&format!("{sibling:?}")), "{error}");
413        assert!(
414            world.root_scope().contains(sibling),
415            "the refusal removed nothing"
416        );
417    }
418
419    #[test]
420    fn a_scoped_despawn_takes_the_subtree() {
421        let (mut world, scene, child, _) = fixture();
422        let name = world.root_scope().entity_name(child).unwrap();
423        assert!(!name.is_empty(), "the row names the entity");
424        let mut scope = scoped_mut(&mut world, Some(scene.to_bits())).unwrap();
425        let answer = despawn_one(&mut scope, child).expect("its own descendant");
426        assert_eq!(
427            answer["removed"],
428            serde_json::json!(1),
429            "the child took no subtree with it"
430        );
431        assert_eq!(
432            answer["name"],
433            serde_json::json!(name),
434            "the reply carries the name the row stores"
435        );
436        assert!(!world.root_scope().contains(child));
437    }
438
439    #[test]
440    fn the_summaries_report_the_scope_not_the_whole_world() {
441        let (world, scene, child, sibling) = fixture();
442        let scope = scoped(&world, Some(scene.to_bits())).unwrap();
443        let world_info = summarize_world(&scope).unwrap();
444        assert_eq!(
445            world_info["entities"],
446            serde_json::json!(2),
447            "the scene and its child"
448        );
449        assert_eq!(
450            world_info["root"],
451            serde_json::json!(world.root().to_bits())
452        );
453        let entities = summarize_entities(&scope, usize::MAX).unwrap();
454        let listed: Vec<u64> = entities
455            .as_array()
456            .unwrap()
457            .iter()
458            .map(|info| info["entity"].as_u64().unwrap())
459            .collect();
460        assert!(listed.contains(&child.to_bits()));
461        assert!(!listed.contains(&sibling.to_bits()));
462        let archetypes = summarize_archetypes(&scope).unwrap();
463        let sum: u64 = archetypes
464            .as_array()
465            .unwrap()
466            .iter()
467            .map(|info| info["len"].as_u64().unwrap())
468            .sum();
469        assert_eq!(sum, 2, "the archetype lengths add up to the count");
470    }
471
472    #[test]
473    fn an_entity_listing_reports_the_scope_it_lives_in() {
474        let (world, scene, child, _) = fixture();
475        let scope = scoped(&world, None).unwrap();
476        let info = summarize_entity(&scope, child).unwrap();
477        assert_eq!(info["scope"], serde_json::json!(scene.to_bits()));
478        let names: Vec<&str> = info["components"]
479            .as_array()
480            .unwrap()
481            .iter()
482            .map(|name| name.as_str().unwrap())
483            .collect();
484        assert!(
485            !names.iter().any(|name| name.ends_with("ScopeOf")),
486            "the scope is not a component: {names:?}"
487        );
488    }
489
490    #[test]
491    fn the_scope_of_an_entity_is_not_addressed_as_a_component() {
492        // The scope is reported beside the entity, not stored in it, so there
493        // is no component to read it from and none to write.
494        let (world, scene, child, _) = fixture();
495        let scope = scoped(&world, None).unwrap();
496        let error = write_component(
497            &scope,
498            child,
499            "ScopeOf",
500            &serde_json::json!({ "0": scene.to_bits() }),
501        )
502        .expect_err("there is no such component");
503        assert!(!error.is_empty(), "{error}");
504        assert_eq!(
505            world.parent_scope(child).unwrap(),
506            scene,
507            "the failed write moved nothing"
508        );
509    }
510
511    #[test]
512    fn a_lifecycle_component_is_not_spawnable_through_the_tools() {
513        let (mut world, _, _, _) = fixture();
514        let mut scope = scoped_mut(&mut world, None).unwrap();
515        let error = spawn_from_value(&mut scope, &serde_json::json!({ "OnDespawn": {} }))
516            .expect_err("a callback has no wire form");
517        assert!(!error.is_empty());
518    }
519}