Skip to main content

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}