use rmcp::schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use tailscale_rest::ApiError;
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::tools::common::{Done, answered_or, path_segment, require_destructive};
crate::tools! {
tailnet_service_list => NoParams, service_list,
toolset: TailnetServices, tier: Read, idempotent: true;
tailnet_service_get => ServiceParams, service_get,
toolset: TailnetServices, tier: Read, idempotent: true;
tailnet_service_replace => ServiceReplaceParams, service_replace,
toolset: TailnetServices, tier: Write, idempotent: true;
tailnet_service_delete => ServiceParams, service_delete,
toolset: TailnetServices, tier: Destructive, idempotent: true;
tailnet_service_devices_list => ServiceParams, service_devices_list,
toolset: TailnetServices, tier: Read, idempotent: true;
tailnet_service_approval_get => ServiceDeviceParams, service_approval_get,
toolset: TailnetServices, tier: Read, idempotent: true;
tailnet_service_approval_set => ServiceApprovalParams, service_approval_set,
toolset: TailnetServices, tier: Write, idempotent: true, varying: true;
}
const DOCUMENTED: &str = "/services";
const GO_CLIENT: &str = "/vip-services";
async fn either_spelling<F, Fut>(send: F) -> ToolResult<Value>
where
F: Fn(&'static str) -> Fut,
Fut: std::future::Future<Output = Result<Value, ApiError>>,
{
match send(DOCUMENTED).await {
Err(error) if error.status() == Some(404) => Ok(send(GO_CLIENT).await?),
other => Ok(other?),
}
}
fn service_path(client: &tailscale_rest::Client, base: &str, name: &str, rest: &str) -> String {
client.tailnet_path(None, &format!("{base}/{name}{rest}"))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
async fn service_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
either_spelling(|base| {
client
.get(client.tailnet_path(None, base))
.send_as::<Value>()
})
.await
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ServiceParams {
pub service_name: String,
}
async fn service_get(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let name = &path_segment("service_name", ¶ms.service_name)?;
either_spelling(|base| {
client
.get(service_path(client, base, name, ""))
.send_as::<Value>()
})
.await
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ServiceReplaceParams {
pub service_name: String,
pub service: Value,
}
async fn service_replace(ctx: &ToolContext, params: ServiceReplaceParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
if !params.service.is_object() {
return Err(ToolError::invalid_args(
"`service` is the service document, an object with `name`, `displayName`, `addrs`, \
`comment`, `ports` and `tags`",
)
.with_hint("Call `tailnet_service_get` and send back what it answered."));
}
let name = &path_segment("service_name", ¶ms.service_name)?;
let body = ¶ms.service;
either_spelling(|base| {
client
.put(service_path(client, base, name, ""))
.json(body)
.send_as::<Value>()
})
.await
}
async fn service_delete(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let name = &path_segment("service_name", ¶ms.service_name)?;
let answer = either_spelling(|base| {
client
.delete(service_path(client, base, name, ""))
.send_as::<Value>()
})
.await?;
answered_or(
answer,
Done::new("service deleted").about("service_name", params.service_name.clone()),
)
}
async fn service_devices_list(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let name = &path_segment("service_name", ¶ms.service_name)?;
either_spelling(|base| {
client
.get(service_path(client, base, name, "/devices"))
.send_as::<Value>()
})
.await
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ServiceDeviceParams {
pub service_name: String,
pub device_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ServiceApprovalParams {
pub service_name: String,
pub device_id: String,
pub approved: bool,
}
fn approval_suffix(device: &str) -> ToolResult<String> {
let device = path_segment("device_id", device)?;
Ok(format!("/device/{device}/approved"))
}
async fn service_approval_get(ctx: &ToolContext, params: ServiceDeviceParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let name = &path_segment("service_name", ¶ms.service_name)?;
let device = crate::tools::tailnet_devices::resolve(ctx, ¶ms.device_id).await?;
let suffix = approval_suffix(&device)?;
either_spelling(|base| {
client
.get(service_path(client, base, name, &suffix))
.send_as::<Value>()
})
.await
}
async fn service_approval_set(
ctx: &ToolContext,
params: ServiceApprovalParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
if !params.approved {
require_destructive(ctx, "withdrawing a device's approval to serve a service")?;
}
let name = &path_segment("service_name", ¶ms.service_name)?;
let device = crate::tools::tailnet_devices::resolve(ctx, ¶ms.device_id).await?;
let suffix = approval_suffix(&device)?;
let mut body = tailscale_rest::models::service::ServiceApprovalRequest::default();
body.approved = Some(params.approved);
either_spelling(|base| {
client
.post(service_path(client, base, name, &suffix))
.json(&body)
.send_as::<Value>()
})
.await
}