tailscale_mcp/tools/
tailnet_webhooks.rs1use rmcp::schemars::JsonSchema;
21use serde::Deserialize;
22use serde_json::Value;
23
24use tailscale_rest::models::webhook::{CreateWebhookRequest, UpdateWebhookRequest};
25
26use crate::context::ToolContext;
27use crate::error::{ToolError, ToolResult};
28use crate::tools::common::{Done, answered_or, each_present, path_segment, report};
29
30crate::tools! {
31 tailnet_webhook_list => NoParams, webhook_list,
34 toolset: TailnetWebhooks, tier: Read, idempotent: true;
35
36 tailnet_webhook_create => WebhookCreateParams, webhook_create,
46 toolset: TailnetWebhooks, tier: Write;
47
48 tailnet_webhook_get => WebhookParams, webhook_get,
50 toolset: TailnetWebhooks, tier: Read, idempotent: true;
51
52 tailnet_webhook_subscriptions_replace => WebhookSubscriptionsParams, webhook_subscriptions_replace,
58 toolset: TailnetWebhooks, tier: Write, idempotent: true;
59
60 tailnet_webhook_delete => WebhookParams, webhook_delete,
62 toolset: TailnetWebhooks, tier: Destructive, idempotent: true;
63
64 tailnet_webhook_test => WebhookParams, webhook_test,
70 toolset: TailnetWebhooks, tier: Write;
71
72 tailnet_webhook_secret_rotate => WebhookParams, webhook_secret_rotate,
78 toolset: TailnetWebhooks, tier: Destructive;
79}
80
81fn webhook_path(id: &str, rest: &str) -> ToolResult<String> {
82 let id = path_segment("endpoint_id", id)?;
83 Ok(format!("/api/v2/webhooks/{id}{rest}"))
84}
85
86fn checked_subscriptions(subscriptions: &[String]) -> ToolResult<Vec<String>> {
92 if subscriptions.is_empty() {
93 return Err(ToolError::invalid_args(
94 "`subscriptions` is empty; an endpoint with no events is one nothing is ever sent to",
95 ));
96 }
97 each_present("subscriptions", subscriptions.to_vec())
104}
105
106#[derive(Debug, Deserialize, JsonSchema)]
107pub struct NoParams {}
108
109#[derive(Debug, Deserialize, JsonSchema)]
110pub struct WebhookParams {
111 pub endpoint_id: String,
113}
114
115async fn webhook_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
116 let client = ctx.tailnet()?;
117 Ok(client
118 .get(client.tailnet_path(None, "/webhooks"))
119 .send_as::<Value>()
120 .await?)
121}
122
123async fn webhook_get(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
124 let client = ctx.tailnet()?;
125 Ok(client
126 .get(webhook_path(¶ms.endpoint_id, "")?)
127 .send_as::<Value>()
128 .await?)
129}
130
131#[derive(Debug, Deserialize, JsonSchema)]
132pub struct WebhookCreateParams {
133 pub endpoint_url: String,
135 #[serde(default)]
138 pub provider_type: Option<String>,
139 pub subscriptions: Vec<String>,
141}
142
143async fn webhook_create(ctx: &ToolContext, params: WebhookCreateParams) -> ToolResult<Value> {
144 let client = ctx.tailnet()?;
145 let endpoint_url = params.endpoint_url.trim();
146 if endpoint_url.is_empty() {
147 return Err(ToolError::invalid_args(
148 "`endpoint_url` is empty; a webhook needs somewhere to post to",
149 ));
150 }
151 let mut body = CreateWebhookRequest::default();
155 body.endpoint_url = Some(endpoint_url.to_owned());
156 body.provider_type = params.provider_type.clone();
159 body.subscriptions = Some(checked_subscriptions(¶ms.subscriptions)?);
160 Ok(client
161 .post(client.tailnet_path(None, "/webhooks"))
162 .json(&body)
163 .send_as::<Value>()
164 .await?)
165}
166
167#[derive(Debug, Deserialize, JsonSchema)]
168pub struct WebhookSubscriptionsParams {
169 pub endpoint_id: String,
171 pub subscriptions: Vec<String>,
174}
175
176async fn webhook_subscriptions_replace(
177 ctx: &ToolContext,
178 params: WebhookSubscriptionsParams,
179) -> ToolResult<Value> {
180 let client = ctx.tailnet()?;
181 let path = webhook_path(¶ms.endpoint_id, "")?;
182 let mut body = UpdateWebhookRequest::default();
183 body.subscriptions = Some(checked_subscriptions(¶ms.subscriptions)?);
184 Ok(client.patch(path).json(&body).send_as::<Value>().await?)
185}
186
187async fn webhook_delete(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
188 let client = ctx.tailnet()?;
189 client
190 .delete(webhook_path(¶ms.endpoint_id, "")?)
191 .send()
192 .await?;
193 report(Done::new("webhook deleted").about("endpoint_id", params.endpoint_id))
194}
195
196async fn webhook_test(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
197 let client = ctx.tailnet()?;
198 let answer = client
199 .post(webhook_path(¶ms.endpoint_id, "/test")?)
200 .send()
201 .await?;
202 answered_or(
203 answer,
204 Done::new("test event queued for delivery").about("endpoint_id", params.endpoint_id),
208 )
209}
210
211async fn webhook_secret_rotate(ctx: &ToolContext, params: WebhookParams) -> ToolResult<Value> {
212 let client = ctx.tailnet()?;
213 Ok(client
215 .post(webhook_path(¶ms.endpoint_id, "/rotate")?)
216 .send_as::<Value>()
217 .await?)
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn an_event_the_description_does_not_have_is_still_subscribed_to() {
226 let good = ["nodeCreated".to_owned(), "userDeleted".to_owned()];
227 assert_eq!(checked_subscriptions(&good).expect("known events"), good);
228
229 let newer = ["categoryTailnetManagement".to_owned()];
233 assert_eq!(checked_subscriptions(&newer).expect("sent anyway"), newer);
234
235 let error = checked_subscriptions(&["nodeCreated".to_owned(), " ".to_owned()])
237 .expect_err("one is blank");
238 let reported = serde_json::to_value(&error).expect("reportable");
239 assert_eq!(reported["code"], serde_json::json!("invalid_args"));
240 }
241
242 #[test]
243 fn an_endpoint_with_no_events_is_refused_rather_than_created() {
244 assert!(checked_subscriptions(&[]).is_err());
247 }
248}