@smbcloud / cli / commits / d88a5b6

Add project_init, deploy and migrate MCP tools

Each takes an absolute cwd and never prompts. project_init writes .smb/config.toml through create_workspace and refuses to replace an existing one unless overwrite is true. deploy and migrate re-run the smb binary with --ci in cwd instead of calling the handlers in process. The deploy paths use cwd-relative paths and print to stdout, which is the JSON-RPC channel here. The tool returns success, the exit code and the last 200 lines of output with ANSI codes stripped, and is flagged as an error on a non-zero exit. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 18, 2026 at 10:25 UTC d88a5b68e88f619bd237c574ded693bbce1aea3b
2 files changed +409 -8
crates/cli/src/mcp.rs
+360 -5
@@ -24,6 +24,7 @@ use {
24 current_project::{resolve_optional_project_id, resolve_required_project_id},
25 send::{resolve_api_key, send_email, OutboundEmail},
26 },
27 + project::crud_create::{create_workspace, write_smb_config, AppInput, RepoInput},
28 token::get_smb_token::get_smb_token,
29 },
30 anyhow::{anyhow, Result},
@@ -38,7 +39,7 @@ use {
39 tool, tool_handler, tool_router, ErrorData, RoleServer, ServiceExt,
40 },
41 schemars::JsonSchema,
41 - serde::Deserialize,
42 + serde::{Deserialize, Serialize},
43 smbcloud_auth::me::me,
44 smbcloud_mail::{
45 mail_app::{
@@ -72,6 +73,16 @@ use {
73 crud_project_read::{get_project, get_projects},
74 crud_project_update::update_project,
75 },
76 + std::{
77 + collections::VecDeque,
78 + path::{Path, PathBuf},
79 + process::Stdio,
80 + sync::{Arc, Mutex},
81 + },
82 + tokio::{
83 + io::{AsyncBufReadExt, AsyncRead, BufReader},
84 + process::Command,
85 + },
86 };
87
88 /// The smbCloud MCP server. Holds the environment selected on the command line
@@ -668,6 +679,210 @@ impl From<DeployConfigUpdateArgs> for DeployConfigUpdate {
679 }
680 }
681
682 +#[derive(Debug, Deserialize, JsonSchema)]
683 +struct InitAppArgs {
684 + /// Name of the app.
685 + name: String,
686 + /// Repo-relative app path, e.g. `apps/web`.
687 + #[serde(default)]
688 + source_path: Option<String>,
689 + runner: McpRunner,
690 +}
691 +
692 +#[derive(Debug, Deserialize, JsonSchema)]
693 +struct ProjectInitArgs {
694 + /// Absolute path of the local directory to write `.smb/config.toml` into.
695 + cwd: String,
696 + /// Name for the new project.
697 + name: String,
698 + #[serde(default)]
699 + description: String,
700 + /// Repository name. Omit to create the project without a repo.
701 + #[serde(default)]
702 + repository: Option<String>,
703 + /// Defaults to `single_app` when `repository` is given.
704 + #[serde(default)]
705 + repo_kind: Option<McpRepoKind>,
706 + /// Runner of a single-app repo. Defaults to `node_js`.
707 + #[serde(default)]
708 + runner: Option<McpRunner>,
709 + /// Apps of a monorepo. Required for `monorepo`, not allowed otherwise.
710 + #[serde(default)]
711 + apps: Vec<InitAppArgs>,
712 + /// Replace an existing `.smb/config.toml` in `cwd`. Off by default.
713 + #[serde(default)]
714 + overwrite: bool,
715 +}
716 +
717 +#[derive(Debug, Deserialize, JsonSchema)]
718 +struct DeployArgs {
719 + /// Absolute path of the project directory containing `.smb/config.toml`.
720 + cwd: String,
721 + /// Sub-project to deploy, for monorepo configs with `[[projects]]`.
722 + #[serde(default)]
723 + project: Option<String>,
724 +}
725 +
726 +#[derive(Debug, Deserialize, JsonSchema)]
727 +struct MigrateArgs {
728 + /// Absolute path of the project directory containing `.smb/config.toml`.
729 + cwd: String,
730 +}
731 +
732 +/// How many trailing lines of a local command's output a tool returns.
733 +const OUTPUT_TAIL_LINES: usize = 200;
734 +
735 +#[derive(Debug, Serialize)]
736 +struct CommandOutcome {
737 + success: bool,
738 + exit_code: Option<i32>,
739 + output_tail: String,
740 +}
741 +
742 +/// Keeps only the newest `limit` lines, so a long build can't grow without
743 +/// bound while the child is still running.
744 +struct TailBuffer {
745 + lines: VecDeque<String>,
746 + limit: usize,
747 +}
748 +
749 +impl TailBuffer {
750 + fn new(limit: usize) -> Self {
751 + Self {
752 + lines: VecDeque::new(),
753 + limit,
754 + }
755 + }
756 +
757 + fn push(&mut self, raw_line: &str) {
758 + if self.lines.len() == self.limit {
759 + self.lines.pop_front();
760 + }
761 + self.lines.push_back(clean_output_line(raw_line));
762 + }
763 +
764 + fn render(&self) -> String {
765 + self.lines.iter().cloned().collect::<Vec<_>>().join("\n")
766 + }
767 +}
768 +
769 +/// Spinners redraw a line with `\r`; only the last frame is meaningful.
770 +fn clean_output_line(raw: &str) -> String {
771 + let last_frame = raw.rsplit('\r').next().unwrap_or(raw);
772 + console::strip_ansi_codes(last_frame).trim_end().to_string()
773 +}
774 +
775 +async fn collect_lines(reader: impl AsyncRead + Unpin, tail: Arc<Mutex<TailBuffer>>) {
776 + let mut lines = BufReader::new(reader).lines();
777 + // A read error ends collection for this stream; the exit status still
778 + // reports the outcome, so the partial tail is the best we can do.
779 + while let Ok(Some(line)) = lines.next_line().await {
780 + if let Ok(mut tail) = tail.lock() {
781 + tail.push(&line);
782 + }
783 + }
784 +}
785 +
786 +fn require_directory(cwd: &str) -> Result<PathBuf, ErrorData> {
787 + let path = PathBuf::from(cwd);
788 + if !path.is_absolute() {
789 + return Err(invalid_request("`cwd` must be an absolute path."));
790 + }
791 + if !path.is_dir() {
792 + return Err(invalid_request(format!("`cwd` is not a directory: {cwd}")));
793 + }
794 + Ok(path)
795 +}
796 +
797 +fn require_smb_config(cwd: &Path) -> Result<(), ErrorData> {
798 + if !cwd.join(".smb/config.toml").is_file() {
799 + return Err(invalid_request(format!(
800 + "No .smb/config.toml in {}. Run `project_init` there first.",
801 + cwd.display()
802 + )));
803 + }
804 + Ok(())
805 +}
806 +
807 +/// Checks that `project_init` may write into `cwd`. Pure so the guards are
808 +/// testable without an API.
809 +fn check_init_target(cwd: &str, overwrite: bool) -> Result<PathBuf, ErrorData> {
810 + let directory = require_directory(cwd)?;
811 + if directory.join(".smb/config.toml").exists() && !overwrite {
812 + return Err(invalid_request(format!(
813 + "{} already has a .smb/config.toml. Pass `overwrite: true` to replace it.",
814 + directory.display()
815 + )));
816 + }
817 + Ok(directory)
818 +}
819 +
820 +fn check_init_shape(args: &ProjectInitArgs) -> Result<(), ErrorData> {
821 + match (&args.repository, args.repo_kind) {
822 + (None, Some(_)) => Err(invalid_request("`repo_kind` needs a `repository`.")),
823 + (None, None) if !args.apps.is_empty() => {
824 + Err(invalid_request("`apps` needs a `repository`."))
825 + }
826 + (Some(_), Some(McpRepoKind::Monorepo)) if args.apps.is_empty() => Err(invalid_request(
827 + "A monorepo needs at least one entry in `apps`.",
828 + )),
829 + (Some(_), Some(McpRepoKind::SingleApp) | None) if !args.apps.is_empty() => Err(
830 + invalid_request("`apps` is only allowed with `repo_kind = monorepo`."),
831 + ),
832 + _ => Ok(()),
833 + }
834 +}
835 +
836 +/// Runs this same binary as `smb -e <env> --ci <args…>` inside `cwd`.
837 +///
838 +/// The deploy paths use cwd-relative paths and print to stdout, which is the
839 +/// MCP JSON-RPC channel, so they can't run in-process. A child keeps both
840 +/// isolated and reuses every deploy path unchanged; `--ci` makes any prompt
841 +/// fail fast instead of blocking.
842 +async fn run_cli_subcommand(
843 + environment: Environment,
844 + cwd: &Path,
845 + args: &[&str],
846 +) -> Result<CallToolResult, ErrorData> {
847 + let executable = std::env::current_exe().map_err(to_error_data)?;
848 + let mut child = Command::new(executable)
849 + .args(["-e", environment.to_str(), "--ci"])
850 + .args(args)
851 + .current_dir(cwd)
852 + .stdin(Stdio::null())
853 + .stdout(Stdio::piped())
854 + .stderr(Stdio::piped())
855 + .kill_on_drop(true)
856 + .spawn()
857 + .map_err(to_error_data)?;
858 +
859 + let tail = Arc::new(Mutex::new(TailBuffer::new(OUTPUT_TAIL_LINES)));
860 + let stdout = child
861 + .stdout
862 + .take()
863 + .ok_or_else(|| to_error_data("child stdout was not captured"))?;
864 + let stderr = child
865 + .stderr
866 + .take()
867 + .ok_or_else(|| to_error_data("child stderr was not captured"))?;
868 + let (status, _, _) = tokio::join!(
869 + child.wait(),
870 + collect_lines(stdout, tail.clone()),
871 + collect_lines(stderr, tail.clone()),
872 + );
873 + let status = status.map_err(to_error_data)?;
874 +
875 + let output_tail = tail.lock().map(|tail| tail.render()).unwrap_or_default();
876 + let outcome = CommandOutcome {
877 + success: status.success(),
878 + exit_code: status.code(),
879 + output_tail,
880 + };
881 + let mut result = CallToolResult::success(vec![ContentBlock::json(&outcome)?]);
882 + result.is_error = Some(!outcome.success);
883 + Ok(result)
884 +}
885 +
886 #[tool_router(router = cloud_tool_router, vis = "pub(crate)")]
887 impl SmbMcpServer {
888 #[tool(description = "Get the authenticated smbCloud user's account info. \
@@ -1461,6 +1676,104 @@ impl SmbMcpServer {
1676 .map_err(to_error_data)?;
1677 json_result(&deploy_config)
1678 }
1679 +
1680 + // ── Local tools: act on a directory, run non-interactively ───────────
1681 +
1682 + #[tool(
1683 + description = "Create a project (plus its repo and apps) and write `.smb/config.toml` \
1684 + into `cwd`, the non-interactive equivalent of `smb init`. Refuses to \
1685 + replace an existing config unless `overwrite` is true, in which case \
1686 + the old config is lost. Created under the selected tenant (`tenant_use`) \
1687 + or the personal tenant. Returns the project and the first app as JSON.",
1688 + annotations(destructive_hint = true)
1689 + )]
1690 + async fn project_init(
1691 + &self,
1692 + Parameters(args): Parameters<ProjectInitArgs>,
1693 + ) -> Result<CallToolResult, ErrorData> {
1694 + let directory = check_init_target(&args.cwd, args.overwrite)?;
1695 + check_init_shape(&args)?;
1696 + let token = self.access_token()?;
1697 +
1698 + let repo = args.repository.as_ref().map(|repository| {
1699 + let repo_kind = args.repo_kind.unwrap_or(McpRepoKind::SingleApp);
1700 + let runner = match repo_kind {
1701 + McpRepoKind::Monorepo => Runner::Monorepo,
1702 + McpRepoKind::SingleApp => args.runner.unwrap_or(McpRunner::NodeJs).into(),
1703 + };
1704 + RepoInput {
1705 + repository: repository.clone(),
1706 + repo_kind: repo_kind.into(),
1707 + runner,
1708 + }
1709 + });
1710 + let apps: Vec<AppInput> = args
1711 + .apps
1712 + .iter()
1713 + .map(|app| AppInput {
1714 + name: app.name.clone(),
1715 + source_path: app.source_path.clone(),
1716 + runner: app.runner.into(),
1717 + })
1718 + .collect();
1719 + let tenant_id = crate::session_config::current_tenant_id(self.environment).unwrap_or(None);
1720 +
1721 + let (project, first_app) = create_workspace(
1722 + self.environment,
1723 + token,
1724 + tenant_id,
1725 + &args.name,
1726 + &args.description,
1727 + repo.as_ref(),
1728 + &apps,
1729 + )
1730 + .await
1731 + .map_err(to_error_data)?;
1732 + write_smb_config(&directory, &project, first_app.as_ref()).map_err(to_error_data)?;
1733 + json_result(&serde_json::json!({
1734 + "project": project,
1735 + "frontend_app": first_app,
1736 + "config_path": directory.join(".smb/config.toml"),
1737 + }))
1738 + }
1739 +
1740 + #[tool(
1741 + description = "Deploy the project in `cwd` (the equivalent of `smb deploy`). Builds \
1742 + locally, pushes to the server, and restarts the app, so it can take \
1743 + minutes. Needs an existing `.smb/config.toml` (see `project_init`) and \
1744 + never prompts. For monorepo configs pass `project` to pick a \
1745 + sub-project. Returns {success, exit_code, output_tail} with the last \
1746 + ~200 lines of output; the call is an error when the deploy exits non-zero."
1747 + )]
1748 + async fn deploy(
1749 + &self,
1750 + Parameters(args): Parameters<DeployArgs>,
1751 + ) -> Result<CallToolResult, ErrorData> {
1752 + self.access_token()?;
1753 + let directory = require_directory(&args.cwd)?;
1754 + require_smb_config(&directory)?;
1755 + let mut command = vec!["deploy"];
1756 + if let Some(project) = args.project.as_deref() {
1757 + command.extend(["--project", project]);
1758 + }
1759 + run_cli_subcommand(self.environment, &directory, &command).await
1760 + }
1761 +
1762 + #[tool(
1763 + description = "Push the deploy fields of the local `.smb/config.toml` in `cwd` up to \
1764 + the smbCloud server (the equivalent of `smb migrate`). Never prompts. \
1765 + Returns {success, exit_code, output_tail}; the call is an error when \
1766 + the command exits non-zero."
1767 + )]
1768 + async fn migrate(
1769 + &self,
1770 + Parameters(args): Parameters<MigrateArgs>,
1771 + ) -> Result<CallToolResult, ErrorData> {
1772 + self.access_token()?;
1773 + let directory = require_directory(&args.cwd)?;
1774 + require_smb_config(&directory)?;
1775 + run_cli_subcommand(self.environment, &directory, &["migrate"]).await
1776 + }
1777 }
1778
1779 #[tool_handler(router = Self::cloud_tool_router())]
@@ -1483,7 +1796,8 @@ impl ServerHandler for SmbMcpServer {
1796 "smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \
1797 `smb login`; tools run non-interactively. `tenant_use` / `project_use` select \
1798 the tenant/project context other tools default to, shared with the CLI's own \
1486 - `smb tenant use` / `smb project use`. Use `smb --mcp --scope automation` \
1799 + `smb tenant use` / `smb project use`. `project_init`, `deploy` and `migrate` act on a local \
1800 + directory given as an absolute `cwd`. Use `smb --mcp --scope automation` \
1801 instead for mobile and TV app automation tools. `deployments` renders an \
1802 interactive dashboard on MCP Apps hosts.",
1803 )
@@ -1564,6 +1878,7 @@ mod tests {
1878 "project_create",
1879 "project_update",
1880 "project_delete",
1881 + "project_init",
1882 "deployments",
1883 "deployment_show",
1884 "frontend_app_list",
@@ -1572,6 +1887,8 @@ mod tests {
1887 "deploy_repo_new",
1888 "deploy_config_show",
1889 "deploy_config_update",
1890 + "deploy",
1891 + "migrate",
1892 "tenant_list",
1893 "tenant_show",
1894 "tenant_new",
@@ -1765,14 +2082,52 @@ mod tests {
2082 }
2083
2084 #[test]
1768 - fn delete_tools_carry_destructive_hint() {
2085 + fn delete_and_overwrite_tools_carry_destructive_hint() {
2086 let tools = SmbMcpServer::cloud_tool_router().list_all();
2087 let mut checked = 0;
1771 - for tool in tools.iter().filter(|tool| tool.name.ends_with("_delete")) {
2088 + for tool in tools
2089 + .iter()
2090 + .filter(|tool| tool.name.ends_with("_delete") || tool.name == "project_init")
2091 + {
2092 let annotations = tool.annotations.as_ref().expect("annotations");
2093 assert_eq!(annotations.destructive_hint, Some(true), "{}", tool.name);
2094 checked += 1;
2095 }
1776 - assert_eq!(checked, 5);
2096 + assert_eq!(checked, 6);
2097 + }
2098 +
2099 + #[test]
2100 + fn init_target_requires_an_existing_absolute_directory() {
2101 + assert!(check_init_target("relative/dir", false).is_err());
2102 + let directory = tempfile::tempdir().expect("temp dir");
2103 + let missing = directory.path().join("missing");
2104 + assert!(check_init_target(&missing.to_string_lossy(), false).is_err());
2105 + assert!(check_init_target(&directory.path().to_string_lossy(), false).is_ok());
2106 + }
2107 +
2108 + #[test]
2109 + fn init_target_refuses_existing_config_unless_overwriting() {
2110 + let directory = tempfile::tempdir().expect("temp dir");
2111 + std::fs::create_dir_all(directory.path().join(".smb")).expect("create .smb");
2112 + std::fs::write(directory.path().join(".smb/config.toml"), "").expect("write config");
2113 + let cwd = directory.path().to_string_lossy().to_string();
2114 +
2115 + assert!(check_init_target(&cwd, false).is_err());
2116 + assert!(check_init_target(&cwd, true).is_ok());
2117 + }
2118 +
2119 + #[test]
2120 + fn deploy_requires_a_config_in_cwd() {
2121 + let directory = tempfile::tempdir().expect("temp dir");
2122 + assert!(require_smb_config(directory.path()).is_err());
2123 + }
2124 +
2125 + #[test]
2126 + fn output_tail_keeps_last_lines_and_strips_ansi() {
2127 + let mut tail = TailBuffer::new(2);
2128 + for line in ["one", "\x1b[32mtwo\x1b[0m", "spinner\rthree"] {
2129 + tail.push(line);
2130 + }
2131 + assert_eq!(tail.render(), "two\nthree");
2132 }
2133 }
docs/mcp.md
+49 -3
@@ -5,8 +5,8 @@ assistant or agent — Claude Desktop, Claude Code, Cursor, or any other
5 MCP-capable client — can send transactional email and manage your smbCloud
6 projects, tenants, Mail apps, and Auth apps directly, without you leaving the
7 chat to run `smb` commands by hand.
8 -Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 38
9 -tools covering the account, project, tenant, Mail, and Auth surfaces.
8 +Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 41
9 +tools covering the account, project, deploy, tenant, Mail, and Auth surfaces.
10
11 Cross-platform mobile and TV app testing is exposed as a separate, focused MCP
12 profile through `xcrs --mcp` or `smb --mcp --scope automation`. Keeping cloud
@@ -177,6 +177,52 @@ frontend apps that ship from it. `runner` is one of `node_js`, `static`, `ruby`,
177 | `deploy_config_show` | `frontend_app_id` | The app's server-side deploy config. |
178 | `deploy_config_update` | `frontend_app_id`, plus any of `runner`, `deployment_method` (`git` or `rsync`), `kind`, `source_path`, `remote_path`, `package_manager`, `pm2_app`, `port`, `output_path`, `build_command`, `install_command`, `binary_name`, `build_target`, `shared_lib_path` | The updated config. Only the fields passed change. |
179
180 +### Local tools (`project_init`, `deploy`, `migrate`)
181 +
182 +These act on a directory on the machine running `smb --mcp`, so each takes an
183 +explicit absolute `cwd`. They never prompt: anything the interactive CLI would
184 +ask is an argument, or the call fails with a message saying what is missing.
185 +
186 +| Tool | Arguments | Returns |
187 +| --- | --- | --- |
188 +| `project_init` | `cwd`, `name`, `description` (optional), `repository`, `repo_kind`, `runner`, `apps` (`name`, `source_path`, `runner`), `overwrite` (all optional) | The project, its first app, and the written config path. Writes `<cwd>/.smb/config.toml`. Refuses to replace an existing config unless `overwrite` is true. |
189 +| `deploy` | `cwd`, `project` (optional monorepo sub-project) | `{ success, exit_code, output_tail }`. |
190 +| `migrate` | `cwd` | `{ success, exit_code, output_tail }`. |
191 +
192 +`deploy` and `migrate` need an existing `.smb/config.toml` in `cwd` (run
193 +`project_init` first). `deploy` builds locally, pushes to the server, and
194 +restarts the app, so it can take minutes; the call returns when it finishes.
195 +
196 +Both run the `smb` binary itself as a child process in `cwd` with `--ci`,
197 +rather than in the MCP process. The deploy paths use paths relative to the
198 +working directory and print progress to stdout, which in an MCP session is the
199 +JSON-RPC channel. `output_tail` is the last 200 lines of combined output with
200 +colors and spinner frames removed. The call is flagged as an error when the
201 +command exits non-zero.
202 +
203 +#### Interactive views (MCP Apps)
204 +
205 +Three tools declare an [MCP Apps](https://modelcontextprotocol.io/extensions/apps) view.
206 +Hosts that support the extension, such as Claude and ChatGPT, render it in a sandboxed frame
207 +next to the result:
208 +
209 +- `deployments` → `ui://smbcloud/deployments.html`: a table of deployments with status, app,
210 + commit, and created/updated times, newest first, plus a **Refresh** button.
211 +- `mail_message_list` → `ui://smbcloud/inbox.html`: the inbox as a table (from, subject,
212 + preview, received, forward status). Click a row to open the message, which calls
213 + `mail_message_show`; **Back** returns to the list and **Refresh** reloads it. Only the
214 + plain-text body is shown, never `html_body`.
215 +
216 +- `auth_app_show` and `auth_app_new` → `ui://smbcloud/auth-app.html`: a setup card with the
217 + client ID (copy), the client secret (masked, with Reveal and Copy), the support email, and a
218 + Sign in with Apple checklist. The secret is only present when the API returns it.
219 +
220 +List views show the newest 50 rows. Other clients see the same JSON result as before. Each
221 +view is a single self-contained HTML document that makes no network requests; Refresh and
222 +drill-in go back through the host with a `tools/call`. Tool `_meta` carries the view URI
223 +under both `ui.resourceUri` and the legacy `ui/resourceUri` key, since older hosts read only
224 +the flat one.
225 +
226 ### Tenants
227
228 A tenant is a workspace — your personal one, or an organization you belong to.
@@ -412,7 +458,7 @@ Yes, and vice versa — `tenant_use`/`project_use` write to the same
458 `~/.smb/config.toml` the CLI itself reads, so the two stay in sync.
459
460 **Is the MCP server safe to point an autonomous agent at?**
415 -Every write tool, including every `*_delete` tool, executes immediately with
461 +Every write tool, including every `*_delete` tool and `deploy`, executes immediately with
462 no confirmation prompt, since MCP is non-interactive by design. Treat tool
463 access the way you'd treat handing someone your terminal with `smb` already
464 logged in: fine for a trusted agent you're directing, not something to expose