Skip to main content

waterui_cli/mcp/
mod.rs

1//! `water mcp`: serves an MCP session that drives the app headless.
2//!
3//! The CLI process fronts the generated Hydrolysis MCP binary for the whole
4//! session rather than `exec`ing it: `initialize` and `tools/list` must
5//! answer inside the client's startup timeout even when the first Hydrolysis
6//! build is still compiling, so the front serves those from the static
7//! contract in `waterui-mcp-protocol` and forwards `tools/call` to the child
8//! once it is up.
9
10pub mod preview;
11mod proxy;
12
13use std::io;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16
17use aither_core::llm::tool::Tools;
18use aither_mcp::McpServer;
19use aither_mcp::transport::StdioTransport;
20use eyre::{Context as _, Result};
21use serde::Serialize;
22use tracing::info;
23use waterui_mcp_protocol::{INSTRUCTIONS, register_session_tools};
24use waterui_preview_protocol::hydrolysis::McpRunConfig;
25
26use crate::hydrolysis::backend::HydrolysisBackend;
27use crate::platform::TargetPlatform;
28use crate::project::Project;
29
30pub use proxy::ChildProxy;
31
32/// One `water mcp` session.
33#[derive(Debug)]
34pub struct McpSessionRequest {
35    /// `WaterUI` project directory.
36    pub project_path: PathBuf,
37    /// Viewport width in logical pixels.
38    pub width: u32,
39    /// Viewport height in logical pixels.
40    pub height: u32,
41    /// Display scale factor.
42    pub scale_factor: f64,
43    /// `sccache` binary used for compilation caching, when available.
44    pub sccache_path: Option<PathBuf>,
45    /// Server name reported to the client at `initialize`.
46    pub server_name: String,
47}
48
49/// Serves an MCP session on stdio until the client disconnects.
50///
51/// The child build starts immediately but in the background; the front
52/// answers `initialize` and `tools/list` itself while it compiles. When the
53/// client closes stdio, the child is killed and this returns.
54///
55/// # Errors
56///
57/// Returns an error when the stdio transport fails. A child that fails to
58/// build does not fail the session — `tools/call` reports the build error to
59/// the model until `restart` retries it.
60///
61/// # Panics
62///
63/// Panics if a static tool registration fails — a programming error, not a
64/// runtime condition.
65pub async fn serve_mcp(request: McpSessionRequest) -> Result<()> {
66    let McpSessionRequest {
67        project_path,
68        width,
69        height,
70        scale_factor,
71        sccache_path,
72        server_name,
73    } = request;
74
75    let proxy = Arc::new(ChildProxy::new(
76        project_path.clone(),
77        width,
78        height,
79        scale_factor,
80        sccache_path.clone(),
81    ));
82    let mut tools = Tools::new();
83    register_session_tools(&mut tools, Arc::clone(&proxy));
84    // `preview` is served by this front — it renders through the preview
85    // machinery rather than the running app, and must answer before the
86    // child's first build finishes.
87    tools
88        .register(preview::PreviewTool::new(project_path, sccache_path))
89        .expect("static tool registration cannot fail");
90
91    // Schedule the first build before the server starts so it is already
92    // compiling while `initialize` is being answered.
93    proxy.rebuild().await;
94
95    let result = McpServer::new(
96        StdioTransport::new(),
97        tools,
98        server_name,
99        env!("CARGO_PKG_VERSION"),
100    )
101    .with_instructions(INSTRUCTIONS)
102    .run()
103    .await;
104
105    // The client closed stdio (or the session is tearing down): the child
106    // must not outlive us.
107    proxy.shutdown().await;
108    info!("water mcp: session ended");
109
110    result.map_err(|error| eyre::eyre!("MCP stdio server failed: {error}"))
111}
112
113/// The Hydrolysis platform of this host — `water mcp` always builds the app
114/// for the machine the agent is running on.
115const fn host_platform() -> TargetPlatform {
116    #[cfg(target_os = "macos")]
117    {
118        TargetPlatform::MacOS
119    }
120    #[cfg(target_os = "linux")]
121    {
122        TargetPlatform::Linux
123    }
124    #[cfg(target_os = "windows")]
125    {
126        TargetPlatform::Windows
127    }
128    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
129    {
130        panic!("`water mcp` requires macOS, Linux, or Windows");
131    }
132}
133
134/// The project-level `.mcp.json`: registers `water mcp` for MCP clients that
135/// read project config. The command carries no `--path` because the client's
136/// working directory is the project root.
137#[derive(Serialize)]
138struct McpJson {
139    #[serde(rename = "mcpServers")]
140    mcp_servers: McpJsonServers,
141}
142
143/// The `mcpServers` map of the project-level `.mcp.json`.
144#[derive(Serialize)]
145struct McpJsonServers {
146    app: McpJsonServer,
147}
148
149/// One server entry of the project-level `.mcp.json`.
150#[derive(Serialize)]
151struct McpJsonServer {
152    command: &'static str,
153    args: [&'static str; 1],
154}
155
156const MCP_JSON: McpJson = McpJson {
157    mcp_servers: McpJsonServers {
158        app: McpJsonServer {
159            command: "water",
160            args: ["mcp"],
161        },
162    },
163};
164
165/// Writes `.mcp.json` at `project_root` when none exists and reports whether
166/// it wrote. The file is user-owned — an existing one is never overwritten.
167pub(crate) async fn ensure_mcp_json(project_root: &Path) -> io::Result<bool> {
168    let path = project_root.join(".mcp.json");
169    match smol::fs::metadata(&path).await {
170        Ok(_) => return Ok(false),
171        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
172        Err(error) => return Err(error),
173    }
174    let json = serde_json::to_vec_pretty(&MCP_JSON).map_err(io::Error::other)?;
175    smol::fs::write(&path, json).await?;
176    Ok(true)
177}
178
179/// Writes the [`McpRunConfig`] JSON next to the backend sources and returns
180/// its path; the file is rewritten on every build.
181pub(crate) async fn write_run_config(project: &Project, config: &McpRunConfig) -> Result<PathBuf> {
182    let path = project
183        .backend_path::<HydrolysisBackend>()
184        .join("mcp-run.json");
185    let json = serde_json::to_vec_pretty(config)
186        .wrap_err("failed to serialize the hydrolysis MCP run config")?;
187    smol::fs::write(&path, json)
188        .await
189        .wrap_err_with(|| format!("failed to write {}", path.display()))?;
190    Ok(path)
191}