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