Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,17 @@ Auth comes from `smb login`, so a terminal session and an agent session share th
token and the same selected project. See [`docs/mcp.md`](./docs/mcp.md) for the full tool
reference.

For mobile and TV app testing, run the focused **XCRS Mobile & TV
Automation** profile:

```sh
claude mcp add xcrs -- smb --mcp --scope automation
```

It exposes target-aware Xcode, ControlKit, CoreDevice, and adb tools without
mixing them into the cloud tool set. The same profile is also available from
the standalone `xcrs --mcp` crate.

## CI / non-interactive mode

Pass `--ci` (or set `SMB_CI=1`, or run under any provider that sets `CI`) to
Expand Down
57 changes: 56 additions & 1 deletion crates/cli/src/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use {
crate::{account, cloud_auth, mail, project, tenant},
clap::{Parser, Subcommand},
clap::{Parser, Subcommand, ValueEnum},
smbcloud_network::environment::Environment,
spinners::Spinner,
std::path::PathBuf,
Expand All @@ -18,6 +18,13 @@ impl CommandResult {
}
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
pub enum McpScope {
#[default]
Cloud,
Automation,
}

#[derive(Parser)]
#[clap(author, version, about)]
pub struct Cli {
Expand Down Expand Up @@ -45,6 +52,17 @@ pub struct Cli {
#[arg(long, global = true)]
pub mcp: bool,

/// MCP tool profile to expose. Cloud serves smbCloud resources; automation
/// serves cross-platform mobile and TV device tools.
#[arg(
long = "scope",
global = true,
value_enum,
default_value_t,
requires = "mcp"
)]
pub mcp_scope: McpScope,

#[command(subcommand)]
pub command: Option<Commands>,
}
Expand Down Expand Up @@ -109,6 +127,43 @@ pub enum Commands {
Migrate {},
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn mcp_defaults_to_cloud_scope() {
let cli = Cli::try_parse_from(["smb", "--mcp"]).expect("MCP arguments should parse");

assert_eq!(cli.mcp_scope, McpScope::Cloud);
}

#[test]
fn mcp_accepts_automation_scope() {
let cli = Cli::try_parse_from(["smb", "--mcp", "--scope", "automation"])
.expect("automation MCP arguments should parse");

assert_eq!(cli.mcp_scope, McpScope::Automation);
}

#[test]
fn scope_requires_mcp_mode() {
// `Cli` has no `Debug` impl (clap's `Parser` doesn't require one), so
// `expect_err`/`unwrap_err` (which bound the `Ok` type on `Debug`)
// don't apply here; match the `Result` instead.
let result = Cli::try_parse_from(["smb", "--scope", "automation"]);
let error = match result {
Err(error) => error,
Ok(_) => panic!("scope without MCP mode should be rejected"),
};

assert_eq!(
error.kind(),
clap::error::ErrorKind::MissingRequiredArgument
);
}
}

#[derive(Subcommand)]
pub enum ControlKitCommands {
#[clap(about = "Build a signed ControlKit XCTest runner for a physical device.")]
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ async fn run_mcp(cli: Cli) -> Result<()> {
} else {
setup_logging(cli.environment, None)?;
}
smbcloud_cli::mcp::serve(cli.environment).await
smbcloud_cli::mcp::serve(cli.environment, cli.mcp_scope).await
}

async fn run(cli: Cli) -> Result<CommandResult> {
Expand Down
78 changes: 70 additions & 8 deletions crates/cli/src/mcp.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! MCP (Model Context Protocol) server interface.
//!
//! When `smb` is started with `--mcp`, it runs as an MCP server over stdio
//! instead of executing a one-shot command. The server exposes smbCloud
//! operations as MCP tools built on the official `rmcp` SDK.
//! instead of executing a one-shot command. The default profile exposes
//! smbCloud operations; `--scope automation` exposes the shared XCRS mobile
//! and TV automation profile.
//!
//! Tools call the same library functions the CLI handlers use, but return
//! structured JSON rather than rendering spinners or a TUI — the stdout stream
Expand All @@ -17,6 +18,7 @@
use {
crate::{
account::lib::is_logged_in,
cli::McpScope,
client,
mail::{
current_project::{resolve_optional_project_id, resolve_required_project_id},
Expand Down Expand Up @@ -978,9 +980,7 @@ impl SmbMcpServer {
}
}

#[tool_handler(
router = (Self::cloud_tool_router() + Self::xcrs_tool_router())
)]
#[tool_handler(router = Self::cloud_tool_router())]
impl ServerHandler for SmbMcpServer {
fn get_info(&self) -> ServerInfo {
// `Implementation` is `#[non_exhaustive]`, so start from the build-env
Expand All @@ -995,14 +995,18 @@ impl ServerHandler for SmbMcpServer {
"smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \
`smb login`; tools run non-interactively. `tenant_use` / `project_use` select \
the tenant/project context other tools default to, shared with the CLI's own \
`smb tenant use` / `smb project use`. ControlKit tools cover iOS, tvOS, \
visionOS, watchOS, and macOS runners.",
`smb tenant use` / `smb project use`. Use `smb --mcp --scope automation` \
instead for mobile and TV app automation tools.",
)
}
}

/// Run the MCP server over stdio until the client disconnects.
pub async fn serve(environment: Environment) -> Result<()> {
pub async fn serve(environment: Environment, scope: McpScope) -> Result<()> {
if scope == McpScope::Automation {
return mcp_xcrs::serve().await;
}

let running = SmbMcpServer::new(environment)
.serve(stdio())
.await
Expand All @@ -1013,3 +1017,61 @@ pub async fn serve(environment: Environment) -> Result<()> {
.map_err(|e| anyhow!("MCP server stopped unexpectedly: {e}"))?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn cloud_router_exposes_known_cloud_tools() {
let tools = SmbMcpServer::cloud_tool_router().list_all();
let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();

for cloud_only in [
"me",
"project_list",
"tenant_list",
"mail_list",
"auth_app_list",
] {
assert!(
names.contains(&cloud_only),
"cloud router should expose {cloud_only:?}"
);
}
}

#[test]
fn cloud_router_excludes_automation_tools() {
let tools = SmbMcpServer::cloud_tool_router().list_all();
let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();

const AUTOMATION_TOOL_NAMES: [&str; 18] = [
"device_list",
"device_select",
"device_capabilities",
"app_install_launch",
"screen_capture",
"app_launch",
"app_terminate",
"url_open",
"ui_describe",
"ui_element_list",
"input_tap",
"input_text",
"input_swipe",
"input_button",
"input_click",
"input_spatial_tap",
"orientation_get",
"orientation_set",
];

for automation_only in AUTOMATION_TOOL_NAMES {
assert!(
!names.contains(&automation_only),
"cloud router should not expose automation tool {automation_only:?}"
);
}
}
}
Loading
Loading