stackql_mcp/lib.rs
1//! Embedded StackQL MCP server for Rust agentic apps.
2//!
3//! StackQL exposes cloud providers (AWS, GitHub, Google, Azure, ...) as SQL
4//! tables, served over the Model Context Protocol. This crate acquires the
5//! `stackql` binary, launches it as an MCP server over stdio, and hands you a
6//! connected [`rmcp`] client.
7//!
8//! Two acquisition modes behind one API:
9//!
10//! - sidecar (default feature): download the platform's .mcpb bundle at first
11//! run, verify its sha256 against the pins rendered into the crate from
12//! platforms.json (the manifest shared by every StackQL wrapper), and cache it
13//! under `~/.stackql/mcp-server-bin/` (shared with the npm and PyPI
14//! wrappers)
15//! - vendored (`vendored` feature): embed the .mcpb with `include_bytes!` and
16//! extract on first run - no network at runtime, single shippable binary
17//!
18//! ```no_run
19//! use stackql_mcp::{Mode, StackqlMcp};
20//!
21//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
22//! let server = StackqlMcp::builder()
23//! .mode(Mode::ReadOnly)
24//! .auth(serde_json::json!({"github": {"type": "null_auth"}}))
25//! .start()
26//! .await?;
27//! let tools = server.list_all_tools().await?;
28//! println!("{} tools available", tools.len());
29//! server.shutdown().await?;
30//! # Ok(())
31//! # }
32//! ```
33
34mod acquire;
35mod bundle;
36mod cache;
37mod download;
38mod error;
39mod launch;
40mod pins;
41mod platform;
42
43use std::ops::Deref;
44use std::path::PathBuf;
45use std::process::Stdio;
46
47use rmcp::service::RunningService;
48use rmcp::{RoleClient, ServiceExt};
49
50pub use cache::{ENV_BIN, ENV_BUNDLE};
51pub use error::{Error, Result};
52pub use pins::{Pin, BASE_URL, PINS, STACKQL_VERSION};
53pub use platform::Platform;
54
55/// Safety contract for query / mutation / lifecycle tools, enforced
56/// server-side. Maps to `server.mode` in the server's `--mcp.config`.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
58pub enum Mode {
59 /// SELECT and metadata tools only. The default: escalation is an
60 /// explicit caller opt-in.
61 #[default]
62 ReadOnly,
63 /// Reads plus non-destructive mutations (the server's own default).
64 Safe,
65 /// Safe plus deletes.
66 DeleteSafe,
67 /// All operations, including lifecycle provisioning.
68 FullAccess,
69}
70
71impl Mode {
72 /// The wire value for `server.mode`.
73 pub fn as_str(self) -> &'static str {
74 match self {
75 Mode::ReadOnly => "read_only",
76 Mode::Safe => "safe",
77 Mode::DeleteSafe => "delete_safe",
78 Mode::FullAccess => "full_access",
79 }
80 }
81}
82
83/// Download the pinned .mcpb bundle for the host platform into the shared
84/// cache (verified against the baked sha256 pin) and return its path. Skips
85/// the download when a verified copy is already present.
86///
87/// This is the producer side of vendored builds: fetch the bundle once on the
88/// build machine, then embed it with [`include_bundle!`].
89#[cfg(feature = "sidecar")]
90pub fn fetch_bundle() -> Result<PathBuf> {
91 let platform = Platform::detect()?;
92 let pin = pins::pin_for(platform)?;
93 let dest = cache::bin_cache_root()?
94 .join(pins::STACKQL_VERSION)
95 .join(pin.bundle_name);
96 if dest.is_file() && download::sha256_file(&dest)? == pin.sha256 {
97 return Ok(dest);
98 }
99 download::download_verified(&pins::bundle_url(pin), pin.sha256, &dest)?;
100 Ok(dest)
101}
102
103/// Embed the .mcpb bundle named by the compile-time env var
104/// `STACKQL_MCP_BUNDLE_FILE`, for use with `Builder::bundle_bytes` (vendored
105/// feature):
106///
107/// ```ignore
108/// let server = StackqlMcp::builder()
109/// .bundle_bytes(stackql_mcp::include_bundle!())
110/// .start()
111/// .await?;
112/// ```
113///
114/// Build with `STACKQL_MCP_BUNDLE_FILE=/abs/path/to/bundle.mcpb cargo build`.
115/// Pair with [`fetch_bundle`] to produce the bundle.
116#[macro_export]
117macro_rules! include_bundle {
118 () => {
119 include_bytes!(env!(
120 "STACKQL_MCP_BUNDLE_FILE",
121 "set STACKQL_MCP_BUNDLE_FILE to the absolute path of the platform .mcpb bundle \
122 (see stackql_mcp::fetch_bundle)"
123 ))
124 };
125}
126
127/// Entry point. See the crate docs for the full example.
128pub struct StackqlMcp;
129
130impl StackqlMcp {
131 pub fn builder() -> Builder {
132 Builder::default()
133 }
134}
135
136/// Configures and starts the embedded server.
137#[derive(Default)]
138pub struct Builder {
139 mode: Mode,
140 auth: Option<serde_json::Value>,
141 approot: Option<PathBuf>,
142 acquisition: acquire::Acquisition,
143}
144
145impl Builder {
146 /// Safety mode for the server. Defaults to [`Mode::ReadOnly`].
147 pub fn mode(mut self, mode: Mode) -> Self {
148 self.mode = mode;
149 self
150 }
151
152 /// Provider auth document, passed to the server as `--auth=<json>`.
153 /// Example: `json!({"github": {"type": "null_auth"}})`.
154 pub fn auth(mut self, auth: serde_json::Value) -> Self {
155 self.auth = Some(auth);
156 self
157 }
158
159 /// Override the server's application root. Defaults to `<home>/.stackql`.
160 pub fn approot(mut self, approot: impl Into<PathBuf>) -> Self {
161 self.approot = Some(approot.into());
162 self
163 }
164
165 /// Run an existing stackql binary instead of acquiring one. The
166 /// `STACKQL_MCP_BIN` env var takes precedence over this.
167 pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
168 self.acquisition.binary = Some(path.into());
169 self
170 }
171
172 /// Extract a local .mcpb bundle instead of downloading. The
173 /// `STACKQL_MCP_BUNDLE` env var takes precedence over this.
174 pub fn bundle_path(mut self, path: impl Into<PathBuf>) -> Self {
175 self.acquisition.bundle_path = Some(path.into());
176 self
177 }
178
179 /// Embed the .mcpb bundle in your binary and extract it on first run:
180 /// `builder.bundle_bytes(include_bytes!("../stackql-mcp-linux-x64.mcpb"))`.
181 #[cfg(feature = "vendored")]
182 pub fn bundle_bytes(mut self, bytes: &'static [u8]) -> Self {
183 self.acquisition.bundle_bytes = Some(bytes);
184 self
185 }
186
187 /// Resolve the binary (acquiring it if needed) and return a
188 /// [`std::process::Command`] preloaded with the canonical launch args.
189 /// Blocking. The escape hatch for callers bringing their own MCP stack
190 /// or process supervision; stdio configuration is left to the caller.
191 pub fn command(&self) -> Result<std::process::Command> {
192 let binary = acquire::resolve_binary(&self.acquisition)?;
193 let approot = self.resolved_approot()?;
194 let mut cmd = std::process::Command::new(binary);
195 cmd.args(launch::launch_args(self.mode, &approot, self.auth.as_ref()));
196 Ok(cmd)
197 }
198
199 /// Acquire the binary if needed, spawn the server, and complete the MCP
200 /// handshake. Must be called from within a tokio runtime.
201 pub async fn start(self) -> Result<RunningServer> {
202 let approot = self.resolved_approot()?;
203 let acquisition = self.acquisition;
204 let binary = tokio::task::spawn_blocking(move || acquire::resolve_binary(&acquisition))
205 .await
206 .map_err(|e| Error::Mcp(format!("acquisition task failed: {e}")))??;
207
208 let mut child = tokio::process::Command::new(&binary)
209 .args(launch::launch_args(self.mode, &approot, self.auth.as_ref()))
210 .stdin(Stdio::piped())
211 .stdout(Stdio::piped())
212 // Diagnostics belong on stderr; let them flow through.
213 .stderr(Stdio::inherit())
214 .kill_on_drop(true)
215 .spawn()
216 .map_err(Error::Spawn)?;
217
218 let stdout = child
219 .stdout
220 .take()
221 .ok_or_else(|| Error::Mcp("child stdout not captured".into()))?;
222 let stdin = child
223 .stdin
224 .take()
225 .ok_or_else(|| Error::Mcp("child stdin not captured".into()))?;
226
227 let client = ()
228 .serve((stdout, stdin))
229 .await
230 .map_err(|e| Error::Mcp(format!("initialize failed: {e}")))?;
231
232 Ok(RunningServer {
233 child,
234 client,
235 binary,
236 })
237 }
238
239 fn resolved_approot(&self) -> Result<PathBuf> {
240 match &self.approot {
241 Some(p) => Ok(p.clone()),
242 None => cache::default_approot(),
243 }
244 }
245}
246
247/// A running embedded server: the child process handle plus a connected
248/// rmcp client. Derefs to the client, so rmcp peer methods
249/// (`list_all_tools`, `call_tool`, ...) are available directly.
250pub struct RunningServer {
251 child: tokio::process::Child,
252 client: RunningService<RoleClient, ()>,
253 binary: PathBuf,
254}
255
256impl RunningServer {
257 /// The connected rmcp client.
258 pub fn client(&self) -> &RunningService<RoleClient, ()> {
259 &self.client
260 }
261
262 /// OS process id of the server, if it is still running.
263 pub fn pid(&self) -> Option<u32> {
264 self.child.id()
265 }
266
267 /// Path of the stackql binary that was launched.
268 pub fn binary_path(&self) -> &std::path::Path {
269 &self.binary
270 }
271
272 /// Close the MCP session and stop the server process.
273 pub async fn shutdown(self) -> Result<()> {
274 let RunningServer {
275 mut child, client, ..
276 } = self;
277 // Cancelling drops the transport; the server sees EOF on stdin and
278 // exits. The kill is a backstop for a wedged process.
279 let _ = client.cancel().await;
280 let _ = child.kill().await;
281 Ok(())
282 }
283}
284
285impl Deref for RunningServer {
286 type Target = RunningService<RoleClient, ()>;
287
288 fn deref(&self) -> &Self::Target {
289 &self.client
290 }
291}