diff --git a/crates/oab-mcp/src/lib.rs b/crates/oab-mcp/src/lib.rs index 3b8d8cd..f05fba5 100644 --- a/crates/oab-mcp/src/lib.rs +++ b/crates/oab-mcp/src/lib.rs @@ -223,6 +223,28 @@ pub fn tools() -> Vec { "properties": {} })), ), + Tool::new( + "list_namespaces", + "List namespaces in the cluster a kubeconfig context resolves to. Read-only; backs the New Fleet wizard's namespace . Any failure here (including an RBAC-denied list) should be treated by the caller as \"leave it unset\" — the namespace's `default` service account applies — not surfaced as an error.", + as_map(json!({ + "type": "object", + "properties": { + "context": { "type": "string", "description": "Kubeconfig context name. Omit to use the kubeconfig's current-context." }, + "namespace": { "type": "string", "description": "k8s namespace to list service accounts in." } + }, + "required": ["namespace"] + })), + ), ] } @@ -337,6 +359,8 @@ impl OabMcp { "fleet_config_write" => self.t_fleet_write(args), "list_aws_profiles" => self.t_list_aws_profiles(args), "list_k8s_contexts" => self.t_list_k8s_contexts(args), + "list_namespaces" => self.t_list_namespaces(args).await, + "list_service_accounts" => self.t_list_service_accounts(args).await, other => anyhow::bail!("unknown tool {other:?}"), } } @@ -748,6 +772,28 @@ impl OabMcp { })) } + /// Namespace discovery (studio#104): backs the New Fleet wizard's + /// namespace ``. Errors (including RBAC-denied) + /// propagate as a normal tool error — per the design, the caller treats + /// any failure here as "leave it unset", not something to surface. + async fn t_list_service_accounts(&self, args: &Map) -> Result { + let context = args.get("context").and_then(Value::as_str); + let namespace = args + .get("namespace") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("missing required arg: namespace"))?; + let service_accounts = scp::list_service_accounts(context, namespace).await?; + Ok(json!({ "service_accounts": service_accounts })) + } + async fn t_delete(&self, args: &Map) -> Result { let t = self.target(args)?; let cluster = t.cluster.clone(); @@ -830,7 +876,7 @@ mod tests { .iter() .map(|t| t["name"].as_str().expect("tool has a name").to_string()) .collect(); - assert_eq!(names.len(), 13); + assert_eq!(names.len(), 15); for expected in [ "deploy_list", "deploy_get", @@ -845,6 +891,8 @@ mod tests { "fleet_config_write", "list_aws_profiles", "list_k8s_contexts", + "list_namespaces", + "list_service_accounts", ] { assert!(names.contains(&expected.to_string()), "missing {expected}"); } diff --git a/crates/studio-cp/src/lib.rs b/crates/studio-cp/src/lib.rs index bbfd49e..c883e61 100644 --- a/crates/studio-cp/src/lib.rs +++ b/crates/studio-cp/src/lib.rs @@ -295,14 +295,9 @@ pub async fn observe_k8s_identity(context: Option<&str>) -> anyhow::Result String::new(), }; - let options = kube::config::KubeConfigOptions { - context: context.map(str::to_string), - ..Default::default() - }; - let config = kube::Config::from_kubeconfig(&options) + let client = k8s_client_for(context) .await .map_err(|e| anyhow::anyhow!("failed to resolve kubeconfig context '{context_name}': {e}"))?; - let client = kube::Client::try_from(config).map_err(|e| anyhow::anyhow!("failed to build k8s client: {e}"))?; let api: Api = Api::all(client); let review = api @@ -519,6 +514,53 @@ pub fn list_k8s_contexts() -> K8sContextsResult { } } +async fn k8s_client_for(context: Option<&str>) -> anyhow::Result { + let options = kube::config::KubeConfigOptions { + context: context.map(str::to_string), + ..Default::default() + }; + let config = kube::Config::from_kubeconfig(&options) + .await + .map_err(|e| anyhow::anyhow!("failed to resolve kubeconfig context: {e}"))?; + kube::Client::try_from(config).map_err(|e| anyhow::anyhow!("failed to build k8s client: {e}")) +} + +/// List namespaces in the cluster the given kubeconfig context (or the +/// ambient current-context) resolves to. Backs the New Fleet wizard's +/// namespace `` — the +/// caller falls back to leaving it unset (the namespace's `default` service +/// account applies) on any error here, including an RBAC-denied `list`, so +/// this deliberately doesn't distinguish failure reasons the way +/// `list_aws_profiles`/`list_k8s_contexts` do. +pub async fn list_service_accounts(context: Option<&str>, namespace: &str) -> anyhow::Result> { + use k8s_openapi::api::core::v1::ServiceAccount; + use kube::api::{Api, ListParams}; + + let client = k8s_client_for(context).await?; + let api: Api = Api::namespaced(client, namespace); + let list = api + .list(&ListParams::default()) + .await + .map_err(|e| anyhow::anyhow!("failed to list service accounts: {e}"))?; + Ok(list.items.into_iter().filter_map(|sa| sa.metadata.name).collect()) +} + // ---- Fleet → managing-credential binding (ADR: Per-Fleet managing identity) -- // // The *declarative* side of the loop: which credential should manage which