use rmcp::schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use tailscale_rest::models::user::CONTACT_TYPES;
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::tools::common::{Done, answered_or, one_of};
crate::tools! {
tailnet_contacts_get => NoParams, contacts_get,
toolset: TailnetSettings, tier: Read, idempotent: true;
tailnet_contact_update => ContactUpdateParams, contact_update,
toolset: TailnetSettings, tier: Write, idempotent: true;
tailnet_contact_verification_resend => ContactParams, contact_verification_resend,
toolset: TailnetSettings, tier: Write, idempotent: false;
tailnet_settings_get => NoParams, settings_get,
toolset: TailnetSettings, tier: Read, idempotent: true;
tailnet_settings_update => SettingsUpdateParams, settings_update,
toolset: TailnetSettings, tier: Write, idempotent: true;
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
async fn contacts_get(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(client.tailnet_path(None, "/contacts"))
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ContactParams {
pub contact_type: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ContactUpdateParams {
pub contact_type: String,
pub email: String,
}
fn contact_path(client: &tailscale_rest::Client, kind: &str, rest: &str) -> ToolResult<String> {
let kind = one_of("contact_type", kind, CONTACT_TYPES)?;
Ok(client.tailnet_path(None, &format!("/contacts/{kind}{rest}")))
}
async fn contact_update(ctx: &ToolContext, params: ContactUpdateParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = contact_path(client, ¶ms.contact_type, "")?;
let email = params.email.trim();
if email.is_empty() {
return Err(ToolError::invalid_args(
"`email` is empty; a contact has to be an address",
));
}
let body = tailscale_rest::models::user::UpdateContact {
email: Some(email.to_owned()),
unknown: Default::default(),
};
let answer = client.patch(path).json(&body).send().await?;
answered_or(
answer,
Done::new("verification sent to the new address")
.about("contact_type", params.contact_type)
.about("email", email),
)
}
async fn contact_verification_resend(
ctx: &ToolContext,
params: ContactParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = contact_path(client, ¶ms.contact_type, "/resend-verification-email")?;
let answer = client.post(path).send().await?;
answered_or(
answer,
Done::new("verification sent again").about("contact_type", params.contact_type),
)
}
async fn settings_get(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(client.tailnet_path(None, "/settings"))
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SettingsUpdateParams {
pub settings: Value,
}
async fn settings_update(ctx: &ToolContext, params: SettingsUpdateParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let Some(settings) = params.settings.as_object() else {
return Err(ToolError::invalid_args(
"`settings` is an object of settings to change, as `tailnet_settings_get` reports \
them",
));
};
if settings.is_empty() {
return Err(ToolError::invalid_args(
"`settings` is empty; give at least one setting to change",
));
}
let answer = client
.patch(client.tailnet_path(None, "/settings"))
.json(¶ms.settings)
.send()
.await?;
answered_or(answer, Done::new("settings changed"))
}
#[cfg(test)]
mod tests {
use super::*;
fn client() -> tailscale_rest::Client {
tailscale_rest::Client::new(tailscale_rest::ClientConfig::new(
tailscale_rest::credentials::Credentials::ApiKey(tailscale_rest::Secret::new(
"tskey-api-nExAmPlE-redacted",
)),
))
.expect("a client with no network behind it")
}
#[test]
fn a_contact_kind_that_is_not_one_of_the_three_never_reaches_a_path() {
let client = client();
assert_eq!(
contact_path(&client, "security", "/resend-verification-email").expect("a kind"),
"/api/v2/tailnet/-/contacts/security/resend-verification-email"
);
assert!(contact_path(&client, "..", "").is_err());
assert!(contact_path(&client, "billing", "").is_err());
}
}