Skip to main content

tailscale_mcp/tools/
tailnet_invites.rs

1//! Sharing a device with someone outside the tailnet, and inviting someone in.
2//!
3//! Two families with the same shape — list, create, get, delete, resend, and
4//! for devices an accept — kept in one toolset because a caller reaching for
5//! one usually wants the other, and because everything below is true of both.
6//!
7//! **Six of these need a user-owned credential.** Creating, resending and
8//! accepting either kind, and withdrawing a tailnet one, act as a person: the
9//! invite records who sent it, and an
10//! OAuth client or a federated identity is nobody. The control plane refuses
11//! such a call, and its message does not say why — so these tools add the
12//! reason as a hint when the refusal arrives (Q76). It is added on the way
13//! back rather than checked on the way out because this server cannot tell
14//! what kind of credential it holds: a token is a token.
15//!
16//! **An invite URL is a credential.** Anyone holding one can accept, not only
17//! the addressee, which is why an invite created without an email exists at
18//! all. They are returned as the control plane sends them and are never
19//! logged, which is the same rule as a minted key.
20//!
21//! **Resending is rate limited to one a minute,** upstream. A 429 carries the
22//! wait, which the error model already turns into `rate_limited`.
23//!
24//! **Four of these answer with a bare array**, where every other listing on
25//! this surface arrives wrapped — `{"devices": …}`, `{"keys": …}`,
26//! `{"users": …}`. A tool result's structured content is an object, so a bare
27//! array cannot be forwarded at all, and these four are wrapped in
28//! `{"invites": […]}`: the API's own envelope for every listing it does wrap
29//! (Q78).
30
31use 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    /// List the outstanding invitations to share one device.
42    tailnet_device_invite_list => DeviceParams, device_invite_list,
43        toolset: TailnetInvites, tier: Read, idempotent: true;
44
45    /// Share a device with people outside the tailnet.
46    ///
47    /// Takes a list, so several people can be invited at once and each gets
48    /// its own settings. Each answer carries an `inviteUrl`, which is a
49    /// credential: anyone holding it can accept. Omitting `email` is how to
50    /// create one nobody is mailed, to be passed on by hand.
51    ///
52    /// Needs a user-owned credential: the invite records who sent it.
53    tailnet_device_invite_create => DeviceInviteCreateParams, device_invite_create,
54        toolset: TailnetInvites, tier: Write;
55
56    /// Read one device invitation by its id.
57    tailnet_device_invite_get => InviteParams, device_invite_get,
58        toolset: TailnetInvites, tier: Read, idempotent: true;
59
60    /// Withdraw a device invitation. Anyone who already accepted keeps the
61    /// share; this only stops it being accepted again.
62    tailnet_device_invite_delete => InviteParams, device_invite_delete,
63        toolset: TailnetInvites, tier: Destructive, idempotent: true;
64
65    /// Send the invitation email again.
66    ///
67    /// Only for an invite that was created with an email address, and rate
68    /// limited upstream to one a minute. Needs a user-owned credential.
69    tailnet_device_invite_resend => InviteParams, device_invite_resend,
70        toolset: TailnetInvites, tier: Write, idempotent: false;
71
72    /// Accept a device invitation, taking the share for this credential's own
73    /// user.
74    ///
75    /// Takes the invite URL or the bare id at the end of it. Needs a
76    /// user-owned credential: a share belongs to a person.
77    tailnet_device_invite_accept => AcceptParams, device_invite_accept,
78        toolset: TailnetInvites, tier: Write;
79
80    /// List the outstanding invitations to join the tailnet.
81    tailnet_user_invite_list => NoParams, user_invite_list,
82        toolset: TailnetInvites, tier: Read, idempotent: true;
83
84    /// Invite people to join the tailnet, each with the role they will get.
85    ///
86    /// Takes a list, so several people can be invited at once. Each answer
87    /// carries an `inviteUrl`, which is a credential: anyone holding it can
88    /// accept. Omitting `email` is how to create one nobody is mailed.
89    ///
90    /// Needs a user-owned credential: the invite records who sent it.
91    tailnet_user_invite_create => UserInviteCreateParams, user_invite_create,
92        toolset: TailnetInvites, tier: Write;
93
94    /// Read one tailnet invitation by its id.
95    tailnet_user_invite_get => InviteParams, user_invite_get,
96        toolset: TailnetInvites, tier: Read, idempotent: true;
97
98    /// Withdraw a tailnet invitation. Needs a user-owned credential.
99    tailnet_user_invite_delete => InviteParams, user_invite_delete,
100        toolset: TailnetInvites, tier: Destructive, idempotent: true;
101
102    /// Send the invitation email again.
103    ///
104    /// Only for an invite that was created with an email address, and rate
105    /// limited upstream to one a minute. Needs a user-owned credential.
106    tailnet_user_invite_resend => InviteParams, user_invite_resend,
107        toolset: TailnetInvites, tier: Write, idempotent: false;
108}
109
110/// Wrap a bare array in the envelope every other listing here arrives in.
111///
112/// Four endpoints answer with a JSON array at the top level. Structured
113/// content in a tool result is an object, so there is nothing to forward
114/// verbatim — the choice is which envelope, not whether (Q78). Anything that
115/// is *not* an array goes through untouched, so a control plane that starts
116/// wrapping these itself is followed rather than double-wrapped.
117fn as_listing(answer: Value) -> Value {
118    match answer {
119        Value::Array(invites) => serde_json::json!({"invites": invites}),
120        answered => answered,
121    }
122}
123
124/// The reason a call that looks fine was refused.
125///
126/// Six of these endpoints accept only a credential owned by a person, and the
127/// control plane's refusal does not say so — it is a 403, or a 400, with a
128/// message about permissions. This server cannot check first, because a bearer
129/// token does not say what minted it, so the requirement is added to the
130/// refusal instead, where a caller will actually read it (Q76).
131const 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
135/// Attach that reason to a refusal that could be it.
136///
137/// Only to a refusal the control plane makes about permission: a 404 is a
138/// missing invite and a 429 is the rate limit, and hanging an explanation
139/// about credentials off either would send a caller the wrong way.
140fn 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    /// The device, by node id, numeric id, MagicDNS name, short name,
172    /// hostname or address.
173    pub device_id: String,
174}
175
176/// An invitation, of either kind.
177///
178/// One struct for both families: the id is the only argument either takes, and
179/// which collection it names is decided by the tool, not by the caller.
180#[derive(Debug, Deserialize, JsonSchema)]
181pub struct InviteParams {
182    /// The invitation's id, as a listing reports it. Not the invite URL.
183    pub invite_id: String,
184}
185
186// ---------------------------------------------------------------------------
187// Device invitations
188// ---------------------------------------------------------------------------
189
190async fn device_invite_list(ctx: &ToolContext, params: DeviceParams) -> ToolResult<Value> {
191    let device = crate::tools::tailnet_devices::resolve(ctx, &params.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    /// The device, by node id, numeric id, MagicDNS name, short name,
204    /// hostname or address.
205    pub device_id: String,
206    /// One entry per person to invite. An empty list is refused: it would be
207    /// a call that creates nothing and answers as though it worked.
208    pub invites: Vec<DeviceInviteRequest>,
209}
210
211#[derive(Debug, Deserialize, Serialize, JsonSchema)]
212pub struct DeviceInviteRequest {
213    /// Whether more than one person may accept this invitation.
214    #[serde(rename = "multiUse", default, skip_serializing_if = "Option::is_none")]
215    pub multi_use: Option<bool>,
216    /// Whether the invited user may route through this device as an exit
217    /// node, where it advertises as one.
218    #[serde(
219        rename = "allowExitNode",
220        default,
221        skip_serializing_if = "Option::is_none"
222    )]
223    pub allow_exit_node: Option<bool>,
224    /// Who to mail it to. Omit for an invite nobody is mailed, whose
225    /// `inviteUrl` is then shared by hand.
226    #[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, &params.device_id).await?;
241    client
242        .post(device_invites_path(&device)?)
243        .json(&params.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(&params.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(&params.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(&params.invite_id, "/resend")?;
270    resend(client, path, params.invite_id).await
271}
272
273/// Both resends, which are the same call under two prefixes.
274async 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    /// The invitation, either as the URL that was shared or as the bare id at
289    /// the end of it.
290    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    // Sent as it was given, URL or bare id: the description accepts both, and
307    // trimming one into the other would be this server guessing at a format
308    // it was told not to care about.
309    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
320// ---------------------------------------------------------------------------
321// Tailnet invitations
322// ---------------------------------------------------------------------------
323
324async 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    /// One entry per person to invite.
337    pub invites: Vec<UserInviteRequest>,
338}
339
340#[derive(Debug, Deserialize, JsonSchema)]
341pub struct UserInviteRequest {
342    /// The role the invitee gets on accepting: `member`, `admin`, `it-admin`,
343    /// `network-admin`, `billing-admin` or `auditor`. There is no `owner`
344    /// here — a tailnet's owner is not something an invitation confers.
345    /// Omit it and the control plane assigns `member` (Q80).
346    #[serde(default)]
347    pub role: Option<String>,
348    /// Who to mail it to. Omit for an invite nobody is mailed, whose
349    /// `inviteUrl` is then shared by hand.
350    #[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(&params.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(&params.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(&params.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        // A 404 is a missing invite and a 429 is the rate limit; hanging an
422        // explanation about credentials off either would send a caller looking
423        // in the wrong place.
424        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        // If the control plane starts wrapping these itself, following it is
452        // right and double-wrapping is not.
453        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}