1use 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 tailnet_service_list => NoParams, service_list,
36 toolset: TailnetServices, tier: Read, idempotent: true;
37
38 tailnet_service_get => ServiceParams, service_get,
40 toolset: TailnetServices, tier: Read, idempotent: true;
41
42 tailnet_service_replace => ServiceReplaceParams, service_replace,
52 toolset: TailnetServices, tier: Write, idempotent: true;
53
54 tailnet_service_delete => ServiceParams, service_delete,
56 toolset: TailnetServices, tier: Destructive, idempotent: true;
57
58 tailnet_service_devices_list => ServiceParams, service_devices_list,
61 toolset: TailnetServices, tier: Read, idempotent: true;
62
63 tailnet_service_approval_get => ServiceDeviceParams, service_approval_get,
66 toolset: TailnetServices, tier: Read, idempotent: true;
67
68 tailnet_service_approval_set => ServiceApprovalParams, service_approval_set,
73 toolset: TailnetServices, tier: Write, idempotent: true, varying: true;
74}
75
76const DOCUMENTED: &str = "/services";
78
79const GO_CLIENT: &str = "/vip-services";
81
82async 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
101fn 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 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", ¶ms.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 pub service_name: String,
145 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", ¶ms.service_name)?;
162 let body = ¶ms.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", ¶ms.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", ¶ms.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 pub service_name: String,
202 pub device_id: String,
205}
206
207#[derive(Debug, Deserialize, JsonSchema)]
208pub struct ServiceApprovalParams {
209 pub service_name: String,
211 pub device_id: String,
214 pub approved: bool,
217}
218
219fn 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", ¶ms.service_name)?;
228 let device = crate::tools::tailnet_devices::resolve(ctx, ¶ms.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 if !params.approved {
247 require_destructive(ctx, "withdrawing a device's approval to serve a service")?;
248 }
249 let name = &path_segment("service_name", ¶ms.service_name)?;
250 let device = crate::tools::tailnet_devices::resolve(ctx, ¶ms.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}