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