use rmcp::schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::tools::common::{Done, path_segment, report};
const LARGEST_PAGE: u32 = 100;
const PAGES_FOLLOWED: usize = 10;
crate::tools! {
tailnet_organization_tailnet_list => OrganizationTailnetListParams, organization_tailnet_list,
toolset: TailnetOrg, tier: Read, idempotent: true;
tailnet_organization_tailnet_create => OrganizationTailnetCreateParams, organization_tailnet_create,
toolset: TailnetOrg, tier: Write, idempotent: false;
tailnet_organization_tailnet_delete => OrganizationTailnetParams, organization_tailnet_delete,
toolset: TailnetOrg, tier: Destructive, confirm: true;
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct OrganizationTailnetListParams {
pub organization: String,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub cursor: Option<String>,
}
fn organization_path(organization: &str) -> ToolResult<String> {
Ok(format!(
"/api/v2/organizations/{}/tailnets",
path_segment("organization", organization)?
))
}
fn checked_limit(limit: Option<u32>) -> ToolResult<u32> {
match limit {
None => Ok(LARGEST_PAGE),
Some(limit) if (1..=LARGEST_PAGE).contains(&limit) => Ok(limit),
Some(limit) => Err(ToolError::invalid_args(format!(
"`limit` is between 1 and {LARGEST_PAGE}; `{limit}` is outside that"
))),
}
}
fn page_of(answer: &Value) -> ToolResult<(Vec<Value>, Option<String>)> {
let tailnets = answer
.get("tailnets")
.and_then(Value::as_array)
.ok_or_else(|| {
ToolError::new(
crate::error::ErrorCode::ApiError,
"the control plane answered a listing with no `tailnets` list, so there is \
nothing to follow",
)
.with_hint("Call again with a `cursor` to take the answer one page at a time.")
})?
.clone();
let cursor = answer
.get("cursor")
.and_then(Value::as_str)
.filter(|cursor| !cursor.is_empty())
.map(str::to_owned);
Ok((tailnets, cursor))
}
async fn organization_tailnet_list(
ctx: &ToolContext,
params: OrganizationTailnetListParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = organization_path(¶ms.organization)?;
let limit = checked_limit(params.limit)?;
if let Some(cursor) = params.cursor {
return Ok(client
.get(path)
.query("limit", limit)
.query("cursor", cursor)
.send_as::<Value>()
.await?);
}
let mut gathered: Vec<Value> = Vec::new();
let mut total: Option<Value> = None;
let mut cursor: Option<String> = None;
for _ in 0..PAGES_FOLLOWED {
let answer = client
.get(path.clone())
.query("limit", limit)
.maybe_query("cursor", cursor.as_deref())
.send_as::<Value>()
.await?;
total = answer.get("totalCount").cloned().or(total);
let (tailnets, next) = page_of(&answer)?;
gathered.extend(tailnets);
cursor = next;
if cursor.is_none() {
break;
}
}
let mut answer = serde_json::Map::new();
answer.insert("tailnets".to_owned(), Value::Array(gathered));
if let Some(total) = total {
answer.insert("totalCount".to_owned(), total);
}
if let Some(cursor) = cursor {
answer.insert("cursor".to_owned(), Value::String(cursor));
answer.insert(
"more".to_owned(),
Value::String(format!(
"stopped after {PAGES_FOLLOWED} pages; call again with this `cursor` for the rest"
)),
);
}
Ok(Value::Object(answer))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct OrganizationTailnetCreateParams {
pub organization: String,
pub display_name: String,
}
async fn organization_tailnet_create(
ctx: &ToolContext,
params: OrganizationTailnetCreateParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = organization_path(¶ms.organization)?;
Ok(client
.post(path)
.json(&serde_json::json!({"displayName": params.display_name}))
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct OrganizationTailnetParams {
pub tailnet: String,
}
async fn organization_tailnet_delete(
ctx: &ToolContext,
params: OrganizationTailnetParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = format!(
"/api/v2/tailnet/{}",
path_segment("tailnet", ¶ms.tailnet)?
);
client.delete(path).send().await?;
report(Done::new("tailnet deleted").about("tailnet", params.tailnet))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_page_size_the_api_would_reject_is_refused_here_rather_than_shortened() {
assert_eq!(checked_limit(None).expect("default"), LARGEST_PAGE);
assert_eq!(checked_limit(Some(25)).expect("in range"), 25);
assert!(checked_limit(Some(0)).is_err());
let error = checked_limit(Some(500)).expect_err("too large");
let reported = serde_json::to_value(&error).expect("reportable");
let message = reported["message"].as_str().expect("a message");
assert!(
message.contains("100") && message.contains("500"),
"should name both the limit and what was asked: {message}"
);
}
#[test]
fn an_empty_cursor_ends_the_walk_rather_than_repeating_the_last_page() {
let (tailnets, cursor) =
page_of(&json!({"tailnets": [{"id": "T1"}], "cursor": ""})).expect("a page");
assert_eq!(tailnets.len(), 1);
assert_eq!(cursor, None, "an empty cursor is not a cursor");
let (_, cursor) = page_of(&json!({"tailnets": [], "cursor": "abc"})).expect("a page");
assert_eq!(cursor.as_deref(), Some("abc"));
}
#[test]
fn a_listing_with_no_tailnets_array_is_not_read_as_an_empty_organisation() {
assert!(page_of(&json!({"totalCount": 3})).is_err());
}
}