Skip to main content

tailscale_mcp/tools/
tailnet_services.rs

1//! Tailscale services: a name and address that several devices stand behind.
2//!
3//! **The path has two spellings.** The vendored description documents
4//! `/tailnet/{tailnet}/services`; Tailscale's own Go client calls
5//! `/tailnet/{tailnet}/vip-services`. Both may be live and neither source
6//! settles it, which the drift test has recorded since ticket 16. So these
7//! tools send the documented spelling and, if the control plane answers that
8//! it is not there, send the same call again at the other one (Q81). The
9//! criterion is that service naming follow the path the live API serves rather
10//! than only the published description, and asking is the only way to know.
11//!
12//! **A service name is `svc:<name>`,** unique across the tailnet — it cannot
13//! collide with a machine name. The colon is in the identifier, not a
14//! separator, and goes into the path as written.
15//!
16//! **The upsert replaces.** `PUT` creates a service that is not there and
17//! overwrites one that is, discarding anything the body leaves out, which is
18//! why it is `_replace` rather than `_update` (Q72).
19
20use rmcp::schemars::JsonSchema;
21use serde::Deserialize;
22use serde_json::Value;
23use tailscale_rest::ApiError;
24
25use crate::context::ToolContext;
26use crate::error::{ToolError, ToolResult};
27use crate::tools::common::{Done, answered_or, path_segment, require_destructive};
28
29crate::tools! {
30    /// List the tailnet's services. Answers with `{"vipServices": [...]}`.
31    ///
32    /// Tailscale calls these VIP services in its own client and its answers,
33    /// and services in its published description; these tools use the
34    /// published name and send whichever path the control plane serves.
35    tailnet_service_list => NoParams, service_list,
36        toolset: TailnetServices, tier: Read, idempotent: true;
37
38    /// Read one service by its name, which is `svc:` followed by the name.
39    tailnet_service_get => ServiceParams, service_get,
40        toolset: TailnetServices, tier: Read, idempotent: true;
41
42    /// Create a service, or replace one that already exists.
43    ///
44    /// One endpoint for both: a service that is not there is created, and one
45    /// that is gets this document in place of its own — anything the document
46    /// leaves out is discarded. Read `tailnet_service_get` first and send back
47    /// what it answered with the one change made.
48    ///
49    /// On a create, the `name` in the document has to match `service_name`; on
50    /// a replace, a different `name` renames the service.
51    tailnet_service_replace => ServiceReplaceParams, service_replace,
52        toolset: TailnetServices, tier: Write, idempotent: true;
53
54    /// Delete a service. Anything addressing it by name stops resolving.
55    tailnet_service_delete => ServiceParams, service_delete,
56        toolset: TailnetServices, tier: Destructive, idempotent: true;
57
58    /// List the devices standing behind a service, and whether each is
59    /// approved to serve it.
60    tailnet_service_devices_list => ServiceParams, service_devices_list,
61        toolset: TailnetServices, tier: Read, idempotent: true;
62
63    /// Read whether one device may host a service, and whether an
64    /// auto-approver decided it rather than a person.
65    tailnet_service_approval_get => ServiceDeviceParams, service_approval_get,
66        toolset: TailnetServices, tier: Read, idempotent: true;
67
68    /// Approve one device to host a service, or withdraw that approval.
69    ///
70    /// Approving is a write. Withdrawing takes the device out of the service —
71    /// traffic stops being sent to it — and needs the destructive tier.
72    tailnet_service_approval_set => ServiceApprovalParams, service_approval_set,
73        toolset: TailnetServices, tier: Write, idempotent: true, varying: true;
74}
75
76/// The spelling the vendored description documents.
77const DOCUMENTED: &str = "/services";
78
79/// The spelling Tailscale's own Go client uses for the same endpoints.
80const GO_CLIENT: &str = "/vip-services";
81
82/// Send a services call at the documented spelling, and at the other one if
83/// the control plane says the first is not there.
84///
85/// The two sources disagree and neither can be checked from here, so this asks
86/// (Q81). A 404 is safe to retry across all seven of these: none of them acts
87/// before answering one, so a request that reached the wrong base path did
88/// nothing. Where the service itself is genuinely missing, the second call
89/// answers 404 as well and that is what the caller gets.
90async fn either_spelling<F, Fut>(send: F) -> ToolResult<Value>
91where
92    F: Fn(&'static str) -> Fut,
93    Fut: std::future::Future<Output = Result<Value, ApiError>>,
94{
95    match send(DOCUMENTED).await {
96        Err(error) if error.status() == Some(404) => Ok(send(GO_CLIENT).await?),
97        other => Ok(other?),
98    }
99}
100
101/// `/api/v2/tailnet/<tailnet><base>/<service><rest>`, from a checked name.
102///
103/// The name arrives already through `path_segment`, which each caller does
104/// before building the closure: a name that is not a path segment is a refusal
105/// rather than something to ask the control plane about at two spellings, and
106/// a fallible closure is one `either_spelling` cannot take.
107fn service_path(client: &tailscale_rest::Client, base: &str, name: &str, rest: &str) -> String {
108    client.tailnet_path(None, &format!("{base}/{name}{rest}"))
109}
110
111#[derive(Debug, Deserialize, JsonSchema)]
112pub struct NoParams {}
113
114async fn service_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
115    let client = ctx.tailnet()?;
116    either_spelling(|base| {
117        client
118            .get(client.tailnet_path(None, base))
119            .send_as::<Value>()
120    })
121    .await
122}
123
124#[derive(Debug, Deserialize, JsonSchema)]
125pub struct ServiceParams {
126    /// The service's name, as `svc:example`.
127    pub service_name: String,
128}
129
130async fn service_get(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
131    let client = ctx.tailnet()?;
132    let name = &path_segment("service_name", &params.service_name)?;
133    either_spelling(|base| {
134        client
135            .get(service_path(client, base, name, ""))
136            .send_as::<Value>()
137    })
138    .await
139}
140
141#[derive(Debug, Deserialize, JsonSchema)]
142pub struct ServiceReplaceParams {
143    /// The service's name, as `svc:example`.
144    pub service_name: String,
145    /// The whole service document, in Tailscale's own shape: `name`,
146    /// `displayName`, `addrs`, `comment`, `ports` — as `"tcp:80"`, or the
147    /// single entry `"do-not-validate"` — and `tags`. Anything left out is
148    /// discarded.
149    pub service: Value,
150}
151
152async fn service_replace(ctx: &ToolContext, params: ServiceReplaceParams) -> ToolResult<Value> {
153    let client = ctx.tailnet()?;
154    if !params.service.is_object() {
155        return Err(ToolError::invalid_args(
156            "`service` is the service document, an object with `name`, `displayName`, `addrs`, \
157             `comment`, `ports` and `tags`",
158        )
159        .with_hint("Call `tailnet_service_get` and send back what it answered."));
160    }
161    let name = &path_segment("service_name", &params.service_name)?;
162    let body = &params.service;
163    either_spelling(|base| {
164        client
165            .put(service_path(client, base, name, ""))
166            .json(body)
167            .send_as::<Value>()
168    })
169    .await
170}
171
172async fn service_delete(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
173    let client = ctx.tailnet()?;
174    let name = &path_segment("service_name", &params.service_name)?;
175    let answer = either_spelling(|base| {
176        client
177            .delete(service_path(client, base, name, ""))
178            .send_as::<Value>()
179    })
180    .await?;
181    answered_or(
182        answer,
183        Done::new("service deleted").about("service_name", params.service_name.clone()),
184    )
185}
186
187async fn service_devices_list(ctx: &ToolContext, params: ServiceParams) -> ToolResult<Value> {
188    let client = ctx.tailnet()?;
189    let name = &path_segment("service_name", &params.service_name)?;
190    either_spelling(|base| {
191        client
192            .get(service_path(client, base, name, "/devices"))
193            .send_as::<Value>()
194    })
195    .await
196}
197
198#[derive(Debug, Deserialize, JsonSchema)]
199pub struct ServiceDeviceParams {
200    /// The service's name, as `svc:example`.
201    pub service_name: String,
202    /// The device, by node id, numeric id, MagicDNS name, short name,
203    /// hostname or address.
204    pub device_id: String,
205}
206
207#[derive(Debug, Deserialize, JsonSchema)]
208pub struct ServiceApprovalParams {
209    /// The service's name, as `svc:example`.
210    pub service_name: String,
211    /// The device, by node id, numeric id, MagicDNS name, short name,
212    /// hostname or address.
213    pub device_id: String,
214    /// Whether the device may host the service. `false` takes it out of the
215    /// service.
216    pub approved: bool,
217}
218
219/// `…/<service>/device/<device>/approved`, which both approval tools use.
220fn approval_suffix(device: &str) -> ToolResult<String> {
221    let device = path_segment("device_id", device)?;
222    Ok(format!("/device/{device}/approved"))
223}
224
225async fn service_approval_get(ctx: &ToolContext, params: ServiceDeviceParams) -> ToolResult<Value> {
226    let client = ctx.tailnet()?;
227    let name = &path_segment("service_name", &params.service_name)?;
228    let device = crate::tools::tailnet_devices::resolve(ctx, &params.device_id).await?;
229    let suffix = approval_suffix(&device)?;
230    either_spelling(|base| {
231        client
232            .get(service_path(client, base, name, &suffix))
233            .send_as::<Value>()
234    })
235    .await
236}
237
238async fn service_approval_set(
239    ctx: &ToolContext,
240    params: ServiceApprovalParams,
241) -> ToolResult<Value> {
242    let client = ctx.tailnet()?;
243    // The danger is in the argument, not the tool: approving adds a host,
244    // withdrawing removes a working one. The row carries the floor and the
245    // call decides, as `tailnet_device_authorize` does (Q70).
246    if !params.approved {
247        require_destructive(ctx, "withdrawing a device's approval to serve a service")?;
248    }
249    let name = &path_segment("service_name", &params.service_name)?;
250    let device = crate::tools::tailnet_devices::resolve(ctx, &params.device_id).await?;
251    let suffix = approval_suffix(&device)?;
252    let mut body = tailscale_rest::models::service::ServiceApprovalRequest::default();
253    body.approved = Some(params.approved);
254    either_spelling(|base| {
255        client
256            .post(service_path(client, base, name, &suffix))
257            .json(&body)
258            .send_as::<Value>()
259    })
260    .await
261}