Skip to main content

waterui_cli/mcp/
proxy.rs

1//! The child-process side of `water mcp`: builds the managed Hydrolysis
2//! backend in `waterui-mcp-mode`, spawns it, and forwards `tools/call`
3//! requests to it over the child's own MCP stdio link.
4//!
5//! The proxy is the [`ToolDispatch`] the fronting `McpServer` registers its
6//! nine tools against. It answers `initialize`/`tools/list` through the
7//! static protocol crate immediately — while the child is still building —
8//! and parks `tools/call` on a readiness cell until the child is up.
9
10use std::future::Future;
11use std::path::PathBuf;
12use std::process::Stdio;
13use std::sync::Arc;
14
15use aither_core::llm::tool::ToolResult;
16use aither_mcp::protocol::{
17    CallToolParams, CallToolResult, Content, InitializeParams, JsonRpcNotification, JsonRpcRequest,
18    JsonRpcResponse,
19};
20use aither_mcp::transport::{StreamTransport, Transport};
21use async_channel::{Receiver, Sender};
22use async_lock::{Mutex, OnceCell};
23use base64::Engine as _;
24use base64::engine::general_purpose::STANDARD as BASE64;
25use eyre::{Context as _, Result, bail, eyre};
26use futures_lite::io::BufReader;
27use serde::Serialize;
28use smol::Task;
29use smol::process::Child;
30use tracing::{debug, error, info};
31use waterui_mcp_protocol::{
32    ActArgs, AdvanceArgs, FindArgs, KeyArgs, PointerArgs, RestartArgs, ScreenshotArgs,
33    SnapshotArgs, ToolDispatch, TypeTextArgs, WaitArgs,
34};
35use waterui_preview_protocol::hydrolysis::{MCP_RUN_CONFIG_ENV, McpRunConfig};
36
37use crate::build::{BuildOptions, BuildProfile};
38use crate::hydrolysis::backend::HydrolysisBackend;
39use crate::hydrolysis::platform::{
40    build_hydrolysis_with_envs_and_features, stage_hydrolysis_shared_runtime,
41};
42use crate::mcp::{host_platform, write_run_config};
43use crate::preview::hydrolysis::{
44    HydrolysisPreviewTheme, ensure_hydrolysis_backend_ready, stage_hydrolysis_resources,
45};
46
47/// The Cargo feature that builds the generated backend as an MCP child.
48const HYDROLYSIS_MCP_FEATURE: &str = "waterui-mcp-mode";
49
50/// Everything one build of the child needs, cloned into the build task.
51#[derive(Debug, Clone)]
52struct ChildConfig {
53    /// `WaterUI` project directory.
54    project_path: PathBuf,
55    /// Viewport width in logical pixels.
56    width: u32,
57    /// Viewport height in logical pixels.
58    height: u32,
59    /// Display scale factor.
60    scale_factor: f64,
61    /// `sccache` binary for compilation caching, when available.
62    sccache_path: Option<PathBuf>,
63}
64
65/// One `tools/call` handed to the child driver task.
66#[derive(Debug)]
67struct ChildCall {
68    /// Tool name as the client called it.
69    name: &'static str,
70    /// JSON-serialized tool arguments.
71    arguments: serde_json::Value,
72    /// Where the mapped [`ToolResult`] travels back to the tool handler.
73    reply: Sender<ToolResult>,
74}
75
76/// The child's MCP link: line-delimited JSON-RPC over its stdin/stdout.
77type ChildTransport =
78    StreamTransport<BufReader<smol::process::ChildStdout>, smol::process::ChildStdin>;
79
80/// Mutable session state, only ever held across short sections.
81#[derive(Debug)]
82struct ProxyInner {
83    /// Resolves to the live child's call channel, or the build error.
84    /// Replaced with a fresh cell at the start of every rebuild.
85    ready: Arc<OnceCell<Result<Sender<ChildCall>, String>>>,
86    /// The task that builds the child and then drives its call channel.
87    /// Cancelling it drops the [`Child`] (kill-on-drop) mid-build or mid-call.
88    task: Option<Task<()>>,
89}
90
91/// The `water mcp` front: owns the child lifecycle and forwards tool calls.
92#[derive(Debug)]
93pub struct ChildProxy {
94    config: ChildConfig,
95    inner: Mutex<ProxyInner>,
96    /// Serializes rebuilds; `restart` takes it so two restarts cannot race.
97    rebuild_lock: Mutex<()>,
98}
99
100impl ChildProxy {
101    /// A proxy for the project at `project_path`; the child is not built until
102    /// [`Self::rebuild`] runs.
103    #[must_use]
104    pub fn new(
105        project_path: PathBuf,
106        width: u32,
107        height: u32,
108        scale_factor: f64,
109        sccache_path: Option<PathBuf>,
110    ) -> Self {
111        Self {
112            config: ChildConfig {
113                project_path,
114                width,
115                height,
116                scale_factor,
117                sccache_path,
118            },
119            inner: Mutex::new(ProxyInner {
120                ready: Arc::new(OnceCell::new()),
121                task: None,
122            }),
123            rebuild_lock: Mutex::new(()),
124        }
125    }
126
127    /// Kills the current child if any, then spawns the task that rebuilds and
128    /// re-drives it. Returns as soon as the build task is scheduled — callers
129    /// gate on the readiness cell, not on this function.
130    pub async fn rebuild(&self) {
131        let _serialized = self.rebuild_lock.lock().await;
132        let (cell, calls, rx) = self.swap_ready_cell().await;
133        let task = smol::spawn(build_and_drive(self.config.clone(), cell, calls, rx));
134        self.inner.lock().await.task = Some(task);
135    }
136
137    /// Cancels the current build/child task and swaps in a fresh readiness
138    /// cell, returning it with the channel the new task drives.
139    ///
140    /// The outgoing cell is resolved with a restart error when it is still
141    /// unset: a caller parked on it would otherwise wait forever, because the
142    /// cancelled task can no longer publish a result.
143    async fn swap_ready_cell(
144        &self,
145    ) -> (
146        Arc<OnceCell<Result<Sender<ChildCall>, String>>>,
147        Sender<ChildCall>,
148        Receiver<ChildCall>,
149    ) {
150        let mut inner = self.inner.lock().await;
151        if let Some(task) = inner.task.take() {
152            task.cancel().await;
153        }
154        let _ = inner
155            .ready
156            .set(Err(
157                "water mcp: the app was restarted while it was still building; retry the call"
158                    .to_owned(),
159            ))
160            .await;
161        let cell = Arc::new(OnceCell::new());
162        inner.ready = cell.clone();
163        drop(inner);
164        let (calls, rx) = async_channel::unbounded();
165        (cell, calls, rx)
166    }
167
168    /// Waits for the current readiness cell and clones the live call channel.
169    async fn child_calls(&self) -> Result<Sender<ChildCall>, ToolResult> {
170        let cell = self.inner.lock().await.ready.clone();
171        match cell.wait().await {
172            Ok(calls) => Ok(calls.clone()),
173            Err(message) => Err(ToolResult::error(message.clone())),
174        }
175    }
176
177    /// Forwards one tool call to the child.
178    async fn forward_call(&self, name: &'static str, args: impl Serialize) -> ToolResult {
179        let arguments = match serde_json::to_value(args) {
180            Ok(arguments) => arguments,
181            Err(error) => {
182                return ToolResult::error(format!(
183                    "water mcp: failed to serialize `{name}` arguments: {error}"
184                ));
185            }
186        };
187        let calls = match self.child_calls().await {
188            Ok(calls) => calls,
189            Err(result) => return result,
190        };
191        let (reply, replies) = async_channel::bounded(1);
192        if calls
193            .send(ChildCall {
194                name,
195                arguments,
196                reply,
197            })
198            .await
199            .is_err()
200        {
201            return ToolResult::error("water mcp: the app process exited before the call");
202        }
203        replies
204            .recv()
205            .await
206            .unwrap_or_else(|_| ToolResult::error("water mcp: the app process dropped the call"))
207    }
208
209    /// Kills the child and any in-flight build. Called when the MCP session
210    /// ends or the process is shutting down.
211    pub async fn shutdown(&self) {
212        let _serialized = self.rebuild_lock.lock().await;
213        let task = self.inner.lock().await.task.take();
214        if let Some(task) = task {
215            task.cancel().await;
216        }
217    }
218}
219
220impl ToolDispatch for ChildProxy {
221    fn snapshot(&self, args: SnapshotArgs) -> impl Future<Output = ToolResult> + Send {
222        self.forward_call("snapshot", args)
223    }
224
225    fn find(&self, args: FindArgs) -> impl Future<Output = ToolResult> + Send {
226        self.forward_call("find", args)
227    }
228
229    fn act(&self, args: ActArgs) -> impl Future<Output = ToolResult> + Send {
230        self.forward_call("act", args)
231    }
232
233    fn pointer(&self, args: PointerArgs) -> impl Future<Output = ToolResult> + Send {
234        self.forward_call("pointer", args)
235    }
236
237    fn key(&self, args: KeyArgs) -> impl Future<Output = ToolResult> + Send {
238        self.forward_call("key", args)
239    }
240
241    fn type_text(&self, args: TypeTextArgs) -> impl Future<Output = ToolResult> + Send {
242        self.forward_call("type_text", args)
243    }
244
245    fn wait(&self, args: WaitArgs) -> impl Future<Output = ToolResult> + Send {
246        self.forward_call("wait", args)
247    }
248
249    fn screenshot(&self, args: ScreenshotArgs) -> impl Future<Output = ToolResult> + Send {
250        self.forward_call("screenshot", args)
251    }
252
253    /// `restart` is intercepted rather than forwarded: the child is killed,
254    /// rebuilt from the current sources — picking up edits — respawned, and
255    /// the fresh tree comes back through the new child's `snapshot`.
256    async fn restart(&self, _args: RestartArgs) -> ToolResult {
257        self.rebuild().await;
258        self.forward_call("snapshot", SnapshotArgs::default()).await
259    }
260
261    fn advance(&self, args: AdvanceArgs) -> impl Future<Output = ToolResult> + Send {
262        self.forward_call("advance", args)
263    }
264}
265
266/// The spawned task's whole lifetime: build, spawn, handshake, publish the
267/// call channel, then drive calls until every sender is gone (session
268/// shutdown) or the task is cancelled (restart / teardown).
269async fn build_and_drive(
270    config: ChildConfig,
271    cell: Arc<OnceCell<Result<Sender<ChildCall>, String>>>,
272    calls: Sender<ChildCall>,
273    rx: Receiver<ChildCall>,
274) {
275    match build_and_spawn(&config).await {
276        Ok((transport, child)) => {
277            info!("water mcp: app is up, forwarding tool calls");
278            cell.set(Ok(calls))
279                .await
280                .expect("a fresh readiness cell is unset");
281            drive_child(transport, child, rx).await;
282        }
283        Err(build_error) => {
284            error!(%build_error, "water mcp: failed to launch the app");
285            cell.set(Err(format!("{build_error:#}")))
286                .await
287                .expect("a fresh readiness cell is unset");
288        }
289    }
290}
291
292/// Builds the generated backend in MCP mode, spawns the binary with its run
293/// config, and performs the MCP handshake.
294async fn build_and_spawn(config: &ChildConfig) -> Result<(ChildTransport, Child)> {
295    let platform = host_platform();
296    let project = ensure_hydrolysis_backend_ready(&config.project_path).await?;
297    stage_hydrolysis_resources(
298        &project,
299        HydrolysisPreviewTheme::Material3,
300        config.sccache_path.as_deref(),
301        None,
302    )
303    .await?;
304
305    let mut build_options = BuildOptions::development(BuildProfile::Debug);
306    if let Some(sccache_path) = &config.sccache_path {
307        build_options = build_options.with_sccache(sccache_path.clone());
308    }
309    let built = build_hydrolysis_with_envs_and_features(
310        &project,
311        platform,
312        build_options,
313        &[],
314        &[HYDROLYSIS_MCP_FEATURE],
315    )
316    .await?;
317    stage_hydrolysis_shared_runtime(&project, &built, platform).await?;
318    let binary_path = &built.artifact;
319
320    let run_config = McpRunConfig {
321        width: config.width,
322        height: config.height,
323        scale_factor: config.scale_factor,
324    };
325    let config_path = write_run_config(&project, &run_config).await?;
326    let backend_path = project.backend_path::<HydrolysisBackend>();
327
328    // stdout is the MCP link — never inherit it; stderr flows straight to the
329    // parent's stderr so app logs and build diagnostics stay visible.
330    let mut command = smol::process::Command::new(binary_path);
331    command
332        .kill_on_drop(true)
333        .current_dir(&backend_path)
334        .env(MCP_RUN_CONFIG_ENV, &config_path)
335        .env("WATERUI_PROJECT_DIR", project.root())
336        .env("WATERUI_APP_NAME", &project.manifest().package.name)
337        .stdin(Stdio::piped())
338        .stdout(Stdio::piped())
339        .stderr(Stdio::inherit());
340    let mut child = command.spawn().wrap_err_with(|| {
341        format!(
342            "failed to spawn the MCP app binary {}",
343            binary_path.display()
344        )
345    })?;
346
347    let stdout = child
348        .stdout
349        .take()
350        .ok_or_else(|| eyre!("app binary spawned without a piped stdout"))?;
351    let stdin = child
352        .stdin
353        .take()
354        .ok_or_else(|| eyre!("app binary spawned without a piped stdin"))?;
355    let mut transport = StreamTransport::new(BufReader::new(stdout), stdin);
356    handshake(&mut transport).await?;
357    Ok((transport, child))
358}
359
360/// The child handshake: `initialize` then `notifications/initialized`, the
361/// same exchange the front just performed with its own client.
362async fn handshake(transport: &mut ChildTransport) -> Result<()> {
363    let response = transport
364        .request(JsonRpcRequest::with_params(
365            0_i64,
366            "initialize",
367            InitializeParams::default(),
368        ))
369        .await
370        .wrap_err("the app binary did not answer `initialize`")?;
371    if response.error.is_some() || response.result.is_none() {
372        bail!("the app binary refused `initialize`: {response:?}");
373    }
374    transport
375        .notify(JsonRpcNotification::new("notifications/initialized"))
376        .await
377        .wrap_err("failed to send `notifications/initialized` to the app")?;
378    Ok(())
379}
380
381/// Reads `tools/call` requests off the channel, forwards each to the child,
382/// and replies with the mapped [`ToolResult`]. When the channel closes — every
383/// sender dropped on session shutdown — the child is killed and reaped.
384async fn drive_child(mut transport: ChildTransport, mut child: Child, calls: Receiver<ChildCall>) {
385    while let Ok(call) = calls.recv().await {
386        let request = JsonRpcRequest::with_params(
387            0_i64,
388            "tools/call",
389            CallToolParams {
390                name: call.name.to_owned(),
391                arguments: call.arguments,
392            },
393        );
394        let result = match transport.request(request).await {
395            Ok(response) => map_tool_call_response(response),
396            Err(error) => ToolResult::error(format!("water mcp: app transport failed: {error}")),
397        };
398        debug!(tool = call.name, "forwarded tools/call to the app");
399        // A caller cancelled during shutdown leaves no receiver — the result
400        // is simply dropped.
401        let _ = call.reply.try_send(result);
402    }
403    // Kill rather than wait on stdin EOF: the app should already be exiting,
404    // but a wedged app must not outlive the session.
405    let _ = child.kill();
406    let _ = child.status().await;
407}
408
409/// Maps a child `tools/call` JSON-RPC response to a [`ToolResult`].
410fn map_tool_call_response(response: JsonRpcResponse) -> ToolResult {
411    if let Some(error) = response.error {
412        return ToolResult::error(format!(
413            "water mcp: the app reported error {}: {}",
414            error.code, error.message
415        ));
416    }
417    let Some(result) = response.result else {
418        return ToolResult::error("water mcp: the app returned an empty `tools/call` response");
419    };
420    match serde_json::from_value::<CallToolResult>(result) {
421        Ok(result) => map_call_tool_result(&result),
422        Err(error) => {
423            ToolResult::error(format!("water mcp: malformed `tools/call` result: {error}"))
424        }
425    }
426}
427
428/// Maps one MCP [`CallToolResult`] onto the in-process [`ToolResult`] the
429/// fronting server's tool returns.
430fn map_call_tool_result(result: &CallToolResult) -> ToolResult {
431    if result.is_error {
432        let message = result
433            .content
434            .iter()
435            .filter_map(|content| match content {
436                Content::Text(text) => Some(text.text.as_str()),
437                _ => None,
438            })
439            .collect::<Vec<_>>()
440            .join("\n");
441        return ToolResult::error(if message.is_empty() {
442            "water mcp: the app reported a tool error".to_owned()
443        } else {
444            message
445        });
446    }
447    match result.content.as_slice() {
448        [Content::Text(text)] => ToolResult::text(text.text.clone()),
449        [Content::Image(image)] => match BASE64.decode(&image.data) {
450            Ok(bytes) => ToolResult::image(bytes, &image.mime_type),
451            Err(error) => ToolResult::error(format!(
452                "water mcp: the app returned malformed base64 image data: {error}"
453            )),
454        },
455        content => {
456            let kinds = content
457                .iter()
458                .map(|content| match content {
459                    Content::Text(_) => "text",
460                    Content::Image(_) => "image",
461                    Content::Resource(_) => "resource",
462                })
463                .collect::<Vec<_>>()
464                .join(", ");
465            ToolResult::error(format!(
466                "water mcp: the app returned unsupported tool content [{kinds}]"
467            ))
468        }
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use std::path::PathBuf;
475
476    use super::{ChildProxy, map_call_tool_result, map_tool_call_response};
477    use aither_mcp::protocol::{
478        CallToolResult, Content, ImageContent, JsonRpcResponse, RequestId, TextContent,
479    };
480    use base64::Engine as _;
481    use base64::engine::general_purpose::STANDARD as BASE64;
482
483    fn text_result(text: &str) -> CallToolResult {
484        CallToolResult {
485            content: vec![Content::Text(TextContent {
486                text: text.to_owned(),
487                annotations: None,
488            })],
489            is_error: false,
490        }
491    }
492
493    fn json_response(result: CallToolResult) -> JsonRpcResponse {
494        JsonRpcResponse {
495            jsonrpc: "2.0".to_owned(),
496            id: RequestId::Number(0),
497            result: Some(serde_json::to_value(result).expect("result serializes")),
498            error: None,
499        }
500    }
501
502    #[test]
503    fn text_content_maps_to_text_result() {
504        let mapped = map_tool_call_response(json_response(text_result("the tree")));
505        assert_eq!(mapped.as_text(), Some("the tree"));
506        assert!(mapped.error_message().is_none());
507    }
508
509    #[test]
510    fn error_flag_maps_to_error_result() {
511        let mut result = text_result("no nodes matched");
512        result.is_error = true;
513        let mapped = map_tool_call_response(json_response(result));
514        assert_eq!(mapped.error_message(), Some("no nodes matched"));
515    }
516
517    #[test]
518    fn image_content_maps_to_image_result() {
519        let pixels = [0x89, b'P', b'N', b'G', 1, 2, 3];
520        let result = CallToolResult {
521            content: vec![Content::Image(ImageContent {
522                data: BASE64.encode(pixels),
523                mime_type: "image/png".to_owned(),
524                annotations: None,
525            })],
526            is_error: false,
527        };
528        let mapped = map_tool_call_response(json_response(result));
529        assert_eq!(mapped.content(), Some(pixels.as_slice()));
530        assert_eq!(
531            mapped.mime().map(|mime| mime.essence_str().to_owned()),
532            Some("image/png".to_owned())
533        );
534    }
535
536    #[test]
537    fn malformed_base64_image_is_an_error() {
538        let result = CallToolResult {
539            content: vec![Content::Image(ImageContent {
540                data: "not base64!!!".to_owned(),
541                mime_type: "image/png".to_owned(),
542                annotations: None,
543            })],
544            is_error: false,
545        };
546        let mapped = map_call_tool_result(&result);
547        assert!(
548            mapped
549                .error_message()
550                .is_some_and(|message| message.contains("base64"))
551        );
552    }
553
554    #[test]
555    fn rebuild_resolves_waiters_on_the_previous_readiness_cell() {
556        smol::block_on(async {
557            let proxy = ChildProxy::new(
558                PathBuf::from("/definitely/not/a/project"),
559                390,
560                844,
561                2.0,
562                None,
563            );
564            // A caller that arrived before the restart is parked on this cell.
565            let parked = proxy.inner.lock().await.ready.clone();
566            proxy.rebuild().await;
567            let result = parked.wait().await;
568            assert!(
569                matches!(result, Err(message) if message.contains("restarted")),
570                "a caller parked during the build should get a restart error, got {result:?}"
571            );
572            proxy.shutdown().await;
573        });
574    }
575
576    #[test]
577    fn multi_item_content_is_an_error() {
578        let result = CallToolResult {
579            content: vec![
580                Content::Text(TextContent {
581                    text: "one".to_owned(),
582                    annotations: None,
583                }),
584                Content::Text(TextContent {
585                    text: "two".to_owned(),
586                    annotations: None,
587                }),
588            ],
589            is_error: false,
590        };
591        let mapped = map_call_tool_result(&result);
592        assert!(
593            mapped
594                .error_message()
595                .is_some_and(|message| message.contains("text, text"))
596        );
597    }
598}