@smbcloud / cli / commits / 32476cb

Update MCP server and what not

Seto Elkahfi committed Jul 21, 2026 at 07:24 UTC 32476cb2adcc8f215f6adf4660043a94f6eabba7
29 files changed +1491 -234
.agents/AGENTS.md
+22
@@ -65,6 +65,28 @@ cargo test --workspace --exclude smbcloud-auth-sdk-wasm
65 cargo test -p smbcloud-model app_auth::tests::some_test # a single test
66 ```
67
68 +**Always run `cargo fmt --all` and the `cargo clippy` command above before considering any
69 +Rust change done** — after every edit, not just before a push. Fix clippy findings rather
70 +than silencing them with `#[allow(...)]` unless there's a specific reason the lint doesn't
71 +apply, and note that reason inline.
72 +
73 +`ci.yml` runs six jobs on every push; run the ones relevant to what changed, not just the
74 +main Rust workspace job — a change that only compiles in the main workspace but breaks one
75 +of these is still a broken CI run:
76 +
77 +| Job | Touches | Local check |
78 +|---|---|---|
79 +| Rust workspace | any `crates/*` change (except the wasm crate) | `cargo fmt --all -- --check && cargo clippy --workspace --exclude smbcloud-auth-sdk-wasm --tests -- -D warnings && cargo test --workspace --exclude smbcloud-auth-sdk-wasm` |
80 +| `smbcloud-auth-sdk-wasm` | that crate, or `smbcloud-auth-sdk` it wraps | `cargo check --package smbcloud-auth-sdk-wasm --target wasm32-unknown-unknown` |
81 +| Python SDK | `sdk/python`, or `smbcloud-auth-sdk` | `cd sdk/python && maturin build --release --locked --out dist` |
82 +| npm/WASM SDK | `sdk/npm/smbcloud-auth`, or `smbcloud-auth-sdk` | `cd sdk/npm/smbcloud-auth && node ./prepare-package.mjs` (needs `wasm-pack` installed) |
83 +| NuGet .NET tool | `nuget/smbcloud-cli` | `dotnet build nuget/smbcloud-cli/SmbCloud.Cli.csproj --configuration Release` |
84 +| Ruby gem | `sdk/gems/auth`, or `smbcloud-auth-sdk` | `cd sdk/gems/auth && bundle exec rake compile` |
85 +
86 +The last four jobs need their own toolchain (maturin/PyO3, wasm-pack + Node, the .NET SDK,
87 +Ruby/bundler) — if it isn't installed, say so explicitly rather than skipping the check
88 +silently, so the user knows it wasn't verified.
89 +
90 `smbcloud-auth-sdk-wasm` targets `wasm32-unknown-unknown` and cannot build or test on the host — it is always excluded from workspace clippy/test and checked separately: `cargo check -p smbcloud-auth-sdk-wasm --target wasm32-unknown-unknown`.
91
92 `CLI_CLIENT_SECRET` (see `.env.example`) is the OAuth client-credentials secret read at runtime; tests and `cargo check` don't need it, but login flows against a real API do.
README.MD
+1 -1
@@ -5,7 +5,7 @@
5 <br>smbCloud CLI
6 </h1>
7 <p align="center">
8 - Deploy with smbCloud from your terminal.
8 + smbCloud Deploy, Auth, Mail, and GresIQ interface from the terminal.
9 <br />
10 <br />
11 <a href="https://smbcloud.xyz/">Website</a>
crates/cli/src/cloud-deploy/process_migrate.rs
+1
@@ -178,6 +178,7 @@ fn build_payload(
178 fn strip_project(project: &Project) -> Project {
179 Project {
180 id: project.id,
181 + tenant_id: project.tenant_id,
182 name: project.name.clone(),
183 runner: project.runner,
184 deployment_method: DeploymentMethod::default(),
crates/cli/src/cloud-deploy/setup_create_new_project.rs
+2
@@ -111,11 +111,13 @@ pub(crate) async fn create_new_project(
111 }
112 };
113
114 + let tenant_id = crate::session_config::current_tenant_id(env).unwrap_or(None);
115 let project = create_project(
116 env,
117 client(),
118 access_token.clone(),
119 ProjectCreate { name, description },
120 + tenant_id,
121 )
122 .await?;
123
crates/cli/src/cloud-mail/current_project.rs
+1 -20
@@ -1,5 +1,4 @@
1 use anyhow::{anyhow, Result};
2 -use smbcloud_model::project::Config;
2 use smbcloud_network::environment::Environment;
3
4 pub(crate) fn resolve_optional_project_id(
@@ -8,7 +7,7 @@ pub(crate) fn resolve_optional_project_id(
7 ) -> Result<Option<String>> {
8 match project_id {
9 Some(project_id) => Ok(Some(project_id)),
11 - None => current_project_id(env),
10 + None => crate::session_config::current_project_id(env),
11 }
12 }
13
@@ -23,21 +22,3 @@ pub(crate) fn resolve_required_project_id(
22 )),
23 }
24 }
26 -
27 -fn current_project_id(env: Environment) -> Result<Option<String>> {
28 - let home_directory =
29 - home::home_dir().ok_or_else(|| anyhow!("Failed to resolve your home directory."))?;
30 - let config_path = home_directory.join(env.smb_dir()).join("config.json");
31 -
32 - if !config_path.exists() {
33 - return Ok(None);
34 - }
35 -
36 - let config_content = std::fs::read_to_string(&config_path)
37 - .map_err(|error| anyhow!("Failed to read `{}`: {}", config_path.display(), error))?;
38 -
39 - let config: Config = serde_json::from_str(&config_content)
40 - .map_err(|error| anyhow!("Failed to parse `{}`: {}", config_path.display(), error))?;
41 -
42 - Ok(config.current_project.map(|project| project.id.to_string()))
43 -}
crates/cli/src/lib.rs
+1
@@ -12,6 +12,7 @@ pub mod interface;
12 pub mod mail;
13 pub mod mcp;
14 pub mod project;
15 +mod session_config;
16 pub mod tenant;
17 mod token;
18 pub mod ui;
crates/cli/src/mcp.rs
+721 -23
@@ -8,9 +8,19 @@
8 //! structured JSON rather than rendering spinners or a TUI — the stdout stream
9 //! is the JSON-RPC channel and must stay free of console output. Logging still
10 //! goes to the on-disk log file (set up by `main`), never to stdout.
11 +//!
12 +//! Tools that select a tenant/project (`tenant_use`, `project_use`) persist
13 +//! that choice to the same `~/.smb[-dev]/config.toml` the CLI itself reads
14 +//! (see `crate::session_config`) — an MCP session and a terminal session
15 +//! share state.
16
17 use {
13 - crate::{account::lib::is_logged_in, client, token::get_smb_token::get_smb_token},
18 + crate::{
19 + account::lib::is_logged_in,
20 + client,
21 + mail::current_project::{resolve_optional_project_id, resolve_required_project_id},
22 + token::get_smb_token::get_smb_token,
23 + },
24 anyhow::{anyhow, Result},
25 rmcp::{
26 handler::server::{wrapper::Parameters, ServerHandler},
@@ -22,8 +32,21 @@ use {
32 schemars::JsonSchema,
33 serde::Deserialize,
34 smbcloud_auth::me::me,
25 - smbcloud_mail::mail_app::get_mail_apps,
26 - smbcloud_model::project::ProjectCreate,
35 + smbcloud_mail::{
36 + mail_app::{
37 + create_mail_app, delete_mail_app, get_mail_app, get_mail_apps, update_mail_app,
38 + },
39 + mail_inbox::{create_mail_inbox, delete_mail_inbox, send_test_email, update_mail_inbox},
40 + mail_message::{get_mail_message, get_mail_messages},
41 + },
42 + smbcloud_model::{
43 + app_auth::{AuthAppCreate, AuthAppUpdate},
44 + mail::{
45 + MailAppCreate, MailAppUpdate, MailInboxCreate, MailInboxUpdate, MailTestEmailRequest,
46 + },
47 + project::ProjectCreate,
48 + tenant::{TenantCreate, TenantUpdate},
49 + },
50 smbcloud_network::environment::Environment,
51 smbcloud_networking_project::{
52 crud_project_create::create_project,
@@ -54,7 +77,7 @@ impl SmbMcpServer {
77 None,
78 ));
79 }
57 - get_smb_token(self.environment).map_err(|e| ErrorData::internal_error(e.to_string(), None))
80 + get_smb_token(self.environment).map_err(to_error_data)
81 }
82 }
83
@@ -63,12 +86,32 @@ fn json_result<T: serde::Serialize>(value: &T) -> Result<CallToolResult, ErrorDa
86 Ok(CallToolResult::success(vec![ContentBlock::json(value)?]))
87 }
88
89 +fn text_result(message: impl Into<String>) -> Result<CallToolResult, ErrorData> {
90 + Ok(CallToolResult::success(vec![ContentBlock::text(
91 + message.into(),
92 + )]))
93 +}
94 +
95 +fn to_error_data(error: impl std::fmt::Display) -> ErrorData {
96 + ErrorData::internal_error(error.to_string(), None)
97 +}
98 +
99 +fn invalid_request(message: impl Into<String>) -> ErrorData {
100 + ErrorData::invalid_request(message.into(), None)
101 +}
102 +
103 #[derive(Debug, Deserialize, JsonSchema)]
104 struct ProjectShowArgs {
105 /// The project ID to show.
106 id: String,
107 }
108
109 +#[derive(Debug, Deserialize, JsonSchema)]
110 +struct ProjectUseArgs {
111 + /// The project ID to select for this session.
112 + id: String,
113 +}
114 +
115 #[derive(Debug, Deserialize, JsonSchema)]
116 struct DeploymentsArgs {
117 /// The project ID whose deployments to list.
@@ -98,6 +141,206 @@ struct ProjectDeleteArgs {
141 id: String,
142 }
143
144 +#[derive(Debug, Deserialize, JsonSchema)]
145 +struct TenantShowArgs {
146 + /// The tenant ID to show.
147 + id: String,
148 +}
149 +
150 +#[derive(Debug, Deserialize, JsonSchema)]
151 +struct TenantNewArgs {
152 + /// Name for the new organization tenant.
153 + name: String,
154 +}
155 +
156 +#[derive(Debug, Deserialize, JsonSchema)]
157 +struct TenantUpdateArgs {
158 + /// The tenant ID to rename.
159 + id: String,
160 + /// The new tenant name.
161 + name: String,
162 +}
163 +
164 +#[derive(Debug, Deserialize, JsonSchema)]
165 +struct TenantDeleteArgs {
166 + /// The tenant ID to delete. Must be an organization tenant — the
167 + /// personal tenant can't be deleted.
168 + id: String,
169 +}
170 +
171 +#[derive(Debug, Deserialize, JsonSchema)]
172 +struct TenantUseArgs {
173 + /// The tenant ID to select for this session. Project creation and other
174 + /// tenant-scoped operations default to this tenant instead of the
175 + /// personal one.
176 + id: String,
177 +}
178 +
179 +#[derive(Debug, Deserialize, JsonSchema)]
180 +struct MailAppListArgs {
181 + /// Restrict to mail apps under this project ID. Defaults to the
182 + /// currently selected project (`project_use`) if omitted; lists across
183 + /// all projects if none is selected.
184 + #[serde(default)]
185 + project_id: Option<String>,
186 +}
187 +
188 +#[derive(Debug, Deserialize, JsonSchema)]
189 +struct MailAppShowArgs {
190 + /// The mail app ID to show.
191 + id: String,
192 +}
193 +
194 +#[derive(Debug, Deserialize, JsonSchema)]
195 +struct MailAppNewArgs {
196 + /// Name for the new mail app.
197 + name: String,
198 + /// The domain the mail app will send/receive on.
199 + domain: String,
200 + /// Project ID to create the mail app under. Defaults to the currently
201 + /// selected project (`project_use`) if omitted.
202 + #[serde(default)]
203 + project_id: Option<String>,
204 + /// AWS region for SES provisioning. Defaults to the API's default region.
205 + #[serde(default)]
206 + aws_region: Option<String>,
207 +}
208 +
209 +#[derive(Debug, Deserialize, JsonSchema)]
210 +struct MailAppUpdateArgs {
211 + /// The mail app ID to update.
212 + id: String,
213 + #[serde(default)]
214 + name: Option<String>,
215 + #[serde(default)]
216 + domain: Option<String>,
217 + #[serde(default)]
218 + aws_region: Option<String>,
219 +}
220 +
221 +#[derive(Debug, Deserialize, JsonSchema)]
222 +struct MailAppDeleteArgs {
223 + /// The mail app ID to delete.
224 + id: String,
225 +}
226 +
227 +#[derive(Debug, Deserialize, JsonSchema)]
228 +struct MailInboxNewArgs {
229 + /// The mail app ID to create the inbox route under.
230 + app_id: String,
231 + /// The local part of the inbox address (before the `@`).
232 + local_part: String,
233 + /// The email address inbound mail is forwarded to.
234 + forward_to_email: String,
235 + /// Sender email used for outbound forwards. Defaults to the mail app's
236 + /// configured sender.
237 + #[serde(default)]
238 + sender_email: Option<String>,
239 +}
240 +
241 +#[derive(Debug, Deserialize, JsonSchema)]
242 +struct MailInboxUpdateArgs {
243 + /// The mail app ID the inbox route belongs to.
244 + app_id: String,
245 + /// The inbox route ID to update.
246 + id: String,
247 + #[serde(default)]
248 + local_part: Option<String>,
249 + #[serde(default)]
250 + forward_to_email: Option<String>,
251 + #[serde(default)]
252 + sender_email: Option<String>,
253 +}
254 +
255 +#[derive(Debug, Deserialize, JsonSchema)]
256 +struct MailInboxDeleteArgs {
257 + /// The mail app ID the inbox route belongs to.
258 + app_id: String,
259 + /// The inbox route ID to delete.
260 + id: String,
261 +}
262 +
263 +#[derive(Debug, Deserialize, JsonSchema)]
264 +struct MailInboxTestArgs {
265 + /// The mail app ID the inbox route belongs to.
266 + app_id: String,
267 + /// The inbox route ID to send a test email through.
268 + id: String,
269 + /// Recipient for the test email. Defaults to the inbox's forward target.
270 + #[serde(default)]
271 + recipient_email: Option<String>,
272 + #[serde(default)]
273 + subject: Option<String>,
274 + #[serde(default)]
275 + body: Option<String>,
276 +}
277 +
278 +#[derive(Debug, Deserialize, JsonSchema)]
279 +struct MailMessageListArgs {
280 + /// The mail app ID the inbox belongs to.
281 + app_id: String,
282 + /// The inbox route ID whose messages to list.
283 + inbox_id: String,
284 + /// Max number of messages to return, most recent first.
285 + #[serde(default = "default_message_limit")]
286 + limit: u32,
287 +}
288 +
289 +fn default_message_limit() -> u32 {
290 + 10
291 +}
292 +
293 +#[derive(Debug, Deserialize, JsonSchema)]
294 +struct MailMessageShowArgs {
295 + /// The mail app ID the inbox belongs to.
296 + app_id: String,
297 + /// The inbox route ID the message belongs to.
298 + inbox_id: String,
299 + /// The message ID to show.
300 + id: String,
301 +}
302 +
303 +#[derive(Debug, Deserialize, JsonSchema)]
304 +struct AuthAppListArgs {
305 + /// Restrict to Auth apps under this project ID.
306 + #[serde(default)]
307 + project_id: Option<String>,
308 +}
309 +
310 +#[derive(Debug, Deserialize, JsonSchema)]
311 +struct AuthAppShowArgs {
312 + /// The Auth app ID to show.
313 + id: String,
314 +}
315 +
316 +#[derive(Debug, Deserialize, JsonSchema)]
317 +struct AuthAppNewArgs {
318 + /// Name for the new Auth app.
319 + name: String,
320 + /// Project ID to create the Auth app under. Defaults to the currently
321 + /// selected project (`project_use`) if omitted.
322 + #[serde(default)]
323 + project_id: Option<String>,
324 + #[serde(default)]
325 + support_email: Option<String>,
326 +}
327 +
328 +#[derive(Debug, Deserialize, JsonSchema)]
329 +struct AuthAppUpdateArgs {
330 + /// The Auth app ID to update.
331 + id: String,
332 + #[serde(default)]
333 + name: Option<String>,
334 + #[serde(default)]
335 + support_email: Option<String>,
336 +}
337 +
338 +#[derive(Debug, Deserialize, JsonSchema)]
339 +struct AuthAppDeleteArgs {
340 + /// The Auth app ID to delete.
341 + id: String,
342 +}
343 +
344 #[tool_router]
345 impl SmbMcpServer {
346 #[tool(description = "Get the authenticated smbCloud user's account info. \
@@ -106,16 +349,18 @@ impl SmbMcpServer {
349 let token = self.access_token()?;
350 let user = me(self.environment, client(), &token)
351 .await
109 - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
352 + .map_err(to_error_data)?;
353 json_result(&user)
354 }
355
356 + // ── Projects ─────────────────────────────────────────────────────────
357 +
358 #[tool(description = "List the authenticated user's smbCloud projects as a JSON array.")]
359 async fn project_list(&self) -> Result<CallToolResult, ErrorData> {
360 let token = self.access_token()?;
361 let projects = get_projects(self.environment, client(), token)
362 .await
118 - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
363 + .map_err(to_error_data)?;
364 json_result(&projects)
365 }
366
@@ -127,7 +372,27 @@ impl SmbMcpServer {
372 let token = self.access_token()?;
373 let project = get_project(self.environment, client(), token, args.id)
374 .await
130 - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
375 + .map_err(to_error_data)?;
376 + json_result(&project)
377 + }
378 +
379 + #[tool(
380 + description = "Select a project for this session, persisted to the same config \
381 + CLI sessions read. Mail/Auth tools that take an optional project_id \
382 + default to this project when omitted. Returns the selected project as JSON."
383 + )]
384 + async fn project_use(
385 + &self,
386 + Parameters(args): Parameters<ProjectUseArgs>,
387 + ) -> Result<CallToolResult, ErrorData> {
388 + let token = self.access_token()?;
389 + let project = get_project(self.environment, client(), token, args.id)
390 + .await
391 + .map_err(to_error_data)?;
392 + // MCP tools are non-interactive, so unlike `smb project use` we don't
393 + // try to resolve/select a frontend app here.
394 + crate::session_config::set_current_project(self.environment, project.clone(), None)
395 + .map_err(to_error_data)?;
396 json_result(&project)
397 }
398
@@ -139,13 +404,15 @@ impl SmbMcpServer {
404 let token = self.access_token()?;
405 let deployments = get_deployments(self.environment, client(), token, args.project_id)
406 .await
142 - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
407 + .map_err(to_error_data)?;
408 json_result(&deployments)
409 }
410
411 #[tool(
412 description = "Create a new smbCloud project with a name and optional description. \
148 - Returns the created project as JSON."
413 + Created under the currently selected tenant (`tenant_use`), or the \
414 + user's personal tenant if none is selected. Returns the created \
415 + project as JSON."
416 )]
417 async fn project_create(
418 &self,
@@ -156,15 +423,16 @@ impl SmbMcpServer {
423 name: args.name,
424 description: args.description,
425 };
159 - let project = create_project(self.environment, client(), token, payload)
426 + let tenant_id = crate::session_config::current_tenant_id(self.environment).unwrap_or(None);
427 + let project = create_project(self.environment, client(), token, payload, tenant_id)
428 .await
161 - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
429 + .map_err(to_error_data)?;
430 json_result(&project)
431 }
432
433 #[tool(
434 description = "Update a project's description by ID, preserving its runner. \
167 - Returns the updated project as JSON."
435 + Returns the updated project as JSON."
436 )]
437 async fn project_update(
438 &self,
@@ -175,7 +443,7 @@ impl SmbMcpServer {
443 // description-only update doesn't clobber it.
444 let current = get_project(self.environment, client(), token.clone(), args.id.clone())
445 .await
178 - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
446 + .map_err(to_error_data)?;
447 let project = update_project(
448 self.environment,
449 client(),
@@ -185,7 +453,7 @@ impl SmbMcpServer {
453 current.runner,
454 )
455 .await
188 - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
456 + .map_err(to_error_data)?;
457 json_result(&project)
458 }
459
@@ -200,19 +468,447 @@ impl SmbMcpServer {
468 let token = self.access_token()?;
469 delete_project(self.environment, client(), token, args.id)
470 .await
203 - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
204 - Ok(CallToolResult::success(vec![ContentBlock::text(
205 - "Project deleted.",
206 - )]))
471 + .map_err(to_error_data)?;
472 + text_result("Project deleted.")
473 + }
474 +
475 + // ── Tenants ──────────────────────────────────────────────────────────
476 +
477 + #[tool(
478 + description = "List the authenticated user's smbCloud tenants (workspaces) as a JSON array."
479 + )]
480 + async fn tenant_list(&self) -> Result<CallToolResult, ErrorData> {
481 + let token = self.access_token()?;
482 + let tenants = crate::tenant::tenant_client::get_tenants(self.environment, client(), token)
483 + .await
484 + .map_err(to_error_data)?;
485 + json_result(&tenants)
486 + }
487 +
488 + #[tool(description = "Show a single smbCloud tenant by ID, returned as JSON.")]
489 + async fn tenant_show(
490 + &self,
491 + Parameters(args): Parameters<TenantShowArgs>,
492 + ) -> Result<CallToolResult, ErrorData> {
493 + let token = self.access_token()?;
494 + let tenant =
495 + crate::tenant::tenant_client::get_tenant(self.environment, client(), token, args.id)
496 + .await
497 + .map_err(to_error_data)?;
498 + json_result(&tenant)
499 + }
500 +
501 + #[tool(
502 + description = "Create a new organization tenant (workspace). Personal tenants are \
503 + bootstrapped on signup and can't be created here. Returns the created \
504 + tenant as JSON."
505 + )]
506 + async fn tenant_new(
507 + &self,
508 + Parameters(args): Parameters<TenantNewArgs>,
509 + ) -> Result<CallToolResult, ErrorData> {
510 + let token = self.access_token()?;
511 + let tenant = crate::tenant::tenant_client::create_tenant(
512 + self.environment,
513 + client(),
514 + token,
515 + TenantCreate { name: args.name },
516 + )
517 + .await
518 + .map_err(to_error_data)?;
519 + json_result(&tenant)
520 }
521
522 + #[tool(description = "Rename a tenant by ID. Returns the updated tenant as JSON.")]
523 + async fn tenant_update(
524 + &self,
525 + Parameters(args): Parameters<TenantUpdateArgs>,
526 + ) -> Result<CallToolResult, ErrorData> {
527 + let token = self.access_token()?;
528 + let tenant = crate::tenant::tenant_client::update_tenant(
529 + self.environment,
530 + client(),
531 + token,
532 + args.id,
533 + TenantUpdate {
534 + name: Some(args.name),
535 + },
536 + )
537 + .await
538 + .map_err(to_error_data)?;
539 + json_result(&tenant)
540 + }
541 +
542 + #[tool(
543 + description = "Delete an organization tenant by ID. This is destructive and \
544 + irreversible — it cascades to every project, Auth app, and Mail \
545 + app the tenant owns. The personal tenant can't be deleted."
546 + )]
547 + async fn tenant_delete(
548 + &self,
549 + Parameters(args): Parameters<TenantDeleteArgs>,
550 + ) -> Result<CallToolResult, ErrorData> {
551 + let token = self.access_token()?;
552 + crate::tenant::tenant_client::delete_tenant(self.environment, client(), token, args.id)
553 + .await
554 + .map_err(to_error_data)?;
555 + text_result("Tenant deleted.")
556 + }
557 +
558 + #[tool(
559 + description = "Select a tenant for this session, persisted to the same config CLI \
560 + sessions read. `project_create` targets this tenant instead of the \
561 + personal one while it's selected. Clears the selected project if it \
562 + belongs to a different tenant. Returns the selected tenant as JSON."
563 + )]
564 + async fn tenant_use(
565 + &self,
566 + Parameters(args): Parameters<TenantUseArgs>,
567 + ) -> Result<CallToolResult, ErrorData> {
568 + let token = self.access_token()?;
569 + let tenant =
570 + crate::tenant::tenant_client::get_tenant(self.environment, client(), token, args.id)
571 + .await
572 + .map_err(to_error_data)?;
573 + crate::session_config::set_current_tenant(self.environment, tenant.clone())
574 + .map_err(to_error_data)?;
575 + json_result(&tenant)
576 + }
577 +
578 + // ── Mail apps ────────────────────────────────────────────────────────
579 +
580 #[tool(description = "List the authenticated user's smbCloud Mail apps as a JSON array.")]
210 - async fn mail_list(&self) -> Result<CallToolResult, ErrorData> {
581 + async fn mail_list(
582 + &self,
583 + Parameters(args): Parameters<MailAppListArgs>,
584 + ) -> Result<CallToolResult, ErrorData> {
585 let token = self.access_token()?;
212 - let projects = get_mail_apps(self.environment, client(), token)
586 + let project_id = resolve_optional_project_id(self.environment, args.project_id)
587 + .map_err(to_error_data)?;
588 + let mail_apps = get_mail_apps(self.environment, client(), token, project_id)
589 .await
214 - .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
215 - json_result(&projects)
590 + .map_err(to_error_data)?;
591 + json_result(&mail_apps)
592 + }
593 +
594 + #[tool(description = "Show a single Mail app by ID, returned as JSON.")]
595 + async fn mail_show(
596 + &self,
597 + Parameters(args): Parameters<MailAppShowArgs>,
598 + ) -> Result<CallToolResult, ErrorData> {
599 + let token = self.access_token()?;
600 + let mail_app = get_mail_app(self.environment, client(), token, args.id)
601 + .await
602 + .map_err(to_error_data)?;
603 + json_result(&mail_app)
604 + }
605 +
606 + #[tool(
607 + description = "Create a Mail app for a domain under a project. Defaults to the \
608 + currently selected project (`project_use`) if project_id is omitted. \
609 + Returns the created Mail app as JSON."
610 + )]
611 + async fn mail_new(
612 + &self,
613 + Parameters(args): Parameters<MailAppNewArgs>,
614 + ) -> Result<CallToolResult, ErrorData> {
615 + let token = self.access_token()?;
616 + let project_id = resolve_required_project_id(self.environment, args.project_id)
617 + .map_err(to_error_data)?;
618 + let mail_app = create_mail_app(
619 + self.environment,
620 + client(),
621 + token,
622 + MailAppCreate {
623 + name: args.name,
624 + project_id,
625 + domain: args.domain,
626 + aws_region: args.aws_region,
627 + },
628 + )
629 + .await
630 + .map_err(to_error_data)?;
631 + json_result(&mail_app)
632 + }
633 +
634 + #[tool(
635 + description = "Update a Mail app's name, domain, and/or AWS region by ID. Returns the updated Mail app as JSON."
636 + )]
637 + async fn mail_update(
638 + &self,
639 + Parameters(args): Parameters<MailAppUpdateArgs>,
640 + ) -> Result<CallToolResult, ErrorData> {
641 + let token = self.access_token()?;
642 + let update = MailAppUpdate {
643 + name: args.name,
644 + domain: args.domain,
645 + aws_region: args.aws_region,
646 + };
647 + if update.is_empty() {
648 + return Err(invalid_request(
649 + "Specify at least one of name, domain, or aws_region.",
650 + ));
651 + }
652 + let mail_app = update_mail_app(self.environment, client(), token, args.id, update)
653 + .await
654 + .map_err(to_error_data)?;
655 + json_result(&mail_app)
656 + }
657 +
658 + #[tool(
659 + description = "Delete a Mail app by ID. This is destructive and irreversible — its \
660 + inbox routes and SES routing are removed."
661 + )]
662 + async fn mail_delete(
663 + &self,
664 + Parameters(args): Parameters<MailAppDeleteArgs>,
665 + ) -> Result<CallToolResult, ErrorData> {
666 + let token = self.access_token()?;
667 + delete_mail_app(self.environment, client(), token, args.id)
668 + .await
669 + .map_err(to_error_data)?;
670 + text_result("Mail app deleted.")
671 + }
672 +
673 + // ── Mail inboxes ─────────────────────────────────────────────────────
674 +
675 + #[tool(
676 + description = "Create a mail inbox route under a Mail app. Returns the created inbox route as JSON."
677 + )]
678 + async fn mail_inbox_new(
679 + &self,
680 + Parameters(args): Parameters<MailInboxNewArgs>,
681 + ) -> Result<CallToolResult, ErrorData> {
682 + let token = self.access_token()?;
683 + let inbox = create_mail_inbox(
684 + self.environment,
685 + client(),
686 + token,
687 + args.app_id,
688 + MailInboxCreate {
689 + local_part: args.local_part,
690 + forward_to_email: args.forward_to_email,
691 + sender_email: args.sender_email,
692 + },
693 + )
694 + .await
695 + .map_err(to_error_data)?;
696 + json_result(&inbox)
697 + }
698 +
699 + #[tool(
700 + description = "Update a mail inbox route's local part, forward target, and/or sender email. Returns the updated inbox route as JSON."
701 + )]
702 + async fn mail_inbox_update(
703 + &self,
704 + Parameters(args): Parameters<MailInboxUpdateArgs>,
705 + ) -> Result<CallToolResult, ErrorData> {
706 + let token = self.access_token()?;
707 + let update = MailInboxUpdate {
708 + local_part: args.local_part,
709 + forward_to_email: args.forward_to_email,
710 + sender_email: args.sender_email,
711 + };
712 + if update.is_empty() {
713 + return Err(invalid_request(
714 + "Specify at least one of local_part, forward_to_email, or sender_email.",
715 + ));
716 + }
717 + let inbox = update_mail_inbox(
718 + self.environment,
719 + client(),
720 + token,
721 + args.app_id,
722 + args.id,
723 + update,
724 + )
725 + .await
726 + .map_err(to_error_data)?;
727 + json_result(&inbox)
728 + }
729 +
730 + #[tool(description = "Delete a mail inbox route. This is destructive and irreversible.")]
731 + async fn mail_inbox_delete(
732 + &self,
733 + Parameters(args): Parameters<MailInboxDeleteArgs>,
734 + ) -> Result<CallToolResult, ErrorData> {
735 + let token = self.access_token()?;
736 + delete_mail_inbox(self.environment, client(), token, args.app_id, args.id)
737 + .await
738 + .map_err(to_error_data)?;
739 + text_result("Mail inbox route deleted.")
740 + }
741 +
742 + #[tool(
743 + description = "Send a test email through a mail inbox route to verify forwarding. Returns the delivery result as JSON."
744 + )]
745 + async fn mail_inbox_test(
746 + &self,
747 + Parameters(args): Parameters<MailInboxTestArgs>,
748 + ) -> Result<CallToolResult, ErrorData> {
749 + let token = self.access_token()?;
750 + let delivery = send_test_email(
751 + self.environment,
752 + client(),
753 + token,
754 + args.app_id,
755 + args.id,
756 + MailTestEmailRequest {
757 + recipient_email: args.recipient_email,
758 + subject: args.subject,
759 + body: args.body,
760 + },
761 + )
762 + .await
763 + .map_err(to_error_data)?;
764 + json_result(&delivery)
765 + }
766 +
767 + // ── Mail messages ────────────────────────────────────────────────────
768 +
769 + #[tool(
770 + description = "List inbound mail messages for an inbox, most recent first, as a JSON array."
771 + )]
772 + async fn mail_message_list(
773 + &self,
774 + Parameters(args): Parameters<MailMessageListArgs>,
775 + ) -> Result<CallToolResult, ErrorData> {
776 + let token = self.access_token()?;
777 + let messages = get_mail_messages(
778 + self.environment,
779 + client(),
780 + token,
781 + args.app_id,
782 + args.inbox_id,
783 + Some(args.limit),
784 + )
785 + .await
786 + .map_err(to_error_data)?;
787 + json_result(&messages)
788 + }
789 +
790 + #[tool(description = "Show a single inbound mail message, returned as JSON.")]
791 + async fn mail_message_show(
792 + &self,
793 + Parameters(args): Parameters<MailMessageShowArgs>,
794 + ) -> Result<CallToolResult, ErrorData> {
795 + let token = self.access_token()?;
796 + let message = get_mail_message(
797 + self.environment,
798 + client(),
799 + token,
800 + args.app_id,
801 + args.inbox_id,
802 + args.id,
803 + )
804 + .await
805 + .map_err(to_error_data)?;
806 + json_result(&message)
807 + }
808 +
809 + // ── Auth apps ────────────────────────────────────────────────────────
810 +
811 + #[tool(description = "List the authenticated user's smbCloud Auth apps as a JSON array.")]
812 + async fn auth_app_list(
813 + &self,
814 + Parameters(args): Parameters<AuthAppListArgs>,
815 + ) -> Result<CallToolResult, ErrorData> {
816 + let token = self.access_token()?;
817 + let project_id = resolve_optional_project_id(self.environment, args.project_id)
818 + .map_err(to_error_data)?;
819 + let auth_apps = crate::cloud_auth::auth_app::get_auth_apps(
820 + self.environment,
821 + client(),
822 + token,
823 + project_id,
824 + )
825 + .await
826 + .map_err(to_error_data)?;
827 + json_result(&auth_apps)
828 + }
829 +
830 + #[tool(description = "Show a single Auth app by ID, returned as JSON.")]
831 + async fn auth_app_show(
832 + &self,
833 + Parameters(args): Parameters<AuthAppShowArgs>,
834 + ) -> Result<CallToolResult, ErrorData> {
835 + let token = self.access_token()?;
836 + let auth_app =
837 + crate::cloud_auth::auth_app::get_auth_app(self.environment, client(), token, args.id)
838 + .await
839 + .map_err(to_error_data)?;
840 + json_result(&auth_app)
841 + }
842 +
843 + #[tool(
844 + description = "Create an Auth app under a project. Defaults to the currently \
845 + selected project (`project_use`) if project_id is omitted. Returns \
846 + the created Auth app as JSON."
847 + )]
848 + async fn auth_app_new(
849 + &self,
850 + Parameters(args): Parameters<AuthAppNewArgs>,
851 + ) -> Result<CallToolResult, ErrorData> {
852 + let token = self.access_token()?;
853 + let project_id = resolve_required_project_id(self.environment, args.project_id)
854 + .map_err(to_error_data)?;
855 + let auth_app = crate::cloud_auth::auth_app::create_auth_app(
856 + self.environment,
857 + client(),
858 + token,
859 + AuthAppCreate {
860 + name: args.name,
861 + project_id,
862 + support_email: args.support_email,
863 + },
864 + )
865 + .await
866 + .map_err(to_error_data)?;
867 + json_result(&auth_app)
868 + }
869 +
870 + #[tool(
871 + description = "Update an Auth app's name and/or support email by ID. Returns the updated Auth app as JSON."
872 + )]
873 + async fn auth_app_update(
874 + &self,
875 + Parameters(args): Parameters<AuthAppUpdateArgs>,
876 + ) -> Result<CallToolResult, ErrorData> {
877 + let token = self.access_token()?;
878 + let update = AuthAppUpdate {
879 + name: args.name,
880 + support_email: args.support_email,
881 + };
882 + if update.is_empty() {
883 + return Err(invalid_request(
884 + "Specify at least one of name or support_email.",
885 + ));
886 + }
887 + let auth_app = crate::cloud_auth::auth_app::update_auth_app(
888 + self.environment,
889 + client(),
890 + token,
891 + args.id,
892 + update,
893 + )
894 + .await
895 + .map_err(to_error_data)?;
896 + json_result(&auth_app)
897 + }
898 +
899 + #[tool(
900 + description = "Delete an Auth app by ID. This is destructive and irreversible — its \
901 + OAuth clients and configuration are removed."
902 + )]
903 + async fn auth_app_delete(
904 + &self,
905 + Parameters(args): Parameters<AuthAppDeleteArgs>,
906 + ) -> Result<CallToolResult, ErrorData> {
907 + let token = self.access_token()?;
908 + crate::cloud_auth::auth_app::delete_auth_app(self.environment, client(), token, args.id)
909 + .await
910 + .map_err(to_error_data)?;
911 + text_result("Auth app deleted.")
912 }
913 }
914
@@ -229,7 +925,9 @@ impl ServerHandler for SmbMcpServer {
925 .with_server_info(server_info)
926 .with_instructions(
927 "smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \
232 - `smb login`; tools run non-interactively.",
928 + `smb login`; tools run non-interactively. `tenant_use` / `project_use` select \
929 + the tenant/project context other tools default to, shared with the CLI's own \
930 + `smb tenant use` / `smb project use`.",
931 )
932 }
933 }
crates/cli/src/project/crud_create.rs
+2
@@ -137,6 +137,7 @@ pub async fn process_project_init(
137 );
138
139 let access_token = get_smb_token(env)?;
140 + let tenant_id = crate::session_config::current_tenant_id(env).unwrap_or(None);
141 let project = match create_project(
142 env,
143 client(),
@@ -145,6 +146,7 @@ pub async fn process_project_init(
146 name: project_name.clone(),
147 description: description.clone(),
148 },
149 + tenant_id,
150 )
151 .await
152 {
crates/cli/src/project/crud_delete.rs
+3 -1
@@ -3,7 +3,9 @@ use crate::token::get_smb_token::get_smb_token;
3 use crate::{
4 account::lib::is_logged_in,
5 cli::CommandResult,
6 - ui::{fail_message, fail_symbol, prompt::confirm_delete_typed, succeed_message, succeed_symbol},
6 + ui::{
7 + fail_message, fail_symbol, prompt::confirm_delete_typed, succeed_message, succeed_symbol,
8 + },
9 };
10 use anyhow::{anyhow, Result};
11 use smbcloud_network::environment::Environment;
crates/cli/src/project/crud_read.rs
+7 -33
@@ -1,5 +1,3 @@
1 -use std::{fs::OpenOptions, io::Write};
2 -
1 use crate::client;
2 use crate::token::get_smb_token::get_smb_token;
3 use crate::{
@@ -12,8 +10,7 @@ use crate::{
10 },
11 };
12 use anyhow::{anyhow, Result};
15 -use log::debug;
16 -use smbcloud_model::project::{Config, Project};
13 +use smbcloud_model::project::Project;
14 use smbcloud_network::environment::Environment;
15 use smbcloud_networking_project::crud_project_read::{get_project, get_projects};
16 use spinners::Spinner;
@@ -104,38 +101,15 @@ pub(crate) async fn process_project_use(env: Environment, id: String) -> Result<
101 let project = get_project(env, client(), access_token.clone(), id).await?;
102 let frontend_app = resolve_frontend_app_for_project(env, &access_token, &project, true).await?;
103
107 - let config = Config {
108 - current_project: Some(project),
109 - current_frontend_app: frontend_app,
110 - current_auth_app: None,
111 - };
112 -
104 let spinner = Spinner::new(
105 spinners::Spinners::SimpleDotsScrolling,
106 succeed_message("Loading"),
107 );
117 - match home::home_dir() {
118 - Some(path) => {
119 - let config_directory = path.join(env.smb_dir());
120 - std::fs::create_dir_all(&config_directory)?;
121 - debug!("{}", config_directory.display());
122 - let mut file = OpenOptions::new()
123 - .create(true)
124 - .truncate(true)
125 - .write(true)
126 - .open(config_directory.join("config.json"))?;
127 - let json = serde_json::to_string(&config)?;
128 - file.write_all(json.as_bytes())?;
108 + crate::session_config::set_current_project(env, project, frontend_app)?;
109
130 - Ok(CommandResult {
131 - spinner,
132 - symbol: succeed_symbol(),
133 - msg: succeed_message("Use project successful."),
134 - })
135 - }
136 - None => {
137 - let error = anyhow!("Failed to get home directory.");
138 - Err(error)
139 - }
140 - }
110 + Ok(CommandResult {
111 + spinner,
112 + symbol: succeed_symbol(),
113 + msg: succeed_message("Use project successful."),
114 + })
115 }
crates/cli/src/session_config.rs new
+134
@@ -0,0 +1,134 @@
1 +//! Session-level CLI state: the tenant and project selected via `smb tenant
2 +//! use` / `smb project use`, persisted to `~/.smb[-dev]/config.toml`.
3 +//!
4 +//! This is distinct from the project-local `.smb/config.toml` written by
5 +//! `smb project new` / `smb init` (deploy target config for the current
6 +//! working directory, handled by `smbcloud_utils::write_config`) — this file
7 +//! lives in the home directory and tracks which tenant/project the CLI
8 +//! session is currently operating against, independent of cwd.
9 +
10 +use anyhow::{anyhow, Result};
11 +use smbcloud_model::{
12 + frontend_app::FrontendApp, project::Config, project::Project, tenant::Tenant,
13 +};
14 +use smbcloud_network::environment::Environment;
15 +use std::path::PathBuf;
16 +
17 +fn config_path(env: Environment) -> Result<PathBuf> {
18 + let home_directory =
19 + home::home_dir().ok_or_else(|| anyhow!("Failed to resolve your home directory."))?;
20 + Ok(home_directory.join(env.smb_dir()).join("config.toml"))
21 +}
22 +
23 +pub fn read(env: Environment) -> Result<Config> {
24 + let path = config_path(env)?;
25 + if !path.exists() {
26 + return Ok(Config::default());
27 + }
28 +
29 + let content = std::fs::read_to_string(&path)
30 + .map_err(|error| anyhow!("Failed to read `{}`: {}", path.display(), error))?;
31 + toml::from_str(&content)
32 + .map_err(|error| anyhow!("Failed to parse `{}`: {}", path.display(), error))
33 +}
34 +
35 +pub fn write(env: Environment, config: &Config) -> Result<()> {
36 + let path = config_path(env)?;
37 + if let Some(parent) = path.parent() {
38 + std::fs::create_dir_all(parent)?;
39 + }
40 +
41 + let content = toml::to_string(config)?;
42 + std::fs::write(&path, content)
43 + .map_err(|error| anyhow!("Failed to write `{}`: {}", path.display(), error))?;
44 + Ok(())
45 +}
46 +
47 +pub fn current_project_id(env: Environment) -> Result<Option<String>> {
48 + Ok(read(env)?
49 + .current_project
50 + .map(|project| project.id.to_string()))
51 +}
52 +
53 +pub fn current_tenant_id(env: Environment) -> Result<Option<String>> {
54 + Ok(read(env)?
55 + .current_tenant
56 + .map(|tenant| tenant.id.to_string()))
57 +}
58 +
59 +/// Selects a project for the session. Clears any previously selected
60 +/// frontend app — it belonged to the old project and no longer applies.
61 +pub fn set_current_project(
62 + env: Environment,
63 + project: Project,
64 + frontend_app: Option<FrontendApp>,
65 +) -> Result<Config> {
66 + let mut config = read(env)?;
67 + config.current_project = Some(project);
68 + config.current_frontend_app = frontend_app;
69 + write(env, &config)?;
70 + Ok(config)
71 +}
72 +
73 +/// Selects a tenant for the session. A previously selected project that
74 +/// belongs to a different tenant is no longer in scope, so it (and its
75 +/// frontend app) is cleared along with it.
76 +pub fn set_current_tenant(env: Environment, tenant: Tenant) -> Result<Config> {
77 + let mut config = read(env)?;
78 +
79 + let project_still_in_scope = config
80 + .current_project
81 + .as_ref()
82 + .and_then(|project| project.tenant_id)
83 + == Some(tenant.id);
84 + if !project_still_in_scope {
85 + config.current_project = None;
86 + config.current_frontend_app = None;
87 + }
88 +
89 + config.current_tenant = Some(tenant);
90 + write(env, &config)?;
91 + Ok(config)
92 +}
93 +
94 +#[cfg(test)]
95 +mod tests {
96 + use super::*;
97 + use chrono::Utc;
98 + use smbcloud_model::tenant::{TenantKind, TenantRole};
99 +
100 + // Regression test: the `toml` crate's deserializer chokes on a
101 + // `deserialize_with` function that calls `String::deserialize` on a
102 + // value shaped like a date/time (e.g. the `ar_date_format` helper used
103 + // elsewhere in this crate for ActiveRecord-style JSON timestamps) —
104 + // "input contains invalid characters" — even though the same value
105 + // round-trips fine as a plain `DateTime<Utc>` field. `Tenant` (embedded
106 + // in `Config`, which this module serializes to TOML) must keep using
107 + // plain chrono serde for `created_at`, not a custom string format.
108 + #[test]
109 + fn config_with_tenant_round_trips_through_toml() {
110 + let tenant = Tenant {
111 + id: 1,
112 + name: "Acme".to_string(),
113 + slug: "acme".to_string(),
114 + kind: TenantKind::Organization,
115 + role: TenantRole::Owner,
116 + projects_count: 1,
117 + default_project: None,
118 + current: false,
119 + created_at: Utc::now(),
120 + };
121 + let config = Config {
122 + current_tenant: Some(tenant),
123 + ..Default::default()
124 + };
125 +
126 + let serialized = toml::to_string(&config).expect("serialize");
127 + let deserialized: Config = toml::from_str(&serialized).expect("deserialize");
128 +
129 + assert_eq!(
130 + deserialized.current_tenant.map(|t| t.id),
131 + config.current_tenant.map(|t| t.id)
132 + );
133 + }
134 +}
crates/cli/src/tenant/cli.rs
+5
@@ -26,4 +26,9 @@ pub enum Commands {
26 #[clap(short, long, required = true)]
27 id: String,
28 },
29 + #[clap(about = "Select a tenant for the current CLI session.")]
30 + Use {
31 + #[clap(short, long, required = true)]
32 + id: String,
33 + },
34 }
crates/cli/src/tenant/mod.rs
+1 -1
@@ -2,5 +2,5 @@ pub mod cli;
2 pub mod process;
3 mod render;
4 mod request;
5 -mod tenant_client;
5 +pub(crate) mod tenant_client;
6 mod url_builder;
crates/cli/src/tenant/process.rs
+40 -2
@@ -7,7 +7,9 @@ use crate::{
7 tenant_client::{create_tenant, delete_tenant, get_tenant, get_tenants, update_tenant},
8 },
9 token::get_smb_token::get_smb_token,
10 - ui::{fail_message, fail_symbol, prompt::confirm_delete_double, succeed_message, succeed_symbol},
10 + ui::{
11 + fail_message, fail_symbol, prompt::confirm_delete_double, succeed_message, succeed_symbol,
12 + },
13 };
14 use anyhow::{anyhow, Result};
15 use smbcloud_model::tenant::{TenantCreate, TenantKind, TenantUpdate};
@@ -21,6 +23,7 @@ pub async fn process_tenant(env: Environment, commands: Commands) -> Result<Comm
23 Commands::New { name } => process_tenant_new(env, name).await,
24 Commands::Update { id, name } => process_tenant_update(env, id, name).await,
25 Commands::Delete { id } => process_tenant_delete(env, id).await,
26 + Commands::Use { id } => process_tenant_use(env, id).await,
27 }
28 }
29
@@ -74,7 +77,11 @@ async fn process_tenant_new(env: Environment, name: String) -> Result<CommandRes
77 Ok(done_result("Tenant created."))
78 }
79
77 -async fn process_tenant_update(env: Environment, id: String, name: String) -> Result<CommandResult> {
80 +async fn process_tenant_update(
81 + env: Environment,
82 + id: String,
83 + name: String,
84 +) -> Result<CommandResult> {
85 let access_token = get_smb_token(env)?;
86 let tenant_id = normalize_required("Tenant id", id)?;
87 let update = TenantUpdate {
@@ -142,6 +149,37 @@ async fn process_tenant_delete(env: Environment, id: String) -> Result<CommandRe
149 }
150 }
151
152 +async fn process_tenant_use(env: Environment, id: String) -> Result<CommandResult> {
153 + let access_token = get_smb_token(env)?;
154 + let tenant_id = normalize_required("Tenant id", id)?;
155 + let mut spinner = loading_spinner("Loading tenant");
156 +
157 + let tenant = get_tenant(env, client(), access_token, tenant_id)
158 + .await
159 + .map_err(api_error)?;
160 +
161 + spinner.stop_and_persist(&succeed_symbol(), succeed_message("Loaded."));
162 + let name = tenant.name.clone();
163 + let tenant_id = tenant.id;
164 + let had_project_in_scope = crate::session_config::current_project_id(env)
165 + .ok()
166 + .flatten()
167 + .is_some();
168 + let updated = crate::session_config::set_current_tenant(env, tenant)?;
169 + if had_project_in_scope && updated.current_project.is_none() {
170 + println!(
171 + "{}",
172 + crate::ui::description(
173 + "The previously selected project belonged to a different tenant, so it was cleared — run `smb project use --id <project-id>` to pick one in this tenant."
174 + )
175 + );
176 + }
177 +
178 + Ok(done_result(&format!(
179 + "Now using tenant \"{name}\" (#{tenant_id})."
180 + )))
181 +}
182 +
183 fn loading_spinner(message: &str) -> Spinner {
184 Spinner::new(
185 spinners::Spinners::SimpleDotsScrolling,
crates/cli/src/ui/prompt.rs
+1 -4
@@ -48,10 +48,7 @@ pub fn confirm_delete(what: &str, message: &str) -> Result<bool> {
48
49 fn print_danger_warning(warning: &str) {
50 println!();
51 - println!(
52 - "{}",
53 - style("⚠ This action cannot be undone.").red().bold()
54 - );
51 + println!("{}", style("⚠ This action cannot be undone.").red().bold());
52 println!("{warning}");
53 println!();
54 }
crates/smbcloud-model/src/project.rs
+11 -2
@@ -1,5 +1,5 @@
1 use {
2 - crate::{app_auth::AuthApp, ar_date_format, runner::Runner},
2 + crate::{app_auth::AuthApp, ar_date_format, runner::Runner, tenant::Tenant},
3 chrono::{DateTime, Utc},
4 serde::{Deserialize, Serialize},
5 serde_repr::{Deserialize_repr, Serialize_repr},
@@ -35,7 +35,7 @@ impl Display for DeploymentMethod {
35 }
36 }
37
38 -#[derive(Deserialize, Debug, Serialize, Clone)]
38 +#[derive(Deserialize, Debug, Serialize, Clone, Default)]
39 pub struct Config {
40 /// Legacy project field — kept for backward compatibility during migration.
41 pub current_project: Option<Project>,
@@ -43,6 +43,11 @@ pub struct Config {
43 #[serde(default)]
44 pub current_frontend_app: Option<crate::frontend_app::FrontendApp>,
45 pub current_auth_app: Option<AuthApp>,
46 + /// The tenant selected with `smb tenant use`. Determines which workspace
47 + /// tenant-scoped operations (e.g. `smb project new`) target — the API
48 + /// falls back to the user's personal tenant when this isn't set.
49 + #[serde(default)]
50 + pub current_tenant: Option<Tenant>,
51 }
52
53 #[derive(Deserialize, Serialize, Debug, Clone)]
@@ -50,6 +55,10 @@ pub struct Config {
55 pub struct Project {
56 /// Umbrella smbCloud workspace ID.
57 pub id: i32,
58 + /// The tenant this project belongs to. Optional for older API responses
59 + /// that predate the tenant/workspace split.
60 + #[serde(default)]
61 + pub tenant_id: Option<i64>,
62 #[serde(default)]
63 pub name: String,
64 #[serde(default)]
crates/smbcloud-model/src/tenant.rs
-2
@@ -1,4 +1,3 @@
1 -use crate::ar_date_format;
1 use chrono::{DateTime, Utc};
2 use serde::{Deserialize, Serialize};
3 use serde_repr::{Deserialize_repr, Serialize_repr};
@@ -67,7 +66,6 @@ pub struct Tenant {
66 /// Whether this is the tenant currently selected for the CLI session.
67 #[serde(default)]
68 pub current: bool,
70 - #[serde(with = "ar_date_format")]
69 pub created_at: DateTime<Utc>,
70 }
71
crates/smbcloud-network/src/network.rs
+9 -15
@@ -74,11 +74,10 @@ pub async fn parse_error_response<T: DeserializeOwned>(
74 };
75
76 if LOG_RESPONSE_BODY {
77 - println!();
78 - println!("Parse Error >>>>");
79 - println!("{:?}", serde_json::to_string_pretty(&error_response_body));
80 - println!("Parse Error >>>>");
81 - println!();
77 + debug!(
78 + "Parse Error >>>> {:?}",
79 + serde_json::to_string_pretty(&error_response_body)
80 + );
81 }
82
83 let error_response = match serde_json::from_str(&error_response_body) {
@@ -110,11 +109,7 @@ pub async fn request_login(builder: RequestBuilder) -> Result<AccountStatus, Err
109 };
110
111 if LOG_RESPONSE_BODY {
113 - println!();
114 - println!("request_login: Parse >>>>");
115 - println!("{:?}", response.status());
116 - println!("request_login: Parse >>>>");
117 - println!();
112 + debug!("request_login: Parse >>>> {:?}", response.status());
113 }
114
115 match (response.status(), response.headers().get("Authorization")) {
@@ -301,11 +296,10 @@ pub async fn request<R: DeserializeOwned>(builder: RequestBuilder) -> Result<R,
296 };
297
298 if LOG_RESPONSE_BODY {
304 - println!();
305 - println!("Parse >>>>");
306 - println!("{:?}", serde_json::to_string_pretty(&response_body));
307 - println!("Parse >>>>");
308 - println!();
299 + debug!(
300 + "Parse >>>> {:?}",
301 + serde_json::to_string_pretty(&response_body)
302 + );
303 }
304
305 let response = match serde_json::from_str::<R>(&response_body) {
crates/smbcloud-networking-project/src/crud_project_create.rs
+9 -1
@@ -8,16 +8,24 @@ use smbcloud_model::{
8 use smbcloud_network::{environment::Environment, network::request};
9 use smbcloud_networking::{constants::SMB_USER_AGENT, smb_client::SmbClient};
10
11 +/// `tenant_id` selects which tenant the project is created under, via the
12 +/// `X-Smbcloud-Tenant-Id` header — the API falls back to the user's personal
13 +/// tenant when it's absent, so this is the only way to create a project under
14 +/// an organization tenant.
15 pub async fn create_project(
16 env: Environment,
17 client: (&SmbClient, &str),
18 access_token: String,
19 project: ProjectCreate,
20 + tenant_id: Option<String>,
21 ) -> Result<Project, ErrorResponse> {
17 - let builder = Client::new()
22 + let mut builder = Client::new()
23 .post(build_project_url(env, client))
24 .json(&project)
25 .header("Authorization", access_token)
26 .header("User-agent", SMB_USER_AGENT);
27 + if let Some(tenant_id) = tenant_id {
28 + builder = builder.header("X-Smbcloud-Tenant-Id", tenant_id);
29 + }
30 request(builder).await
31 }
crates/smbcloud-utils/src/lib.rs
+2 -14
@@ -27,12 +27,7 @@ pub async fn get_config() -> Result<Config> {
27 Some(mut path) => {
28 path.push(".smb/config.json");
29 if !path.exists() {
30 - let config = Config {
31 - current_project: None,
32 - current_frontend_app: None,
33 - current_auth_app: None,
34 - };
35 - return Ok(config);
30 + return Ok(Config::default());
31 }
32 let config_string = std::fs::read_to_string(path).map_err(|e| {
33 debug!("Error while reading config file: {}", e);
@@ -45,14 +40,7 @@ pub async fn get_config() -> Result<Config> {
40
41 Ok(config)
42 }
48 - None => {
49 - let config = Config {
50 - current_project: None,
51 - current_frontend_app: None,
52 - current_auth_app: None,
53 - };
54 - Ok(config)
55 - }
43 + None => Ok(Config::default()),
44 }
45 }
46
docs/interfaces.md
+13 -85
@@ -11,7 +11,8 @@ pipe, or a script. Two global flags switch interface:
11 | `--mcp` | **MCP** | Runs as a Model Context Protocol server over stdio instead of a one-shot command. Implies non-interactive. |
12
13 `--tui` and `--mcp` are mutually exclusive. The `--ci` flag is orthogonal: it
14 -forces non-interactive behavior (see [ci.md](./ci.md)) and applies to the
14 +forces non-interactive behavior (see
15 +[CI / non-interactive deploys](./ci.md)) and applies to the
16 headless interface; `--mcp` is always non-interactive regardless of `--ci`.
17
18 ## Headless (default)
@@ -47,90 +48,17 @@ dialog under `--tui`; in the headless interface they ask inline instead.
48
49 ## MCP (`--mcp`)
50
50 -`smb --mcp` starts an MCP server that speaks JSON-RPC over stdio. Instead of
51 -running a single command and exiting, it stays up and exposes smbCloud
52 -operations as MCP **tools** that an MCP-capable client (assistant, IDE, agent)
53 -can call. The subcommand is ignored in this mode.
51 +`smb --mcp` starts an MCP (Model Context Protocol) server that speaks
52 +JSON-RPC over stdio. Instead of running a single command and exiting, it stays
53 +up and exposes smbCloud operations — 30 tools spanning accounts, projects,
54 +tenants, Mail, and Auth — as MCP **tools** that an MCP-capable client
55 +(Claude Desktop, Claude Code, Cursor, or any other assistant/agent) can call.
56 +The subcommand is ignored in this mode.
57
58 Authentication uses the token stored by `smb login`, so log in once before
56 -starting the server. Tools run non-interactively and return structured JSON.
59 +starting the server. Tools run non-interactively and return structured JSON —
60 +including the `*_delete` tools, which apply immediately with no confirmation
61 +prompt.
62
58 -### Available tools
59 -
60 -| Tool | Arguments | Returns |
61 -|---|---|---|
62 -| `me` | _(none)_ | The authenticated user. |
63 -| `project_list` | _(none)_ | The user's projects. |
64 -| `project_show` | `id` | A single project by ID. |
65 -| `deployments` | `project_id` | A project's deployments. |
66 -| `project_create` | `name`, `description` (optional) | The created project. |
67 -| `project_update` | `id`, `description` | The updated project (runner preserved). |
68 -| `project_delete` | `id` | Confirmation. **Destructive and irreversible.** |
69 -
70 -Tools run non-interactively, so the write tools apply immediately without a
71 -confirmation prompt — the calling client is responsible for confirming intent
72 -before invoking `project_delete`.
73 -
74 -### Not exposed as tools (yet)
75 -
76 -`deploy` and `migrate` are **deliberately not** MCP tools. Both are tightly
77 -coupled to the local working directory in ways a stdio tool call can't satisfy
78 -cleanly:
79 -
80 -- **`deploy`** reads `.smb/config.toml` (running interactive `setup_project`
81 - when it is missing), builds the app locally, then uploads over rsync and
82 - restarts over SSH. It depends on the caller's current directory, local build
83 - toolchains, and on-disk SSH keys, and it streams long-running progress — none
84 - of which map onto a single non-interactive tool call. For automated deploys,
85 - use the headless CLI path instead (`smb --ci deploy`, see [ci.md](./ci.md)),
86 - which is what CI and the deploy action already drive.
87 -- **`migrate`** pushes local `.smb/config.toml` deploy fields up to the server,
88 - so it is meaningless without a project directory to read from. The current
89 - tools operate on explicit arguments (a project `id`, a `name`), not on
90 - whatever directory the server process happens to be running in.
91 -
92 -Exposing either as a tool would mean designing an explicit, directory-free
93 -contract — e.g. accepting the full deploy configuration as arguments and
94 -returning streamed progress as structured events — rather than wrapping the
95 -existing directory-coupled handlers. That is intentionally left for a later
96 -pass; the tools above cover the read and project-management surface that is
97 -well-defined over stdio today.
98 -
99 -### Wiring it into an MCP client
100 -
101 -Most MCP clients take a command plus arguments. Point the client at the `smb`
102 -binary with `--mcp`:
103 -
104 -```json
105 -{
106 - "mcpServers": {
107 - "smbcloud": {
108 - "command": "smb",
109 - "args": ["--mcp"]
110 - }
111 - }
112 -}
113 -```
114 -
115 -To target a local API during development, add the environment flag —
116 -`"args": ["-e", "dev", "--mcp"]`.
117 -
118 -### Talking to it directly
119 -
120 -The server reads newline-delimited JSON-RPC on stdin and writes responses on
121 -stdout. Diagnostics go to stderr and logging goes to the on-disk log file, so
122 -stdout stays a clean protocol channel. A minimal handshake:
123 -
124 -```sh
125 -printf '%s\n' \
126 - '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
127 - '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
128 - '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
129 - | smb --mcp
130 -```
131 -
132 -A tool call names the tool and its arguments:
133 -
134 -```json
135 -{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"project_show","arguments":{"id":"<id>"}}}
136 -```
63 +For the client setup guide and the full tool reference, see
64 +[MCP Server](./mcp.md).
docs/mcp.md new
+295
@@ -0,0 +1,295 @@
1 +# MCP Server
2 +
3 +The smbCloud CLI can run as a **Model Context Protocol (MCP) server**, so an AI
4 +assistant or agent — Claude Desktop, Claude Code, Cursor, or any other
5 +MCP-capable client — can manage your smbCloud projects, tenants, Mail apps, and
6 +Auth apps directly, without you leaving the chat to run `smb` commands by hand.
7 +Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 30
8 +tools covering the account, project, tenant, Mail, and Auth surfaces.
9 +
10 +This page is the setup guide and the full tool reference. For how `--mcp`
11 +compares to the CLI's other two interfaces (headless and `--tui`), see
12 +[Interfaces](./interfaces.md).
13 +
14 +## Prerequisites
15 +
16 +1. [Install the CLI](./cli-install.md) (`smb --version` should print something).
17 +2. Log in once, headlessly — the MCP server itself is non-interactive and can't
18 + run the login flow:
19 +
20 + ```sh
21 + smb login
22 + ```
23 +
24 + This writes a token to `~/.smb/token` (`~/.smb-dev/token` in the `dev`
25 + environment). Every MCP tool call reuses that token; there's nothing
26 + client-specific to configure for auth.
27 +
28 +## Connect your client
29 +
30 +Every MCP client ultimately does the same thing: run `smb --mcp` as a
31 +subprocess and speak JSON-RPC over its stdin/stdout. The differences are in
32 +where each client wants that command declared.
33 +
34 +### Claude Desktop
35 +
36 +Edit your Claude Desktop config —
37 +`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS,
38 +`%APPDATA%\Claude\claude_desktop_config.json` on Windows — and add an entry
39 +under `mcpServers`:
40 +
41 +```json
42 +{
43 + "mcpServers": {
44 + "smbcloud": {
45 + "command": "smb",
46 + "args": ["--mcp"]
47 + }
48 + }
49 +}
50 +```
51 +
52 +Restart Claude Desktop. The tools listed below become available in any chat.
53 +
54 +### Claude Code
55 +
56 +```sh
57 +claude mcp add smbcloud -- smb --mcp
58 +```
59 +
60 +Or add it directly to your project's `.mcp.json`:
61 +
62 +```json
63 +{
64 + "mcpServers": {
65 + "smbcloud": {
66 + "command": "smb",
67 + "args": ["--mcp"]
68 + }
69 + }
70 +}
71 +```
72 +
73 +### Cursor
74 +
75 +Add the same shape to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json`
76 +(project-scoped):
77 +
78 +```json
79 +{
80 + "mcpServers": {
81 + "smbcloud": {
82 + "command": "smb",
83 + "args": ["--mcp"]
84 + }
85 + }
86 +}
87 +```
88 +
89 +### Any other MCP client
90 +
91 +If your client's config isn't listed above, it almost certainly still takes a
92 +`command` + `args` pair for a stdio server — point it at `smb` with `--mcp`
93 +exactly as shown above. That's the entire integration surface; there's no
94 +separate server to run or port to expose.
95 +
96 +### Targeting a local API during development
97 +
98 +Add the global `-e`/`--environment` flag before `--mcp` to point the server at
99 +a local dev API instead of production:
100 +
101 +```json
102 +{
103 + "mcpServers": {
104 + "smbcloud-dev": {
105 + "command": "smb",
106 + "args": ["-e", "dev", "--mcp"]
107 + }
108 + }
109 +}
110 +```
111 +
112 +## Session state: tenant and project selection
113 +
114 +Several tools take an optional `project_id`, and project creation targets
115 +whichever tenant is currently selected. Two tools manage that selection:
116 +
117 +- **`tenant_use`** — selects a tenant (workspace) for the session. While
118 + selected, `project_create` targets it instead of your personal tenant.
119 +- **`project_use`** — selects a project for the session. Tools with an
120 + optional `project_id` (Mail apps, Auth apps) default to it when omitted.
121 +
122 +Both persist to `~/.smb/config.toml` (`~/.smb-dev/config.toml` in `dev`) — the
123 +same file `smb tenant use` / `smb project use` write from the terminal. Switch
124 +context in a chat with an agent, then run `smb project list` in a terminal a
125 +minute later, and it reflects the same selection. Selecting a tenant that the
126 +current project doesn't belong to clears the project selection, so a stale
127 +cross-tenant `project_id` can never linger silently.
128 +
129 +## Tools reference
130 +
131 +Every tool returns structured JSON — a single object for `_show`/`_new`/
132 +`_update`/`_use`, an array for `_list`. Fields are the CLI's own JSON models,
133 +not a separate MCP-specific shape.
134 +
135 +### Account
136 +
137 +| Tool | Arguments | Returns |
138 +| --- | --- | --- |
139 +| `me` | _(none)_ | The authenticated user. |
140 +
141 +### Projects
142 +
143 +| Tool | Arguments | Returns |
144 +| --- | --- | --- |
145 +| `project_list` | _(none)_ | The user's projects. |
146 +| `project_show` | `id` | A single project by ID. |
147 +| `project_use` | `id` | The selected project. Persists the selection (see above). |
148 +| `project_create` | `name`, `description` (optional) | The created project. Targets the selected tenant, if any. |
149 +| `project_update` | `id`, `description` | The updated project (runner preserved). |
150 +| `project_delete` | `id` | Confirmation. **Destructive and irreversible.** |
151 +| `deployments` | `project_id` | A project's deployments. |
152 +
153 +### Tenants
154 +
155 +A tenant is a workspace — your personal one, or an organization you belong to.
156 +`tenant_new`/`tenant_delete` only apply to organization tenants; the personal
157 +tenant is bootstrapped on signup and isn't user-managed.
158 +
159 +| Tool | Arguments | Returns |
160 +| --- | --- | --- |
161 +| `tenant_list` | _(none)_ | The user's tenants, with role and project count. |
162 +| `tenant_show` | `id` | A single tenant by ID. |
163 +| `tenant_use` | `id` | The selected tenant. Persists the selection (see above). |
164 +| `tenant_new` | `name` | The created organization tenant. |
165 +| `tenant_update` | `id`, `name` | The renamed tenant. |
166 +| `tenant_delete` | `id` | Confirmation. **Destructive and irreversible** — cascades to every project, Mail app, and Auth app the tenant owns. |
167 +
168 +### Mail apps
169 +
170 +A Mail app owns one domain's mail routing; it belongs to a project.
171 +
172 +| Tool | Arguments | Returns |
173 +| --- | --- | --- |
174 +| `mail_list` | `project_id` (optional, defaults to the selected project) | The user's Mail apps. |
175 +| `mail_show` | `id` | A single Mail app by ID. |
176 +| `mail_new` | `name`, `domain`, `project_id` (optional), `aws_region` (optional) | The created Mail app. |
177 +| `mail_update` | `id`, `name`/`domain`/`aws_region` (at least one) | The updated Mail app. |
178 +| `mail_delete` | `id` | Confirmation. **Destructive and irreversible.** |
179 +
180 +### Mail inboxes
181 +
182 +An inbox route forwards mail arriving at one address under a Mail app's domain.
183 +
184 +| Tool | Arguments | Returns |
185 +| --- | --- | --- |
186 +| `mail_inbox_new` | `app_id`, `local_part`, `forward_to_email`, `sender_email` (optional) | The created inbox route. |
187 +| `mail_inbox_update` | `app_id`, `id`, `local_part`/`forward_to_email`/`sender_email` (at least one) | The updated inbox route. |
188 +| `mail_inbox_delete` | `app_id`, `id` | Confirmation. **Destructive and irreversible.** |
189 +| `mail_inbox_test` | `app_id`, `id`, `recipient_email`/`subject`/`body` (all optional) | The test delivery result. |
190 +
191 +### Mail messages
192 +
193 +Read-only access to an inbox's stored inbound message history.
194 +
195 +| Tool | Arguments | Returns |
196 +| --- | --- | --- |
197 +| `mail_message_list` | `app_id`, `inbox_id`, `limit` (optional, default 10) | Recent inbound messages, newest first. |
198 +| `mail_message_show` | `app_id`, `inbox_id`, `id` | A single inbound message. |
199 +
200 +### Auth apps
201 +
202 +An Auth app is a hosted identity/sign-in service for your own product's end
203 +users; it belongs to a project.
204 +
205 +| Tool | Arguments | Returns |
206 +| --- | --- | --- |
207 +| `auth_app_list` | `project_id` (optional, defaults to the selected project) | The user's Auth apps. |
208 +| `auth_app_show` | `id` | A single Auth app by ID. |
209 +| `auth_app_new` | `name`, `project_id` (optional), `support_email` (optional) | The created Auth app. |
210 +| `auth_app_update` | `id`, `name`/`support_email` (at least one) | The updated Auth app. |
211 +| `auth_app_delete` | `id` | Confirmation. **Destructive and irreversible.** |
212 +
213 +## Safety: tools run without confirmation
214 +
215 +The CLI's own `project delete`, `tenant delete`, and similar commands ask for
216 +confirmation on a terminal — some require typing the resource's name back.
217 +**MCP tools skip that entirely.** MCP is a non-interactive protocol, so a
218 +`*_delete` tool call executes immediately the moment the client sends it. The
219 +calling agent — and the person directing it — is responsible for confirming
220 +intent before invoking a destructive tool, exactly as with any other
221 +irreversible action an assistant can take on your behalf.
222 +
223 +If you want a harder guardrail, keep destructive operations out of an agent's
224 +reach at the client level (most MCP clients let you allow/deny individual
225 +tools per server) rather than relying on the server to ask twice.
226 +
227 +## Not exposed as tools (yet)
228 +
229 +`deploy` and `migrate` are **deliberately not** MCP tools. Both are tightly
230 +coupled to the local working directory in ways a stdio tool call can't satisfy
231 +cleanly:
232 +
233 +- **`deploy`** reads `.smb/config.toml` (running interactive `setup_project`
234 + when it is missing), builds the app locally, then uploads over rsync and
235 + restarts over SSH. It depends on the caller's current directory, local build
236 + toolchains, and on-disk SSH keys, and it streams long-running progress — none
237 + of which map onto a single non-interactive tool call. For automated deploys,
238 + use the headless CLI path instead (`smb --ci deploy`, see
239 + [CI / non-interactive deploys](./ci.md)), which is what CI and the deploy
240 + action already drive.
241 +- **`migrate`** pushes local `.smb/config.toml` deploy fields up to the server,
242 + so it is meaningless without a project directory to read from.
243 +
244 +Exposing either as a tool would mean designing an explicit, directory-free
245 +contract — e.g. accepting the full deploy configuration as arguments and
246 +returning streamed progress as structured events — rather than wrapping the
247 +existing directory-coupled handlers. That's intentionally left for a later
248 +pass; the tools above cover the account, project, tenant, Mail, and Auth
249 +surface that's well-defined over stdio today.
250 +
251 +## Talking to it directly
252 +
253 +You don't need an MCP client to try this — the server reads newline-delimited
254 +JSON-RPC on stdin and writes responses on stdout. Diagnostics go to stderr and
255 +logging goes to the on-disk log file, so stdout stays a clean protocol channel
256 +end to end. A minimal handshake plus a tool listing:
257 +
258 +```sh
259 +printf '%s\n' \
260 + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"0"}}}' \
261 + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
262 + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
263 + | smb --mcp
264 +```
265 +
266 +A tool call names the tool and its arguments:
267 +
268 +```json
269 +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"project_show","arguments":{"id":"<id>"}}}
270 +```
271 +
272 +## FAQ
273 +
274 +**Does the MCP server need a different install than the regular CLI?**
275 +No — `smb --mcp` is the same binary you already use for `smb deploy`, `smb
276 +project list`, and so on. There's nothing extra to install.
277 +
278 +**Do I need to log in separately for MCP?**
279 +No. Log in once with `smb login` on a terminal; every interface (headless,
280 +`--tui`, `--mcp`) reads the same token from `~/.smb/token`.
281 +
282 +**Can I run multiple smbCloud MCP servers for different environments?**
283 +Yes — register two entries with different `args` (e.g. one with `-e dev`, one
284 +without) under different names, as shown above.
285 +
286 +**Does selecting a tenant or project in an agent chat affect my terminal?**
287 +Yes, and vice versa — `tenant_use`/`project_use` write to the same
288 +`~/.smb/config.toml` the CLI itself reads, so the two stay in sync.
289 +
290 +**Is the MCP server safe to point an autonomous agent at?**
291 +Every write tool — including every `*_delete` tool — executes immediately with
292 +no confirmation prompt, since MCP is non-interactive by design. Treat tool
293 +access the way you'd treat handing someone your terminal with `smb` already
294 +logged in: fine for a trusted agent you're directing, not something to expose
295 +unsupervised.
website/.gitignore
+3
@@ -42,6 +42,9 @@ yarn-error.log*
42 /content/developer/cli/homebrew.mdx
43 /content/developer/cli/npm.mdx
44 /content/developer/cli/pypi.mdx
45 +/content/developer/cli/interfaces.mdx
46 +/content/developer/cli/mcp.mdx
47 +/content/developer/cli/ci.mdx
48 /content/developer/contributing/_meta.js
49 /content/developer/contributing/debugging.mdx
50 /content/developer/contributing/development.mdx
website/app/robots.js new
+13
@@ -0,0 +1,13 @@
1 +const BASE_URL = 'https://docs.smbcloud.xyz'
2 +
3 +// A blanket allow already covers the AI answer-engine crawlers (GPTBot,
4 +// ClaudeBot, PerplexityBot, and friends) as well as classic search bots, so
5 +// they can cite these docs. Naming individual agents is only worth doing to
6 +// treat one differently — that's a content-licensing call, not a default.
7 +export default function robots() {
8 + return {
9 + rules: [{ userAgent: '*', allow: '/' }],
10 + sitemap: `${BASE_URL}/sitemap.xml`,
11 + host: BASE_URL
12 + }
13 +}
website/app/sitemap.js new
+48
@@ -0,0 +1,48 @@
1 +import { getPageMap } from 'nextra/page-map'
2 +
3 +const BASE_URL = 'https://docs.smbcloud.xyz'
4 +
5 +/**
6 + * Collect every real page route from Nextra's page map.
7 + *
8 + * The map mixes three kinds of entry: `_meta` files (a bare `data` object),
9 + * folders (a `route` plus `children`), and pages (a `route`). Folders here use
10 + * `asIndexPage` frontmatter, so they resolve to a real page too and belong in
11 + * the sitemap — but a folder without its own page would still carry a `route`,
12 + * so we only emit entries Nextra gave a `name` (pages and index pages), never
13 + * the synthetic separators declared in `_meta.js`.
14 + */
15 +function collectRoutes(pageMap, seen = new Set()) {
16 + for (const item of pageMap) {
17 + if (!item || item.data) continue
18 + if (typeof item.route === 'string' && item.name) {
19 + seen.add(item.route)
20 + }
21 + if (Array.isArray(item.children)) {
22 + collectRoutes(item.children, seen)
23 + }
24 + }
25 + return seen
26 +}
27 +
28 +// Shallower pages are the entry points people and crawlers land on first.
29 +// Google largely ignores `priority`, but it costs nothing and it keeps the
30 +// hierarchy explicit for the crawlers that still read it.
31 +function priorityFor(route) {
32 + if (route === '/') return 1
33 + const depth = route.split('/').filter(Boolean).length
34 + if (depth === 1) return 0.8
35 + if (depth === 2) return 0.7
36 + return 0.6
37 +}
38 +
39 +export default async function sitemap() {
40 + const pageMap = await getPageMap()
41 + const routes = [...collectRoutes(pageMap)].sort()
42 +
43 + return routes.map(route => ({
44 + url: `${BASE_URL}${route === '/' ? '' : route}`,
45 + changeFrequency: 'weekly',
46 + priority: priorityFor(route)
47 + }))
48 +}
website/content/developer/cli/index.mdx
+34 -10
@@ -1,24 +1,40 @@
1 ---
2 asIndexPage: true
3 title: CLI
4 +description: Install the smbCloud CLI (smb), run it headless/--tui/--mcp, and connect it to Claude, Cursor, or CI.
5 ---
6
7 # CLI
8
9 `smb` is the smbCloud command-line tool. You use it to log in and to manage and
9 -deploy your smbCloud resources from the terminal.
10 +deploy your smbCloud resources — projects, tenants, Mail, and Auth — from the
11 +terminal, a script, or an MCP-connected AI agent.
12
13 ```text
12 -Usage: smb [OPTIONS] <COMMAND>
14 +Usage: smb [OPTIONS] [COMMAND]
15
16 Commands:
15 - login Login to your account. To create an account, visit https://smbcloud.xyz
16 - help Print this message or the help of the given subcommand(s)
17 + deploy Deploy project. This is smb main command. Requires an smbCloud account.
18 + init Initialize project. Requires an smbCloud account.
19 + login Login to your account.
20 + logout Logout from your account.
21 + me Your account info.
22 + migrate Migrate local .smb/config.toml deploy fields to the smbCloud server.
23 + account Manage your account.
24 + project Manage your projects.
25 + tenant Manage your tenants.
26 + mail Manage smbCloud Mail.
27 + auth Manage smbCloud Auth apps.
28 + help Print this message or the help of the given subcommand(s)
29
30 Options:
19 - -l, --log-level <LOG_LEVEL> Log level: trace, debug, info, warn, error, off
20 - -h, --help Print help
21 - -V, --version Print version
31 + -e, --environment <ENVIRONMENT> Environment: dev, production [default: production]
32 + -l, --log-level <LOG_LEVEL> Log level: trace, debug, info, warn, error, off
33 + --ci Non-interactive mode for CI/automation
34 + --tui Full-screen TUI mode for the read commands
35 + --mcp Run as an MCP server over stdio
36 + -h, --help Print help
37 + -V, --version Print version
38 ```
39
40 ## Install
@@ -41,10 +57,18 @@ smb --version
57 You can also download a binary for any release directly from the
58 [GitHub releases page](https://github.com/smbcloudXYZ/smbcloud-cli/releases).
59
60 +## Guides
61 +
62 +| Guide | What it covers |
63 +| --- | --- |
64 +| [Interfaces](/developer/cli/interfaces) | The three ways `smb` runs — headless, `--tui`, and `--mcp` — and when to use each. |
65 +| [MCP Server](/developer/cli/mcp) | Connect Claude Desktop, Claude Code, Cursor, or any MCP client to `smb --mcp`; the full 30-tool reference. |
66 +| [CI](/developer/cli/ci) | Run `smb` non-interactively with `--ci` and deploy from GitHub Actions. |
67 +
68 import { Callout } from 'nextra/components'
69
70 <Callout type="info">
47 - The install and contributing pages in this section are synced from the
48 - [smbcloud-cli](https://github.com/smbcloudXYZ/smbcloud-cli) repository. Edit
49 - them there.
71 + The install, guide, and contributing pages in this section are synced from
72 + the [smbcloud-cli](https://github.com/smbcloudXYZ/smbcloud-cli) repository.
73 + Edit them there.
74 </Callout>
website/package.json
-5
@@ -18,10 +18,5 @@
18 },
19 "devDependencies": {
20 "pagefind": "^1.4.0"
21 - },
22 - "pnpm": {
23 - "overrides": {
24 - "zod": "4.1.12"
25 - }
21 }
22 }
website/pnpm-workspace.yaml
+13
@@ -1,2 +1,15 @@
1 +# sharp (Next's image optimizer) ships native binaries and needs its install
2 +# script to run. `allowBuilds` is the pnpm 11 spelling; `onlyBuiltDependencies`
3 +# is kept for older pnpm, which is what the deploy host may still run.
4 +allowBuilds:
5 + sharp: true
6 +
7 onlyBuiltDependencies:
8 - sharp
9 +
10 +# Nextra 4 pulls zod in transitively and breaks on newer releases — without
11 +# this pin every docs page 500s at runtime. It lives here rather than in
12 +# package.json's `pnpm.overrides` because pnpm 11 no longer reads that field:
13 +# it warns and silently ignores it, which un-pins zod and brings the 500s back.
14 +overrides:
15 + zod: 4.1.12
website/scripts/generate-developer-docs.mjs
+99 -15
@@ -24,40 +24,124 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
24 const SRC = join(ROOT, '..', SRC_DIR)
25 const DEST = join(ROOT, 'content', 'developer')
26
27 -// Source file -> where it lands and how it's labelled in the sidebar.
27 +// Source file -> where it lands, how it's labelled in the sidebar, and its
28 +// meta description (frontmatter -> real <title>/<meta name="description">
29 +// tags via Nextra's per-page metadata — see app/[[...mdxPath]]/page.jsx).
30 // `mail-service.md` is intentionally excluded: it's a scratch notes file in the
31 // source repo, not publishable documentation.
32 const MANIFEST = {
33 cli: {
34 title: 'CLI',
35 pages: [
34 - { src: 'cli-install.md', slug: 'native', title: 'Native (Cargo)' },
35 - { src: 'cli-install-homebrew.md', slug: 'homebrew', title: 'Homebrew' },
36 - { src: 'cli-install-npm.md', slug: 'npm', title: 'npm' },
37 - { src: 'cli-install-pypi.md', slug: 'pypi', title: 'PyPI' }
36 + {
37 + src: 'cli-install.md',
38 + slug: 'native',
39 + title: 'Native (Cargo)',
40 + description: 'Install the smbCloud CLI (smb) from crates.io with Cargo.'
41 + },
42 + {
43 + src: 'cli-install-homebrew.md',
44 + slug: 'homebrew',
45 + title: 'Homebrew',
46 + description: 'Install the smbCloud CLI (smb) on macOS or Linux with Homebrew.'
47 + },
48 + {
49 + src: 'cli-install-npm.md',
50 + slug: 'npm',
51 + title: 'npm',
52 + description: 'Install the smbCloud CLI (smb) from npm for JavaScript toolchains.'
53 + },
54 + {
55 + src: 'cli-install-pypi.md',
56 + slug: 'pypi',
57 + title: 'PyPI',
58 + description: 'Install the smbCloud CLI (smb) from PyPI for Python toolchains.'
59 + },
60 + {
61 + src: 'interfaces.md',
62 + slug: 'interfaces',
63 + title: 'Interfaces',
64 + description:
65 + 'How the smbCloud CLI’s three interfaces — headless, --tui, and --mcp — work, and when to use each.'
66 + },
67 + {
68 + src: 'mcp.md',
69 + slug: 'mcp',
70 + title: 'MCP Server',
71 + description:
72 + 'Run the smbCloud CLI as an MCP server and connect Claude Desktop, Claude Code, Cursor, or any MCP client to manage your projects, tenants, Mail, and Auth apps. Setup guide plus the full 30-tool reference.'
73 + },
74 + {
75 + src: 'ci.md',
76 + slug: 'ci',
77 + title: 'CI',
78 + description:
79 + 'Run the smbCloud CLI non-interactively with --ci and deploy from GitHub Actions using smbcloud-deploy-action.'
80 + }
81 ]
82 },
83 contributing: {
84 title: 'Contributing',
85 pages: [
43 - { src: 'debugging.md', slug: 'debugging', title: 'VSCode Debugging' },
44 - { src: 'development.md', slug: 'development', title: 'Releasing & ADRs' },
45 - { src: 'gems.md', slug: 'gems', title: 'Publishing the gem' }
86 + {
87 + src: 'debugging.md',
88 + slug: 'debugging',
89 + title: 'VSCode Debugging',
90 + description: 'Debug the smbCloud CLI in VS Code with CodeLLDB and cargo debugger.'
91 + },
92 + {
93 + src: 'development.md',
94 + slug: 'development',
95 + title: 'Releasing & ADRs',
96 + description: 'The smbCloud CLI release process and its architecture decision records.'
97 + },
98 + {
99 + src: 'gems.md',
100 + slug: 'gems',
101 + title: 'Publishing the gem',
102 + description: 'Publish the smbcloud-model Ruby gem alongside its Rust crate release.'
103 + }
104 ]
105 }
106 }
107
108 +// Cross-links between docs pages use plain relative `./file.md` links —
109 +// correct as-is on GitHub, where these files are read directly. Rewrite them
110 +// to absolute site paths so they also resolve on the published site.
111 +const SITE_PATH_BY_SRC = Object.fromEntries(
112 + Object.entries(MANIFEST).flatMap(([section, { pages }]) =>
113 + pages.map(p => [p.src, `/developer/${section}/${p.slug}`])
114 + )
115 +)
116 +
117 +function rewriteLinks(markdown) {
118 + return markdown.replace(/\]\((?:\.\/)?([\w-]+\.md)\)/g, (match, src) => {
119 + const sitePath = SITE_PATH_BY_SRC[src]
120 + return sitePath ? `](${sitePath})` : match
121 + })
122 +}
123 +
124 const blobUrl = src =>
125 `https://github.com/smbcloudXYZ/smbcloud-cli/blob/${BRANCH}/${SRC_DIR}/${src}`
126
53 -/** Wrap generated markdown: header note + a source callout after the first H1. */
54 -function decorate(markdown, src) {
55 - const note = `{/* AUTO-GENERATED — do not edit. Generated by scripts/generate-developer-docs.mjs from ../${SRC_DIR}/${src}. */}`
127 +/**
128 + * Wrap generated markdown: frontmatter (for real per-page SEO metadata),
129 + * header note, and a source callout after the first H1.
130 + */
131 +function decorate(markdown, page) {
132 + markdown = rewriteLinks(markdown)
133 + const frontmatter = [
134 + '---',
135 + `title: ${JSON.stringify(page.title)}`,
136 + `description: ${JSON.stringify(page.description)}`,
137 + '---'
138 + ].join('\n')
139 + const note = `{/* AUTO-GENERATED — do not edit. Generated by scripts/generate-developer-docs.mjs from ../${SRC_DIR}/${page.src}. */}`
140 const callout = [
141 "import { Callout } from 'nextra/components'",
142 '',
143 '<Callout type="info">',
60 - ` Generated from [\`${SRC_DIR}/${src}\`](${blobUrl(src)}) in`,
144 + ` Generated from [\`${SRC_DIR}/${page.src}\`](${blobUrl(page.src)}) in`,
145 ' [smbcloud-cli](https://github.com/smbcloudXYZ/smbcloud-cli). Edit it there.',
146 '</Callout>'
147 ].join('\n')
@@ -65,11 +149,11 @@ function decorate(markdown, src) {
149 const lines = markdown.replace(/\r\n/g, '\n').trimEnd().split('\n')
150 const h1 = lines.findIndex(l => /^#\s/.test(l))
151 if (h1 === -1) {
68 - return `${note}\n\n${callout}\n\n${lines.join('\n')}\n`
152 + return `${frontmatter}\n\n${note}\n\n${callout}\n\n${lines.join('\n')}\n`
153 }
154 const before = lines.slice(0, h1 + 1).join('\n')
155 const after = lines.slice(h1 + 1).join('\n')
72 - return `${note}\n\n${before}\n\n${callout}\n${after}\n`
156 + return `${frontmatter}\n\n${note}\n\n${before}\n\n${callout}\n${after}\n`
157 }
158
159 // Note: the folder's own index page uses `asIndexPage` frontmatter, so it must
@@ -86,7 +170,7 @@ function main() {
170 mkdirSync(dir, { recursive: true })
171 for (const page of pages) {
172 const raw = readFileSync(join(SRC, page.src), 'utf8')
89 - writeFileSync(join(dir, `${page.slug}.mdx`), decorate(raw, page.src))
173 + writeFileSync(join(dir, `${page.slug}.mdx`), decorate(raw, page))
174 count++
175 }
176 writeFileSync(join(dir, '_meta.js'), metaFile(pages))