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, 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
48const HYDROLYSIS_MCP_FEATURE: &str = "waterui-mcp-mode";
50
51#[derive(Debug, Clone)]
53struct ChildConfig {
54 project_path: PathBuf,
56 width: u32,
58 height: u32,
60 scale_factor: f64,
62 sccache_path: Option<PathBuf>,
64}
65
66#[derive(Debug)]
68struct ChildCall {
69 name: &'static str,
71 arguments: serde_json::Value,
73 reply: Sender<ToolResult>,
75}
76
77type ChildTransport =
79 StreamTransport<BufReader<smol::process::ChildStdout>, smol::process::ChildStdin>;
80
81#[derive(Debug)]
83struct ProxyInner {
84 ready: Arc<OnceCell<Result<Sender<ChildCall>, String>>>,
87 task: Option<Task<()>>,
90}
91
92#[derive(Debug)]
94pub struct ChildProxy {
95 config: ChildConfig,
96 inner: Mutex<ProxyInner>,
97 rebuild_lock: Mutex<()>,
99}
100
101impl ChildProxy {
102 #[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 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 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 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 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 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 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
267async 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
293async 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 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
364async 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
385async 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 let _ = call.reply.try_send(result);
406 }
407 let _ = child.kill();
410 let _ = child.status().await;
411}
412
413fn 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
432fn 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 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}