1use rmcp::schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34use tailscale_rest::models::user::INVITE_ROLES;
35
36use crate::context::ToolContext;
37use crate::error::{ErrorCode, ToolError, ToolResult};
38use crate::tools::common::{Done, one_of, path_segment, report};
39
40crate::tools! {
41 tailnet_device_invite_list => DeviceParams, device_invite_list,
43 toolset: TailnetInvites, tier: Read, idempotent: true;
44
45 tailnet_device_invite_create => DeviceInviteCreateParams, device_invite_create,
54 toolset: TailnetInvites, tier: Write;
55
56 tailnet_device_invite_get => InviteParams, device_invite_get,
58 toolset: TailnetInvites, tier: Read, idempotent: true;
59
60 tailnet_device_invite_delete => InviteParams, device_invite_delete,
63 toolset: TailnetInvites, tier: Destructive, idempotent: true;
64
65 tailnet_device_invite_resend => InviteParams, device_invite_resend,
70 toolset: TailnetInvites, tier: Write, idempotent: false;
71
72 tailnet_device_invite_accept => AcceptParams, device_invite_accept,
78 toolset: TailnetInvites, tier: Write;
79
80 tailnet_user_invite_list => NoParams, user_invite_list,
82 toolset: TailnetInvites, tier: Read, idempotent: true;
83
84 tailnet_user_invite_create => UserInviteCreateParams, user_invite_create,
92 toolset: TailnetInvites, tier: Write;
93
94 tailnet_user_invite_get => InviteParams, user_invite_get,
96 toolset: TailnetInvites, tier: Read, idempotent: true;
97
98 tailnet_user_invite_delete => InviteParams, user_invite_delete,
100 toolset: TailnetInvites, tier: Destructive, idempotent: true;
101
102 tailnet_user_invite_resend => InviteParams, user_invite_resend,
107 toolset: TailnetInvites, tier: Write, idempotent: false;
108}
109
110fn as_listing(answer: Value) -> Value {
118 match answer {
119 Value::Array(invites) => serde_json::json!({"invites": invites}),
120 answered => answered,
121 }
122}
123
124const NEEDS_A_PERSON: &str = "This endpoint accepts only a credential owned by a user, because an invitation records who \
132 sent it. A token minted from an OAuth client or a federated identity is refused however \
133 wide its scopes are.";
134
135fn as_person(error: tailscale_rest::ApiError) -> ToolError {
141 explain_credential(ToolError::from(error))
142}
143
144fn explain_credential(error: ToolError) -> ToolError {
145 if matches!(error.code, ErrorCode::ApiError) && matches!(error.status, Some(400 | 401 | 403)) {
146 return error.with_hint(NEEDS_A_PERSON);
147 }
148 error
149}
150
151fn device_invites_path(device: &str) -> ToolResult<String> {
152 let id = path_segment("device_id", device)?;
153 Ok(format!("/api/v2/device/{id}/device-invites"))
154}
155
156fn device_invite_path(id: &str, rest: &str) -> ToolResult<String> {
157 let id = path_segment("invite_id", id)?;
158 Ok(format!("/api/v2/device-invites/{id}{rest}"))
159}
160
161fn user_invite_path(id: &str, rest: &str) -> ToolResult<String> {
162 let id = path_segment("invite_id", id)?;
163 Ok(format!("/api/v2/user-invites/{id}{rest}"))
164}
165
166#[derive(Debug, Deserialize, JsonSchema)]
167pub struct NoParams {}
168
169#[derive(Debug, Deserialize, JsonSchema)]
170pub struct DeviceParams {
171 pub device_id: String,
174}
175
176#[derive(Debug, Deserialize, JsonSchema)]
181pub struct InviteParams {
182 pub invite_id: String,
184}
185
186async fn device_invite_list(ctx: &ToolContext, params: DeviceParams) -> ToolResult<Value> {
191 let device = crate::tools::tailnet_devices::resolve(ctx, ¶ms.device_id).await?;
192 let client = ctx.tailnet()?;
193 Ok(as_listing(
194 client
195 .get(device_invites_path(&device)?)
196 .send_as::<Value>()
197 .await?,
198 ))
199}
200
201#[derive(Debug, Deserialize, JsonSchema)]
202pub struct DeviceInviteCreateParams {
203 pub device_id: String,
206 pub invites: Vec<DeviceInviteRequest>,
209}
210
211#[derive(Debug, Deserialize, Serialize, JsonSchema)]
212pub struct DeviceInviteRequest {
213 #[serde(rename = "multiUse", default, skip_serializing_if = "Option::is_none")]
215 pub multi_use: Option<bool>,
216 #[serde(
219 rename = "allowExitNode",
220 default,
221 skip_serializing_if = "Option::is_none"
222 )]
223 pub allow_exit_node: Option<bool>,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub email: Option<String>,
228}
229
230async fn device_invite_create(
231 ctx: &ToolContext,
232 params: DeviceInviteCreateParams,
233) -> ToolResult<Value> {
234 let client = ctx.tailnet()?;
235 if params.invites.is_empty() {
236 return Err(ToolError::invalid_args(
237 "`invites` is empty; give at least one invitation to create",
238 ));
239 }
240 let device = crate::tools::tailnet_devices::resolve(ctx, ¶ms.device_id).await?;
241 client
242 .post(device_invites_path(&device)?)
243 .json(¶ms.invites)
244 .send_as::<Value>()
245 .await
246 .map(as_listing)
247 .map_err(as_person)
248}
249
250async fn device_invite_get(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
251 let client = ctx.tailnet()?;
252 Ok(client
253 .get(device_invite_path(¶ms.invite_id, "")?)
254 .send_as::<Value>()
255 .await?)
256}
257
258async fn device_invite_delete(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
259 let client = ctx.tailnet()?;
260 client
261 .delete(device_invite_path(¶ms.invite_id, "")?)
262 .send()
263 .await?;
264 report(Done::new("invitation withdrawn").about("invite_id", params.invite_id))
265}
266
267async fn device_invite_resend(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
268 let client = ctx.tailnet()?;
269 let path = device_invite_path(¶ms.invite_id, "/resend")?;
270 resend(client, path, params.invite_id).await
271}
272
273async fn resend(
275 client: &tailscale_rest::Client,
276 path: String,
277 invite_id: String,
278) -> ToolResult<Value> {
279 let answer = client.post(path).send().await.map_err(as_person)?;
280 crate::tools::common::answered_or(
281 answer,
282 Done::new("invitation sent again").about("invite_id", invite_id),
283 )
284}
285
286#[derive(Debug, Deserialize, JsonSchema)]
287pub struct AcceptParams {
288 pub invite: String,
291}
292
293#[derive(Debug, Serialize)]
294struct Accept {
295 invite: String,
296}
297
298async fn device_invite_accept(ctx: &ToolContext, params: AcceptParams) -> ToolResult<Value> {
299 let client = ctx.tailnet()?;
300 let invite = params.invite.trim();
301 if invite.is_empty() {
302 return Err(ToolError::invalid_args(
303 "`invite` is empty; give the invitation URL or its id",
304 ));
305 }
306 let body = Accept {
310 invite: invite.to_owned(),
311 };
312 client
313 .post("/api/v2/device-invites/-/accept")
314 .json(&body)
315 .send_as::<Value>()
316 .await
317 .map_err(as_person)
318}
319
320async fn user_invite_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
325 let client = ctx.tailnet()?;
326 Ok(as_listing(
327 client
328 .get(client.tailnet_path(None, "/user-invites"))
329 .send_as::<Value>()
330 .await?,
331 ))
332}
333
334#[derive(Debug, Deserialize, JsonSchema)]
335pub struct UserInviteCreateParams {
336 pub invites: Vec<UserInviteRequest>,
338}
339
340#[derive(Debug, Deserialize, JsonSchema)]
341pub struct UserInviteRequest {
342 #[serde(default)]
347 pub role: Option<String>,
348 #[serde(default)]
351 pub email: Option<String>,
352}
353
354async fn user_invite_create(
355 ctx: &ToolContext,
356 params: UserInviteCreateParams,
357) -> ToolResult<Value> {
358 let client = ctx.tailnet()?;
359 if params.invites.is_empty() {
360 return Err(ToolError::invalid_args(
361 "`invites` is empty; give at least one invitation to create",
362 ));
363 }
364 let body: Vec<Value> = params
365 .invites
366 .iter()
367 .map(|invite| {
368 let mut entry = serde_json::Map::new();
369 if let Some(role) = &invite.role {
370 entry.insert(
371 "role".to_owned(),
372 Value::String(one_of("role", role, INVITE_ROLES)?),
373 );
374 }
375 if let Some(email) = &invite.email {
376 entry.insert("email".to_owned(), Value::String(email.clone()));
377 }
378 Ok(Value::Object(entry))
379 })
380 .collect::<ToolResult<_>>()?;
381 client
382 .post(client.tailnet_path(None, "/user-invites"))
383 .json(&body)
384 .send_as::<Value>()
385 .await
386 .map(as_listing)
387 .map_err(as_person)
388}
389
390async fn user_invite_get(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
391 let client = ctx.tailnet()?;
392 Ok(client
393 .get(user_invite_path(¶ms.invite_id, "")?)
394 .send_as::<Value>()
395 .await?)
396}
397
398async fn user_invite_delete(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
399 let client = ctx.tailnet()?;
400 client
401 .delete(user_invite_path(¶ms.invite_id, "")?)
402 .send()
403 .await
404 .map_err(as_person)?;
405 report(Done::new("invitation withdrawn").about("invite_id", params.invite_id))
406}
407
408async fn user_invite_resend(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
409 let client = ctx.tailnet()?;
410 let path = user_invite_path(¶ms.invite_id, "/resend")?;
411 resend(client, path, params.invite_id).await
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use serde_json::json;
418
419 #[test]
420 fn only_a_refusal_about_permission_gets_the_credential_explanation() {
421 let refused = explain_credential(ToolError::api_error(403, "forbidden"));
425 assert_eq!(refused.hint.as_deref(), Some(NEEDS_A_PERSON));
426
427 assert_eq!(
428 explain_credential(ToolError::new(ErrorCode::NotFound, "gone").with_status(404)).hint,
429 None
430 );
431 assert!(
432 explain_credential(ToolError::rate_limited(Some(60)))
433 .hint
434 .is_some()
435 );
436 assert_ne!(
437 explain_credential(ToolError::rate_limited(Some(60)))
438 .hint
439 .as_deref(),
440 Some(NEEDS_A_PERSON),
441 "the rate limit keeps its own hint"
442 );
443 }
444
445 #[test]
446 fn a_bare_array_is_wrapped_and_anything_else_is_left_alone() {
447 assert_eq!(
448 as_listing(json!([{"id": "di-example"}])),
449 json!({"invites": [{"id": "di-example"}]})
450 );
451 assert_eq!(as_listing(json!({"invites": []})), json!({"invites": []}));
454 }
455
456 #[test]
457 fn an_invitation_is_sent_in_tailscales_spelling_and_carries_only_what_was_given() {
458 let invite = DeviceInviteRequest {
459 multi_use: Some(true),
460 allow_exit_node: None,
461 email: Some("someone@example.com".to_owned()),
462 };
463 assert_eq!(
464 serde_json::to_value(&invite).expect("serialisable"),
465 json!({"multiUse": true, "email": "someone@example.com"}),
466 "an absent field is absent, not false"
467 );
468 }
469}