unlit_ecs/mcp/server.rs
1//! The MCP service: the tool router, the shared argument types and the transport.
2//!
3//! Each tool turns its JSON arguments into a [McpCommand] and sends it to the
4//! thread that owns the world. The tool then awaits the result: structured JSON
5//! for most tools, an image or audio content block for the ones that read
6//! pixels or samples back inline, and a written path for the ones that save
7//! to a file. A tool that fails returns a tool-level error result rather
8//! than a protocol error, so a model sees the message instead of a transport
9//! failure.
10//!
11//! The router starts empty and the engine crates fill it in: a crate submits a
12//! [ToolSource] whose `extend` registers the tools that crate defines. The
13//! server therefore carries tools this crate knows nothing about, and a tool
14//! whose types live elsewhere still runs against the world through the same
15//! command channel.
16
17use std::error::Error;
18use std::future::Future;
19use std::sync::Arc;
20
21use rmcp::handler::server::tool::schema_for_input;
22use rmcp::handler::server::wrapper::Parameters;
23use rmcp::model::{
24 CallToolResponse, CallToolResult, ContentBlock, ServerCapabilities, ServerConfig, Tool,
25};
26use rmcp::{ErrorData, ServerHandler, tool_handler};
27use schemars::JsonSchema;
28use serde::Deserialize;
29use serde::de::DeserializeOwned;
30use serde_json::Value;
31
32use crate::mcp::host::{McpCommand, Output, WorldId};
33use crate::{Entity, World};
34
35pub use rmcp::handler::server::router::tool::{ToolRoute, ToolRouter};
36
37/// The channel a tool sends its commands over.
38pub type CommandSender = async_channel::Sender<McpCommand>;
39
40/// What a tool handler answers with.
41pub type ToolResult = Result<CallToolResult, ErrorData>;
42
43/// A crate's contribution to the server's tool set.
44///
45/// A crate that defines tools of its own submits one of these from a link-time
46/// registration; the server calls every `extend` once, while it is built.
47pub struct ToolSource {
48 /// Register this crate's tools on the router.
49 pub extend: fn(&mut ToolRouter<McpServer>),
50}
51
52inventory::collect!(ToolSource);
53
54/// An entity handle from the `u64` bits a JSON argument carries.
55pub fn entity(bits: u64) -> Entity {
56 Entity::from_bits(bits)
57}
58
59/// A tool route from a name, a description and an asynchronous handler.
60///
61/// The handler receives the command channel it sends its work over and the
62/// arguments the client passed. Its argument type derives [JsonSchema], so the
63/// advertised input schema is the one this crate's own tools advertise too.
64pub fn tool<A, F, Fut>(
65 name: &'static str,
66 description: &'static str,
67 handler: F,
68) -> ToolRoute<McpServer>
69where
70 A: DeserializeOwned + JsonSchema + Send + 'static,
71 F: Fn(CommandSender, A) -> Fut + Send + Sync + 'static,
72 Fut: Future<Output = ToolResult> + Send + 'static,
73{
74 let schema =
75 schema_for_input::<Parameters<A>>().expect("a tool's arguments describe an object");
76 let attribute = Tool::new(name, description, schema);
77 let handler = Arc::new(handler);
78 ToolRoute::<McpServer>::new_dyn(attribute, move |context| {
79 let handler = Arc::clone(&handler);
80 let commands = context.service.commands.clone();
81 let arguments = context.arguments;
82 Box::pin(async move {
83 let args = serde_json::from_value(Value::Object(arguments.unwrap_or_default()))
84 .map_err(|error| ErrorData::invalid_params(error.to_string(), None))?;
85 let result: CallToolResponse = handler(commands, args).await?.into();
86 Ok(result)
87 })
88 })
89}
90
91/// The MCP server driving one or more worlds.
92#[derive(Debug, Clone)]
93pub struct McpServer {
94 commands: CommandSender,
95 tool_router: ToolRouter<Self>,
96}
97
98impl McpServer {
99 /// A server that sends its commands over `commands`.
100 ///
101 /// Every submitted [ToolSource] registers its tools as the server is built.
102 pub fn new(commands: CommandSender) -> Self {
103 let mut tool_router = ToolRouter::new();
104 for source in inventory::iter::<ToolSource> {
105 (source.extend)(&mut tool_router);
106 }
107 Self {
108 commands,
109 tool_router,
110 }
111 }
112}
113
114/// Send a job to the world `world` names and await what it answered.
115///
116/// The host - not this crate - turns `world` into a world: it routes the
117/// command to the thread that owns that world, which calls
118/// [`McpCommand::dispatch`] on it.
119pub async fn send(
120 commands: &CommandSender,
121 world: WorldId,
122 job: impl FnOnce(&mut World) -> Result<Output, String> + Send + 'static,
123) -> Result<Result<Output, String>, ErrorData> {
124 let (command, receiver) = McpCommand::new(job);
125 let command = command.with_world(world);
126 commands
127 .send(command)
128 .await
129 .map_err(|_| ErrorData::internal_error("the world thread is gone", None))?;
130 receiver
131 .recv()
132 .await
133 .map_err(|_| ErrorData::internal_error("the world thread dropped the command", None))
134}
135
136/// Send a job to the world `world` names and await its output.
137pub async fn run(
138 commands: &CommandSender,
139 world: WorldId,
140 job: impl FnOnce(&mut World) -> Result<Output, String> + Send + 'static,
141) -> ToolResult {
142 match send(commands, world, job).await? {
143 Ok(Output::Json(value)) => Ok(CallToolResult::structured(value)),
144 // An image answers with an MCP image content block so the client
145 // renders the pixels, and carries its size as structured content so a
146 // client reads it as data instead of from the picture.
147 Ok(Output::Image(image)) => {
148 let mut result =
149 CallToolResult::success(vec![ContentBlock::image(image.data, image.mime_type)]);
150 result.structured_content = Some(image.structured);
151 Ok(result)
152 }
153 // A sound answers the same way, in an audio content block, so a
154 // client decodes the stream itself rather than a base64 field it has
155 // to find in the JSON.
156 Ok(Output::Audio(audio)) => {
157 let mut result =
158 CallToolResult::success(vec![ContentBlock::audio(audio.data, audio.mime_type)]);
159 result.structured_content = Some(audio.structured);
160 Ok(result)
161 }
162 Err(message) => Ok(CallToolResult::error(vec![ContentBlock::text(message)])),
163 }
164}
165
166/// Send a job to the world `world` names and await its JSON result.
167pub async fn command(
168 commands: &CommandSender,
169 world: WorldId,
170 job: impl FnOnce(&mut World) -> Result<Value, String> + Send + 'static,
171) -> ToolResult {
172 run(commands, world, move |world| job(world).map(Output::from)).await
173}
174
175#[tool_handler(router = self.tool_router)]
176impl ServerHandler for McpServer {
177 fn get_info(&self) -> ServerConfig {
178 ServerConfig::new(ServerCapabilities::builder().enable_tools().build()).with_instructions(
179 "Drives one or more worlds through the public API of the crates that registered their tools: inspect and edit entities and components, inspect a world's resource graph, read or save buffers and textures, send input, and render or screenshot frames. Every tool names its world with a WorldId (\"Host\", or {\"Handle\": bits} for a world a host entity carries), and a tool that needs engine handles names them explicitly.",
180 )
181 }
182}
183
184/// No arguments.
185#[derive(Debug, Default, Deserialize, JsonSchema)]
186pub struct NoArgs {}
187
188/// Serve MCP over stdio, handing every command to `sink`.
189///
190/// `sink` is how a command reaches the thread that owns the world its
191/// [WorldId] names: a host sends it to its own render thread, or a windowed host
192/// posts it to its event loop, where it resolves the id to a world and calls
193/// [`McpCommand::dispatch`]. Returning `false` means the command could not be
194/// delivered - the event loop is gone, say - and the waiting tool call is
195/// answered with an internal error rather than left hanging.
196///
197/// The host answers the `list_worlds` tool itself: when a command's
198/// [`wants_worlds`](McpCommand::wants_worlds) is true, it names every world it
199/// carries - with the name each was spawned under - through
200/// [`answer_worlds`](McpCommand::answer_worlds) instead of dispatching a job.
201/// This crate keeps no list of its own, because the worlds on other threads are
202/// not types it can recognise.
203///
204/// # Errors
205///
206/// Fails when the tokio runtime cannot be built or the transport cannot start.
207pub fn serve_stdio_with(
208 sink: impl Fn(McpCommand) -> bool + Send + 'static,
209) -> Result<(), Box<dyn Error + Send + Sync>> {
210 let runtime = tokio::runtime::Builder::new_current_thread()
211 .enable_time()
212 .build()?;
213 runtime.block_on(async move {
214 let service = serve_with(rmcp::transport::stdio(), sink)
215 .await
216 .map_err(|error| -> Box<dyn Error + Send + Sync> { error })?;
217 service.waiting().await?;
218 Ok::<(), Box<dyn Error + Send + Sync>>(())
219 })
220}
221
222/// Serve MCP over any transport, handing every command to `sink`.
223///
224/// The transport is whatever rmcp accepts - stdio, or a pair of streams for a
225/// test that wants the server in its own process. The returned service is
226/// running; await [`waiting`](rmcp::service::RunningService::waiting) to serve
227/// until the peer leaves.
228///
229/// # Errors
230///
231/// Fails when the service cannot complete its handshake over `transport`.
232pub async fn serve_with<T, E, A>(
233 transport: T,
234 sink: impl Fn(McpCommand) -> bool + Send + 'static,
235) -> Result<
236 rmcp::service::RunningService<rmcp::RoleServer, McpServer>,
237 // Boxed because the error is large and this is the only place it is
238 // constructed: a caller reports it, it does not match on it.
239 Box<rmcp::service::ServerInitializeError>,
240>
241where
242 T: rmcp::transport::IntoTransport<rmcp::RoleServer, E, A>,
243 E: std::error::Error + Send + Sync + 'static,
244{
245 let (sender, receiver) = async_channel::bounded::<McpCommand>(4);
246 // A pump rather than a task per command: the sink is synchronous, and the
247 // service would otherwise be blocked on it.
248 tokio::task::spawn_blocking(move || {
249 while let Ok(command) = receiver.recv_blocking() {
250 if !sink(command) {
251 break;
252 }
253 }
254 });
255 rmcp::serve_server(McpServer::new(sender), transport)
256 .await
257 .map_err(Box::new)
258}
259
260/// Which world a tool runs against.
261#[derive(Debug, Deserialize, JsonSchema)]
262pub struct WorldArg {
263 /// The world to drive: `"Host"` for the world the transport thread itself
264 /// holds, or `{"Handle": bits}` for a world a host entity carries.
265 #[serde(default)]
266 pub world: WorldId,
267 /// The entity whose scope the tool runs in, as its `u64` bits. Omit for the
268 /// world's root scope, which sees everything.
269 #[serde(default)]
270 pub scope: Option<u64>,
271}
272
273/// Which world and render context a GPU tool runs against.
274#[derive(Debug, Deserialize, JsonSchema)]
275pub struct ContextArg {
276 /// The world to drive: `"Host"` for the world the transport thread itself
277 /// holds, or `{"Handle": bits}` for a world a host entity carries.
278 #[serde(default)]
279 pub world: WorldId,
280 /// Any entity carrying the world's render context, as its `u64` bits —
281 /// its device, queue, graph or capabilities. Omit when the world holds
282 /// exactly one.
283 #[serde(default)]
284 pub context: Option<u64>,
285}
286
287/// Which world and resource a handle tool runs against.
288#[derive(Debug, Deserialize, JsonSchema)]
289pub struct HandleArg {
290 /// The world to drive: `"Host"` for the world the transport thread itself
291 /// holds, or `{"Handle": bits}` for a world a host entity carries.
292 #[serde(default)]
293 pub world: WorldId,
294 /// Any entity carrying the world's render context, as its `u64` bits.
295 /// Omit when the world holds exactly one.
296 #[serde(default)]
297 pub context: Option<u64>,
298 /// The resource's id, as `list_resources` reports it. A texture view is
299 /// read through the texture it views.
300 pub id: u64,
301 /// The image format to answer with: `"webp"` (lossless, the default),
302 /// `"png"`, or `"jpeg"`.
303 #[serde(default)]
304 pub format: Option<String>,
305}
306
307/// Arguments for one entity in a world.
308#[derive(Debug, Deserialize, JsonSchema)]
309pub struct EntityArg {
310 /// The world to drive: `"Host"` for the world the transport thread itself
311 /// holds, or `{"Handle": bits}` for a world a host entity carries.
312 #[serde(default)]
313 pub world: WorldId,
314 /// The entity whose scope the tool runs in, as its `u64` bits. Omit for the
315 /// world's root scope, which sees everything.
316 #[serde(default)]
317 pub scope: Option<u64>,
318 /// The entity's bits.
319 pub entity: u64,
320 /// The entity carrying the world's mesh source, as its `u64` bits, for the tools
321 /// that need one. Omit for the world's only mesh source.
322 #[serde(default)]
323 pub source: Option<u64>,
324}
325
326/// Arguments for listing entities.
327#[derive(Debug, Deserialize, JsonSchema)]
328pub struct ListEntities {
329 /// The world to drive: `"Host"` for the world the transport thread itself
330 /// holds, or `{"Handle": bits}` for a world a host entity carries.
331 #[serde(default)]
332 pub world: WorldId,
333 /// The entity whose scope the tool runs in, as its `u64` bits. Omit for the
334 /// world's root scope, which sees everything.
335 #[serde(default)]
336 pub scope: Option<u64>,
337 /// How many entities to report.
338 #[serde(default = "default_limit")]
339 pub limit: usize,
340}
341
342fn default_limit() -> usize {
343 256
344}
345
346/// Arguments for reading one component.
347#[derive(Debug, Deserialize, JsonSchema)]
348pub struct GetComponent {
349 /// The world to drive: `"Host"` for the world the transport thread itself
350 /// holds, or `{"Handle": bits}` for a world a host entity carries.
351 #[serde(default)]
352 pub world: WorldId,
353 /// The entity whose scope the tool runs in, as its `u64` bits. Omit for the
354 /// world's root scope, which sees everything.
355 #[serde(default)]
356 pub scope: Option<u64>,
357 /// The entity's bits.
358 pub entity: u64,
359 /// The component's name.
360 pub component: String,
361}
362
363/// Arguments for writing one component.
364#[derive(Debug, Deserialize, JsonSchema)]
365pub struct SetComponent {
366 /// The world to drive: `"Host"` for the world the transport thread itself
367 /// holds, or `{"Handle": bits}` for a world a host entity carries.
368 #[serde(default)]
369 pub world: WorldId,
370 /// The entity whose scope the tool runs in, as its `u64` bits. Omit for the
371 /// world's root scope, which sees everything.
372 #[serde(default)]
373 pub scope: Option<u64>,
374 /// The entity's bits.
375 pub entity: u64,
376 /// The component's name.
377 pub component: String,
378 /// The new value.
379 pub value: Value,
380}
381
382/// Arguments for spawning an entity.
383#[derive(Debug, Deserialize, JsonSchema)]
384pub struct SpawnEntity {
385 /// The world to drive: `"Host"` for the world the transport thread itself
386 /// holds, or `{"Handle": bits}` for a world a host entity carries.
387 #[serde(default)]
388 pub world: WorldId,
389 /// The entity whose scope the tool runs in, as its `u64` bits. Omit for the
390 /// world's root scope, which sees everything.
391 #[serde(default)]
392 pub scope: Option<u64>,
393 /// The components, as an object of name to value.
394 pub components: Value,
395}
396
397/// Arguments for reading a buffer.
398#[derive(Debug, Deserialize, JsonSchema)]
399pub struct ReadBuffer {
400 /// The world to drive: `"Host"` for the world the transport thread itself
401 /// holds, or `{"Handle": bits}` for a world a host entity carries.
402 #[serde(default)]
403 pub world: WorldId,
404 /// Any entity carrying the world's render context, as its `u64` bits.
405 /// Omit when the world holds exactly one.
406 #[serde(default)]
407 pub context: Option<u64>,
408 /// The buffer's id, as `list_resources` reports it.
409 pub id: u64,
410 /// The byte offset to read from.
411 #[serde(default)]
412 pub offset: u64,
413 /// How many bytes to read; the rest of the buffer when omitted.
414 #[serde(default)]
415 pub size: Option<u64>,
416}
417
418/// Arguments for saving a buffer to a file.
419#[derive(Debug, Deserialize, JsonSchema)]
420pub struct SaveBuffer {
421 /// The world to drive: `"Host"` for the world the transport thread itself
422 /// holds, or `{"Handle": bits}` for a world a host entity carries.
423 #[serde(default)]
424 pub world: WorldId,
425 /// Any entity carrying the world's render context, as its `u64` bits.
426 /// Omit when the world holds exactly one.
427 #[serde(default)]
428 pub context: Option<u64>,
429 /// The buffer's id, as `list_resources` reports it.
430 pub id: u64,
431 /// The byte offset to read from.
432 #[serde(default)]
433 pub offset: u64,
434 /// How many bytes to read; the rest of the buffer when omitted.
435 #[serde(default)]
436 pub size: Option<u64>,
437 /// The directory to write the file into; created when it does not exist.
438 pub directory: String,
439 /// The file name to write; it is given a `.bin` extension when it carries
440 /// none.
441 #[serde(default)]
442 pub file_name: Option<String>,
443}
444
445/// Arguments for saving a texture as an image.
446#[derive(Debug, Deserialize, JsonSchema)]
447pub struct SaveTexture {
448 /// The world to drive: `"Host"` for the world the transport thread itself
449 /// holds, or `{"Handle": bits}` for a world a host entity carries.
450 #[serde(default)]
451 pub world: WorldId,
452 /// Any entity carrying the world's render context, as its `u64` bits.
453 /// Omit when the world holds exactly one.
454 #[serde(default)]
455 pub context: Option<u64>,
456 /// The resource's id, as `list_resources` reports it. A texture view is
457 /// read through the texture it views.
458 pub id: u64,
459 /// The directory to write the file into; created when it does not exist.
460 pub directory: String,
461 /// The file name to write; its extension picks the image format, and it is
462 /// given a `.webp` one when it carries none.
463 #[serde(default)]
464 pub file_name: Option<String>,
465}
466
467/// Arguments for saving a screenshot as an image.
468#[derive(Debug, Deserialize, JsonSchema)]
469pub struct SaveScreenshot {
470 /// The world to drive: `"Host"` for the world the transport thread itself
471 /// holds, or `{"Handle": bits}` for a world a host entity carries.
472 #[serde(default)]
473 pub world: WorldId,
474 /// The entity carrying the renderer, as its `u64` bits. Omit for the
475 /// world's only one.
476 #[serde(default)]
477 pub renderer: Option<u64>,
478 /// The id of the texture to read, as `list_resources` reports it, or the
479 /// id of any view of it. Omit for the world's only readable texture.
480 #[serde(default)]
481 pub target: Option<u64>,
482 /// The directory to write the file into; created when it does not exist.
483 pub directory: String,
484 /// The file name to write; its extension picks the image format, and it is
485 /// given a `.webp` one when it carries none.
486 #[serde(default)]
487 pub file_name: Option<String>,
488}
489
490/// Arguments for reading or sending input.
491#[derive(Debug, Deserialize, JsonSchema)]
492pub struct InputArg {
493 /// The world to drive: `"Host"` for the world the transport thread itself
494 /// holds, or `{"Handle": bits}` for a world a host entity carries.
495 #[serde(default)]
496 pub world: WorldId,
497 /// The entity carrying the input state, as its `u64` bits. Omit for the
498 /// world's only one.
499 #[serde(default)]
500 pub entity: Option<u64>,
501}
502
503/// Arguments for sending input events.
504#[derive(Debug, Deserialize, JsonSchema)]
505pub struct SendInput {
506 /// The world to drive: `"Host"` for the world the transport thread itself
507 /// holds, or `{"Handle": bits}` for a world a host entity carries.
508 #[serde(default)]
509 pub world: WorldId,
510 /// The entity carrying the input state, as its `u64` bits. Omit for the
511 /// world's only one.
512 #[serde(default)]
513 pub entity: Option<u64>,
514 /// The events to deliver.
515 pub events: Vec<Value>,
516}
517
518/// Arguments for rendering a frame.
519#[derive(Debug, Deserialize, JsonSchema)]
520pub struct RendererArg {
521 /// The world to drive: `"Host"` for the world the transport thread itself
522 /// holds, or `{"Handle": bits}` for a world a host entity carries.
523 #[serde(default)]
524 pub world: WorldId,
525 /// The entity carrying the renderer, as its `u64` bits. Omit for the
526 /// world's only one.
527 #[serde(default)]
528 pub renderer: Option<u64>,
529}
530
531/// Arguments for taking a screenshot.
532#[derive(Debug, Deserialize, JsonSchema)]
533pub struct Screenshot {
534 /// The world to drive: `"Host"` for the world the transport thread itself
535 /// holds, or `{"Handle": bits}` for a world a host entity carries.
536 #[serde(default)]
537 pub world: WorldId,
538 /// The entity carrying the renderer, as its `u64` bits. Omit for the
539 /// world's only one.
540 #[serde(default)]
541 pub renderer: Option<u64>,
542 /// The id of the texture to read, as `list_resources` reports it, or the
543 /// id of any view of it. Omit for the world's only readable texture.
544 #[serde(default)]
545 pub target: Option<u64>,
546 /// The image format to answer with: `"webp"` (lossless, the default),
547 /// `"png"`, or `"jpeg"`.
548 #[serde(default)]
549 pub format: Option<String>,
550}
551
552/// Arguments for creating a mesh.
553#[derive(Debug, Deserialize, JsonSchema)]
554pub struct CreateMesh {
555 /// The world to drive: `"Host"` for the world the transport thread itself
556 /// holds, or `{"Handle": bits}` for a world a host entity carries.
557 #[serde(default)]
558 pub world: WorldId,
559 /// The scope to spawn into, as its `u64` bits. Omit for the entity
560 /// carrying the mesh source.
561 #[serde(default)]
562 pub scope: Option<u64>,
563 /// The entity carrying the world's mesh source, as its `u64` bits. Omit
564 /// for the world's only one.
565 #[serde(default)]
566 pub source: Option<u64>,
567 /// Vertex positions, three floats each.
568 pub positions: Vec<[f32; 3]>,
569 /// Vertex UVs, two floats each.
570 #[serde(default)]
571 pub uvs: Option<Vec<[f32; 2]>>,
572 /// Vertex colours, RGBA bytes each.
573 #[serde(default)]
574 pub colors: Option<Vec<[u8; 4]>>,
575 /// Vertex normals, three floats each. Omit to shade the surface by the
576 /// normals its positions give it.
577 #[serde(default)]
578 pub normals: Option<Vec<[f32; 3]>>,
579 /// Triangle indices.
580 #[serde(default)]
581 pub indices: Option<Vec<u32>>,
582 /// The entity's transform.
583 #[serde(default)]
584 pub transform: Option<MeshTransform>,
585 /// The instance colour; opaque white by default.
586 #[serde(default)]
587 pub color: Option<[f32; 4]>,
588 /// The alpha cutoff, when the mesh should be cut out.
589 #[serde(default)]
590 pub cutoff: Option<f32>,
591 /// How far the mesh's inverted hull is pushed along its normals, in world
592 /// units. Spawning one entity with this width beside a plain draw of the
593 /// same mesh is what outlines it; an entity without it draws no hull.
594 #[serde(default)]
595 pub hull_width: Option<f32>,
596}
597
598/// A transform as JSON.
599#[derive(Debug, Deserialize, JsonSchema)]
600pub struct MeshTransform {
601 /// The translation.
602 #[serde(default = "zero3")]
603 pub translation: [f32; 3],
604 /// The rotation quaternion, xyzw.
605 #[serde(default = "identity4")]
606 pub rotation: [f32; 4],
607 /// The scale.
608 #[serde(default = "one3")]
609 pub scale: [f32; 3],
610}
611
612fn zero3() -> [f32; 3] {
613 [0.0; 3]
614}
615
616fn one3() -> [f32; 3] {
617 [1.0; 3]
618}
619
620fn identity4() -> [f32; 4] {
621 [0.0, 0.0, 0.0, 1.0]
622}
623
624/// Arguments for load_gltf.
625#[derive(Debug, Deserialize, JsonSchema)]
626pub struct LoadGltf {
627 /// The world to drive: `"Host"` for the world the transport thread itself
628 /// holds, or `{"Handle": bits}` for a world a host entity carries.
629 #[serde(default)]
630 pub world: WorldId,
631 /// The scope to load into, as its `u64` bits. Omit for the entity
632 /// carrying the mesh source.
633 #[serde(default)]
634 pub scope: Option<u64>,
635 /// The entity carrying the world's mesh source, as its `u64` bits. Omit
636 /// for the world's only one.
637 #[serde(default)]
638 pub source: Option<u64>,
639 /// The path to the glTF or GLB file.
640 pub path: String,
641}