Skip to content

Commit 4deb0e4

Browse files
Align api cli, and mcp (#278)
* feat: add environment support to apps list/show CLI and MCP tools The Tower API scopes apps to environments but the CLI and MCP tools were not fully exposing this. This commit: - Adds --environment/-e flag to `tower apps list` and `tower apps show` (defaulting to "default") - Adds environment parameter to MCP tools: tower_deploy, tower_apps_list, tower_apps_show, and tower_run_remote - Adds new MCP tools: tower_catalogs_list and tower_catalogs_show - Includes CLI arg parsing tests for the new flags Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: default apps list to no environment filter The API returns all apps when environment is omitted, and only filters when explicitly provided. Updated list_apps to take Option<&str> so the default behavior shows all apps across environments. The environment param is still available for explicit filtering. Also added version field to MCP apps list output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d44c67c commit 4deb0e4

4 files changed

Lines changed: 193 additions & 18 deletions

File tree

crates/tower-cmd/src/api.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ where
9292
pub async fn describe_app(
9393
config: &Config,
9494
name: &str,
95+
environment: Option<&str>,
9596
) -> Result<
9697
tower_api::models::DescribeAppResponse,
9798
Error<tower_api::apis::default_api::DescribeAppError>,
@@ -104,7 +105,7 @@ pub async fn describe_app(
104105
start_at: None,
105106
end_at: None,
106107
timezone: None,
107-
environment: None,
108+
environment: environment.map(|s| s.to_string()),
108109
};
109110

110111
unwrap_api_response(tower_api::apis::default_api::describe_app(
@@ -115,12 +116,15 @@ pub async fn describe_app(
115116

116117
pub async fn list_apps(
117118
config: &Config,
119+
environment: Option<&str>,
118120
) -> Result<Vec<tower_api::models::AppSummary>, Error<tower_api::apis::default_api::ListAppsError>>
119121
{
120122
let api_config: configuration::Configuration = config.into();
123+
let environment = environment.map(|s| s.to_string());
121124

122125
fetch_all_pages(|page, page_size| {
123126
let api_config = &api_config;
127+
let environment = &environment;
124128
async move {
125129
let params = tower_api::apis::default_api::ListAppsParams {
126130
query: None,
@@ -129,7 +133,7 @@ pub async fn list_apps(
129133
num_runs: Some(0),
130134
sort: None,
131135
filter: None,
132-
environment: None,
136+
environment: environment.clone(),
133137
};
134138
unwrap_api_response(tower_api::apis::default_api::list_apps(api_config, params)).await
135139
}

crates/tower-cmd/src/apps.rs

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,24 @@ use tokio::time::{sleep, Duration, Instant};
55

66
use tower_api::models::{Run, RunLogLine};
77

8-
use crate::{api, output};
8+
use crate::{api, output, util::cmd};
99

1010
pub fn apps_cmd() -> Command {
1111
Command::new("apps")
1212
.about("Manage the apps in your current Tower account")
1313
.arg_required_else_help(true)
14-
.subcommand(Command::new("list").about("List all apps in your Tower account"))
14+
.subcommand(
15+
Command::new("list")
16+
.arg(
17+
Arg::new("environment")
18+
.short('e')
19+
.long("environment")
20+
.value_parser(value_parser!(String))
21+
.help("Filter apps by environment")
22+
.action(clap::ArgAction::Set),
23+
)
24+
.about("List all apps in your Tower account"),
25+
)
1526
.subcommand(
1627
Command::new("show")
1728
.arg(
@@ -21,6 +32,15 @@ pub fn apps_cmd() -> Command {
2132
.required(true)
2233
.help("Name of the app"),
2334
)
35+
.arg(
36+
Arg::new("environment")
37+
.short('e')
38+
.long("environment")
39+
.default_value("default")
40+
.value_parser(value_parser!(String))
41+
.help("The environment to resolve the app against")
42+
.action(clap::ArgAction::Set),
43+
)
2444
.about("Show details for a Tower app and its recent runs"),
2545
)
2646
.subcommand(
@@ -130,8 +150,9 @@ pub async fn do_show(config: Config, cmd: &ArgMatches) {
130150
let name = cmd
131151
.get_one::<String>("app_name")
132152
.expect("app_name is required");
153+
let env = cmd::get_string_flag(cmd, "environment");
133154

134-
match api::describe_app(&config, &name).await {
155+
match api::describe_app(&config, &name, Some(&env)).await {
135156
Ok(app_response) => {
136157
if output::get_output_mode().is_json() {
137158
output::json(&app_response);
@@ -209,8 +230,9 @@ pub async fn do_show(config: Config, cmd: &ArgMatches) {
209230
}
210231
}
211232

212-
pub async fn do_list_apps(config: Config) {
213-
let apps = output::with_spinner("Listing apps", api::list_apps(&config)).await;
233+
pub async fn do_list_apps(config: Config, args: &ArgMatches) {
234+
let env = args.get_one::<String>("environment").map(|s| s.as_str());
235+
let apps = output::with_spinner("Listing apps", api::list_apps(&config, env)).await;
214236

215237
let items = apps
216238
.iter()
@@ -269,7 +291,7 @@ pub async fn do_cancel(config: Config, cmd: &ArgMatches) {
269291
}
270292

271293
async fn latest_run_number(config: &Config, name: &str) -> i64 {
272-
match api::describe_app(config, name).await {
294+
match api::describe_app(config, name, None).await {
273295
Ok(resp) => resp
274296
.runs
275297
.iter()
@@ -808,4 +830,53 @@ mod tests {
808830
let result = apps_cmd().try_get_matches_from(["apps", "cancel"]);
809831
assert!(result.is_err());
810832
}
833+
834+
#[test]
835+
fn list_defaults_to_no_environment_filter() {
836+
let matches = apps_cmd()
837+
.try_get_matches_from(["apps", "list"])
838+
.unwrap();
839+
let (_, list_args) = matches.subcommand().unwrap();
840+
841+
assert_eq!(list_args.get_one::<String>("environment"), None);
842+
}
843+
844+
#[test]
845+
fn list_accepts_environment_flag() {
846+
let matches = apps_cmd()
847+
.try_get_matches_from(["apps", "list", "-e", "production"])
848+
.unwrap();
849+
let (_, list_args) = matches.subcommand().unwrap();
850+
851+
assert_eq!(
852+
list_args.get_one::<String>("environment").map(|s| s.as_str()),
853+
Some("production")
854+
);
855+
}
856+
857+
#[test]
858+
fn show_defaults_to_default_environment() {
859+
let matches = apps_cmd()
860+
.try_get_matches_from(["apps", "show", "my-app"])
861+
.unwrap();
862+
let (_, show_args) = matches.subcommand().unwrap();
863+
864+
assert_eq!(
865+
show_args.get_one::<String>("environment").unwrap(),
866+
"default"
867+
);
868+
}
869+
870+
#[test]
871+
fn show_accepts_environment_flag() {
872+
let matches = apps_cmd()
873+
.try_get_matches_from(["apps", "show", "my-app", "-e", "production"])
874+
.unwrap();
875+
let (_, show_args) = matches.subcommand().unwrap();
876+
877+
assert_eq!(
878+
show_args.get_one::<String>("environment").unwrap(),
879+
"production"
880+
);
881+
}
811882
}

crates/tower-cmd/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ impl App {
123123
let apps_command = sub_matches.subcommand();
124124

125125
match apps_command {
126-
Some(("list", _)) => apps::do_list_apps(sessionized_config).await,
126+
Some(("list", args)) => apps::do_list_apps(sessionized_config, args).await,
127127
Some(("create", args)) => apps::do_create(sessionized_config, args).await,
128128
Some(("show", args)) => apps::do_show(sessionized_config, args).await,
129129
Some(("logs", args)) => apps::do_logs(sessionized_config, args).await,

crates/tower-cmd/src/mcp.rs

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,44 @@ struct RunRequest {
159159
#[serde(flatten)]
160160
common: CommonParams,
161161
parameters: Option<std::collections::HashMap<String, String>>,
162+
/// The environment to run the app in (defaults to "default")
163+
environment: Option<String>,
164+
}
165+
166+
#[derive(Debug, Deserialize, JsonSchema)]
167+
struct DeployRequest {
168+
#[serde(flatten)]
169+
common: CommonParams,
170+
/// The environment to deploy to (defaults to "default")
171+
environment: Option<String>,
172+
}
173+
174+
#[derive(Debug, Deserialize, JsonSchema)]
175+
struct ListAppsRequest {
176+
/// Filter apps by environment. If not provided, apps across all environments are returned.
177+
environment: Option<String>,
178+
}
179+
180+
#[derive(Debug, Deserialize, JsonSchema)]
181+
struct ShowAppRequest {
182+
/// Name of the app
183+
name: String,
184+
/// The environment to resolve the app against (defaults to "default")
185+
environment: Option<String>,
186+
}
187+
188+
#[derive(Debug, Deserialize, JsonSchema)]
189+
struct ListCatalogsRequest {
190+
/// The environment to list catalogs from (defaults to "default")
191+
environment: Option<String>,
192+
}
193+
194+
#[derive(Debug, Deserialize, JsonSchema)]
195+
struct ShowCatalogRequest {
196+
/// Name of the catalog
197+
name: String,
198+
/// The environment the catalog belongs to (defaults to "default")
199+
environment: Option<String>,
162200
}
163201

164202
pub fn mcp_cmd() -> Command {
@@ -464,8 +502,12 @@ impl TowerService {
464502
// share constants directly. MCP-only descriptions (with Prerequisites/Optional) are
465503
// intentionally more detailed and don't need a CLI counterpart.
466504
#[tool(description = "List all apps in your Tower account")]
467-
async fn tower_apps_list(&self) -> Result<CallToolResult, McpError> {
468-
match api::list_apps(&self.config).await {
505+
async fn tower_apps_list(
506+
&self,
507+
Parameters(request): Parameters<ListAppsRequest>,
508+
) -> Result<CallToolResult, McpError> {
509+
let environment = request.environment.as_deref();
510+
match api::list_apps(&self.config, environment).await {
469511
Ok(apps) => {
470512
let apps: Vec<Value> = apps
471513
.into_iter()
@@ -474,6 +516,7 @@ impl TowerService {
474516
json!({
475517
"name": app.name,
476518
"description": app.short_description,
519+
"version": app.version,
477520
"created_at": app.created_at,
478521
"status": format!("{:?}", app.status)
479522
})
@@ -499,9 +542,10 @@ impl TowerService {
499542
#[tool(description = "Show details for a Tower app and its recent runs")]
500543
async fn tower_apps_show(
501544
&self,
502-
Parameters(request): Parameters<NameRequest>,
545+
Parameters(request): Parameters<ShowAppRequest>,
503546
) -> Result<CallToolResult, McpError> {
504-
match api::describe_app(&self.config, &request.name).await {
547+
let environment = request.environment.as_deref().unwrap_or("default");
548+
match api::describe_app(&self.config, &request.name, Some(environment)).await {
505549
Ok(response) => {
506550
let data = json!({
507551
"app": {
@@ -657,6 +701,61 @@ impl TowerService {
657701
}
658702
}
659703

704+
#[tool(description = "List catalogs in your Tower account")]
705+
async fn tower_catalogs_list(
706+
&self,
707+
Parameters(request): Parameters<ListCatalogsRequest>,
708+
) -> Result<CallToolResult, McpError> {
709+
let environment = request.environment.as_deref().unwrap_or("default");
710+
match api::list_catalogs(&self.config, environment, false).await {
711+
Ok(catalogs) => {
712+
let catalogs: Vec<Value> = catalogs
713+
.into_iter()
714+
.map(|catalog| {
715+
json!({
716+
"name": catalog.name,
717+
"type": catalog.r#type,
718+
"environment": catalog.environment,
719+
})
720+
})
721+
.collect();
722+
Self::json_success(json!({"catalogs": catalogs}))
723+
}
724+
Err(e) => Self::error_result("Failed to list catalogs", e),
725+
}
726+
}
727+
728+
#[tool(description = "Show details for a catalog, including its property names")]
729+
async fn tower_catalogs_show(
730+
&self,
731+
Parameters(request): Parameters<ShowCatalogRequest>,
732+
) -> Result<CallToolResult, McpError> {
733+
let environment = request.environment.as_deref().unwrap_or("default");
734+
match api::describe_catalog(&self.config, &request.name, environment).await {
735+
Ok(response) => {
736+
let catalog = &response.catalog;
737+
let properties: Vec<Value> = catalog
738+
.properties
739+
.iter()
740+
.map(|prop| {
741+
json!({
742+
"name": prop.name,
743+
"environment_variable": prop.environment_variable,
744+
"preview": prop.preview,
745+
})
746+
})
747+
.collect();
748+
Self::json_success(json!({
749+
"name": catalog.name,
750+
"type": catalog.r#type,
751+
"environment": catalog.environment,
752+
"properties": properties,
753+
}))
754+
}
755+
Err(e) => Self::error_result("Failed to show catalog", e),
756+
}
757+
}
758+
660759
#[tool(description = "List teams you belong to")]
661760
async fn tower_teams_list(&self) -> Result<CallToolResult, McpError> {
662761
if self.config.api_key.is_some() {
@@ -703,14 +802,15 @@ impl TowerService {
703802
}
704803

705804
#[tool(
706-
description = "Deploy to Tower cloud. Prerequisites: Towerfile, tower_apps_create. Optional: working_directory."
805+
description = "Deploy to Tower cloud. Prerequisites: Towerfile, tower_apps_create. Optional: working_directory, environment."
707806
)]
708807
async fn tower_deploy(
709808
&self,
710-
Parameters(request): Parameters<EmptyRequest>,
809+
Parameters(request): Parameters<DeployRequest>,
711810
) -> Result<CallToolResult, McpError> {
712811
let working_dir = Self::resolve_working_directory(&request.common);
713-
let deploy_target = deploy::DeployTarget::Environment("default".to_string());
812+
let env = request.environment.unwrap_or_else(|| "default".to_string());
813+
let deploy_target = deploy::DeployTarget::Environment(env);
714814

715815
match deploy::deploy_from_dir(self.config.clone(), working_dir, true, deploy_target).await {
716816
Ok(_) => Self::text_success("Deploy completed successfully".to_string()),
@@ -770,7 +870,7 @@ impl TowerService {
770870
let config = self.config.clone();
771871
let working_dir = Self::resolve_working_directory(&request.common);
772872
let path = working_dir;
773-
let env = "default";
873+
let env = request.environment.unwrap_or_else(|| "default".to_string());
774874
let params = request.parameters.unwrap_or_default();
775875

776876
// Load Towerfile to get app name
@@ -782,7 +882,7 @@ impl TowerService {
782882
let app_name = towerfile.app.name.clone();
783883

784884
let (result, output) = Self::execute_with_streaming(&ctx, || {
785-
run::do_run_remote(config, path, env, params, None, true)
885+
run::do_run_remote(config, path, &env, params, None, true)
786886
})
787887
.await;
788888
match result {

0 commit comments

Comments
 (0)