From 6a42121ca570f273f2a938f409318f92f6d04e3a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:28:33 +0000 Subject: [PATCH 1/6] Add interface modes: headless (default), TUI, and MCP server Split the CLI's presentation into three explicit interfaces selectable at runtime, with headless plain-text as the new default: - `interface.rs`: an `Interface { Headless, Tui, Mcp }` global mirroring `ci.rs`, resolved once in `main` from new global `--tui` / `--mcp` flags (mutually exclusive). `--ci` is unchanged; MCP implies non-interactive. - Headless is now the default. Read commands (`me`, `project list`/`show`, `project deployments`, delete confirmations) previously always dropped into a full-screen ratatui view; they now render plain text (`ui/plain.rs`) and only enter the TUI under `--tui`. Delete confirmations share a `prompt::confirm_delete` dispatcher (CI-refuse / TUI dialog / inline). - `mcp.rs`: a stdio MCP server built on the official `rmcp` SDK, started by `smb --mcp` instead of one-shot dispatch. Exposes the `me` command as the first tool (calls the library fetch directly, returns JSON, keeps stdout clean for JSON-RPC). Diagnostics go to stderr; logging stays file-only. Verified end-to-end: `--help` lists the flags, clap enforces `--tui`/`--mcp` exclusivity, and the MCP server completes initialize / tools/list / tools/call over stdio. CI gate (fmt, clippy -D warnings, tests) is green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019fkbx6fUq1uDR5N75Ph5zi --- Cargo.lock | 152 ++++++++++++++++++++++++++ Cargo.toml | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/account/me/mod.rs | 11 +- crates/cli/src/cli/mod.rs | 10 ++ crates/cli/src/interface.rs | 114 +++++++++++++++++++ crates/cli/src/lib.rs | 2 + crates/cli/src/mail/process.rs | 19 ++-- crates/cli/src/main.rs | 48 ++++++++ crates/cli/src/mcp.rs | 88 +++++++++++++++ crates/cli/src/project/crud_delete.rs | 19 +--- crates/cli/src/project/crud_read.rs | 15 ++- crates/cli/src/project/deployment.rs | 15 ++- crates/cli/src/ui/mod.rs | 1 + crates/cli/src/ui/plain.rs | 113 +++++++++++++++++++ crates/cli/src/ui/prompt.rs | 17 ++- 16 files changed, 593 insertions(+), 33 deletions(-) create mode 100644 crates/cli/src/interface.rs create mode 100644 crates/cli/src/mcp.rs create mode 100644 crates/cli/src/ui/plain.rs diff --git a/Cargo.lock b/Cargo.lock index 275da87a..298ea568 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -587,6 +587,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.16.0" @@ -669,6 +675,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -685,6 +706,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -703,9 +752,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1594,6 +1647,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1965,6 +2024,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "regex" version = "1.13.0" @@ -2047,6 +2126,41 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.118", +] + [[package]] name = "roff" version = "1.1.1" @@ -2162,6 +2276,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.118", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -2238,6 +2378,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "serde_json" version = "1.0.150" @@ -2453,6 +2604,7 @@ dependencies = [ "ratatui", "regex", "reqwest", + "rmcp", "serde", "serde_json", "serde_repr", diff --git a/Cargo.toml b/Cargo.toml index a16773d2..fcf66568 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ openssl = { version = "0.10", features = ["vendored"] } ratatui = { version = "0.29", default-features = false, features = ["crossterm"] } regex = "1.11" reqwest = { version = "0.12.18", default-features = false } +rmcp = { version = "2.2.0", features = ["server", "macros", "transport-io", "schemars"] } serde = "1" serde_json = "1.0.82" serde_repr = "0.1" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 6aa551f8..dc9bc6dd 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -40,6 +40,7 @@ open = { workspace = true } ratatui = { workspace = true } regex = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] } +rmcp = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_repr = { workspace = true } diff --git a/crates/cli/src/account/me/mod.rs b/crates/cli/src/account/me/mod.rs index e430dbd6..4db278a3 100644 --- a/crates/cli/src/account/me/mod.rs +++ b/crates/cli/src/account/me/mod.rs @@ -3,7 +3,10 @@ use crate::client; use crate::token::get_smb_token::get_smb_token; use crate::{ cli::CommandResult, - ui::{fail_message, fail_symbol, me_view::show_user_tui, succeed_message, succeed_symbol}, + interface::is_tui, + ui::{ + fail_message, fail_symbol, me_view::show_user_tui, plain, succeed_message, succeed_symbol, + }, }; use anyhow::{anyhow, Result}; use smbcloud_auth::me::me; @@ -30,7 +33,11 @@ pub async fn process_me(env: Environment) -> Result { match me(env, client(), &token).await { Ok(user) => { spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded.")); - show_user_tui(&user).map_err(|e| anyhow!(e))?; + if is_tui() { + show_user_tui(&user).map_err(|e| anyhow!(e))?; + } else { + plain::render_user(&user); + } Ok(CommandResult { spinner: Spinner::new( spinners::Spinners::SimpleDotsScrolling, diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 5281149d..a66be7db 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -34,6 +34,16 @@ pub struct Cli { #[arg(long, global = true, env = "SMB_CI")] pub ci: bool, + /// Full-screen TUI mode: render read commands in an interactive ratatui + /// view instead of plain text. Mutually exclusive with --mcp. + #[arg(long, global = true, conflicts_with = "mcp")] + pub tui: bool, + + /// Run as an MCP (Model Context Protocol) server over stdio instead of a + /// one-shot command. Implies non-interactive; the subcommand is ignored. + #[arg(long, global = true)] + pub mcp: bool, + #[command(subcommand)] pub command: Option, } diff --git a/crates/cli/src/interface.rs b/crates/cli/src/interface.rs new file mode 100644 index 00000000..04c2caad --- /dev/null +++ b/crates/cli/src/interface.rs @@ -0,0 +1,114 @@ +//! Global interface mode. +//! +//! `smb` presents through one of three interfaces, chosen once at startup and +//! stored here so the deep call tree can consult it without threading a value +//! through every function signature (the same approach as [`crate::ci`]): +//! +//! * [`Interface::Headless`] — the default. Line-based plain-text output; no +//! full-screen takeover. Interactive prompts are still allowed on a TTY +//! unless `--ci` is also set. +//! * [`Interface::Tui`] — full-screen `ratatui` views (`--tui`). +//! * [`Interface::Mcp`] — a Model Context Protocol server over stdio (`--mcp`). +//! Implies non-interactive, structured output. +//! +//! `--tui` and `--mcp` are mutually exclusive. + +use anyhow::{anyhow, Result}; +use std::sync::atomic::{AtomicU8, Ordering}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Interface { + Headless, + Tui, + Mcp, +} + +impl Interface { + fn as_u8(self) -> u8 { + match self { + Interface::Headless => 0, + Interface::Tui => 1, + Interface::Mcp => 2, + } + } + + fn from_u8(value: u8) -> Interface { + match value { + 1 => Interface::Tui, + 2 => Interface::Mcp, + _ => Interface::Headless, + } + } +} + +static INTERFACE: AtomicU8 = AtomicU8::new(0); + +/// Set the active interface. Called once from `main`. +pub fn set_interface(interface: Interface) { + INTERFACE.store(interface.as_u8(), Ordering::Relaxed); +} + +/// The active interface. +pub fn current() -> Interface { + Interface::from_u8(INTERFACE.load(Ordering::Relaxed)) +} + +/// Whether the full-screen TUI interface is active. +pub fn is_tui() -> bool { + current() == Interface::Tui +} + +/// Whether the MCP server interface is active. +pub fn is_mcp() -> bool { + current() == Interface::Mcp +} + +/// Whether the default headless interface is active. +pub fn is_headless() -> bool { + current() == Interface::Headless +} + +/// Resolve the interface from the parsed `--tui` / `--mcp` flags. The two flags +/// are mutually exclusive; passing both is an error. +pub fn resolve(tui: bool, mcp: bool) -> Result { + match (tui, mcp) { + (true, true) => Err(anyhow!( + "--tui and --mcp cannot be used together: choose one interface." + )), + (false, true) => Ok(Interface::Mcp), + (true, false) => Ok(Interface::Tui), + (false, false) => Ok(Interface::Headless), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_defaults_to_headless() { + assert_eq!(resolve(false, false).unwrap(), Interface::Headless); + } + + #[test] + fn resolve_selects_tui_and_mcp() { + assert_eq!(resolve(true, false).unwrap(), Interface::Tui); + assert_eq!(resolve(false, true).unwrap(), Interface::Mcp); + } + + #[test] + fn resolve_rejects_both_flags() { + assert!(resolve(true, true).is_err()); + } + + #[test] + fn set_and_read_roundtrip() { + set_interface(Interface::Tui); + assert!(is_tui()); + assert!(!is_mcp()); + set_interface(Interface::Mcp); + assert!(is_mcp()); + set_interface(Interface::Headless); + assert!(is_headless()); + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 44f05890..0e515766 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -4,7 +4,9 @@ pub mod account; pub mod ci; pub mod cli; pub mod deploy; +pub mod interface; pub mod mail; +pub mod mcp; pub mod project; mod token; pub mod ui; diff --git a/crates/cli/src/mail/process.rs b/crates/cli/src/mail/process.rs index c502f3f1..9dfaa81d 100644 --- a/crates/cli/src/mail/process.rs +++ b/crates/cli/src/mail/process.rs @@ -10,10 +10,7 @@ use crate::{ }, }, token::get_smb_token::get_smb_token, - ui::{ - confirm_dialog::confirm_delete_tui, fail_message, fail_symbol, succeed_message, - succeed_symbol, - }, + ui::{fail_message, fail_symbol, prompt::confirm_delete, succeed_message, succeed_symbol}, }; use anyhow::{anyhow, Result}; use smbcloud_mail::{ @@ -187,8 +184,10 @@ async fn process_mail_update( async fn process_mail_delete(env: Environment, id: String) -> Result { let mail_app_id = normalize_required("mail app id", id)?; - let confirmed = confirm_delete_tui(&format!("Delete mail app #{mail_app_id}")) - .map_err(|error| anyhow!(error))?; + let confirmed = confirm_delete( + "Mail app deletion confirmation", + &format!("Delete mail app #{mail_app_id}"), + )?; if !confirmed { return Ok(done_result("Cancelled.")); @@ -288,10 +287,10 @@ async fn process_mail_inbox_delete( ) -> Result { let mail_app_id = normalize_required("mail app id", app_id)?; let mail_inbox_id = normalize_required("mail inbox id", id)?; - let confirmed = confirm_delete_tui(&format!( - "Delete mail inbox #{mail_inbox_id} from mail app #{mail_app_id}" - )) - .map_err(|error| anyhow!(error))?; + let confirmed = confirm_delete( + "Mail inbox deletion confirmation", + &format!("Delete mail inbox #{mail_inbox_id} from mail app #{mail_app_id}"), + )?; if !confirmed { return Ok(done_result("Cancelled.")); diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 64e0e523..ac84d719 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -78,6 +78,39 @@ async fn main() { let environment = cli.environment; // Resolve CI / non-interactive mode once, before any command can prompt. smbcloud_cli::ci::set_ci(smbcloud_cli::ci::resolve(cli.ci)); + + // Resolve the interface (headless / TUI / MCP) once, before dispatch. + let interface = match smbcloud_cli::interface::resolve(cli.tui, cli.mcp) { + Ok(interface) => interface, + Err(e) => { + eprintln!( + "{} {}", + style("✘".to_string()).for_stderr().red(), + style(e).red() + ); + std::process::exit(1); + } + }; + smbcloud_cli::interface::set_interface(interface); + + // MCP mode runs a stdio server instead of a one-shot command. It implies + // non-interactive, and all diagnostics must go to stderr so the stdout + // JSON-RPC stream stays clean. + if smbcloud_cli::interface::is_mcp() { + smbcloud_cli::ci::set_ci(true); + match run_mcp(cli).await { + Ok(()) => std::process::exit(0), + Err(e) => { + eprintln!( + "{} {}", + style("✘".to_string()).for_stderr().red(), + style(e).red() + ); + std::process::exit(1); + } + } + } + match run(cli).await { Ok(result) => { result.stop_and_persist(); @@ -103,6 +136,21 @@ async fn main() { } } +/// Run the MCP stdio server. Sets up file logging (never stdout, which is the +/// JSON-RPC channel) and hands off to the server loop. The global internet +/// check is skipped here — the server starts regardless, and individual tool +/// calls surface their own network errors. +async fn run_mcp(cli: Cli) -> Result<()> { + if let Some(user_filter) = &cli.log_level { + let filter = EnvFilter::from_str(user_filter) + .map_err(|_| anyhow!("Invalid log level: {:?}.", cli.log_level))?; + setup_logging(cli.environment, Some(filter))?; + } else { + setup_logging(cli.environment, None)?; + } + smbcloud_cli::mcp::serve(cli.environment).await +} + async fn run(cli: Cli) -> Result { // println!("Environment: {}", cli.environment); diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs new file mode 100644 index 00000000..233aae75 --- /dev/null +++ b/crates/cli/src/mcp.rs @@ -0,0 +1,88 @@ +//! MCP (Model Context Protocol) server interface. +//! +//! When `smb` is started with `--mcp`, it runs as an MCP server over stdio +//! instead of executing a one-shot command. The server exposes smbCloud +//! operations as MCP tools built on the official `rmcp` SDK. +//! +//! Tools call the same library functions the CLI handlers use, but return +//! structured JSON rather than rendering spinners or a TUI — the stdout stream +//! is the JSON-RPC channel and must stay free of console output. Logging still +//! goes to the on-disk log file (set up by `main`), never to stdout. + +use { + crate::{account::lib::is_logged_in, client, token::get_smb_token::get_smb_token}, + anyhow::{anyhow, Result}, + rmcp::{ + handler::server::ServerHandler, + model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo}, + tool, tool_handler, tool_router, + transport::stdio, + ErrorData, ServiceExt, + }, + smbcloud_auth::me::me, + smbcloud_network::environment::Environment, +}; + +/// The smbCloud MCP server. Holds the environment selected on the command line +/// so every tool talks to the same API host and on-disk state dir. +pub struct SmbMcpServer { + environment: Environment, +} + +impl SmbMcpServer { + pub fn new(environment: Environment) -> Self { + Self { environment } + } +} + +#[tool_router] +impl SmbMcpServer { + #[tool(description = "Get the authenticated smbCloud user's account info. \ + Requires a prior `smb login`; returns the user as JSON.")] + async fn me(&self) -> Result { + if !is_logged_in(self.environment) { + return Err(ErrorData::invalid_request( + "Not logged in. Run `smb login` first.", + None, + )); + } + let token = get_smb_token(self.environment) + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let user = me(self.environment, client(), &token) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let content = ContentBlock::json(&user)?; + Ok(CallToolResult::success(vec![content])) + } +} + +#[tool_handler] +impl ServerHandler for SmbMcpServer { + fn get_info(&self) -> ServerInfo { + // `Implementation` is `#[non_exhaustive]`, so start from the build-env + // default and override the identity fields to report `smb`, not `rmcp`. + let mut server_info = Implementation::from_build_env(); + server_info.name = "smb".to_string(); + server_info.version = env!("CARGO_PKG_VERSION").to_string(); + + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(server_info) + .with_instructions( + "smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \ + `smb login`; tools run non-interactively.", + ) + } +} + +/// Run the MCP server over stdio until the client disconnects. +pub async fn serve(environment: Environment) -> Result<()> { + let running = SmbMcpServer::new(environment) + .serve(stdio()) + .await + .map_err(|e| anyhow!("Failed to start MCP server: {e}"))?; + running + .waiting() + .await + .map_err(|e| anyhow!("MCP server stopped unexpectedly: {e}"))?; + Ok(()) +} diff --git a/crates/cli/src/project/crud_delete.rs b/crates/cli/src/project/crud_delete.rs index 9d7eaf78..e9c741f1 100644 --- a/crates/cli/src/project/crud_delete.rs +++ b/crates/cli/src/project/crud_delete.rs @@ -2,12 +2,8 @@ use crate::client; use crate::token::get_smb_token::get_smb_token; use crate::{ account::lib::is_logged_in, - ci::{interactive_message, is_ci}, cli::CommandResult, - ui::{ - confirm_dialog::confirm_delete_tui, fail_message, fail_symbol, succeed_message, - succeed_symbol, - }, + ui::{fail_message, fail_symbol, prompt::confirm_delete, succeed_message, succeed_symbol}, }; use anyhow::{anyhow, Result}; use smbcloud_network::environment::Environment; @@ -19,15 +15,10 @@ pub async fn process_project_delete(env: Environment, id: String) -> Result) -> Result<()> { if projects.is_empty() { return Ok(()); } - show_projects_tui(projects).map_err(|e| anyhow!(e)) + if is_tui() { + return show_projects_tui(projects).map_err(|e| anyhow!(e)); + } + plain::render_projects(&projects); + Ok(()) } pub(crate) fn show_project_detail(project: &Project) -> Result<()> { - show_project_detail_tui(project).map_err(|e| anyhow!(e)) + if is_tui() { + return show_project_detail_tui(project).map_err(|e| anyhow!(e)); + } + plain::render_project_detail(project); + Ok(()) } pub(crate) async fn process_project_use(env: Environment, id: String) -> Result { diff --git a/crates/cli/src/project/deployment.rs b/crates/cli/src/project/deployment.rs index f9b64185..2c51ccb5 100644 --- a/crates/cli/src/project/deployment.rs +++ b/crates/cli/src/project/deployment.rs @@ -3,9 +3,10 @@ use crate::token::get_smb_token::get_smb_token; use crate::{ cli::CommandResult, deploy::config::{check_project, get_config}, + interface::is_tui, ui::{ deployment_detail_view::show_deployment_detail_tui, deployment_table::show_deployments_tui, - succeed_message, succeed_symbol, + plain, succeed_message, succeed_symbol, }, }; use anyhow::{anyhow, Result}; @@ -38,13 +39,21 @@ pub(crate) async fn process_deployment( ) .await?; spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded")); - show_deployment_detail_tui(&deployment).map_err(|e| anyhow!(e))?; + if is_tui() { + show_deployment_detail_tui(&deployment).map_err(|e| anyhow!(e))?; + } else { + plain::render_deployment_detail(&deployment); + } } else { // List all deployments for the project let access_token = get_smb_token(env)?; let deployments = get_deployments(env, client(), access_token, config.project.id).await?; spinner.stop_and_persist(&succeed_symbol(), succeed_message("Load all deployments")); - show_deployments_tui(deployments).map_err(|e| anyhow!(e))?; + if is_tui() { + show_deployments_tui(deployments).map_err(|e| anyhow!(e))?; + } else { + plain::render_deployments(&deployments); + } }; Ok(CommandResult { diff --git a/crates/cli/src/ui/mod.rs b/crates/cli/src/ui/mod.rs index 9f156acb..fef07f4d 100644 --- a/crates/cli/src/ui/mod.rs +++ b/crates/cli/src/ui/mod.rs @@ -2,6 +2,7 @@ pub mod confirm_dialog; pub mod deployment_detail_view; pub mod deployment_table; pub mod me_view; +pub mod plain; pub mod project_detail_view; pub mod project_table; pub mod prompt; diff --git a/crates/cli/src/ui/plain.rs b/crates/cli/src/ui/plain.rs new file mode 100644 index 00000000..10c86bce --- /dev/null +++ b/crates/cli/src/ui/plain.rs @@ -0,0 +1,113 @@ +//! Plain-text renderers for the headless interface. +//! +//! The default (headless) interface prints line-based plain text instead of +//! taking over the terminal with a full-screen `ratatui` view. These renderers +//! are the headless counterparts to the `show_*_tui` entry points; a handler +//! chooses between them with [`crate::interface::is_tui`]. + +use { + crate::ui::highlight, + smbcloud_model::{ + account::User, + project::{Deployment, Project}, + }, + tabled::{builder::Builder, settings::Style}, +}; + +/// Render the authenticated user's account details as a plain key/value block. +pub fn render_user(user: &User) { + println!(); + println!(" {}", highlight(&user.email)); + println!(" {:<14}{}", "ID", user.id); + println!( + " {:<14}{}", + "Member since", + user.created_at.format("%Y-%m-%d") + ); + println!( + " {:<14}{}", + "Last updated", + user.updated_at.format("%Y-%m-%d") + ); + println!(); +} + +/// Render the project list as a plain table. +pub fn render_projects(projects: &[Project]) { + if projects.is_empty() { + println!("No projects found."); + return; + } + let mut builder = Builder::default(); + builder.push_record(["ID", "Name", "Runner", "Repository"]); + for project in projects { + builder.push_record([ + project.id.to_string(), + project.name.clone(), + project.runner.to_string(), + project.repository.clone().unwrap_or_default(), + ]); + } + let mut table = builder.build(); + table.with(Style::rounded()); + println!("{table}"); +} + +/// Render a single project's details as a plain key/value block. +pub fn render_project_detail(project: &Project) { + println!(); + println!(" {}", highlight(&project.name)); + println!(" {:<16}{}", "ID", project.id); + println!(" {:<16}{}", "Runner", project.runner); + println!(" {:<16}{}", "Deployment", project.deployment_method); + field_opt("Repository", project.repository.as_deref()); + field_opt("Description", project.description.as_deref()); + field_opt("Path", project.path.as_deref()); + println!(); +} + +/// Render the deployment list as a plain table. +pub fn render_deployments(deployments: &[Deployment]) { + if deployments.is_empty() { + println!("No deployments found."); + return; + } + let mut builder = Builder::default(); + builder.push_record(["ID", "Commit", "Status", "Created"]); + for deployment in deployments { + builder.push_record([ + deployment.id.to_string(), + deployment.commit_hash.chars().take(8).collect::(), + deployment.status.to_string(), + deployment.created_at.format("%Y-%m-%d %H:%M").to_string(), + ]); + } + let mut table = builder.build(); + table.with(Style::rounded()); + println!("{table}"); +} + +/// Render a single deployment's details as a plain key/value block. +pub fn render_deployment_detail(deployment: &Deployment) { + println!(); + println!(" {}", highlight(&format!("Deployment #{}", deployment.id))); + println!(" {:<16}{}", "Project", deployment.project_id); + println!(" {:<16}{}", "Commit", deployment.commit_hash); + println!(" {:<16}{}", "Status", deployment.status); + field_opt("App", deployment.frontend_app_name.as_deref()); + println!( + " {:<16}{}", + "Created", + deployment.created_at.format("%Y-%m-%d %H:%M") + ); + println!(); +} + +/// Print a `label: value` line only when the optional value is present. +fn field_opt(label: &str, value: Option<&str>) { + if let Some(value) = value { + if !value.is_empty() { + println!(" {:<16}{}", label, value); + } + } +} diff --git a/crates/cli/src/ui/prompt.rs b/crates/cli/src/ui/prompt.rs index d4f4c5bd..7cb4f398 100644 --- a/crates/cli/src/ui/prompt.rs +++ b/crates/cli/src/ui/prompt.rs @@ -16,7 +16,8 @@ use { crate::{ ci::{interactive_message, is_ci}, - ui::fail_message, + interface::is_tui, + ui::{confirm_dialog::confirm_delete_tui, fail_message}, }, anyhow::{anyhow, Result}, dialoguer::{console::Term, theme::ColorfulTheme, Confirm, Input, Password, Select}, @@ -30,6 +31,20 @@ fn io_error(err: dialoguer::Error) -> anyhow::Error { anyhow!(fail_message(&format!("Prompt failed: {err}"))) } +/// Destructive-action confirmation, dispatched by interface. Refuses in CI +/// (deleting unconfirmed is never safe); renders the full-screen danger dialog +/// under `--tui`; otherwise asks inline with a `false` default. `what` names +/// the confirmation for the CI error; `message` is shown to the user. +pub fn confirm_delete(what: &str, message: &str) -> Result { + if is_ci() { + return Err(ci_required(what)); + } + if is_tui() { + return confirm_delete_tui(message).map_err(|e| anyhow!(fail_message(&e.to_string()))); + } + confirm(message, false) +} + /// Yes/no confirmation. In CI mode returns `default` without prompting. pub fn confirm(prompt: &str, default: bool) -> Result { if is_ci() { From 212a6d32d2d358d6945eac7609b226f6820b0a4a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:56:20 +0000 Subject: [PATCH 2/6] Add project_list, project_show, and deployments MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the MCP server beyond the `me` slice with the read-only project and deployment tools, all reusing the existing library fetch functions: - `project_list` — lists the user's projects as JSON. - `project_show { id }` — a single project by ID (parameterized tool). - `deployments { project_id }` — a project's deployments as JSON. Auth handling is factored into `SmbMcpServer::access_token`, and a shared `json_result` helper serializes any value into a tool result. Parameterized tools use `schemars`-derived arg structs (added as a direct dependency) so `tools/list` advertises correct input schemas. Verified over stdio: `tools/list` shows all four tools with their required arguments, and a parameterized `project_show` call deserializes and dispatches correctly. CI gate (fmt, clippy -D warnings, tests) is green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019fkbx6fUq1uDR5N75Ph5zi --- Cargo.lock | 1 + Cargo.toml | 1 + crates/cli/Cargo.toml | 1 + crates/cli/src/mcp.rs | 82 +++++++++++++++++++++++++++++++++++++------ 4 files changed, 74 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 298ea568..15f52689 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2605,6 +2605,7 @@ dependencies = [ "regex", "reqwest", "rmcp", + "schemars", "serde", "serde_json", "serde_repr", diff --git a/Cargo.toml b/Cargo.toml index fcf66568..a1e08eeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ ratatui = { version = "0.29", default-features = false, features = ["crossterm"] regex = "1.11" reqwest = { version = "0.12.18", default-features = false } rmcp = { version = "2.2.0", features = ["server", "macros", "transport-io", "schemars"] } +schemars = "1" serde = "1" serde_json = "1.0.82" serde_repr = "0.1" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index dc9bc6dd..d0a2836b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -41,6 +41,7 @@ ratatui = { workspace = true } regex = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] } rmcp = { workspace = true } +schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_repr = { workspace = true } diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index 233aae75..3f66639c 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -13,14 +13,20 @@ use { crate::{account::lib::is_logged_in, client, token::get_smb_token::get_smb_token}, anyhow::{anyhow, Result}, rmcp::{ - handler::server::ServerHandler, + handler::server::{wrapper::Parameters, ServerHandler}, model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo}, tool, tool_handler, tool_router, transport::stdio, ErrorData, ServiceExt, }, + schemars::JsonSchema, + serde::Deserialize, smbcloud_auth::me::me, smbcloud_network::environment::Environment, + smbcloud_networking_project::{ + crud_project_deployment_read::get_deployments, crud_project_read::get_project, + crud_project_read::get_projects, + }, }; /// The smbCloud MCP server. Holds the environment selected on the command line @@ -33,26 +39,80 @@ impl SmbMcpServer { pub fn new(environment: Environment) -> Self { Self { environment } } -} -#[tool_router] -impl SmbMcpServer { - #[tool(description = "Get the authenticated smbCloud user's account info. \ - Requires a prior `smb login`; returns the user as JSON.")] - async fn me(&self) -> Result { + /// Resolve the stored auth token, mapping "not logged in" and read failures + /// to MCP errors. Every tool that hits the API goes through this. + fn access_token(&self) -> Result { if !is_logged_in(self.environment) { return Err(ErrorData::invalid_request( "Not logged in. Run `smb login` first.", None, )); } - let token = get_smb_token(self.environment) - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + get_smb_token(self.environment).map_err(|e| ErrorData::internal_error(e.to_string(), None)) + } +} + +/// Serialize any JSON-able value into a single-content successful tool result. +fn json_result(value: &T) -> Result { + Ok(CallToolResult::success(vec![ContentBlock::json(value)?])) +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ProjectShowArgs { + /// The project ID to show. + id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct DeploymentsArgs { + /// The project ID whose deployments to list. + project_id: i32, +} + +#[tool_router] +impl SmbMcpServer { + #[tool(description = "Get the authenticated smbCloud user's account info. \ + Requires a prior `smb login`; returns the user as JSON.")] + async fn me(&self) -> Result { + let token = self.access_token()?; let user = me(self.environment, client(), &token) .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; - let content = ContentBlock::json(&user)?; - Ok(CallToolResult::success(vec![content])) + json_result(&user) + } + + #[tool(description = "List the authenticated user's smbCloud projects as a JSON array.")] + async fn project_list(&self) -> Result { + let token = self.access_token()?; + let projects = get_projects(self.environment, client(), token) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + json_result(&projects) + } + + #[tool(description = "Show a single smbCloud project by ID, returned as JSON.")] + async fn project_show( + &self, + Parameters(args): Parameters, + ) -> Result { + let token = self.access_token()?; + let project = get_project(self.environment, client(), token, args.id) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + json_result(&project) + } + + #[tool(description = "List deployments for a project by project ID, returned as a JSON array.")] + async fn deployments( + &self, + Parameters(args): Parameters, + ) -> Result { + let token = self.access_token()?; + let deployments = get_deployments(self.environment, client(), token, args.project_id) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + json_result(&deployments) } } From 5e236028c5197f9feee6f323ad65ad895e68cacc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 12:58:36 +0000 Subject: [PATCH 3/6] Document the headless / TUI / MCP interfaces Add docs/interfaces.md describing the three runtime interfaces and how to select them: headless (default plain text), --tui (full-screen views), and --mcp (stdio MCP server). Covers the available MCP tools, wiring the server into an MCP client, and a raw JSON-RPC handshake example. Kept generic with placeholders per the public-repo policy. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019fkbx6fUq1uDR5N75Ph5zi --- docs/interfaces.md | 104 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/interfaces.md diff --git a/docs/interfaces.md b/docs/interfaces.md new file mode 100644 index 00000000..00695501 --- /dev/null +++ b/docs/interfaces.md @@ -0,0 +1,104 @@ +# Interfaces: headless, TUI, and MCP + +`smb` presents through one of three interfaces, chosen at runtime. The default +is **headless** — plain, line-based text that works the same in a terminal, a +pipe, or a script. Two global flags switch interface: + +| Flag | Interface | What it does | +|---|---|---| +| _(none)_ | **Headless** (default) | Plain-text output. Interactive prompts still appear on a TTY unless `--ci` is set. No full-screen takeover. | +| `--tui` | **TUI** | Full-screen interactive views (`ratatui`) for the read commands. | +| `--mcp` | **MCP** | Runs as a Model Context Protocol server over stdio instead of a one-shot command. Implies non-interactive. | + +`--tui` and `--mcp` are mutually exclusive. The `--ci` flag is orthogonal: it +forces non-interactive behavior (see [ci.md](./ci.md)) and applies to the +headless interface; `--mcp` is always non-interactive regardless of `--ci`. + +## Headless (default) + +The default interface prints plain text and never seizes the terminal, so it is +safe to pipe or redirect: + +```sh +smb me # plain key/value account block +smb project list # plain table +smb project show --id # plain detail block +smb project deployment # plain table (or detail with --id ) +``` + +On a real terminal, commands that need input still prompt (project setup, a +monorepo target picker, delete confirmations). Add `--ci` — or run under CI — to +turn those prompts off and fail fast instead of blocking. + +## TUI (`--tui`) + +`--tui` renders the read commands as full-screen interactive views. Press +`q` / `Esc` to leave a view and return to the shell. + +```sh +smb --tui me +smb --tui project list +smb --tui project show --id +smb --tui project deployment +``` + +Destructive confirmations (e.g. `project delete`) show a full-screen danger +dialog under `--tui`; in the headless interface they ask inline instead. + +## MCP (`--mcp`) + +`smb --mcp` starts an MCP server that speaks JSON-RPC over stdio. Instead of +running a single command and exiting, it stays up and exposes smbCloud +operations as MCP **tools** that an MCP-capable client (assistant, IDE, agent) +can call. The subcommand is ignored in this mode. + +Authentication uses the token stored by `smb login`, so log in once before +starting the server. Tools run non-interactively and return structured JSON. + +### Available tools + +| Tool | Arguments | Returns | +|---|---|---| +| `me` | _(none)_ | The authenticated user. | +| `project_list` | _(none)_ | The user's projects. | +| `project_show` | `id` | A single project by ID. | +| `deployments` | `project_id` | A project's deployments. | + +### Wiring it into an MCP client + +Most MCP clients take a command plus arguments. Point the client at the `smb` +binary with `--mcp`: + +```json +{ + "mcpServers": { + "smbcloud": { + "command": "smb", + "args": ["--mcp"] + } + } +} +``` + +To target a local API during development, add the environment flag — +`"args": ["-e", "dev", "--mcp"]`. + +### Talking to it directly + +The server reads newline-delimited JSON-RPC on stdin and writes responses on +stdout. Diagnostics go to stderr and logging goes to the on-disk log file, so +stdout stays a clean protocol channel. A minimal handshake: + +```sh +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ + | smb --mcp +``` + +A tool call names the tool and its arguments: + +```json +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"project_show","arguments":{"id":""}}} +``` From feae5421ad7f37e0696a8f8d907e46f9e2aadd86 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:15:30 +0000 Subject: [PATCH 4/6] Sync npm SDK manifest to 0.4.9 The v0.4.9 version bump updated the Rust crates but left sdk/npm/smbcloud-auth/package.json at 0.4.8, so prepare-package.mjs failed its crate/npm version-match check and reddened the npm SDK CI job. Bump the manifest to 0.4.9 to match the workspace. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019fkbx6fUq1uDR5N75Ph5zi --- sdk/npm/smbcloud-auth/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/npm/smbcloud-auth/package.json b/sdk/npm/smbcloud-auth/package.json index 6d4f3073..1cbd6e0e 100644 --- a/sdk/npm/smbcloud-auth/package.json +++ b/sdk/npm/smbcloud-auth/package.json @@ -1,7 +1,7 @@ { "name": "@smbcloud/sdk-auth", "description": "Browser Auth SDK for smbCloud, built from Rust and WebAssembly.", - "version": "0.4.8", + "version": "0.4.9", "type": "module", "main": "dist/smbcloud_auth_wasm.js", "module": "dist/smbcloud_auth_wasm.js", From 312fccfd1042609bc5da60d3f41e82a96bb7b171 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 13:18:30 +0000 Subject: [PATCH 5/6] Add project create/update/delete MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the MCP server with the project write operations, reusing the same networking-project calls the CLI handlers use: - `project_create { name, description? }` — creates a project. - `project_update { id, description }` — updates the description, fetching the project first to preserve its runner (which `update_project` requires). - `project_delete { id }` — deletes a project; destructive and irreversible. Tools are non-interactive by nature, so writes apply immediately; the tool descriptions and docs flag `project_delete` accordingly. Verified over stdio: all seven tools list with correct required-argument schemas and dispatch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019fkbx6fUq1uDR5N75Ph5zi --- crates/cli/src/mcp.rs | 90 ++++++++++++++++++++++++++++++++++++++++++- docs/interfaces.md | 7 ++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index 3f66639c..61def692 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -22,10 +22,12 @@ use { schemars::JsonSchema, serde::Deserialize, smbcloud_auth::me::me, + smbcloud_model::project::ProjectCreate, smbcloud_network::environment::Environment, smbcloud_networking_project::{ + crud_project_create::create_project, crud_project_delete::delete_project, crud_project_deployment_read::get_deployments, crud_project_read::get_project, - crud_project_read::get_projects, + crud_project_read::get_projects, crud_project_update::update_project, }, }; @@ -70,6 +72,29 @@ struct DeploymentsArgs { project_id: i32, } +#[derive(Debug, Deserialize, JsonSchema)] +struct ProjectCreateArgs { + /// Name for the new project. + name: String, + /// Optional description for the new project. + #[serde(default)] + description: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ProjectUpdateArgs { + /// The project ID to update. + id: String, + /// The new description. + description: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ProjectDeleteArgs { + /// The project ID to delete. + id: String, +} + #[tool_router] impl SmbMcpServer { #[tool(description = "Get the authenticated smbCloud user's account info. \ @@ -114,6 +139,69 @@ impl SmbMcpServer { .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; json_result(&deployments) } + + #[tool( + description = "Create a new smbCloud project with a name and optional description. \ + Returns the created project as JSON." + )] + async fn project_create( + &self, + Parameters(args): Parameters, + ) -> Result { + let token = self.access_token()?; + let payload = ProjectCreate { + name: args.name, + description: args.description, + }; + let project = create_project(self.environment, client(), token, payload) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + json_result(&project) + } + + #[tool( + description = "Update a project's description by ID, preserving its runner. \ + Returns the updated project as JSON." + )] + async fn project_update( + &self, + Parameters(args): Parameters, + ) -> Result { + let token = self.access_token()?; + // `update_project` requires the runner; fetch the current project so the + // description-only update doesn't clobber it. + let current = get_project(self.environment, client(), token.clone(), args.id.clone()) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + let project = update_project( + self.environment, + client(), + token, + args.id, + &args.description, + current.runner, + ) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + json_result(&project) + } + + #[tool( + description = "Delete a project by ID. This is destructive and irreversible — the \ + project and its deploy configuration are removed." + )] + async fn project_delete( + &self, + Parameters(args): Parameters, + ) -> Result { + let token = self.access_token()?; + delete_project(self.environment, client(), token, args.id) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; + Ok(CallToolResult::success(vec![ContentBlock::text( + "Project deleted.", + )])) + } } #[tool_handler] diff --git a/docs/interfaces.md b/docs/interfaces.md index 00695501..396ab306 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -63,6 +63,13 @@ starting the server. Tools run non-interactively and return structured JSON. | `project_list` | _(none)_ | The user's projects. | | `project_show` | `id` | A single project by ID. | | `deployments` | `project_id` | A project's deployments. | +| `project_create` | `name`, `description` (optional) | The created project. | +| `project_update` | `id`, `description` | The updated project (runner preserved). | +| `project_delete` | `id` | Confirmation. **Destructive and irreversible.** | + +Tools run non-interactively, so the write tools apply immediately without a +confirmation prompt — the calling client is responsible for confirming intent +before invoking `project_delete`. ### Wiring it into an MCP client From ac3adc4fa3f16be241b1bc9a02c0cba15814b0f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 13:04:57 +0000 Subject: [PATCH 6/6] Document why deploy and migrate are not MCP tools Add a "Not exposed as tools (yet)" section to docs/interfaces.md explaining the deliberate omission of `deploy` and `migrate` from the MCP tool set: both are coupled to the local working directory (`.smb/config.toml`, local build toolchains, on-disk SSH keys, streamed progress) in ways a non-interactive stdio tool call cannot satisfy cleanly. Points automated deploys at the headless `smb --ci deploy` path and notes what a future directory-free tool contract would require. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019fkbx6fUq1uDR5N75Ph5zi --- docs/interfaces.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/interfaces.md b/docs/interfaces.md index 396ab306..be9d00ac 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -71,6 +71,31 @@ Tools run non-interactively, so the write tools apply immediately without a confirmation prompt — the calling client is responsible for confirming intent before invoking `project_delete`. +### Not exposed as tools (yet) + +`deploy` and `migrate` are **deliberately not** MCP tools. Both are tightly +coupled to the local working directory in ways a stdio tool call can't satisfy +cleanly: + +- **`deploy`** reads `.smb/config.toml` (running interactive `setup_project` + when it is missing), builds the app locally, then uploads over rsync and + restarts over SSH. It depends on the caller's current directory, local build + toolchains, and on-disk SSH keys, and it streams long-running progress — none + of which map onto a single non-interactive tool call. For automated deploys, + use the headless CLI path instead (`smb --ci deploy`, see [ci.md](./ci.md)), + which is what CI and the deploy action already drive. +- **`migrate`** pushes local `.smb/config.toml` deploy fields up to the server, + so it is meaningless without a project directory to read from. The current + tools operate on explicit arguments (a project `id`, a `name`), not on + whatever directory the server process happens to be running in. + +Exposing either as a tool would mean designing an explicit, directory-free +contract — e.g. accepting the full deploy configuration as arguments and +returning streamed progress as structured events — rather than wrapping the +existing directory-coupled handlers. That is intentionally left for a later +pass; the tools above cover the read and project-management surface that is +well-defined over stdio today. + ### Wiring it into an MCP client Most MCP clients take a command plus arguments. Point the client at the `smb`