Skip to main content

mz_deploy/cli/commands/
mcp.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Mcp command - proxy stdio JSON-RPC to Materialize's developer MCP server.
11//!
12//! Materialize exposes a developer MCP server at `POST /api/mcp/developer`
13//! over HTTP with HTTP Basic auth. Most MCP clients (Claude Desktop, Claude
14//! Code, Cursor) launch their MCP servers as subprocesses speaking JSON-RPC
15//! over stdio. This command bridges the two: read newline-delimited JSON-RPC
16//! from stdin, POST each message to the developer endpoint with the active
17//! profile's credentials, and write each response back to stdout.
18
19use 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
29/// Path of the developer MCP endpoint on the Materialize HTTP listener.
30const MCP_DEVELOPER_PATH: &str = "/api/mcp/developer";
31
32/// Default HTTP port for a local Materialize.
33const LOCAL_HTTP_PORT: u16 = 6876;
34
35/// Proxy stdio JSON-RPC to the developer MCP HTTP endpoint.
36///
37/// Unlike most subcommands, `mcp` does not require a `project.toml` — MCP
38/// clients launch this binary from their own working directory, not from a
39/// deploy project root. The active profile is resolved from `--profile` /
40/// `MZ_DEPLOY_PROFILE` (passed in via `cli_profile`), falling back to the
41/// `.mzprofile` file in `directory` if one happens to exist.
42pub 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        // Only attach Basic auth when the profile carries a non-empty
65        // password. The MCP endpoint doesn't mandate auth — let the server
66        // return 401 if it requires credentials we don't have, rather than
67        // sending a header with an empty password.
68        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        // MCP stdio framing: exactly one trailing newline per message.
98        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
114/// Resolve the active profile without going through `Settings::load`, so the
115/// MCP proxy works outside an mz-deploy project directory.
116fn 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
134/// Build the developer MCP URL from the profile's `http_host`.
135///
136/// `http_host` is flexible:
137/// - Bare hostname (`console.foo.cloud`) → infer `https://`.
138/// - Loopback (`localhost`, `127.0.0.0/8`, `::1`) → infer `http://` and, if
139///   no port was given, default to `LOCAL_HTTP_PORT`.
140/// - `host:port` or `[ipv6]:port` → keep the port; infer scheme as above.
141/// - `http://...` / `https://...` → use the URL verbatim. The user picked
142///   the scheme and any port; no defaults are applied.
143///
144/// IPv6 hosts are bracketed automatically by `url::Url` regardless of how
145/// the user wrote them.
146fn developer_url(http_host: &str) -> Result<String, CliError> {
147    // `Url::parse` returns `RelativeUrlWithoutBase` when no scheme is
148    // present, so a successful parse means the user wrote one. We filter to
149    // the schemes we accept; `Url::parse("localhost:8000")` succeeds with
150    // `scheme="localhost"`, which would otherwise be a false positive.
151    let (mut url, user_supplied_scheme) = match Url::parse(http_host) {
152        Ok(u) if matches!(u.scheme(), "http" | "https") => (u, true),
153        _ => {
154            // `Url::parse` requires IPv6 hosts to be bracketed. Bracket bare
155            // IPv6 input so `::1` parses the same as `[::1]`.
156            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    // For http/https URLs the host is always present and the scheme is
167    // changeable, so the `Url` setters that return `Result` cannot fail.
168    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
185/// Build a JSON-RPC error response so the MCP client gets a structured error
186/// instead of a hang when the HTTP layer fails before the server can respond.
187fn 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        // Both bare and bracketed forms must produce a parseable URL.
233        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        // url::Url normalizes the scheme to lowercase.
265        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}