mz_deploy/cli/commands/
mcp.rs1use std::path::Path;
20
21use reqwest::Client;
22use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
23use url::Url;
24
25use crate::cli::CliError;
26use crate::client::is_loopback_host;
27use crate::config::{Profile, ProfilesConfig, read_mzprofile};
28
29const MCP_DEVELOPER_PATH: &str = "/api/mcp/developer";
31
32const LOCAL_HTTP_PORT: u16 = 6876;
34
35pub async fn run(
43 directory: &Path,
44 cli_profile: Option<&str>,
45 profiles_dir: Option<&Path>,
46) -> Result<(), CliError> {
47 let profile = resolve_profile(directory, cli_profile, profiles_dir)?;
48 let url = developer_url(profile.require_http_host()?)?;
49 let client = Client::new();
50
51 let stdin = BufReader::new(tokio::io::stdin());
52 let mut lines = stdin.lines();
53 let mut stdout = tokio::io::stdout();
54
55 while let Some(line) = lines
56 .next_line()
57 .await
58 .map_err(|e| CliError::Message(format!("failed to read from stdin: {e}")))?
59 {
60 if line.trim().is_empty() {
61 continue;
62 }
63
64 let mut req = client
69 .post(&url)
70 .header(reqwest::header::CONTENT_TYPE, "application/json")
71 .body(line.clone());
72 if let Some(password) = profile.password.as_deref().filter(|p| !p.is_empty()) {
73 req = req.basic_auth(&profile.username, Some(password));
74 }
75 let response = req.send().await;
76
77 let mut response_body = match response {
78 Ok(resp) => {
79 let status = resp.status();
80 match resp.text().await {
81 Ok(body) if !body.is_empty() => body,
82 Ok(_) => synthesize_error(
83 &line,
84 status.as_u16().into(),
85 format!("HTTP {status} with empty body"),
86 ),
87 Err(e) => synthesize_error(
88 &line,
89 -32603,
90 format!("failed to read HTTP response body: {e}"),
91 ),
92 }
93 }
94 Err(e) => synthesize_error(&line, -32603, format!("HTTP request to {url} failed: {e}")),
95 };
96
97 if !response_body.ends_with('\n') {
99 response_body.push('\n');
100 }
101 stdout
102 .write_all(response_body.as_bytes())
103 .await
104 .map_err(|e| CliError::Message(format!("failed to write to stdout: {e}")))?;
105 stdout
106 .flush()
107 .await
108 .map_err(|e| CliError::Message(format!("failed to flush stdout: {e}")))?;
109 }
110
111 Ok(())
112}
113
114fn resolve_profile(
117 directory: &Path,
118 cli_profile: Option<&str>,
119 profiles_dir: Option<&Path>,
120) -> Result<Profile, CliError> {
121 let name = match cli_profile {
122 Some(p) => p.to_string(),
123 None => read_mzprofile(directory)?.ok_or_else(|| {
124 CliError::Message(
125 "no profile selected: pass --profile <name>, set MZ_DEPLOY_PROFILE, \
126 or run from a directory with a .mzprofile file"
127 .to_string(),
128 )
129 })?,
130 };
131 Ok(ProfilesConfig::resolve_profile(profiles_dir, &name)?)
132}
133
134fn developer_url(http_host: &str) -> Result<String, CliError> {
147 let (mut url, user_supplied_scheme) = match Url::parse(http_host) {
152 Ok(u) if matches!(u.scheme(), "http" | "https") => (u, true),
153 _ => {
154 let host_part = match http_host.parse::<std::net::IpAddr>() {
157 Ok(std::net::IpAddr::V6(_)) => format!("[{http_host}]"),
158 _ => http_host.to_owned(),
159 };
160 let parsed = Url::parse(&format!("http://{host_part}"))
161 .map_err(|e| CliError::Message(format!("invalid http_host {http_host:?}: {e}")))?;
162 (parsed, false)
163 }
164 };
165
166 if !user_supplied_scheme {
169 let host = url.host_str().expect("http URL has a host").to_owned();
170 if is_loopback_host(&host) {
171 if url.port().is_none() {
172 url.set_port(Some(LOCAL_HTTP_PORT))
173 .expect("http URL accepts an explicit port");
174 }
175 } else {
176 url.set_scheme("https")
177 .expect("http→https swap is always allowed");
178 }
179 }
180
181 url.set_path(MCP_DEVELOPER_PATH);
182 Ok(url.to_string())
183}
184
185fn synthesize_error(request_line: &str, code: i64, message: String) -> String {
188 let id = serde_json::from_str::<serde_json::Value>(request_line)
189 .ok()
190 .and_then(|v| v.get("id").cloned())
191 .unwrap_or(serde_json::Value::Null);
192
193 let response = serde_json::json!({
194 "jsonrpc": "2.0",
195 "id": id,
196 "error": { "code": code, "message": message },
197 });
198 response.to_string()
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[track_caller]
206 fn build(input: &str) -> String {
207 developer_url(input).expect("developer_url should accept valid input")
208 }
209
210 #[mz_ore::test]
211 fn loopback_hostname_gets_default_http_port() {
212 assert_eq!(
213 build("localhost"),
214 "http://localhost:6876/api/mcp/developer"
215 );
216 assert_eq!(
217 build("127.0.0.1"),
218 "http://127.0.0.1:6876/api/mcp/developer"
219 );
220 }
221
222 #[mz_ore::test]
223 fn loopback_with_explicit_port_keeps_it() {
224 assert_eq!(
225 build("localhost:8000"),
226 "http://localhost:8000/api/mcp/developer"
227 );
228 }
229
230 #[mz_ore::test]
231 fn ipv6_loopback_is_bracketed_correctly() {
232 assert_eq!(build("::1"), "http://[::1]:6876/api/mcp/developer");
234 assert_eq!(build("[::1]"), "http://[::1]:6876/api/mcp/developer");
235 assert_eq!(build("[::1]:9000"), "http://[::1]:9000/api/mcp/developer");
236 }
237
238 #[mz_ore::test]
239 fn non_loopback_defaults_to_https_no_port() {
240 assert_eq!(
241 build("console.foo.cloud"),
242 "https://console.foo.cloud/api/mcp/developer"
243 );
244 assert_eq!(
245 build("console.foo.cloud:8443"),
246 "https://console.foo.cloud:8443/api/mcp/developer"
247 );
248 }
249
250 #[mz_ore::test]
251 fn explicit_scheme_is_preserved() {
252 assert_eq!(
253 build("http://localhost:8000"),
254 "http://localhost:8000/api/mcp/developer"
255 );
256 assert_eq!(
257 build("https://console.foo.cloud"),
258 "https://console.foo.cloud/api/mcp/developer"
259 );
260 }
261
262 #[mz_ore::test]
263 fn explicit_scheme_is_case_insensitive() {
264 assert_eq!(
266 build("HTTPS://console.foo.cloud"),
267 "https://console.foo.cloud/api/mcp/developer"
268 );
269 }
270
271 #[mz_ore::test]
272 fn trailing_slashes_collapse() {
273 assert_eq!(
274 build("localhost/"),
275 "http://localhost:6876/api/mcp/developer"
276 );
277 assert_eq!(
278 build("https://console.foo.cloud/"),
279 "https://console.foo.cloud/api/mcp/developer"
280 );
281 }
282}