use rmcp::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tailscale_rest::models::user::INVITE_ROLES;
use crate::context::ToolContext;
use crate::error::{ErrorCode, ToolError, ToolResult};
use crate::tools::common::{Done, one_of, path_segment, report};
crate::tools! {
tailnet_device_invite_list => DeviceParams, device_invite_list,
toolset: TailnetInvites, tier: Read, idempotent: true;
tailnet_device_invite_create => DeviceInviteCreateParams, device_invite_create,
toolset: TailnetInvites, tier: Write;
tailnet_device_invite_get => InviteParams, device_invite_get,
toolset: TailnetInvites, tier: Read, idempotent: true;
tailnet_device_invite_delete => InviteParams, device_invite_delete,
toolset: TailnetInvites, tier: Destructive, idempotent: true;
tailnet_device_invite_resend => InviteParams, device_invite_resend,
toolset: TailnetInvites, tier: Write, idempotent: false;
tailnet_device_invite_accept => AcceptParams, device_invite_accept,
toolset: TailnetInvites, tier: Write;
tailnet_user_invite_list => NoParams, user_invite_list,
toolset: TailnetInvites, tier: Read, idempotent: true;
tailnet_user_invite_create => UserInviteCreateParams, user_invite_create,
toolset: TailnetInvites, tier: Write;
tailnet_user_invite_get => InviteParams, user_invite_get,
toolset: TailnetInvites, tier: Read, idempotent: true;
tailnet_user_invite_delete => InviteParams, user_invite_delete,
toolset: TailnetInvites, tier: Destructive, idempotent: true;
tailnet_user_invite_resend => InviteParams, user_invite_resend,
toolset: TailnetInvites, tier: Write, idempotent: false;
}
fn as_listing(answer: Value) -> Value {
match answer {
Value::Array(invites) => serde_json::json!({"invites": invites}),
answered => answered,
}
}
const NEEDS_A_PERSON: &str = "This endpoint accepts only a credential owned by a user, because an invitation records who \
sent it. A token minted from an OAuth client or a federated identity is refused however \
wide its scopes are.";
fn as_person(error: tailscale_rest::ApiError) -> ToolError {
explain_credential(ToolError::from(error))
}
fn explain_credential(error: ToolError) -> ToolError {
if matches!(error.code, ErrorCode::ApiError) && matches!(error.status, Some(400 | 401 | 403)) {
return error.with_hint(NEEDS_A_PERSON);
}
error
}
fn device_invites_path(device: &str) -> ToolResult<String> {
let id = path_segment("device_id", device)?;
Ok(format!("/api/v2/device/{id}/device-invites"))
}
fn device_invite_path(id: &str, rest: &str) -> ToolResult<String> {
let id = path_segment("invite_id", id)?;
Ok(format!("/api/v2/device-invites/{id}{rest}"))
}
fn user_invite_path(id: &str, rest: &str) -> ToolResult<String> {
let id = path_segment("invite_id", id)?;
Ok(format!("/api/v2/user-invites/{id}{rest}"))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceParams {
pub device_id: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct InviteParams {
pub invite_id: String,
}
async fn device_invite_list(ctx: &ToolContext, params: DeviceParams) -> ToolResult<Value> {
let device = crate::tools::tailnet_devices::resolve(ctx, ¶ms.device_id).await?;
let client = ctx.tailnet()?;
Ok(as_listing(
client
.get(device_invites_path(&device)?)
.send_as::<Value>()
.await?,
))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DeviceInviteCreateParams {
pub device_id: String,
pub invites: Vec<DeviceInviteRequest>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeviceInviteRequest {
#[serde(rename = "multiUse", default, skip_serializing_if = "Option::is_none")]
pub multi_use: Option<bool>,
#[serde(
rename = "allowExitNode",
default,
skip_serializing_if = "Option::is_none"
)]
pub allow_exit_node: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
}
async fn device_invite_create(
ctx: &ToolContext,
params: DeviceInviteCreateParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
if params.invites.is_empty() {
return Err(ToolError::invalid_args(
"`invites` is empty; give at least one invitation to create",
));
}
let device = crate::tools::tailnet_devices::resolve(ctx, ¶ms.device_id).await?;
client
.post(device_invites_path(&device)?)
.json(¶ms.invites)
.send_as::<Value>()
.await
.map(as_listing)
.map_err(as_person)
}
async fn device_invite_get(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(device_invite_path(¶ms.invite_id, "")?)
.send_as::<Value>()
.await?)
}
async fn device_invite_delete(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
client
.delete(device_invite_path(¶ms.invite_id, "")?)
.send()
.await?;
report(Done::new("invitation withdrawn").about("invite_id", params.invite_id))
}
async fn device_invite_resend(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = device_invite_path(¶ms.invite_id, "/resend")?;
resend(client, path, params.invite_id).await
}
async fn resend(
client: &tailscale_rest::Client,
path: String,
invite_id: String,
) -> ToolResult<Value> {
let answer = client.post(path).send().await.map_err(as_person)?;
crate::tools::common::answered_or(
answer,
Done::new("invitation sent again").about("invite_id", invite_id),
)
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct AcceptParams {
pub invite: String,
}
#[derive(Debug, Serialize)]
struct Accept {
invite: String,
}
async fn device_invite_accept(ctx: &ToolContext, params: AcceptParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let invite = params.invite.trim();
if invite.is_empty() {
return Err(ToolError::invalid_args(
"`invite` is empty; give the invitation URL or its id",
));
}
let body = Accept {
invite: invite.to_owned(),
};
client
.post("/api/v2/device-invites/-/accept")
.json(&body)
.send_as::<Value>()
.await
.map_err(as_person)
}
async fn user_invite_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(as_listing(
client
.get(client.tailnet_path(None, "/user-invites"))
.send_as::<Value>()
.await?,
))
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct UserInviteCreateParams {
pub invites: Vec<UserInviteRequest>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct UserInviteRequest {
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub email: Option<String>,
}
async fn user_invite_create(
ctx: &ToolContext,
params: UserInviteCreateParams,
) -> ToolResult<Value> {
let client = ctx.tailnet()?;
if params.invites.is_empty() {
return Err(ToolError::invalid_args(
"`invites` is empty; give at least one invitation to create",
));
}
let body: Vec<Value> = params
.invites
.iter()
.map(|invite| {
let mut entry = serde_json::Map::new();
if let Some(role) = &invite.role {
entry.insert(
"role".to_owned(),
Value::String(one_of("role", role, INVITE_ROLES)?),
);
}
if let Some(email) = &invite.email {
entry.insert("email".to_owned(), Value::String(email.clone()));
}
Ok(Value::Object(entry))
})
.collect::<ToolResult<_>>()?;
client
.post(client.tailnet_path(None, "/user-invites"))
.json(&body)
.send_as::<Value>()
.await
.map(as_listing)
.map_err(as_person)
}
async fn user_invite_get(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
Ok(client
.get(user_invite_path(¶ms.invite_id, "")?)
.send_as::<Value>()
.await?)
}
async fn user_invite_delete(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
client
.delete(user_invite_path(¶ms.invite_id, "")?)
.send()
.await
.map_err(as_person)?;
report(Done::new("invitation withdrawn").about("invite_id", params.invite_id))
}
async fn user_invite_resend(ctx: &ToolContext, params: InviteParams) -> ToolResult<Value> {
let client = ctx.tailnet()?;
let path = user_invite_path(¶ms.invite_id, "/resend")?;
resend(client, path, params.invite_id).await
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn only_a_refusal_about_permission_gets_the_credential_explanation() {
let refused = explain_credential(ToolError::api_error(403, "forbidden"));
assert_eq!(refused.hint.as_deref(), Some(NEEDS_A_PERSON));
assert_eq!(
explain_credential(ToolError::new(ErrorCode::NotFound, "gone").with_status(404)).hint,
None
);
assert!(
explain_credential(ToolError::rate_limited(Some(60)))
.hint
.is_some()
);
assert_ne!(
explain_credential(ToolError::rate_limited(Some(60)))
.hint
.as_deref(),
Some(NEEDS_A_PERSON),
"the rate limit keeps its own hint"
);
}
#[test]
fn a_bare_array_is_wrapped_and_anything_else_is_left_alone() {
assert_eq!(
as_listing(json!([{"id": "di-example"}])),
json!({"invites": [{"id": "di-example"}]})
);
assert_eq!(as_listing(json!({"invites": []})), json!({"invites": []}));
}
#[test]
fn an_invitation_is_sent_in_tailscales_spelling_and_carries_only_what_was_given() {
let invite = DeviceInviteRequest {
multi_use: Some(true),
allow_exit_node: None,
email: Some("someone@example.com".to_owned()),
};
assert_eq!(
serde_json::to_value(&invite).expect("serialisable"),
json!({"multiUse": true, "email": "someone@example.com"}),
"an absent field is absent, not false"
);
}
}