@smbcloud / cli / commits / 7c04753

Add deployment, frontend app, deploy repo and deploy config MCP tools

New: deployment_show, frontend_app_list/show/new, deploy_repo_new and deploy_config_show/update. project_update takes an optional runner and keeps the current one when it is omitted. project_use can select a frontend app that belongs to the project. The delete tools now carry destructiveHint so clients can prompt before running them. docs/mcp.md lists the new tools and what stays out of MCP (login, signup, logout, forgot, control-kit). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 18, 2026 at 10:25 UTC 7c04753e55f2f5f4adf56def0871448556788ba2
2 files changed +453 -68
crates/cli/src/mcp.rs
+425 -20
@@ -49,18 +49,26 @@ use {
49 },
50 smbcloud_model::{
51 app_auth::{AuthApp, AuthAppCreate, AuthAppUpdate},
52 + deploy_config_update::DeployConfigUpdate,
53 + frontend_app::{AppType, DeployRepoCreate, FrontendAppCreate, RepoKind},
54 mail::{
55 MailAppCreate, MailAppUpdate, MailInboxCreate, MailInboxUpdate, MailMessage,
56 MailTestEmailRequest,
57 },
58 project::{Deployment, ProjectCreate},
59 + runner::Runner,
60 tenant::{TenantCreate, TenantUpdate},
61 },
62 smbcloud_network::environment::Environment,
63 smbcloud_networking_project::{
64 + crud_deploy_repo_create::create_deploy_repo,
65 + crud_frontend_app_create::create_frontend_app,
66 + crud_frontend_app_deploy_config::get_deploy_config,
67 + crud_frontend_app_read::{get_frontend_app, get_frontend_apps_by_project},
68 + crud_frontend_app_update_deploy_config::update_deploy_config,
69 crud_project_create::create_project,
70 crud_project_delete::delete_project,
63 - crud_project_deployment_read::get_deployments,
71 + crud_project_deployment_read::{get_deployment, get_deployments},
72 crud_project_read::{get_project, get_projects},
73 crud_project_update::update_project,
74 },
@@ -229,6 +237,10 @@ struct ProjectShowArgs {
237 struct ProjectUseArgs {
238 /// The project ID to select for this session.
239 id: String,
240 + /// Also select this frontend app (it must belong to the project). Tools
241 + /// and CLI commands that resolve a deploy target default to it.
242 + #[serde(default)]
243 + frontend_app_id: Option<String>,
244 }
245
246 #[derive(Debug, Deserialize, JsonSchema)]
@@ -252,6 +264,9 @@ struct ProjectUpdateArgs {
264 id: String,
265 /// The new description.
266 description: String,
267 + /// The new runner. Keeps the project's current runner when omitted.
268 + #[serde(default)]
269 + runner: Option<McpRunner>,
270 }
271
272 #[derive(Debug, Deserialize, JsonSchema)]
@@ -487,6 +502,172 @@ struct AuthAppDeleteArgs {
502 id: String,
503 }
504
505 +/// Runners a tool caller can pick. `Monorepo` is deliberately absent: it is
506 +/// implied by `repo_kind = monorepo`, not chosen per app.
507 +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
508 +#[serde(rename_all = "snake_case")]
509 +enum McpRunner {
510 + NodeJs,
511 + Static,
512 + Ruby,
513 + Swift,
514 + Rust,
515 +}
516 +
517 +impl From<McpRunner> for Runner {
518 + fn from(runner: McpRunner) -> Self {
519 + match runner {
520 + McpRunner::NodeJs => Runner::NodeJs,
521 + McpRunner::Static => Runner::Static,
522 + McpRunner::Ruby => Runner::Ruby,
523 + McpRunner::Swift => Runner::Swift,
524 + McpRunner::Rust => Runner::Rust,
525 + }
526 + }
527 +}
528 +
529 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
530 +#[serde(rename_all = "snake_case")]
531 +enum McpRepoKind {
532 + SingleApp,
533 + Monorepo,
534 +}
535 +
536 +impl From<McpRepoKind> for RepoKind {
537 + fn from(repo_kind: McpRepoKind) -> Self {
538 + match repo_kind {
539 + McpRepoKind::SingleApp => RepoKind::SingleApp,
540 + McpRepoKind::Monorepo => RepoKind::Monorepo,
541 + }
542 + }
543 +}
544 +
545 +#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
546 +#[serde(rename_all = "snake_case")]
547 +enum McpDeploymentMethod {
548 + Git,
549 + Rsync,
550 +}
551 +
552 +#[derive(Debug, Deserialize, JsonSchema)]
553 +struct DeploymentShowArgs {
554 + /// The project ID the deployment belongs to.
555 + project_id: i32,
556 + /// The deployment ID to show.
557 + id: i32,
558 +}
559 +
560 +#[derive(Debug, Deserialize, JsonSchema)]
561 +struct FrontendAppListArgs {
562 + /// The project ID whose frontend apps to list.
563 + project_id: i32,
564 +}
565 +
566 +#[derive(Debug, Deserialize, JsonSchema)]
567 +struct FrontendAppShowArgs {
568 + /// The frontend app ID to show.
569 + id: String,
570 +}
571 +
572 +#[derive(Debug, Deserialize, JsonSchema)]
573 +struct FrontendAppNewArgs {
574 + /// The project ID to create the frontend app under.
575 + project_id: i32,
576 + /// Name for the new frontend app.
577 + name: String,
578 + /// Repo-relative app path for monorepo targets, e.g. `apps/web`.
579 + #[serde(default)]
580 + source_path: Option<String>,
581 + /// Runner for the app. Defaults to `node_js`.
582 + #[serde(default)]
583 + runner: Option<McpRunner>,
584 +}
585 +
586 +#[derive(Debug, Deserialize, JsonSchema)]
587 +struct DeployRepoNewArgs {
588 + /// The project ID to create the deploy repo under.
589 + project_id: i32,
590 + /// Repository name.
591 + repository: String,
592 + /// Whether the repo holds one deployable app or several. A single-app
593 + /// repo creates its app automatically.
594 + repo_kind: McpRepoKind,
595 + /// Runner for a single-app repo. Defaults to `node_js`; ignored for
596 + /// monorepos, whose apps pick their own runner.
597 + #[serde(default)]
598 + runner: Option<McpRunner>,
599 +}
600 +
601 +#[derive(Debug, Deserialize, JsonSchema)]
602 +struct DeployConfigShowArgs {
603 + /// The frontend app ID whose server-side deploy config to show.
604 + frontend_app_id: String,
605 +}
606 +
607 +#[derive(Debug, Deserialize, JsonSchema)]
608 +struct DeployConfigUpdateArgs {
609 + /// The frontend app ID whose deploy config to update. Only the fields you
610 + /// pass are changed.
611 + frontend_app_id: String,
612 + #[serde(default)]
613 + runner: Option<McpRunner>,
614 + #[serde(default)]
615 + deployment_method: Option<McpDeploymentMethod>,
616 + /// Deployment kind, e.g. `vite-spa`, `sveltekit`, `nextjs-ssr`, `rust`.
617 + #[serde(default)]
618 + kind: Option<String>,
619 + #[serde(default)]
620 + source_path: Option<String>,
621 + #[serde(default)]
622 + remote_path: Option<String>,
623 + #[serde(default)]
624 + package_manager: Option<String>,
625 + #[serde(default)]
626 + pm2_app: Option<String>,
627 + #[serde(default)]
628 + port: Option<u16>,
629 + #[serde(default)]
630 + output_path: Option<String>,
631 + #[serde(default)]
632 + build_command: Option<String>,
633 + #[serde(default)]
634 + install_command: Option<String>,
635 + #[serde(default)]
636 + binary_name: Option<String>,
637 + #[serde(default)]
638 + build_target: Option<String>,
639 + #[serde(default)]
640 + shared_lib_path: Option<String>,
641 +}
642 +
643 +impl From<DeployConfigUpdateArgs> for DeployConfigUpdate {
644 + fn from(args: DeployConfigUpdateArgs) -> Self {
645 + DeployConfigUpdate {
646 + runner: args.runner.map(|runner| {
647 + let runner: Runner = runner.into();
648 + runner as u8
649 + }),
650 + deployment_method: args.deployment_method.map(|method| match method {
651 + McpDeploymentMethod::Git => 0,
652 + McpDeploymentMethod::Rsync => 1,
653 + }),
654 + kind: args.kind,
655 + source_path: args.source_path,
656 + remote_path: args.remote_path,
657 + package_manager: args.package_manager,
658 + pm2_app: args.pm2_app,
659 + pm2_env: None,
660 + port: args.port,
661 + output_path: args.output_path,
662 + build_command: args.build_command,
663 + install_command: args.install_command,
664 + binary_name: args.binary_name,
665 + build_target: args.build_target,
666 + shared_lib_path: args.shared_lib_path,
667 + }
668 + }
669 +}
670 +
671 #[tool_router(router = cloud_tool_router, vis = "pub(crate)")]
672 impl SmbMcpServer {
673 #[tool(description = "Get the authenticated smbCloud user's account info. \
@@ -525,19 +706,36 @@ impl SmbMcpServer {
706 #[tool(
707 description = "Select a project for this session, persisted to the same config \
708 CLI sessions read. Mail/Auth tools that take an optional project_id \
528 - default to this project when omitted. Returns the selected project as JSON."
709 + default to this project when omitted. Optionally selects one of its \
710 + frontend apps too. Returns the selected project as JSON."
711 )]
712 async fn project_use(
713 &self,
714 Parameters(args): Parameters<ProjectUseArgs>,
715 ) -> Result<CallToolResult, ErrorData> {
716 let token = self.access_token()?;
535 - let project = get_project(self.environment, client(), token, args.id)
717 + let project = get_project(self.environment, client(), token.clone(), args.id)
718 .await
719 .map_err(to_error_data)?;
538 - // MCP tools are non-interactive, so unlike `smb project use` we don't
539 - // try to resolve/select a frontend app here.
540 - crate::session_config::set_current_project(self.environment, project.clone(), None)
720 + // MCP tools are non-interactive, so unlike `smb project use` we never
721 + // prompt to pick a frontend app; the caller names one explicitly.
722 + let frontend_app = match args.frontend_app_id {
723 + Some(frontend_app_id) => {
724 + let frontend_app =
725 + get_frontend_app(self.environment, client(), token, &frontend_app_id)
726 + .await
727 + .map_err(to_error_data)?;
728 + if frontend_app.project_id != project.id {
729 + return Err(invalid_request(format!(
730 + "Frontend app {frontend_app_id} does not belong to project {}.",
731 + project.id
732 + )));
733 + }
734 + Some(frontend_app)
735 + }
736 + None => None,
737 + };
738 + crate::session_config::set_current_project(self.environment, project.clone(), frontend_app)
739 .map_err(to_error_data)?;
740 json_result(&project)
741 }
@@ -581,26 +779,33 @@ impl SmbMcpServer {
779 }
780
781 #[tool(
584 - description = "Update a project's description by ID, preserving its runner. \
585 - Returns the updated project as JSON."
782 + description = "Update a project's description and, optionally, its runner. The \
783 + current runner is kept when `runner` is omitted. Returns the updated \
784 + project as JSON."
785 )]
786 async fn project_update(
787 &self,
788 Parameters(args): Parameters<ProjectUpdateArgs>,
789 ) -> Result<CallToolResult, ErrorData> {
790 let token = self.access_token()?;
592 - // `update_project` requires the runner; fetch the current project so the
593 - // description-only update doesn't clobber it.
594 - let current = get_project(self.environment, client(), token.clone(), args.id.clone())
595 - .await
596 - .map_err(to_error_data)?;
791 + // `update_project` requires the runner, so a description-only update
792 + // fetches the current one rather than clobbering it.
793 + let runner = match args.runner {
794 + Some(runner) => runner.into(),
795 + None => {
796 + get_project(self.environment, client(), token.clone(), args.id.clone())
797 + .await
798 + .map_err(to_error_data)?
799 + .runner
800 + }
801 + };
802 let project = update_project(
803 self.environment,
804 client(),
805 token,
806 args.id,
807 &args.description,
603 - current.runner,
808 + runner,
809 )
810 .await
811 .map_err(to_error_data)?;
@@ -609,7 +814,8 @@ impl SmbMcpServer {
814
815 #[tool(
816 description = "Delete a project by ID. This is destructive and irreversible — the \
612 - project and its deploy configuration are removed."
817 + project and its deploy configuration are removed.",
818 + annotations(destructive_hint = true)
819 )]
820 async fn project_delete(
821 &self,
@@ -692,7 +898,8 @@ impl SmbMcpServer {
898 #[tool(
899 description = "Delete an organization tenant by ID. This is destructive and \
900 irreversible — it cascades to every project, Auth app, and Mail \
695 - app the tenant owns. The personal tenant can't be deleted."
901 + app the tenant owns. The personal tenant can't be deleted.",
902 + annotations(destructive_hint = true)
903 )]
904 async fn tenant_delete(
905 &self,
@@ -807,7 +1014,8 @@ impl SmbMcpServer {
1014
1015 #[tool(
1016 description = "Delete a Mail app by ID. This is destructive and irreversible — its \
810 - inbox routes and SES routing are removed."
1017 + inbox routes and SES routing are removed.",
1018 + annotations(destructive_hint = true)
1019 )]
1020 async fn mail_delete(
1021 &self,
@@ -877,7 +1085,10 @@ impl SmbMcpServer {
1085 json_result(&inbox)
1086 }
1087
880 - #[tool(description = "Delete a mail inbox route. This is destructive and irreversible.")]
1088 + #[tool(
1089 + description = "Delete a mail inbox route. This is destructive and irreversible.",
1090 + annotations(destructive_hint = true)
1091 + )]
1092 async fn mail_inbox_delete(
1093 &self,
1094 Parameters(args): Parameters<MailInboxDeleteArgs>,
@@ -1090,7 +1301,8 @@ impl SmbMcpServer {
1301
1302 #[tool(
1303 description = "Delete an Auth app by ID. This is destructive and irreversible — its \
1093 - OAuth clients and configuration are removed."
1304 + OAuth clients and configuration are removed.",
1305 + annotations(destructive_hint = true)
1306 )]
1307 async fn auth_app_delete(
1308 &self,
@@ -1102,6 +1314,153 @@ impl SmbMcpServer {
1314 .map_err(to_error_data)?;
1315 text_result("Auth app deleted.")
1316 }
1317 +
1318 + // ── Deployments, frontend apps, deploy repos ─────────────────────────
1319 +
1320 + #[tool(
1321 + description = "Show a single deployment by project ID and deployment ID, returned as JSON."
1322 + )]
1323 + async fn deployment_show(
1324 + &self,
1325 + Parameters(args): Parameters<DeploymentShowArgs>,
1326 + ) -> Result<CallToolResult, ErrorData> {
1327 + let token = self.access_token()?;
1328 + let deployment =
1329 + get_deployment(self.environment, client(), token, args.project_id, args.id)
1330 + .await
1331 + .map_err(to_error_data)?;
1332 + json_result(&deployment)
1333 + }
1334 +
1335 + #[tool(description = "List the frontend apps (deployable apps) of a project as a JSON array.")]
1336 + async fn frontend_app_list(
1337 + &self,
1338 + Parameters(args): Parameters<FrontendAppListArgs>,
1339 + ) -> Result<CallToolResult, ErrorData> {
1340 + let token = self.access_token()?;
1341 + let frontend_apps =
1342 + get_frontend_apps_by_project(self.environment, client(), token, args.project_id)
1343 + .await
1344 + .map_err(to_error_data)?;
1345 + json_result(&frontend_apps)
1346 + }
1347 +
1348 + #[tool(description = "Show a single frontend app by ID, returned as JSON.")]
1349 + async fn frontend_app_show(
1350 + &self,
1351 + Parameters(args): Parameters<FrontendAppShowArgs>,
1352 + ) -> Result<CallToolResult, ErrorData> {
1353 + let token = self.access_token()?;
1354 + let frontend_app = get_frontend_app(self.environment, client(), token, &args.id)
1355 + .await
1356 + .map_err(to_error_data)?;
1357 + json_result(&frontend_app)
1358 + }
1359 +
1360 + #[tool(
1361 + description = "Create a frontend app (a deployable app) under a project. For monorepo \
1362 + apps pass `source_path`. Returns the created app as JSON."
1363 + )]
1364 + async fn frontend_app_new(
1365 + &self,
1366 + Parameters(args): Parameters<FrontendAppNewArgs>,
1367 + ) -> Result<CallToolResult, ErrorData> {
1368 + let token = self.access_token()?;
1369 + let runner: Runner = args.runner.unwrap_or(McpRunner::NodeJs).into();
1370 + let frontend_app = create_frontend_app(
1371 + self.environment,
1372 + client(),
1373 + token,
1374 + FrontendAppCreate {
1375 + name: args.name,
1376 + project_id: args.project_id,
1377 + app_type: AppType::Web,
1378 + runner,
1379 + deployment_method: crate::project::crud_create::deployment_method_for(runner),
1380 + repository: None,
1381 + description: None,
1382 + deploy_repo_id: None,
1383 + source_path: args.source_path,
1384 + },
1385 + )
1386 + .await
1387 + .map_err(to_error_data)?;
1388 + json_result(&frontend_app)
1389 + }
1390 +
1391 + #[tool(
1392 + description = "Create a deploy repo (the git repository or monorepo root) under a \
1393 + project. A single-app repo creates its app automatically; the response \
1394 + embeds it. Returns the repo as JSON."
1395 + )]
1396 + async fn deploy_repo_new(
1397 + &self,
1398 + Parameters(args): Parameters<DeployRepoNewArgs>,
1399 + ) -> Result<CallToolResult, ErrorData> {
1400 + let token = self.access_token()?;
1401 + let runner = match args.repo_kind {
1402 + McpRepoKind::Monorepo => Runner::Monorepo,
1403 + McpRepoKind::SingleApp => args.runner.unwrap_or(McpRunner::NodeJs).into(),
1404 + };
1405 + let deploy_repo = create_deploy_repo(
1406 + self.environment,
1407 + client(),
1408 + token,
1409 + DeployRepoCreate {
1410 + project_id: args.project_id,
1411 + name: args.repository.clone(),
1412 + repository: args.repository,
1413 + repo_kind: args.repo_kind.into(),
1414 + runner,
1415 + deployment_method: crate::project::crud_create::deployment_method_for(runner),
1416 + },
1417 + )
1418 + .await
1419 + .map_err(to_error_data)?;
1420 + json_result(&deploy_repo)
1421 + }
1422 +
1423 + #[tool(
1424 + description = "Show the server-side deploy config of a frontend app (runner, kind, \
1425 + paths, build commands), returned as JSON."
1426 + )]
1427 + async fn deploy_config_show(
1428 + &self,
1429 + Parameters(args): Parameters<DeployConfigShowArgs>,
1430 + ) -> Result<CallToolResult, ErrorData> {
1431 + let token = self.access_token()?;
1432 + let deploy_config =
1433 + get_deploy_config(self.environment, client(), token, &args.frontend_app_id)
1434 + .await
1435 + .map_err(to_error_data)?;
1436 + json_result(&deploy_config)
1437 + }
1438 +
1439 + #[tool(
1440 + description = "Update fields of a frontend app's server-side deploy config. Only the \
1441 + fields passed change. Returns the updated config as JSON."
1442 + )]
1443 + async fn deploy_config_update(
1444 + &self,
1445 + Parameters(args): Parameters<DeployConfigUpdateArgs>,
1446 + ) -> Result<CallToolResult, ErrorData> {
1447 + let token = self.access_token()?;
1448 + let frontend_app_id = args.frontend_app_id.clone();
1449 + let payload = DeployConfigUpdate::from(args);
1450 + if payload.is_empty() {
1451 + return Err(invalid_request("No deploy config fields to update."));
1452 + }
1453 + let deploy_config = update_deploy_config(
1454 + self.environment,
1455 + client(),
1456 + token,
1457 + &frontend_app_id,
1458 + &payload,
1459 + )
1460 + .await
1461 + .map_err(to_error_data)?;
1462 + json_result(&deploy_config)
1463 + }
1464 }
1465
1466 #[tool_handler(router = Self::cloud_tool_router())]
@@ -1200,9 +1559,42 @@ mod tests {
1559 for cloud_only in [
1560 "me",
1561 "project_list",
1562 + "project_show",
1563 + "project_use",
1564 + "project_create",
1565 + "project_update",
1566 + "project_delete",
1567 + "deployments",
1568 + "deployment_show",
1569 + "frontend_app_list",
1570 + "frontend_app_show",
1571 + "frontend_app_new",
1572 + "deploy_repo_new",
1573 + "deploy_config_show",
1574 + "deploy_config_update",
1575 "tenant_list",
1576 + "tenant_show",
1577 + "tenant_new",
1578 + "tenant_update",
1579 + "tenant_delete",
1580 + "tenant_use",
1581 "mail_list",
1582 + "mail_show",
1583 + "mail_new",
1584 + "mail_update",
1585 + "mail_delete",
1586 + "mail_inbox_new",
1587 + "mail_inbox_update",
1588 + "mail_inbox_delete",
1589 + "mail_inbox_test",
1590 + "mail_send",
1591 + "mail_message_list",
1592 + "mail_message_show",
1593 "auth_app_list",
1594 + "auth_app_show",
1595 + "auth_app_new",
1596 + "auth_app_update",
1597 + "auth_app_delete",
1598 ] {
1599 assert!(
1600 names.contains(&cloud_only),
@@ -1342,7 +1734,7 @@ mod tests {
1734 let tools = SmbMcpServer::cloud_tool_router().list_all();
1735 let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
1736
1345 - const AUTOMATION_TOOL_NAMES: [&str; 18] = [
1737 + const AUTOMATION_TOOL_NAMES: [&str; 19] = [
1738 "device_list",
1739 "device_select",
1740 "device_capabilities",
@@ -1353,6 +1745,7 @@ mod tests {
1745 "url_open",
1746 "ui_describe",
1747 "ui_element_list",
1748 + "ui_tap",
1749 "input_tap",
1750 "input_text",
1751 "input_swipe",
@@ -1370,4 +1763,16 @@ mod tests {
1763 );
1764 }
1765 }
1766 +
1767 + #[test]
1768 + fn delete_tools_carry_destructive_hint() {
1769 + let tools = SmbMcpServer::cloud_tool_router().list_all();
1770 + let mut checked = 0;
1771 + for tool in tools.iter().filter(|tool| tool.name.ends_with("_delete")) {
1772 + let annotations = tool.annotations.as_ref().expect("annotations");
1773 + assert_eq!(annotations.destructive_hint, Some(true), "{}", tool.name);
1774 + checked += 1;
1775 + }
1776 + assert_eq!(checked, 5);
1777 + }
1778 }
docs/mcp.md
+28 -48
@@ -5,7 +5,7 @@ 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 31
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.
10
11 Cross-platform mobile and TV app testing is exposed as a separate, focused MCP
@@ -128,6 +128,7 @@ whichever tenant is currently selected. Two tools manage that selection:
128 selected, `project_create` targets it instead of your personal tenant.
129 - **`project_use`** — selects a project for the session. Tools with an
130 optional `project_id` (Mail apps, Auth apps) default to it when omitted.
131 + Pass `frontend_app_id` to select one of the project's frontend apps too.
132
133 Both persist to `~/.smb/config.toml` (`~/.smb-dev/config.toml` in `dev`) — the
134 same file `smb tenant use` / `smb project use` write from the terminal. Switch
@@ -154,34 +155,27 @@ not a separate MCP-specific shape.
155 | --- | --- | --- |
156 | `project_list` | _(none)_ | The user's projects. |
157 | `project_show` | `id` | A single project by ID. |
157 -| `project_use` | `id` | The selected project. Persists the selection (see above). |
158 +| `project_use` | `id`, `frontend_app_id` (optional, must belong to the project) | The selected project. Persists the selection (see above). |
159 | `project_create` | `name`, `description` (optional) | The created project. Targets the selected tenant, if any. |
159 -| `project_update` | `id`, `description` | The updated project (runner preserved). |
160 +| `project_update` | `id`, `description`, `runner` (optional) | The updated project. The runner is kept when omitted. |
161 | `project_delete` | `id` | Confirmation. **Destructive and irreversible.** |
162 | `deployments` | `project_id` | A project's deployments. On MCP Apps hosts it also renders a dashboard (see below). |
163 +| `deployment_show` | `project_id`, `id` | A single deployment. |
164
163 -#### Interactive views (MCP Apps)
165 +### Frontend apps, deploy repos, and deploy config
166
165 -Three tools declare an [MCP Apps](https://modelcontextprotocol.io/extensions/apps) view.
166 -Hosts that support the extension, such as Claude and ChatGPT, render it in a sandboxed frame
167 -next to the result:
167 +A project holds a deploy repo (the git repository or monorepo root) and the
168 +frontend apps that ship from it. `runner` is one of `node_js`, `static`, `ruby`,
169 +`swift`, `rust`.
170
169 -- `deployments` → `ui://smbcloud/deployments.html`: a table of deployments with status, app,
170 - commit, and created/updated times, newest first, plus a **Refresh** button.
171 -- `mail_message_list` → `ui://smbcloud/inbox.html`: the inbox as a table (from, subject,
172 - preview, received, forward status). Click a row to open the message, which calls
173 - `mail_message_show`; **Back** returns to the list and **Refresh** reloads it. Only the
174 - plain-text body is shown, never `html_body`.
175 -
176 -- `auth_app_show` and `auth_app_new` → `ui://smbcloud/auth-app.html`: a setup card with the
177 - client ID (copy), the client secret (masked, with Reveal and Copy), the support email, and a
178 - Sign in with Apple checklist. The secret is only present when the API returns it.
179 -
180 -List views show the newest 50 rows. Other clients see the same JSON result as before. Each
181 -view is a single self-contained HTML document that makes no network requests; Refresh and
182 -drill-in go back through the host with a `tools/call`. Tool `_meta` carries the view URI
183 -under both `ui.resourceUri` and the legacy `ui/resourceUri` key, since older hosts read only
184 -the flat one.
171 +| Tool | Arguments | Returns |
172 +| --- | --- | --- |
173 +| `frontend_app_list` | `project_id` | The project's frontend apps. |
174 +| `frontend_app_show` | `id` | A single frontend app. |
175 +| `frontend_app_new` | `project_id`, `name`, `source_path` (optional), `runner` (optional, default `node_js`) | The created frontend app. |
176 +| `deploy_repo_new` | `project_id`, `repository`, `repo_kind` (`single_app` or `monorepo`), `runner` (optional) | The created repo. A single-app repo embeds the app it creates. |
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 ### Tenants
181
@@ -356,7 +350,7 @@ prefix.
350
351 The CLI's own `project delete`, `tenant delete`, and similar commands ask for
352 confirmation on a terminal — some require typing the resource's name back.
359 -**MCP tools skip that entirely.** MCP is a non-interactive protocol, so a
353 +**MCP tools skip that entirely.** Destructive tools carry the `destructiveHint` annotation so clients can prompt you, but the server itself does not ask. MCP is a non-interactive protocol, so a
354 `*_delete` tool call executes immediately the moment the client sends it. The
355 calling agent — and the person directing it — is responsible for confirming
356 intent before invoking a destructive tool, exactly as with any other
@@ -366,29 +360,15 @@ If you want a harder guardrail, keep destructive operations out of an agent's
360 reach at the client level (most MCP clients let you allow/deny individual
361 tools per server) rather than relying on the server to ask twice.
362
369 -## Not exposed as tools (yet)
370 -
371 -`deploy` and `migrate` are **deliberately not** MCP tools. Both are tightly
372 -coupled to the local working directory in ways a stdio tool call can't satisfy
373 -cleanly:
374 -
375 -- **`deploy`** reads `.smb/config.toml` (running interactive `setup_project`
376 - when it is missing), builds the app locally, then uploads over rsync and
377 - restarts over SSH. It depends on the caller's current directory, local build
378 - toolchains, and on-disk SSH keys, and it streams long-running progress — none
379 - of which map onto a single non-interactive tool call. For automated deploys,
380 - use the headless CLI path instead (`smb --ci deploy`, see
381 - [CI / non-interactive deploys](./ci.md)), which is what CI and the deploy
382 - action already drive.
383 -- **`migrate`** pushes local `.smb/config.toml` deploy fields up to the server,
384 - so it is meaningless without a project directory to read from.
385 -
386 -Exposing either as a tool would mean designing an explicit, directory-free
387 -contract — e.g. accepting the full deploy configuration as arguments and
388 -returning streamed progress as structured events — rather than wrapping the
389 -existing directory-coupled handlers. That's intentionally left for a later
390 -pass; the tools above cover the account, project, tenant, Mail, and Auth
391 -surface that's well-defined over stdio today.
363 +## Not exposed as tools
364 +
365 +These stay in the terminal on purpose:
366 +
367 +- **`login`, `signup`, `logout`, `account forgot`.** They take credentials or
368 + end the session, and a password should not pass through a chat. Tools that
369 + need a session tell you to run `smb login`.
370 +- **`control-kit`.** Device automation lives in its own profile
371 + (`--scope automation`, above).
372
373 ## Talking to it directly
374
@@ -432,7 +412,7 @@ Yes, and vice versa — `tenant_use`/`project_use` write to the same
412 `~/.smb/config.toml` the CLI itself reads, so the two stay in sync.
413
414 **Is the MCP server safe to point an autonomous agent at?**
435 -Every write tool — including every `*_delete` tool — executes immediately with
415 +Every write tool, including every `*_delete` tool, executes immediately with
416 no confirmation prompt, since MCP is non-interactive by design. Treat tool
417 access the way you'd treat handing someone your terminal with `smb` already
418 logged in: fine for a trusted agent you're directing, not something to expose