1use 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
47const HYDROLYSIS_MCP_FEATURE: &str = "waterui-mcp-mode";
49
50#[derive(Debug, Clone)]
52struct ChildConfig {
53 project_path: PathBuf,
55 width: u32,
57 height: u32,
59 scale_factor: f64,
61 sccache_path: Option<PathBuf>,
63}
64
65#[derive(Debug)]
67struct ChildCall {
68 name: &'static str,
70 arguments: serde_json::Value,
72 reply: Sender<ToolResult>,
74}
75
76type ChildTransport =
78 StreamTransport<BufReader<smol::process::ChildStdout>, smol::process::ChildStdin>;
79
80#[derive(Debug)]
82struct ProxyInner {
83 ready: Arc<OnceCell<Result<Sender<ChildCall>, String>>>,
86 task: Option<Task<()>>,
89}
90
91#[derive(Debug)]
93pub struct ChildProxy {
94 config: ChildConfig,
95 inner: Mutex<ProxyInner>,
96 rebuild_lock: Mutex<()>,
98}
99
100impl ChildProxy {
101 #[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 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 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 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 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 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 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
266async 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
292async 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 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
360async 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
381async 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 let _ = call.reply.try_send(result);
402 }
403 let _ = child.kill();
406 let _ = child.status().await;
407}
408
409fn 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
428fn 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 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}