Skip to main content

unlit3dx/mcp/
mod.rs

1//! The MCP tools an audio world answers: recording the mix it fills.
2//!
3//! Each tool routes its work to the world it names through [command], exactly
4//! as the rendering tools do. The tools register themselves through
5//! [ToolSource], so a server built from [unlit_ecs] carries them without
6//! depending on this crate.
7
8mod bridge;
9
10pub use bridge::{pump_mcp, serve_mcp};
11
12use unlit_ecs::mcp::serde_json::{self, Value};
13use unlit_ecs::mcp::{
14    CommandSender, EncodedAudio, McpServer, Output, ToolResult, ToolRouter, ToolSource, WorldArg,
15    command, run, tool,
16};
17use unlit_ecs::{Entity, World, bundle};
18use unlit3d::winit::builtin::ExitRequest;
19
20use crate::record::{RecordingError, RecordingFormat, RecordingHandle, start_recording};
21
22/// Why a recording tool could not answer its request.
23///
24/// The MCP transport carries errors as strings, so this type lives only
25/// inside the world-side tools; the tool boundary renders it for the client.
26#[derive(Debug, thiserror::Error)]
27enum ToolError {
28    /// The host world carries no exit request.
29    #[error("the host has no exit request")]
30    NoExitRequest,
31    /// A recording is already running on this world.
32    #[error("a recording is already in progress on this world")]
33    AlreadyRecording,
34    /// No recording is running on this world.
35    #[error("no recording is in progress on this world")]
36    NoRecording,
37    /// The recording captured no audio fill.
38    #[error("the recording captured no fill")]
39    EmptyRecording,
40    /// The format or the encoder refused the recording.
41    #[error(transparent)]
42    Recording(#[from] RecordingError),
43    /// The report could not be serialized into JSON.
44    #[error(transparent)]
45    Serialize(#[from] serde_json::Error),
46    /// The file could not be written to the host's directory.
47    #[error("{0}")]
48    Write(String),
49}
50
51/// How long a recording lasts when its caller names no limit.
52const DEFAULT_RECORDING_SECONDS: f32 = 60.0;
53
54/// The recording in progress on a world, kept as a component so it lives on
55/// that world's own thread.
56struct ActiveRecording(RecordingHandle);
57
58unlit_ecs::mcp::inventory::submit! {
59    ToolSource { extend: register }
60}
61
62/// Register every audio tool on `router`.
63fn register(router: &mut ToolRouter<McpServer>) {
64    router.add_route(tool(
65        "start_audio_recording",
66        "Start recording the mix an audio world fills. The recording runs until it is stopped or cancelled, or until it reaches its length limit; query it with audio_recording_status.",
67        start_audio_recording,
68    ));
69    router.add_route(tool(
70        "stop_audio_recording",
71        "Stop an audio world's recording and write what it captured to a file in a directory, returning the written path and size. The file name's extension picks the format: .flac (lossless, the default when it has none) or .wav. Prefer this over read_audio_recording, which returns the mix inline.",
72        stop_audio_recording,
73    ));
74    router.add_route(tool(
75        "cancel_audio_recording",
76        "Stop an audio world's recording and discard what it captured.",
77        cancel_audio_recording,
78    ));
79    router.add_route(tool(
80        "read_audio_recording",
81        "Stop an audio world's recording and return its captured mix as an audio content block, without writing a file. The format argument picks the encoding: \"flac\" (lossless, the default) or \"wav\".",
82        read_audio_recording,
83    ));
84    router.add_route(tool(
85        "request_exit",
86        "Ask the host to exit normally. The host must carry an ExitRequest component.",
87        request_host_exit,
88    ));
89    router.add_route(tool(
90        "audio_recording_status",
91        "Report what an audio world's recording has captured so far, or that none is in progress.",
92        audio_recording_status,
93    ));
94}
95
96/// Start recording the mix of the audio world the arguments name.
97async fn start_audio_recording(commands: CommandSender, args: StartRecording) -> ToolResult {
98    command(&commands, args.world, move |world| {
99        start(world, args.duration_seconds).map_err(|error| error.to_string())
100    })
101    .await
102}
103
104/// Finish a recording and write its file.
105async fn stop_audio_recording(commands: CommandSender, args: StopRecording) -> ToolResult {
106    command(&commands, args.world, move |world| {
107        stop(world, &args.directory, args.file_name.as_deref()).map_err(|error| error.to_string())
108    })
109    .await
110}
111
112/// Finish a recording and return its sound without touching the filesystem.
113async fn read_audio_recording(commands: CommandSender, args: ReadRecording) -> ToolResult {
114    run(&commands, args.world, move |world| {
115        read(world, args.format.as_deref()).map_err(|error| error.to_string())
116    })
117    .await
118}
119
120/// Ask a windowed or headless host to exit normally.
121async fn request_host_exit(commands: CommandSender, args: WorldArg) -> ToolResult {
122    command(&commands, args.world, |world| {
123        request_exit(world).map_err(|error| error.to_string())
124    })
125    .await
126}
127
128/// Discard the recording in progress.
129async fn cancel_audio_recording(commands: CommandSender, args: WorldArg) -> ToolResult {
130    command(&commands, args.world, |world| {
131        cancel(world).map_err(|error| error.to_string())
132    })
133    .await
134}
135
136/// Report what the recording in progress has captured.
137async fn audio_recording_status(commands: CommandSender, args: WorldArg) -> ToolResult {
138    command(&commands, args.world, |world| {
139        status(world).map_err(|error| error.to_string())
140    })
141    .await
142}
143
144/// Ask the host to exit, unless it carries no request component.
145fn request_exit(world: &mut World) -> Result<Value, ToolError> {
146    let mut found = false;
147    for request in world.root_scope().query::<&mut ExitRequest>().iter() {
148        request.0 = true;
149        found = true;
150    }
151    if !found {
152        return Err(ToolError::NoExitRequest);
153    }
154    Ok(serde_json::json!({ "requested": true }))
155}
156
157/// Start a recording, unless one is already running.
158fn start(world: &mut World, duration_seconds: Option<f32>) -> Result<Value, ToolError> {
159    if world
160        .root_scope()
161        .query::<&ActiveRecording>()
162        .iter()
163        .next()
164        .is_some()
165    {
166        return Err(ToolError::AlreadyRecording);
167    }
168    let seconds = duration_seconds.unwrap_or(DEFAULT_RECORDING_SECONDS);
169    let handle = start_recording(world, seconds);
170    let report = Started {
171        duration_seconds: seconds,
172    };
173    world
174        .root_scope_mut()
175        .spawn(bundle![ActiveRecording(handle)]);
176    Ok(serde_json::to_value(report)?)
177}
178
179/// Stop the recording, write its samples and report the file.
180fn stop(world: &mut World, directory: &str, file_name: Option<&str>) -> Result<Value, ToolError> {
181    // The name is settled before the recording is taken: an extension the
182    // server cannot write is a mistake to report while the mix is still in the
183    // world, not after it has been despawned.
184    let name = unlit_ecs::mcp::saved_name(file_name, "audio", "flac");
185    let format = RecordingFormat::of(&name)?;
186    let handle = take(world)?;
187    let Some(audio) = handle.stop(world) else {
188        return Err(ToolError::EmptyRecording);
189    };
190    let bytes = format.encode(audio.format, &audio.samples)?;
191    let saved = unlit_ecs::mcp::write_file(directory, &name, &bytes).map_err(ToolError::Write)?;
192    let report = Recorded {
193        path: saved.path,
194        size: saved.size,
195        frames: audio.frame_count(),
196        seconds: audio.seconds(),
197        sample_rate: audio.format.sample_rate,
198        channels: audio.format.channels,
199    };
200    Ok(serde_json::to_value(report)?)
201}
202
203/// Stop the recording and answer with its sound in memory.
204fn read(world: &mut World, format: Option<&str>) -> Result<Output, ToolError> {
205    use base64::Engine as _;
206
207    let format = match format {
208        Some(name) => RecordingFormat::from_name(name)?,
209        None => RecordingFormat::DEFAULT,
210    };
211    let handle = take(world)?;
212    let Some(audio) = handle.stop(world) else {
213        return Err(ToolError::EmptyRecording);
214    };
215    let bytes = format.encode(audio.format, &audio.samples)?;
216    Ok(Output::Audio(EncodedAudio {
217        data: base64::engine::general_purpose::STANDARD.encode(&bytes),
218        mime_type: format.mime_type().to_owned(),
219        structured: serde_json::json!({
220            "size": bytes.len(),
221            "frames": audio.frame_count(),
222            "seconds": audio.seconds(),
223            "sample_rate": audio.format.sample_rate,
224            "channels": audio.format.channels,
225        }),
226    }))
227}
228
229/// Stop the recording and throw its samples away.
230fn cancel(world: &mut World) -> Result<Value, ToolError> {
231    take(world)?.cancel(world);
232    Ok(serde_json::json!({ "cancelled": true }))
233}
234
235/// Report how far the recording in progress has come.
236fn status(world: &mut World) -> Result<Value, ToolError> {
237    let mut active_recording_rows = world.root_scope().query::<&ActiveRecording>();
238
239    let Some(recording) = active_recording_rows
240        .iter()
241        .next()
242        .map(|active_recording| active_recording.0.clone())
243    else {
244        return Ok(serde_json::json!({ "recording": false }));
245    };
246    let format = recording.format();
247    let report = InProgress {
248        recording: true,
249        sample_rate: format.map(|format| format.sample_rate),
250        channels: format.map(|format| format.channels),
251        frames: recording.frame_count(),
252        seconds: recording.seconds(),
253        full: recording.is_full(),
254    };
255    Ok(serde_json::to_value(report)?)
256}
257
258/// Take the recording in progress out of the world.
259fn take(world: &mut World) -> Result<RecordingHandle, ToolError> {
260    let entity = world
261        .root_scope()
262        .query::<(&ActiveRecording, Entity)>()
263        .iter()
264        .next()
265        .map(|(_active_recording, entity)| entity)
266        .ok_or(ToolError::NoRecording)?;
267    let handle = {
268        let mut recording = world
269            .root_scope()
270            .query_one::<&ActiveRecording>(entity)
271            .expect("the recording entity exists");
272        recording.get().0.clone()
273    };
274    world.root_scope_mut().despawn(entity).unwrap();
275    Ok(handle)
276}
277
278/// Arguments for starting a recording.
279#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
280pub(crate) struct StartRecording {
281    /// The world to drive: `"Host"` for the world the transport thread itself
282    /// holds, or `{"Handle": bits}` for a world a host entity carries.
283    #[serde(default)]
284    pub world: unlit_ecs::mcp::WorldId,
285    /// How many seconds of audio to record at most. The recording stops and
286    /// keeps what it captured when it reaches the limit.
287    #[serde(default)]
288    pub duration_seconds: Option<f32>,
289}
290
291/// Arguments for finishing a recording.
292#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
293pub(crate) struct StopRecording {
294    /// The world to drive: `"Host"` for the world the transport thread itself
295    /// holds, or `{"Handle": bits}` for a world a host entity carries.
296    #[serde(default)]
297    pub world: unlit_ecs::mcp::WorldId,
298    /// The directory to write the file into; created when it does not exist.
299    pub directory: String,
300    /// The file name to write; it is given a `.flac` extension when it
301    /// carries none.
302    #[serde(default)]
303    pub file_name: Option<String>,
304}
305
306/// Arguments for finishing a recording in memory.
307#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
308pub(crate) struct ReadRecording {
309    /// The world to drive: `"Host"` for the world the transport thread itself
310    /// holds, or `{"Handle": bits}` for a world a host entity carries.
311    #[serde(default)]
312    pub world: unlit_ecs::mcp::WorldId,
313    /// The audio format to answer with: `"flac"` (lossless, the default) or
314    /// `"wav"`.
315    #[serde(default)]
316    pub format: Option<String>,
317}
318
319/// A recording that has just started.
320#[derive(Debug, serde::Serialize, schemars::JsonSchema)]
321struct Started {
322    /// How many seconds the recording will capture at most.
323    duration_seconds: f32,
324}
325
326/// What a recording in progress has captured.
327#[derive(Debug, serde::Serialize, schemars::JsonSchema)]
328struct InProgress {
329    /// Whether a recording is in progress.
330    recording: bool,
331    /// The rate the recording captures at, once a fill has named it.
332    sample_rate: Option<u32>,
333    /// How many channels the recording captures, once a fill has named them.
334    channels: Option<u16>,
335    /// How many frames the recording has captured.
336    frames: usize,
337    /// How many seconds of audio the recording has captured.
338    seconds: f64,
339    /// Whether the recording reached its limit and stopped capturing.
340    full: bool,
341}
342
343/// A finished recording, written to a file.
344#[derive(Debug, serde::Serialize, schemars::JsonSchema)]
345struct Recorded {
346    /// The absolute path of the file that was written.
347    path: String,
348    /// How many bytes were written.
349    size: usize,
350    /// How many frames the file holds.
351    frames: usize,
352    /// How many seconds of audio the file holds.
353    seconds: f64,
354    /// The rate the recording captured at.
355    sample_rate: u32,
356    /// How many channels the recording captured.
357    channels: u16,
358}
359
360#[cfg(all(test, feature = "audio"))]
361mod tests {
362    use super::*;
363    use crate::audio::{
364        AudioChunk, AudioFillOrder, AudioFormat, GlobalAudioGain, OnAudioFill, dispatch_audio_fill,
365    };
366
367    /// A world with one source that fills a constant level, plus the format
368    /// its fills carry.
369    fn audio_world(level: f32) -> (World, AudioFormat) {
370        let mut world = World::new();
371        world.root_scope_mut().spawn(bundle![GlobalAudioGain(1.0)]);
372        world.root_scope_mut().spawn(bundle![OnAudioFill::new(
373            AudioFillOrder(0),
374            move |_world, _entity, chunk: &mut AudioChunk| chunk.out.fill(level),
375        ),]);
376        let format = AudioFormat {
377            sample_rate: 8,
378            channels: 1,
379        };
380        (world, format)
381    }
382
383    #[test]
384    fn a_recording_started_and_stopped_writes_its_mix() {
385        let (mut world, format) = audio_world(0.5);
386        let started = start(&mut world, Some(1.0)).expect("the recording starts");
387        assert_eq!(started["duration_seconds"], 1.0);
388
389        let mut out = [0.0f32; 4];
390        dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
391        let status = status(&mut world).expect("a recording reports");
392        assert_eq!(status["recording"], true);
393        assert_eq!(status["frames"], 4);
394        assert_eq!(status["sample_rate"], 8);
395
396        let directory = std::env::temp_dir().join(format!("unlit3dx-mcp-{}", std::process::id()));
397        let _ = std::fs::remove_dir_all(&directory);
398        let recorded = stop(&mut world, &directory.to_string_lossy(), Some("take.wav"))
399            .expect("the recording is written");
400        assert_eq!(recorded["frames"], 4);
401        assert_eq!(recorded["channels"], 1);
402        assert_eq!(recorded["sample_rate"], 8);
403        let path = recorded["path"].as_str().expect("the file has a path");
404        assert!(
405            path.ends_with("take.wav"),
406            "the file keeps its name: {path}"
407        );
408        let bytes = std::fs::read(path).expect("the file is written");
409        assert_eq!(&bytes[0..4], b"RIFF");
410        assert_eq!(bytes.len(), 44 + 8);
411    }
412
413    #[test]
414    fn a_name_without_an_extension_is_written_as_flac() {
415        let (mut world, format) = audio_world(0.5);
416        start(&mut world, Some(1.0)).expect("the recording starts");
417        let mut out = [0.0f32; 4];
418        dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
419
420        let directory =
421            std::env::temp_dir().join(format!("unlit3dx-mcp-{}-flac", std::process::id()));
422        let _ = std::fs::remove_dir_all(&directory);
423        let recorded = stop(&mut world, &directory.to_string_lossy(), Some("take"))
424            .expect("the recording is written");
425        let path = recorded["path"].as_str().expect("the file has a path");
426        assert!(
427            path.ends_with("take.flac"),
428            "the name is given the default extension: {path}"
429        );
430        let bytes = std::fs::read(path).expect("the file is written");
431        assert_eq!(&bytes[0..4], b"fLaC");
432    }
433
434    #[test]
435    fn a_name_the_server_cannot_write_leaves_the_recording_in_the_world() {
436        let (mut world, format) = audio_world(0.5);
437        start(&mut world, Some(1.0)).expect("the recording starts");
438        let mut out = [0.0f32; 4];
439        dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
440
441        let directory =
442            std::env::temp_dir().join(format!("unlit3dx-mcp-{}-refused", std::process::id()));
443        let _ = std::fs::remove_dir_all(&directory);
444        let refused = stop(&mut world, &directory.to_string_lossy(), Some("take.mp3"))
445            .expect_err("an extension the server cannot write is refused")
446            .to_string();
447        assert!(refused.contains("mp3"), "{refused}");
448        assert_eq!(
449            status(&mut world).expect("the recording is still there")["frames"],
450            4,
451            "a refused name keeps the mix it could not write"
452        );
453
454        let recorded = stop(&mut world, &directory.to_string_lossy(), Some("take.flac"))
455            .expect("a name the server can write finishes the recording");
456        assert_eq!(recorded["frames"], 4);
457    }
458
459    #[test]
460    fn a_finished_recording_is_answered_with_its_sound() {
461        use base64::Engine as _;
462
463        let (mut world, format) = audio_world(0.5);
464        start(&mut world, Some(1.0)).expect("the recording starts");
465        let mut out = [0.0f32; 4];
466        dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
467
468        let Output::Audio(sound) = read(&mut world, None).expect("the recording is read") else {
469            panic!("the answer carries a sound");
470        };
471        assert_eq!(sound.mime_type, "audio/flac");
472        assert_eq!(sound.structured["frames"], 4);
473        let flac = base64::engine::general_purpose::STANDARD
474            .decode(&sound.data)
475            .expect("the sound is base64");
476        assert_eq!(&flac[0..4], b"fLaC");
477
478        start(&mut world, Some(1.0)).expect("the recording starts");
479        dispatch_audio_fill(&world, &mut out, format.sample_rate, format.channels);
480        let Output::Audio(sound) = read(&mut world, Some("wav")).expect("the recording is read")
481        else {
482            panic!("the answer carries a sound");
483        };
484        assert_eq!(sound.mime_type, "audio/wav");
485        let wav = base64::engine::general_purpose::STANDARD
486            .decode(&sound.data)
487            .expect("the sound is base64");
488        assert_eq!(&wav[0..4], b"RIFF");
489    }
490
491    #[test]
492    fn a_second_recording_is_refused_until_the_first_ends() {
493        let (mut world, _) = audio_world(0.0);
494        start(&mut world, None).expect("the first recording starts");
495        let refused = start(&mut world, None)
496            .expect_err("the second is refused")
497            .to_string();
498        assert!(refused.contains("already in progress"), "{refused}");
499        cancel(&mut world).expect("the recording is cancelled");
500        start(&mut world, None).expect("a fresh recording starts");
501    }
502
503    #[test]
504    fn a_cancelled_or_finished_recording_reports_nothing() {
505        let (mut world, _) = audio_world(0.0);
506        assert!(status(&mut world).expect("no recording is fine")["recording"] == false);
507        let missing = cancel(&mut world)
508            .expect_err("there is nothing to cancel")
509            .to_string();
510        assert!(missing.contains("no recording"), "{missing}");
511    }
512
513    #[test]
514    fn a_recording_that_saw_no_fill_is_reported_when_stopped() {
515        let (mut world, _) = audio_world(0.0);
516        start(&mut world, Some(1.0)).expect("the recording starts");
517        let directory =
518            std::env::temp_dir().join(format!("unlit3dx-mcp-empty-{}", std::process::id()));
519        let failed = stop(&mut world, &directory.to_string_lossy(), None)
520            .expect_err("a recording with no fill cannot be written")
521            .to_string();
522        assert!(failed.contains("captured no fill"), "{failed}");
523    }
524}