use rmcp::model::{CallToolResult, ContentBlock}; use rmcp::schemars::{json_schema, JsonSchema, Schema, SchemaGenerator}; use serde::{Deserialize, Serialize}; use serde_json::Value; use super::service::{CredentialReport, DiscoveryReport, KcpService}; /// Explicit object schema for `serde_json::Value` fields. Schemars otherwise /// emits the boolean schema `true`, which some OpenAI-compatible backends /// reject during tool conversion. fn freeform_object_schema(_: &mut SchemaGenerator) -> Schema { json_schema!({"type": "object"}) } #[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] pub struct ListKcpsRequest { /// Rediscover kcps instead of using the 30 second cache. pub refresh: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct KcpWorkspaceRequest { /// Kcp id or kubeconfig path from list_kcps. Omit when only one kcp is running. pub kcp: Option, /// Workspace path, for example "root" or "root:app". Defaults to the kcp's current workspace. pub workspace: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct ListWorkspacesRequest { /// Kcp id or kubeconfig path from list_kcps. Omit when only one kcp is running. pub kcp: Option, /// Workspace path to start from. Defaults to the kcp's current workspace. pub workspace: Option, /// Walk child workspaces. Defaults to true. pub recursive: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct ListObjectsRequest { /// Kcp id or kubeconfig path from list_kcps. Omit when only one kcp is running. pub kcp: Option, /// Workspace path, for example "root:app". Defaults to the kcp's current workspace. pub workspace: Option, /// Resource name as kubectl accepts it, for example "computevms" or "workspaces.tenancy.kcp.io". pub resource: String, /// Namespace. Use "*" or set all_namespaces for every namespace. pub namespace: Option, pub all_namespaces: Option, pub label_selector: Option, pub field_selector: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct GetObjectRequest { /// Kcp id from list_kcps. Omit when only one kcp is running. pub kcp: Option, pub workspace: Option, pub resource: String, pub name: String, pub namespace: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct GetLogsRequest { /// Kcp id from list_kcps. Omit when only one kcp is running. pub kcp: Option, pub workspace: Option, pub pod: String, pub namespace: Option, pub container: Option, /// Number of trailing lines. Defaults to 200. pub tail_lines: Option, pub previous: Option, /// Only return logs newer than this duration, for example "5m". pub since: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct GetEventsRequest { /// Kcp id from list_kcps. Omit when only one kcp is running. pub kcp: Option, pub workspace: Option, pub namespace: Option, pub field_selector: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct ReadonlyCredentialsRequest { /// Kcp id from list_kcps. Omit when only one kcp is running. pub kcp: Option, pub workspace: Option, /// Mint a fresh token instead of using the cached one. pub refresh: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct ApplyObjectRequest { /// Kcp id from list_kcps. Omit when only one kcp is running. pub kcp: Option, pub workspace: Option, /// One Kubernetes object with apiVersion, kind, and metadata.name. #[schemars(schema_with = "freeform_object_schema")] pub manifest: Value, pub namespace: Option, /// Server-side apply field manager. Defaults to "socialweb-computer-kcp-readwrite". pub field_manager: Option, /// Take ownership of fields another field manager owns. pub force: Option, /// Run admission and validation without storing the object. pub dry_run: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct DeleteObjectRequest { /// Kcp id from list_kcps. Omit when only one kcp is running. pub kcp: Option, pub workspace: Option, pub resource: String, pub name: String, pub namespace: Option, /// Wait for deletion to complete. Defaults to true. pub wait: Option, pub ignore_not_found: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct PatchObjectRequest { /// Kcp id from list_kcps. Omit when only one kcp is running. pub kcp: Option, pub workspace: Option, pub resource: String, pub name: String, pub namespace: Option, #[schemars(schema_with = "freeform_object_schema")] pub patch: Value, /// merge, strategic, or json. Defaults to merge. pub patch_type: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct ScaleObjectRequest { /// Kcp id from list_kcps. Omit when only one kcp is running. pub kcp: Option, pub workspace: Option, pub resource: String, pub name: String, pub namespace: Option, pub replicas: i64, } pub async fn list_kcps(service: &KcpService, request: ListKcpsRequest) -> ToolResult { let (kcps, errors) = service.discover(request.refresh.unwrap_or(false)).await; json_result(Ok(DiscoveryReport { kcps: &kcps, errors: &errors, })) } pub async fn list_workspaces(service: &KcpService, request: ListWorkspacesRequest) -> ToolResult { let result = async { let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; service .list_workspaces( &kcp, request.workspace.as_deref(), request.recursive.unwrap_or(true), ) .await .map(|workspaces| serde_json::json!({ "workspaces": workspaces })) } .await; json_result(result) } pub async fn get_api_resources(service: &KcpService, request: KcpWorkspaceRequest) -> ToolResult { let result = async { let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref()); api_resources(service, &kcp, &workspace).await } .await; json_result(result) } /// Lists the api-resources of a workspace as JSON (`{"resources": [...]}`). pub async fn api_resources( service: &KcpService, kcp: &super::discovery::KcpInfo, workspace: &str, ) -> Result { let command = vec![ "api-resources".to_string(), "--verbs=list,get".to_string(), "-o".to_string(), "wide".to_string(), ]; let table = service.read(kcp, workspace, &command).await?; api_resources_json(&table) } /// kubectl's unknown-resource error, extended with the closest known names. pub(crate) async fn with_resource_hint( service: &KcpService, kcp: &super::discovery::KcpInfo, workspace: &str, error: String, ) -> String { let Some(unknown) = unknown_resource(&error) else { return error; }; let Ok(listed) = api_resources(service, kcp, workspace).await else { return error; }; let mut candidates = Vec::new(); for resource in listed["resources"].as_array().into_iter().flatten() { let name = resource["name"].as_str().unwrap_or_default(); let group = resource["api_version"] .as_str() .and_then(|version| version.rsplit_once('/')) .map(|(group, _)| group); candidates.push(name.to_string()); if let Some(group) = group { candidates.push(format!("{name}.{group}")); } for short in resource["short_names"].as_array().into_iter().flatten() { candidates.extend(short.as_str().map(str::to_string)); } if let Some(kind) = resource["kind"].as_str() { candidates.push(kind.to_lowercase()); } } let suggestions = suggest(&unknown, &candidates); if suggestions.is_empty() { format!("{error}; list kinds with get_api_resources()") } else { format!("{error}; did you mean: {}", suggestions.join(", ")) } } /// `the server doesn't have a resource type "X"` → `X`. fn unknown_resource(error: &str) -> Option { let rest = error.split_once("doesn't have a resource type \"")?.1; Some(rest.split_once('"')?.0.to_string()) } /// Up to three closest names: exact after normalizing `-`/`_` to `.` in /// the group, then substring matches, then edit distance ≤ 3. pub fn suggest(unknown: &str, candidates: &[String]) -> Vec { let wanted = unknown.to_lowercase(); let normalized = wanted.replace(['-', '_'], "."); let mut scored: Vec<(usize, &String)> = candidates .iter() .filter_map(|candidate| { let lower = candidate.to_lowercase(); if lower == normalized && lower != wanted { return Some((0, candidate)); } if lower == wanted { return None; } // Short names need a tight bound: "bids" must not suggest "ns". let distance = edit_distance(&wanted, &lower); if distance <= (wanted.chars().count() / 4).clamp(1, 3) { return Some((1 + distance, candidate)); } if wanted.len() >= 4 && (lower.contains(&wanted) || wanted.contains(&lower)) { return Some((5, candidate)); } None }) .collect(); scored.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.len().cmp(&b.1.len()))); let mut out: Vec = Vec::new(); for (_, candidate) in scored { if !out.contains(candidate) { out.push(candidate.clone()); } if out.len() == 3 { break; } } out } fn edit_distance(a: &str, b: &str) -> usize { let b: Vec = b.chars().collect(); let mut previous: Vec = (0..=b.len()).collect(); for (i, ca) in a.chars().enumerate() { let mut current = vec![i + 1]; for (j, cb) in b.iter().enumerate() { let cost = usize::from(ca != *cb); current.push( (previous[j] + cost) .min(previous[j + 1] + 1) .min(current[j] + 1), ); } previous = current; } previous[b.len()] } pub async fn list_objects(service: &KcpService, request: ListObjectsRequest) -> ToolResult { let result = async { let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref()); let mut command = vec![ "get".to_string(), request.resource.clone(), "-o".to_string(), "json".to_string(), ]; append_namespace( &mut command, request.namespace.as_deref(), request.all_namespaces.unwrap_or(false), ); if let Some(selector) = &request.label_selector { command.push("-l".to_string()); command.push(selector.clone()); } if let Some(selector) = &request.field_selector { command.push("--field-selector".to_string()); command.push(selector.clone()); } match service.read(&kcp, &workspace, &command).await { Ok(text) => Ok(text), Err(error) => Err(with_resource_hint(service, &kcp, &workspace, error).await), } } .await; kubectl_json_result(result) } pub async fn get_object(service: &KcpService, request: GetObjectRequest) -> ToolResult { let result = async { let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref()); let mut command = vec![ "get".to_string(), request.resource, request.name, "-o".to_string(), "json".to_string(), ]; append_namespace(&mut command, request.namespace.as_deref(), false); match service.read(&kcp, &workspace, &command).await { Ok(text) => Ok(text), Err(error) => Err(with_resource_hint(service, &kcp, &workspace, error).await), } } .await; kubectl_json_result(result) } pub async fn get_logs(service: &KcpService, request: GetLogsRequest) -> ToolResult { let result = async { let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref()); let mut command = vec![ "logs".to_string(), request.pod, format!("--tail={}", request.tail_lines.unwrap_or(200)), ]; append_namespace(&mut command, request.namespace.as_deref(), false); if let Some(container) = request.container { command.push("-c".to_string()); command.push(container); } if request.previous.unwrap_or(false) { command.push("--previous".to_string()); } if let Some(since) = request.since { command.push(format!("--since={since}")); } service.read(&kcp, &workspace, &command).await } .await; text_result(result) } pub async fn get_events(service: &KcpService, request: GetEventsRequest) -> ToolResult { let result = async { let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref()); let mut command = vec![ "get".to_string(), "events".to_string(), "-o".to_string(), "json".to_string(), ]; append_namespace(&mut command, request.namespace.as_deref(), true); if let Some(selector) = request.field_selector { command.push("--field-selector".to_string()); command.push(selector); } service.read(&kcp, &workspace, &command).await } .await; kubectl_json_result(result) } pub async fn readonly_credentials( service: &KcpService, request: ReadonlyCredentialsRequest, ) -> ToolResult { let result = async { let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref()); let credential = service .readonly_credential_status(&kcp, &workspace, request.refresh.unwrap_or(false)) .await?; Ok(CredentialReport { kcp: kcp.id.clone(), host: kcp.host.clone(), workspace: workspace.clone(), token_kubeconfig: credential.kubeconfig.clone(), token_expires_in_seconds: credential.token_expires_in_seconds, note: "read-only service account token; the token itself is never returned", }) } .await; json_result(result) } pub async fn apply_object(service: &KcpService, request: ApplyObjectRequest) -> ToolResult { let result = async { validate_manifest(&request.manifest)?; let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref()); let mut command = vec![ "apply".to_string(), "--server-side".to_string(), "-f".to_string(), "-".to_string(), "-o".to_string(), "json".to_string(), format!( "--field-manager={}", request .field_manager .unwrap_or_else(|| "socialweb-computer-kcp-readwrite".to_string()) ), ]; if request.force.unwrap_or(false) { command.push("--force-conflicts".to_string()); } if request.dry_run.unwrap_or(false) { command.push("--dry-run=server".to_string()); } if let Some(namespace) = request.namespace { command.push("-n".to_string()); command.push(namespace); } service .admin( &kcp, &workspace, &command, Some(&request.manifest.to_string()), ) .await } .await; kubectl_json_result(result) } pub async fn delete_object(service: &KcpService, request: DeleteObjectRequest) -> ToolResult { let result = async { let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref()); let mut command = vec![ "delete".to_string(), request.resource, request.name, "-o".to_string(), "name".to_string(), ]; append_namespace(&mut command, request.namespace.as_deref(), false); command.push(format!("--wait={}", request.wait.unwrap_or(true))); if request.ignore_not_found.unwrap_or(false) { command.push("--ignore-not-found".to_string()); } service.admin(&kcp, &workspace, &command, None).await } .await; text_result(result) } pub async fn patch_object(service: &KcpService, request: PatchObjectRequest) -> ToolResult { let result = async { let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref()); let mut command = vec![ "patch".to_string(), request.resource, request.name, "--type".to_string(), request.patch_type.unwrap_or_else(|| "merge".to_string()), "-p".to_string(), request.patch.to_string(), "-o".to_string(), "json".to_string(), ]; append_namespace(&mut command, request.namespace.as_deref(), false); service.admin(&kcp, &workspace, &command, None).await } .await; kubectl_json_result(result) } pub async fn scale_object(service: &KcpService, request: ScaleObjectRequest) -> ToolResult { let result = async { let kcp = service.resolve_kcp(request.kcp.as_deref()).await?; let workspace = service.workspace_or_default(&kcp, request.workspace.as_deref()); let mut command = vec![ "scale".to_string(), request.resource, request.name, format!("--replicas={}", request.replicas), "-o".to_string(), "json".to_string(), ]; append_namespace(&mut command, request.namespace.as_deref(), false); service.admin(&kcp, &workspace, &command, None).await } .await; kubectl_json_result(result) } pub(crate) fn append_namespace( command: &mut Vec, namespace: Option<&str>, all_namespaces: bool, ) { if all_namespaces || namespace == Some("*") { command.push("--all-namespaces".to_string()); return; } if let Some(namespace) = namespace.filter(|namespace| !namespace.is_empty()) { command.push("-n".to_string()); command.push(namespace.to_string()); } } fn validate_manifest(manifest: &Value) -> Result<(), String> { let object = manifest .as_object() .ok_or_else(|| "manifest must be one JSON object".to_string())?; for field in ["apiVersion", "kind"] { if object.get(field).and_then(Value::as_str).is_none() { return Err(format!("manifest is missing {field}")); } } if object .get("metadata") .and_then(|metadata| metadata.get("name")) .and_then(Value::as_str) .is_none() { return Err("manifest is missing metadata.name".to_string()); } if object.get("kind").and_then(Value::as_str) == Some("List") { return Err("manifest must be one object, not a List".to_string()); } if object .get("metadata") .and_then(|metadata| metadata.get("generateName")) .is_some() { return Err("manifest must use metadata.name, not metadata.generateName".to_string()); } Ok(()) } /// Tool output. JSON results carry `structuredContent` (plus the same JSON as /// text); failures set `is_error`, which code mode raises as `RuntimeError`. pub type ToolResult = CallToolResult; fn error_result(error: String) -> ToolResult { CallToolResult::error(vec![ContentBlock::text(format!("error: {error}"))]) } /// `structuredContent` must be an object; anything else is wrapped. fn structured(value: Value) -> ToolResult { if value.is_object() { CallToolResult::structured(value) } else { CallToolResult::structured(serde_json::json!({ "value": value })) } } fn json_result(result: Result) -> ToolResult { match result.and_then(|value| { serde_json::to_value(value).map_err(|error| format!("failed to encode JSON: {error}")) }) { Ok(value) => structured(value), Err(error) => error_result(error), } } /// kubectl `-o json` output. fn kubectl_json_result(result: Result) -> ToolResult { match result { Ok(text) => match serde_json::from_str::(&text) { Ok(value) => structured(value), Err(_) => CallToolResult::success(vec![ContentBlock::text(text)]), }, Err(error) => error_result(error), } } fn text_result(result: Result) -> ToolResult { match result { Ok(text) => CallToolResult::success(vec![ContentBlock::text(text)]), Err(error) => error_result(error), } } #[derive(Debug, Serialize)] struct ApiResource { name: String, short_names: Vec, api_version: String, namespaced: bool, kind: String, verbs: Vec, #[serde(skip_serializing_if = "Vec::is_empty")] categories: Vec, } struct ApiColumns { name: usize, short_names: usize, api_version: usize, namespaced: usize, kind: usize, verbs: usize, categories: Option, } impl ApiColumns { fn parse(header: &str) -> Result { let names = header.split_whitespace().collect::>(); let starts = column_starts(header); if names.len() != starts.len() { return Err(format!( "unexpected api-resources header: {}", header.trim() )); } let index = |name: &str| { names .iter() .position(|column| column.eq_ignore_ascii_case(name)) }; Ok(Self { name: index("NAME") .ok_or_else(|| "api-resources header has no NAME column".to_string())?, short_names: index("SHORTNAMES") .ok_or_else(|| "api-resources header has no SHORTNAMES column".to_string())?, api_version: index("APIVERSION") .ok_or_else(|| "api-resources header has no APIVERSION column".to_string())?, namespaced: index("NAMESPACED") .ok_or_else(|| "api-resources header has no NAMESPACED column".to_string())?, kind: index("KIND") .ok_or_else(|| "api-resources header has no KIND column".to_string())?, verbs: index("VERBS") .ok_or_else(|| "api-resources header has no VERBS column".to_string())?, categories: index("CATEGORIES"), }) } } /// `kubectl api-resources` only supports `-o wide` or `-o name`. Convert the /// wide table into a JSON array so it parses like every other kcp result. fn api_resources_json(table: &str) -> Result { let mut lines = table.lines(); let header = lines .next() .ok_or_else(|| "api-resources returned no header".to_string())?; let starts = column_starts(header); let columns = ApiColumns::parse(header)?; let mut resources = Vec::new(); for row in lines.filter(|line| !line.trim().is_empty()) { let field = |index: usize| field(row, &starts, index); let name = field(columns.name); if name.is_empty() { continue; } resources.push(ApiResource { name: name.to_string(), short_names: split_list(field(columns.short_names)), api_version: field(columns.api_version).to_string(), namespaced: field(columns.namespaced) == "true", kind: field(columns.kind).to_string(), verbs: split_list(field(columns.verbs)), categories: columns .categories .map(|index| split_list(field(index))) .unwrap_or_default(), }); } Ok(serde_json::json!({ "resources": resources })) } fn column_starts(header: &str) -> Vec { let mut starts = Vec::new(); let mut after_space = true; for (index, character) in header.char_indices() { if character.is_whitespace() { after_space = true; } else { if after_space { starts.push(index); } after_space = false; } } starts } fn field<'a>(row: &'a str, starts: &[usize], index: usize) -> &'a str { let start = starts[index]; let end = starts .get(index + 1) .copied() .unwrap_or(row.len()) .min(row.len()); row.get(start..end).unwrap_or("").trim() } fn split_list(value: &str) -> Vec { value .split(',') .map(str::trim) .filter(|item| !item.is_empty()) .map(str::to_string) .collect() } #[cfg(test)] mod tests { use super::{ api_resources_json, suggest, unknown_resource, ApplyObjectRequest, PatchObjectRequest, }; use rmcp::schemars::{schema_for, Schema}; use serde_json::Value; #[test] fn unknown_resources_get_close_suggestions() { let candidates: Vec = [ "atprotorecords", "atprotorecords.socialweb.computer", "atprotorepos", "policyengines", "policyengines.policy.socialweb.computer", "policyworkflows", "roles", "configmaps", "cm", ] .iter() .map(|name| name.to_string()) .collect(); assert_eq!( unknown_resource(r#"error: the server doesn't have a resource type "atproprecord""#) .as_deref(), Some("atproprecord") ); assert_eq!(suggest("atproprecord", &candidates)[0], "atprotorecords"); assert_eq!( suggest("atprotorecords.socialweb-computer", &candidates)[0], "atprotorecords.socialweb.computer" ); assert!(suggest("policy", &candidates).contains(&"policyengines".to_string())); assert!(suggest("zzzzzzzzzz", &candidates).is_empty()); let short: Vec = ["crds", "ns", "ws", "bidders"] .iter() .map(|name| name.to_string()) .collect(); assert!( suggest("bids", &short).is_empty(), "no junk for short names" ); } fn assert_no_boolean_schemas(value: &Value) { match value { Value::Bool(boolean) => panic!("boolean schema {boolean} emitted: {value}"), Value::Array(items) => items.iter().for_each(assert_no_boolean_schemas), Value::Object(object) => object.values().for_each(assert_no_boolean_schemas), _ => {} } } fn rendered_schema(schema: Schema) -> Value { serde_json::to_value(schema).expect("schema serializes") } #[test] fn freeform_fields_emit_object_schemas_not_booleans() { let apply = rendered_schema(schema_for!(ApplyObjectRequest)); let patch = rendered_schema(schema_for!(PatchObjectRequest)); for schema in [&apply, &patch] { assert_no_boolean_schemas(schema); } assert_eq!(apply["properties"]["manifest"]["type"], "object"); assert_eq!(patch["properties"]["patch"]["type"], "object"); } #[test] fn api_resources_table_becomes_a_json_array() { let table = format!( "{:<12}{:<12}{:<12}{:<12}{:<12}{:<12}{}\n{:<12}{:<12}{:<12}{:<12}{:<12}{:<12}{}\n{:<12}{:<12}{:<12}{:<12}{:<12}{:<12}{}\n", "NAME", "SHORTNAMES", "APIVERSION", "NAMESPACED", "KIND", "VERBS", "CATEGORIES", "configmaps", "cm", "v1", "true", "ConfigMap", "get,list", "all", "secrets", "", "v1", "true", "Secret", "get", "", ); let rendered = api_resources_json(&table).expect("table parses"); let value: Value = rendered["resources"].clone(); assert!(value.is_array()); assert_eq!(value[0]["name"], "configmaps"); assert_eq!(value[0]["short_names"], serde_json::json!(["cm"])); assert_eq!(value[0]["api_version"], "v1"); assert_eq!(value[0]["namespaced"], true); assert_eq!(value[0]["kind"], "ConfigMap"); assert_eq!(value[0]["verbs"], serde_json::json!(["get", "list"])); assert_eq!(value[0]["categories"], serde_json::json!(["all"])); assert_eq!(value[1]["name"], "secrets"); assert_eq!(value[1]["short_names"], serde_json::json!([])); assert!(value[1].get("categories").is_none()); } }