1use std::sync::Arc;
29
30use rmcp::model::{
31 Prompt, PromptArgument, PromptMessage, ReadResourceResult, Resource, ResourceContents,
32 ResourceTemplate, Role,
33};
34use serde_json::Value;
35use tailscale_cli::Invocation;
36
37use crate::context::ToolContext;
38use crate::error::{ToolError, ToolResult};
39use crate::meta::Surface;
40
41const JSON: &str = "application/json";
43const HUJSON: &str = "application/hujson";
45
46pub struct ResourceEntry {
49 pub uri: &'static str,
51 pub name: &'static str,
52 pub title: &'static str,
53 pub description: &'static str,
54 pub mime_type: &'static str,
55 pub surface: Surface,
56 pub templated: bool,
58 read: fn(Arc<ToolContext>, String) -> tailscale_cli::BoxFuture<'static, ToolResult<String>>,
59}
60
61impl std::fmt::Debug for ResourceEntry {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.debug_struct("ResourceEntry")
64 .field("uri", &self.uri)
65 .field("surface", &self.surface)
66 .finish_non_exhaustive()
67 }
68}
69
70impl ResourceEntry {
71 pub fn describe(&self) -> Resource {
73 Resource::new(self.uri, self.name)
74 .with_title(self.title)
75 .with_description(self.description)
76 .with_mime_type(self.mime_type)
77 }
78
79 pub fn describe_template(&self) -> ResourceTemplate {
81 ResourceTemplate::new(self.uri, self.name)
82 .with_title(self.title)
83 .with_description(self.description)
84 .with_mime_type(self.mime_type)
85 }
86
87 fn captures(&self, uri: &str) -> Option<String> {
93 if !self.templated {
94 return (uri == self.uri).then(String::new);
95 }
96 let prefix = self.uri.split_once('{')?.0;
97 uri.strip_prefix(prefix)
98 .filter(|rest| !rest.is_empty() && !rest.contains('/'))
99 .map(str::to_owned)
100 }
101}
102
103macro_rules! resources {
104 ($(
105 $uri:literal => $name:literal, $title:literal, $mime:expr, $surface:ident,
106 templated: $templated:literal,
107 $description:literal,
108 $read:expr;
109 )*) => {
110 pub fn all() -> Vec<ResourceEntry> {
112 vec![$(
113 ResourceEntry {
114 uri: $uri,
115 name: $name,
116 title: $title,
117 description: $description,
118 mime_type: $mime,
119 surface: Surface::$surface,
120 templated: $templated,
121 read: |ctx, id| Box::pin($read(ctx, id)),
122 },
123 )*]
124 }
125 };
126}
127
128resources! {
129 "tailscale://status" => "status", "Node status", JSON, Local,
130 templated: false,
131 "This node and the peers it knows about, as `tailscale status --json` \
132 reports them: backend state, addresses, MagicDNS names and who is \
133 online.",
134 |ctx, _id| local_json(ctx, ["status", "--json"]);
135
136 "tailscale://prefs" => "prefs", "Node preferences", JSON, Local,
137 templated: false,
138 "How this node is configured: routes it advertises, whether it is an \
139 exit node, DNS and subnet-route acceptance, and the rest of what \
140 `tailscale set` writes.",
141 |ctx, _id| local_json(ctx, ["get", "--json"]);
147
148 "tailscale://netcheck" => "netcheck", "Connectivity report", JSON, Local,
149 templated: false,
150 "What this node can reach: DERP latencies, whether UDP works, the \
151 NAT mapping it sees and whether it has IPv6.",
152 |ctx, _id| local_json(ctx, ["netcheck", "--format=json"]);
153
154 "tailscale://lock" => "lock", "Tailnet lock status", JSON, Local,
155 templated: false,
156 "Whether tailnet lock is on for this tailnet, this node's own lock \
157 key, and the signing nodes it trusts.",
158 |ctx, _id| local_json(ctx, ["lock", "status", "--json"]);
159
160 "tailnet://policy" => "policy", "Policy file", HUJSON, Tailnet,
161 templated: false,
162 "The tailnet policy file as written, comments and all. HuJSON, not \
163 JSON: `tailnet_policy_get` with `format: \"json\"` parses it, and \
164 loses the comments doing so.",
165 |ctx, _id| policy(ctx);
166
167 "tailnet://devices" => "devices", "Tailnet devices", JSON, Tailnet,
168 templated: false,
169 "Every device in the tailnet, as the control plane lists them.",
170 |ctx, _id| tailnet_json(ctx, "/devices");
171
172 "tailnet://device/{device_id}" => "device", "One device", JSON, Tailnet,
173 templated: true,
174 "One device by its node id (`n1234567CNTRL`) or its numeric id, as \
175 `tailnet://devices` reports them.",
176 device;
177
178 "tailnet://dns" => "dns", "Tailnet DNS", JSON, Tailnet,
179 templated: false,
180 "The tailnet's whole DNS configuration: nameservers, split DNS, \
181 search paths and MagicDNS.",
182 |ctx, _id| tailnet_json(ctx, "/dns/configuration");
183
184 "tailnet://settings" => "settings", "Tailnet settings", JSON, Tailnet,
185 templated: false,
186 "Tailnet-wide settings: device and user approval, key durations, \
187 automatic updates and network flow logging.",
188 |ctx, _id| tailnet_json(ctx, "/settings");
189}
190
191async fn local_json<const N: usize>(ctx: Arc<ToolContext>, argv: [&str; N]) -> ToolResult<String> {
193 let output = ctx
194 .local
195 .run(Invocation::read(argv.map(str::to_owned)))
196 .await
197 .map_err(|error| {
198 ToolError::backend_unavailable("the `tailscale` command", &error.to_string())
199 })?;
200 if !output.success() {
201 return Err(ToolError::cli_failed(
202 "tailscale",
203 output.exit_code,
204 &output.stderr,
205 ));
206 }
207 Ok(output.stdout_str().trim().to_owned())
208}
209
210async fn tailnet_json(ctx: Arc<ToolContext>, rest: &str) -> ToolResult<String> {
212 let client = ctx.tailnet()?;
213 let answer = client
214 .get(client.tailnet_path(None, rest))
215 .send_as::<Value>()
216 .await?;
217 Ok(crate::tools::common::pretty(&answer))
218}
219
220async fn device(ctx: Arc<ToolContext>, device_id: String) -> ToolResult<String> {
221 let resolved = crate::tools::tailnet_devices::resolve(&ctx, &device_id).await?;
225 let client = ctx.tailnet()?;
226 let answer = client
227 .get(crate::tools::tailnet_devices::device_path(&resolved, "")?)
228 .send_as::<Value>()
229 .await?;
230 Ok(crate::tools::common::pretty(&answer))
231}
232
233async fn policy(ctx: Arc<ToolContext>) -> ToolResult<String> {
235 let client = ctx.tailnet()?;
236 let body = client
239 .get(client.tailnet_path(None, "/acl"))
240 .header("Accept", HUJSON)
241 .send_text()
242 .await?;
243 Ok(body.text)
244}
245
246pub async fn read(
252 ctx: &Arc<ToolContext>,
253 offers: impl Fn(Surface) -> bool,
254 uri: &str,
255) -> ToolResult<ReadResourceResult> {
256 for entry in all() {
257 let Some(id) = entry.captures(uri) else {
258 continue;
259 };
260 if !offers(entry.surface) {
261 return Err(ToolError::not_found(&format!(
262 "the resource `{uri}`, because this server has no {} surface",
263 entry.surface.as_str()
264 )));
265 }
266 let body = (entry.read)(Arc::clone(ctx), id).await?;
267 let body = ctx.redactor.apply(&body).into_owned();
270 if body.len() > ctx.max_result_bytes {
278 return Err(ToolError::result_too_large(
279 body.len(),
280 ctx.max_result_bytes,
281 ));
282 }
283 return Ok(ReadResourceResult::new(vec![
284 ResourceContents::text(body, uri).with_mime_type(entry.mime_type),
285 ]));
286 }
287 Err(ToolError::not_found(&format!("the resource `{uri}`")))
288}
289
290#[derive(Clone, Copy, Debug)]
299pub struct Surfaces {
300 local: bool,
301 tailnet: bool,
302}
303
304impl Surfaces {
305 pub fn new(offers: impl Fn(Surface) -> bool) -> Self {
307 Self {
308 local: offers(Surface::Local),
309 tailnet: offers(Surface::Tailnet),
310 }
311 }
312
313 pub const fn has(self, surface: Surface) -> bool {
314 match surface {
315 Surface::Local => self.local,
316 Surface::Tailnet => self.tailnet,
317 }
318 }
319}
320
321pub struct PromptEntry {
323 pub name: &'static str,
324 pub title: &'static str,
325 pub description: &'static str,
326 pub argument: (&'static str, &'static str),
328 pub surface: Surface,
332 expand: fn(Option<&str>, Surfaces) -> String,
333}
334
335impl std::fmt::Debug for PromptEntry {
336 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337 f.debug_struct("PromptEntry")
338 .field("name", &self.name)
339 .finish_non_exhaustive()
340 }
341}
342
343impl PromptEntry {
344 pub fn describe(&self) -> Prompt {
345 let (name, about) = self.argument;
346 Prompt::new(
347 self.name,
348 Some(self.description),
349 Some(vec![
350 PromptArgument::new(name)
351 .with_description(about)
352 .with_required(false),
353 ]),
354 )
355 .with_title(self.title)
356 }
357
358 pub fn expand(&self, argument: Option<&str>, surfaces: Surfaces) -> Vec<PromptMessage> {
359 vec![PromptMessage::new_text(
360 Role::User,
361 (self.expand)(argument, surfaces),
362 )]
363 }
364}
365
366pub fn prompts() -> Vec<PromptEntry> {
377 vec![
378 PromptEntry {
379 name: "diagnose_connectivity",
380 title: "Diagnose connectivity",
381 description: "Work out why this node cannot reach something, using only reads.",
382 argument: (
383 "peer",
384 "The peer that cannot be reached, by name or address.",
385 ),
386 surface: Surface::Local,
390 expand: |peer, surfaces| {
391 let subject = match peer {
392 Some(peer) => format!("the peer `{peer}`"),
393 None => "the tailnet in general".to_owned(),
394 };
395 let control_plane = if surfaces.has(Surface::Tailnet) {
400 "4. `tailnet_device_list` — does the control plane agree the peer exists, \
401 is it authorised, and has its key expired?\n\
402 5. `tailnet_policy_preview` — would the policy in force let these two \
403 talk?\n"
404 } else {
405 ""
406 };
407 format!(
408 "Diagnose connectivity between this node and {subject}, using read-only \
409 tools only. Work outwards:\n\
410 \n\
411 1. `tailscale_status` — is the backend running, and is the peer known and \
412 online?\n\
413 2. `tailscale_netcheck` — can this node reach a DERP relay, does UDP work, \
414 and what NAT does it see?\n\
415 3. `tailscale_ping` — does traffic actually arrive, and does it go direct \
416 or over a relay?\n\
417 {control_plane}\
418 \n\
419 Report what you found at each step and name the first one that explains the \
420 failure. Do not change anything: every tool above is a read, and a fix is \
421 the operator's to approve."
422 )
423 },
424 },
425 PromptEntry {
426 name: "review_policy_change",
427 title: "Review a policy change",
428 description: "Read, validate and preview a policy change before anyone writes it.",
429 argument: (
430 "goal",
431 "What the change is meant to achieve, in a sentence.",
432 ),
433 surface: Surface::Tailnet,
434 expand: |goal, _| {
435 let purpose = match goal {
436 Some(goal) => format!("The change is meant to: {goal}\n\n"),
437 None => String::new(),
438 };
439 format!(
440 "{purpose}Review a change to the tailnet policy file. In this order, and \
441 without writing anything:\n\
442 \n\
443 1. `tailnet_policy_get` — read the policy as it stands, and keep the `etag` \
444 it answers with. Every later step is about *this* version.\n\
445 2. `tailnet_policy_validate` — send the proposed document and read back the \
446 warnings and errors. A document that does not validate is not a change \
447 worth discussing.\n\
448 3. `tailnet_policy_preview` — for each access the change is supposed to \
449 grant or remove, ask what the rule actually does. A rule that reads \
450 correctly and matches nothing is the commonest mistake.\n\
451 4. Say what the change does that the goal did not ask for. Widened tags, \
452 an `autogroup:member` where a group was meant, an `accept` that shadows \
453 a later rule.\n\
454 \n\
455 Then stop and report. Writing the policy is `tailnet_policy_set`, it needs \
456 the `etag` from step 1, and it is the operator's call — not this review's."
457 )
458 },
459 },
460 PromptEntry {
461 name: "audit_tailnet_access",
462 title: "Audit tailnet access",
463 description: "Survey who and what can reach the tailnet, using only reads.",
464 argument: (
465 "subject",
466 "A user, tag or device to audit rather than the whole tailnet.",
467 ),
468 surface: Surface::Tailnet,
469 expand: |subject, _| {
470 let scope = match subject {
471 Some(subject) => format!("Limit the audit to `{subject}`.\n\n"),
472 None => String::new(),
473 };
474 format!(
475 "{scope}Audit who can reach what in this tailnet, using read-only tools \
476 only:\n\
477 \n\
478 1. `tailnet_user_list` — who has an account, what role each holds, and who \
479 is suspended or waiting for approval.\n\
480 2. `tailnet_key_list` — which credentials exist, which are close to \
481 expiring, and which have capabilities wider than their purpose.\n\
482 3. `tailnet_device_list` — which devices are unauthorised, which have key \
483 expiry disabled, and which are tagged.\n\
484 4. `tailnet_policy_get`, then `tailnet_policy_preview` for the accesses \
485 that matter — what the rules grant, rather than what they appear to.\n\
486 5. `tailnet_settings_get` — is device approval on, is user approval on, and \
487 how long may a key live?\n\
488 \n\
489 Report by risk, worst first, and say for each what you read that shows it. \
490 Do not change anything."
491 )
492 },
493 },
494 ]
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn the_policy_is_the_one_resource_that_is_not_json() {
503 let all = all();
504 let odd: Vec<_> = all.iter().filter(|r| r.mime_type != JSON).collect();
505 assert_eq!(odd.len(), 1);
506 assert_eq!(odd[0].uri, "tailnet://policy");
507 assert_eq!(odd[0].mime_type, HUJSON);
508 }
509
510 #[test]
511 fn the_template_captures_an_identifier_and_nothing_that_is_not_one() {
512 let all = all();
513 let device = all
514 .iter()
515 .find(|r| r.uri == "tailnet://device/{device_id}")
516 .expect("declared");
517
518 assert_eq!(
519 device.captures("tailnet://device/n1111111CNTRL").as_deref(),
520 Some("n1111111CNTRL")
521 );
522 assert_eq!(device.captures("tailnet://device/"), None);
524 assert_eq!(device.captures("tailnet://device/n1/routes"), None);
525 assert_eq!(device.captures("tailnet://devices"), None);
526
527 let devices = all
528 .iter()
529 .find(|r| r.uri == "tailnet://devices")
530 .expect("declared");
531 assert_eq!(devices.captures("tailnet://devices").as_deref(), Some(""));
532 assert_eq!(devices.captures("tailnet://devices/n1"), None);
533 }
534
535 fn both() -> Surfaces {
537 Surfaces::new(|_| true)
538 }
539
540 #[test]
541 fn every_prompt_expands_with_and_without_its_argument() {
542 for prompt in prompts() {
543 let (name, _) = prompt.argument;
544 let without = prompt.expand(None, both());
545 let with = prompt.expand(Some("example"), both());
546 assert_eq!(without.len(), 1);
547 assert_ne!(
548 format!("{with:?}"),
549 format!("{without:?}"),
550 "`{}`'s `{name}` should change what it expands to",
551 prompt.name
552 );
553 assert!(
554 format!("{with:?}").contains("example"),
555 "`{}` should use the argument it was given",
556 prompt.name
557 );
558
559 let described = prompt.describe();
562 let arguments = described.arguments.expect("one argument");
563 assert_eq!(arguments.len(), 1);
564 assert_eq!(arguments[0].required, Some(false));
565 }
566 }
567
568 #[test]
569 fn no_prompt_asks_for_a_tool_that_needs_more_than_the_read_tier() {
570 let table = crate::tools::entries();
573 for prompt in prompts() {
574 let text = format!("{:?}", prompt.expand(Some("example"), both()));
575 for entry in &table {
576 if entry.meta.tier != crate::meta::Tier::Read && text.contains(entry.meta.name) {
577 assert_eq!(
579 entry.meta.name, "tailnet_policy_set",
580 "`{}` names `{}`, which needs more than the read tier",
581 prompt.name, entry.meta.name
582 );
583 }
584 }
585 }
586 }
587
588 #[test]
596 fn no_prompt_names_a_tool_from_a_surface_the_session_lacks() {
597 let table = crate::tools::entries();
598 for present in [Surface::Local, Surface::Tailnet] {
599 let only = Surfaces::new(|surface| surface == present);
600 for prompt in prompts() {
601 if prompt.surface != present {
602 continue;
603 }
604 let text = format!("{:?}", prompt.expand(Some("example"), only));
605 for entry in &table {
606 let named = entry.meta.surface() != present && text.contains(entry.meta.name);
607 assert!(
608 !named || entry.meta.name == "tailnet_policy_set",
609 "with only the {} surface, `{}` still names `{}`",
610 present.as_str(),
611 prompt.name,
612 entry.meta.name
613 );
614 }
615 }
616 }
617 }
618}