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