use rmcp::schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::tools::common::each_present;
crate::tools! {
tailnet_dns_nameservers_get => NoParams, nameservers_get,
toolset: TailnetDns, tier: Read, idempotent: true;
tailnet_dns_nameservers_replace => NameserversParams, nameservers_replace,
toolset: TailnetDns, tier: Write, idempotent: true;
tailnet_dns_preferences_get => NoParams, preferences_get,
toolset: TailnetDns, tier: Read, idempotent: true;
tailnet_dns_preferences_set => PreferencesParams, preferences_set,
toolset: TailnetDns, tier: Write, idempotent: true;
tailnet_dns_search_paths_get => NoParams, search_paths_get,
toolset: TailnetDns, tier: Read, idempotent: true;
tailnet_dns_search_paths_replace => SearchPathsParams, search_paths_replace,
toolset: TailnetDns, tier: Write, idempotent: true;
tailnet_dns_split_get => NoParams, split_get,
toolset: TailnetDns, tier: Read, idempotent: true;
tailnet_dns_split_update => SplitParams, split_update,
toolset: TailnetDns, tier: Write, idempotent: true;
tailnet_dns_split_replace => SplitParams, split_replace,
toolset: TailnetDns, tier: Write, idempotent: true;
tailnet_dns_configuration_get => NoParams, configuration_get,
toolset: TailnetDns, tier: Read, idempotent: true;
tailnet_dns_configuration_replace => ConfigurationParams, configuration_replace,
toolset: TailnetDns, tier: Write, idempotent: true;
}
fn dns_path(client: &tailscale_rest::Client, rest: &str) -> String {
client.tailnet_path(None, &format!("/dns{rest}"))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
async fn nameservers_get(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(dns_path(client, "/nameservers"))
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NameserversParams {
pub dns: Vec<String>,
}
async fn nameservers_replace(ctx: &ToolContext, params: NameserversParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let body = tailscale_rest::models::dns::DnsNameserversRequest {
dns: Some(each_present("dns", params.dns)?),
unknown: Default::default(),
};
Ok(client
.post(dns_path(client, "/nameservers"))
.json(&body)
.send_as::<Value>()
.await?)
}
async fn preferences_get(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(dns_path(client, "/preferences"))
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct PreferencesParams {
pub magic_dns: bool,
}
async fn preferences_set(ctx: &ToolContext, params: PreferencesParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let body = tailscale_rest::models::dns::DnsPreferences {
magic_dns: Some(params.magic_dns),
unknown: Default::default(),
};
Ok(client
.post(dns_path(client, "/preferences"))
.json(&body)
.send_as::<Value>()
.await?)
}
async fn search_paths_get(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(dns_path(client, "/searchpaths"))
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SearchPathsParams {
pub search_paths: Vec<String>,
}
async fn search_paths_replace(ctx: &ToolContext, params: SearchPathsParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let body = tailscale_rest::models::dns::DnsSearchPaths {
search_paths: Some(each_present("search_paths", params.search_paths)?),
unknown: Default::default(),
};
Ok(client
.post(dns_path(client, "/searchpaths"))
.json(&body)
.send_as::<Value>()
.await?)
}
async fn split_get(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(dns_path(client, "/split-dns"))
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct SplitParams {
pub domains: tailscale_rest::models::dns::SplitDns,
}
async fn split_update(ctx: &ToolContext, params: SplitParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.patch(dns_path(client, "/split-dns"))
.json(¶ms.domains)
.send_as::<Value>()
.await?)
}
async fn split_replace(ctx: &ToolContext, params: SplitParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.put(dns_path(client, "/split-dns"))
.json(¶ms.domains)
.send_as::<Value>()
.await?)
}
async fn configuration_get(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(dns_path(client, "/configuration"))
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ConfigurationParams {
pub configuration: Value,
}
async fn configuration_replace(
ctx: &ToolContext,
params: ConfigurationParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
if !params.configuration.is_object() {
return Err(ToolError::invalid_args(
"`configuration` is the DNS configuration document, an object with `nameservers`, \
`splitDNS`, `searchPaths` and `preferences`",
)
.with_hint("Call `tailnet_dns_configuration_get` and send back what it answered."));
}
Ok(client
.post(dns_path(client, "/configuration"))
.json(¶ms.configuration)
.send_as::<Value>()
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_blank_entry_is_refused_naming_the_parameter_it_came_from() {
assert_eq!(
each_present("dns", Vec::new()).expect("an empty list"),
Vec::<String>::new()
);
assert_eq!(
each_present("dns", vec![" 8.8.8.8 ".to_owned()]).expect("one address"),
["8.8.8.8"]
);
for what in ["dns", "search_paths"] {
let error = each_present(what, vec![String::new()]).expect_err("a blank entry");
let reported = serde_json::to_value(&error).expect("reportable");
assert!(
reported["message"]
.as_str()
.is_some_and(|m| m.contains(what)),
"{reported:#?}"
);
}
}
}