Skip to main content

tailscale_mcp/
resources.rs

1//! Nine resources and three prompts.
2//!
3//! **Two schemes, because there are two surfaces.** `tailscale://` is this
4//! node as the local `tailscale` binary sees it; `tailnet://` is the tailnet
5//! as the control plane describes it. A caller reading a URI knows which
6//! backend answers it and therefore what a missing one means, which one scheme
7//! would have hidden (Q85).
8//!
9//! **A resource is a read a caller did not have to spend a tool call on.**
10//! Every one of them is something a Read-tier tool could also fetch, so none
11//! carries a tier of its own: a resource is offered whenever its surface is,
12//! and never otherwise. There are no subscriptions — `spec.md` puts them out
13//! of scope — so nothing here is cached or invalidated; a read is a read.
14//!
15//! **The policy is the one that is not JSON.** It is HuJSON, and the comments
16//! are the part a person wrote, so `tailnet://policy` is served
17//! `application/hujson` as text rather than parsed into something smaller.
18//!
19//! **Nothing here answers with something a tool result would have redacted,
20//! and nothing answers with more than one would have carried.** Each body goes
21//! through the session's redactor on the way out, which is what `status
22//! --json` needs: it carries key material for this node, and a resource is
23//! exactly as public as a tool result. It is held to the session's result cap
24//! for the same reason — `tailscale://status` and `tailscale_status` answer
25//! with the same bytes, so a ceiling on one and not the other is a ceiling on
26//! neither.
27
28use 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
41/// The media type everything but the policy answers with.
42const JSON: &str = "application/json";
43/// The policy file's own, which is not JSON: the comments are the point.
44const HUJSON: &str = "application/hujson";
45
46/// One resource: what it is called, what answers it, and which surface it
47/// needs to be there.
48pub struct ResourceEntry {
49    /// The fixed URI, or the template for the one that takes an identifier.
50    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    /// `true` for the one entry that is a template rather than a URI.
57    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    /// The resource as a client sees it in a listing.
72    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    /// The template as a client sees it in a template listing.
80    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    /// Whether `uri` is this entry, and the identifier it carried if so.
88    ///
89    /// A fixed URI matches itself. The template matches a prefix and yields
90    /// what follows, which is then held to `path_segment` like any other
91    /// identifier before it reaches a path.
92    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        /// Every resource, in listing order.
111        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        // `tailscale get --json`, not `debug prefs`: the latter prints this
142        // node's private keys along with its preferences and is on
143        // `local_debug::EXCLUDED` for that reason, which a resource must not
144        // be a way around (Q89). `tailscale_prefs_get` reads the same
145        // preferences from the same place.
146        |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
191/// A local read, as the pretty-printed JSON the command produced.
192async 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
210/// A control-plane read of a tailnet-scoped path.
211async 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    // The template accepts what the tools accept, which is the whole point of
222    // the resolution: `tailnet://device/laptop` and `tailnet_device_get` are
223    // two ways of asking the same question and should take the same answer.
224    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
233/// The policy file as text, which is what makes it the odd one out.
234async fn policy(ctx: Arc<ToolContext>) -> ToolResult<String> {
235    let client = ctx.tailnet()?;
236    // Text, not JSON: HuJSON is not JSON to parse, and the comments are the
237    // part a person wrote.
238    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
246/// Read one resource, or say why it is not there.
247///
248/// The surface check comes first and says the same thing an absent tool says:
249/// a resource whose backend is missing is not listed, so a client asking for
250/// one by name is asking for something it was never offered.
251pub 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        // Exactly as public as a tool result: `status --json` carries this
268        // node's key material, and a resource is not a way around that.
269        let body = ctx.redactor.apply(&body).into_owned();
270        // And held to the same ceiling, for the same reason. Redaction is one
271        // of the two things a tool result passes on the way out; a resource
272        // that skipped the other would fetch, uncapped, the document the tool
273        // beside it had just refused — `tailscale://status` and
274        // `tailscale_status` answer with the same bytes. The default cap is a
275        // mebibyte, so this changes nothing until an operator asks for a
276        // smaller one, which is the operator who wanted the ceiling.
277        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/// Which surfaces a session has, for the prompt steps that reach across both.
291///
292/// [`PromptEntry::surface`] decides whether a prompt is offered at all; this
293/// is the finer question the ones that reach across have to ask. A numbered
294/// procedure that tells the model to call `tailnet_device_list` in a session
295/// with no credential is naming a tool that does not exist there, which is the
296/// thing [`crate::gating::Gate::offers`] was written to stop the instructions
297/// doing.
298#[derive(Clone, Copy, Debug)]
299pub struct Surfaces {
300    local: bool,
301    tailnet: bool,
302}
303
304impl Surfaces {
305    /// From the same question the resource listing asks of every entry.
306    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
321/// One prompt: a name, an optional argument, and the guidance it expands to.
322pub struct PromptEntry {
323    pub name: &'static str,
324    pub title: &'static str,
325    pub description: &'static str,
326    /// The one optional argument, and what it is for.
327    pub argument: (&'static str, &'static str),
328    /// The surface the prompt cannot work without, and so the one that decides
329    /// whether it is listed at all — the rule [`read`] already applies to a
330    /// resource.
331    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
366/// Every prompt, in listing order.
367///
368/// All three work under the read tier, which is why none of them tells the
369/// model to write anything: validation and preview do not mutate, and a prompt
370/// that ended in a write would be one a read-only session could not finish.
371///
372/// Tier is not the only way a step can be out of reach. Each prompt also names
373/// the surface it cannot work without, so that a session missing that surface
374/// is not offered a procedure whose every step it would refuse; and the one
375/// prompt that reads from both drops the steps whose surface is absent.
376pub 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            // The question is why *this node* cannot reach something, and the
387            // first three steps are what answers it. The control plane
388            // corroborates; it does not stand in for the node.
389            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                // Steps 4 and 5 are control-plane reads. A session with no
396                // credential has neither tool, and a numbered procedure that
397                // names them there sends the model at something it was never
398                // offered — so the list ends at 3 and says so.
399                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        // Not a device: no identifier, or one carrying a path.
523        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    /// A session that has both surfaces, which is what most of these ask about.
536    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            // The argument is declared optional, which is what lets a client
560            // ask for the prompt without it.
561            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        // All three have to work under the read tier, so a prompt naming a
571        // write tool would be one a read-only session could not finish.
572        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                    // `tailnet_policy_set` is named as the thing *not* to do.
578                    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    /// The surface half of the question the test above asks about the tier.
589    ///
590    /// A prompt is listed when its own surface is there, so what it expands to
591    /// may still name the *other* one. Every tool it names has to be on a
592    /// surface the session actually has — otherwise the procedure sends the
593    /// model at a tool that does not exist for it, which is the thing hiding
594    /// a toolset was meant to prevent.
595    #[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}