use rmcp::schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use tailscale_rest::models::webhook::{CreateWebhookRequest, UpdateWebhookRequest};
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::tools::common::{Done, answered_or, each_present, path_segment, report};
crate::tools! {
tailnet_webhook_list => NoParams, webhook_list,
toolset: TailnetWebhooks, tier: Read, idempotent: true;
tailnet_webhook_create => WebhookCreateParams, webhook_create,
toolset: TailnetWebhooks, tier: Write;
tailnet_webhook_get => WebhookParams, webhook_get,
toolset: TailnetWebhooks, tier: Read, idempotent: true;
tailnet_webhook_subscriptions_replace => WebhookSubscriptionsParams, webhook_subscriptions_replace,
toolset: TailnetWebhooks, tier: Write, idempotent: true;
tailnet_webhook_delete => WebhookParams, webhook_delete,
toolset: TailnetWebhooks, tier: Destructive, idempotent: true;
tailnet_webhook_test => WebhookParams, webhook_test,
toolset: TailnetWebhooks, tier: Write;
tailnet_webhook_secret_rotate => WebhookParams, webhook_secret_rotate,
toolset: TailnetWebhooks, tier: Destructive;
}
fn webhook_path(id: &str, rest: &str) -> ToolResult<String> {
let id = path_segment("endpoint_id", id)?;
Ok(format!("/api/v2/webhooks/{id}{rest}"))
}
fn checked_subscriptions(subscriptions: &[String]) -> ToolResult<Vec<String>> {
if subscriptions.is_empty() {
return Err(ToolError::invalid_args(
"`subscriptions` is empty; an endpoint with no events is one nothing is ever sent to",
));
}
each_present("subscriptions", subscriptions.to_vec())
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct WebhookParams {
pub endpoint_id: String,
}
async fn webhook_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(client.tailnet_path(None, "/webhooks"))
.send_as::<Value>()
.await?)
}
async fn webhook_get(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(webhook_path(¶ms.endpoint_id, "")?)
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct WebhookCreateParams {
pub endpoint_url: String,
#[serde(default)]
pub provider_type: Option<String>,
pub subscriptions: Vec<String>,
}
async fn webhook_create(ctx: &ToolContext, params: WebhookCreateParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let endpoint_url = params.endpoint_url.trim();
if endpoint_url.is_empty() {
return Err(ToolError::invalid_args(
"`endpoint_url` is empty; a webhook needs somewhere to post to",
));
}
let mut body = CreateWebhookRequest::default();
body.endpoint_url = Some(endpoint_url.to_owned());
body.provider_type = params.provider_type.clone();
body.subscriptions = Some(checked_subscriptions(¶ms.subscriptions)?);
Ok(client
.post(client.tailnet_path(None, "/webhooks"))
.json(&body)
.send_as::<Value>()
.await?)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct WebhookSubscriptionsParams {
pub endpoint_id: String,
pub subscriptions: Vec<String>,
}
async fn webhook_subscriptions_replace(
ctx: &ToolContext,
params: WebhookSubscriptionsParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = webhook_path(¶ms.endpoint_id, "")?;
let mut body = UpdateWebhookRequest::default();
body.subscriptions = Some(checked_subscriptions(¶ms.subscriptions)?);
Ok(client.patch(path).json(&body).send_as::<Value>().await?)
}
async fn webhook_delete(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
client
.delete(webhook_path(¶ms.endpoint_id, "")?)
.send()
.await?;
report(Done::new("webhook deleted").about("endpoint_id", params.endpoint_id))
}
async fn webhook_test(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let answer = client
.post(webhook_path(¶ms.endpoint_id, "/test")?)
.send()
.await?;
answered_or(
answer,
Done::new("test event queued for delivery").about("endpoint_id", params.endpoint_id),
)
}
async fn webhook_secret_rotate(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.post(webhook_path(¶ms.endpoint_id, "/rotate")?)
.send_as::<Value>()
.await?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_event_the_description_does_not_have_is_still_subscribed_to() {
let good = ["nodeCreated".to_owned(), "userDeleted".to_owned()];
assert_eq!(checked_subscriptions(&good).expect("known events"), good);
let newer = ["categoryTailnetManagement".to_owned()];
assert_eq!(checked_subscriptions(&newer).expect("sent anyway"), newer);
let error = checked_subscriptions(&["nodeCreated".to_owned(), " ".to_owned()])
.expect_err("one is blank");
let reported = serde_json::to_value(&error).expect("reportable");
assert_eq!(reported["code"], serde_json::json!("invalid_args"));
}
#[test]
fn an_endpoint_with_no_events_is_refused_rather_than_created() {
assert!(checked_subscriptions(&[]).is_err());
}
}