Skip to main content

tailscale_mcp/
meta.rs

1//! Tool metadata: the single table that the router, the `tools` subcommand,
2//! the contract tests and the generated documentation all read.
3//!
4//! A tool cannot exist without a row here. Ticket 02 makes that structural: the
5//! declaration macro in [`crate::registry`] emits the row and the handler from
6//! one declaration, so the two cannot drift apart.
7
8use std::fmt;
9
10/// Which of the two backends a tool acts through.
11///
12/// The distinction is not cosmetic: the local surface can only ever act on the
13/// node the server runs on, while the tailnet surface acts on the whole tailnet
14/// and needs a credential rather than a binary.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub enum Surface {
17    /// The `tailscale` command-line interface, acting on the local node.
18    Local,
19    /// The control-plane REST API, acting on the tailnet.
20    Tailnet,
21}
22
23impl Surface {
24    /// The prefix every tool name on this surface carries.
25    pub const fn prefix(self) -> &'static str {
26        match self {
27            Self::Local => "tailscale_",
28            Self::Tailnet => "tailnet_",
29        }
30    }
31
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::Local => "local",
35            Self::Tailnet => "tailnet",
36        }
37    }
38}
39
40impl fmt::Display for Surface {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.write_str(self.as_str())
43    }
44}
45
46/// The verbs a `tailnet_*` tool's name may end in.
47///
48/// `spec.md` asks for "a fixed verb vocabulary", and this is it. The point is
49/// not tidiness: there are ninety-three of these tools across five tickets, and
50/// without one list to conform to the same operation gets called `delete` in
51/// one toolset and `remove` in the next, which a model then has to learn
52/// twice. So the set is closed and `every_tailnet_tool_ends_in_a_known_verb`
53/// holds every name to it.
54///
55/// Deliberately declared whole rather than grown as tools land. A vocabulary
56/// that gains a word whenever a name does not fit is not one, and the entries
57/// with no tool yet are the constraint on the tickets that add them.
58///
59/// - `list`, `get` — read a collection, read one thing.
60/// - `create`, `update`, `delete` — the usual three. `update` changes the
61///   fields it is given; `replace` is for the endpoints that take the whole
62///   object and discard what is missing.
63/// - `set` — assign one named thing on a resource: `..._tags_set`. Distinct
64///   from `update` because the resource is not what is being replaced.
65/// - `authorize`, `approve`, `expire`, `rename`, `enable`, `disable`,
66///   `suspend`, `restore` — actions with no CRUD spelling, each the API's own
67///   word for it. `suspend` and `restore` were added at ticket 19 (Q77): the
68///   endpoints are `suspendUser` and `restoreUser`, and calling them
69///   `disable`/`enable` would have been this server renaming something
70///   Tailscale had already named.
71/// - `accept`, `resend` — what an invitation can have done to it.
72/// - `validate`, `preview` — the policy file's two dry runs.
73/// - `test`, `rotate` — a webhook's delivery check and its secret.
74pub const TAILNET_VERBS: &[&str] = &[
75    "accept",
76    "approve",
77    "authorize",
78    "create",
79    "delete",
80    "disable",
81    "enable",
82    "expire",
83    "get",
84    "list",
85    "preview",
86    "rename",
87    "replace",
88    "resend",
89    "restore",
90    "rotate",
91    "set",
92    "suspend",
93    "test",
94    "update",
95    "validate",
96];
97
98/// A tool's risk class.
99///
100/// The ordering is meaningful and is relied upon by the gate: a server allowed
101/// to run destructive tools may also run write and read tools.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
103pub enum Tier {
104    /// Changes nothing. Available by default.
105    Read,
106    /// Changes configuration that can be changed back.
107    Write,
108    /// Removes something, or exposes something, in a way that is not simply
109    /// undone: deleting a device, revoking a key, publishing to the internet.
110    Destructive,
111}
112
113impl Tier {
114    pub const fn as_str(self) -> &'static str {
115        match self {
116            Self::Read => "read",
117            Self::Write => "write",
118            Self::Destructive => "destructive",
119        }
120    }
121
122    /// The flag an operator passes to permit this tier, if any.
123    pub const fn flag(self) -> Option<&'static str> {
124        match self {
125            Self::Read => None,
126            Self::Write => Some("--allow-write"),
127            Self::Destructive => Some("--allow-destructive"),
128        }
129    }
130}
131
132impl fmt::Display for Tier {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        f.write_str(self.as_str())
135    }
136}
137
138/// A named group of tools switched on or off together.
139///
140/// Kept as an enum rather than a string so that a preset cannot name a toolset
141/// that does not exist, and so that adding a toolset forces every preset to be
142/// reconsidered.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
144pub enum Toolset {
145    // Local surface.
146    LocalStatus,
147    LocalPrefs,
148    LocalServe,
149    LocalFiles,
150    LocalLock,
151    LocalDebug,
152    LocalPassthrough,
153    // Tailnet surface.
154    TailnetDevices,
155    TailnetInvites,
156    TailnetLogging,
157    TailnetDns,
158    TailnetKeys,
159    TailnetPolicy,
160    TailnetPosture,
161    TailnetUsers,
162    TailnetSettings,
163    TailnetWebhooks,
164    TailnetServices,
165    TailnetOauthApps,
166    TailnetOrg,
167}
168
169impl Toolset {
170    /// Every toolset, in listing order. Adding a variant without adding it here
171    /// is caught by `all_is_exhaustive`, which is a test and so cannot be linked
172    /// from documentation built without `cfg(test)`.
173    pub const ALL: &'static [Toolset] = &[
174        Self::LocalStatus,
175        Self::LocalPrefs,
176        Self::LocalServe,
177        Self::LocalFiles,
178        Self::LocalLock,
179        Self::LocalDebug,
180        Self::LocalPassthrough,
181        Self::TailnetDevices,
182        Self::TailnetInvites,
183        Self::TailnetLogging,
184        Self::TailnetDns,
185        Self::TailnetKeys,
186        Self::TailnetPolicy,
187        Self::TailnetPosture,
188        Self::TailnetUsers,
189        Self::TailnetSettings,
190        Self::TailnetWebhooks,
191        Self::TailnetServices,
192        Self::TailnetOauthApps,
193        Self::TailnetOrg,
194    ];
195
196    /// The name an operator writes in configuration.
197    pub const fn as_str(self) -> &'static str {
198        match self {
199            Self::LocalStatus => "local-status",
200            Self::LocalPrefs => "local-prefs",
201            Self::LocalServe => "local-serve",
202            Self::LocalFiles => "local-files",
203            Self::LocalLock => "local-lock",
204            Self::LocalDebug => "local-debug",
205            Self::LocalPassthrough => "local-passthrough",
206            Self::TailnetDevices => "tailnet-devices",
207            Self::TailnetInvites => "tailnet-invites",
208            Self::TailnetLogging => "tailnet-logging",
209            Self::TailnetDns => "tailnet-dns",
210            Self::TailnetKeys => "tailnet-keys",
211            Self::TailnetPolicy => "tailnet-policy",
212            Self::TailnetPosture => "tailnet-posture",
213            Self::TailnetUsers => "tailnet-users",
214            Self::TailnetSettings => "tailnet-settings",
215            Self::TailnetWebhooks => "tailnet-webhooks",
216            Self::TailnetServices => "tailnet-services",
217            Self::TailnetOauthApps => "tailnet-oauth-apps",
218            Self::TailnetOrg => "tailnet-org",
219        }
220    }
221
222    /// Which surface this toolset belongs to. Used to hide a whole surface when
223    /// its backend is absent or has been disabled.
224    pub const fn surface(self) -> Surface {
225        match self {
226            Self::LocalStatus
227            | Self::LocalPrefs
228            | Self::LocalServe
229            | Self::LocalFiles
230            | Self::LocalLock
231            | Self::LocalDebug
232            | Self::LocalPassthrough => Surface::Local,
233            _ => Surface::Tailnet,
234        }
235    }
236
237    /// Parse an operator-written toolset name.
238    pub fn parse(s: &str) -> Option<Self> {
239        Self::ALL.iter().copied().find(|t| t.as_str() == s)
240    }
241
242    #[cfg(test)]
243    fn all_is_exhaustive() {}
244}
245
246impl fmt::Display for Toolset {
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        f.write_str(self.as_str())
249    }
250}
251
252/// The hints a client sees on a tool.
253///
254/// These are advisory in the protocol but load-bearing for a planning model, so
255/// they are derived from the tier wherever the tier determines them and stated
256/// explicitly only where it does not.
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258pub struct Annotations {
259    pub read_only: bool,
260    pub destructive: bool,
261    pub idempotent: bool,
262    pub open_world: bool,
263}
264
265/// One row of the tool table.
266#[derive(Debug, Clone, Copy)]
267pub struct ToolMeta {
268    /// The tool name as the client sees it, including its surface prefix.
269    pub name: &'static str,
270    pub toolset: Toolset,
271    pub tier: Tier,
272    /// One sentence, shown to the model. The full description lives on the
273    /// generated schema; this is what the tool table prints.
274    pub summary: &'static str,
275    /// Whether calling this can cut the server off from the tailnet or from the
276    /// client it serves. Self-severing tools always require confirmation; the
277    /// two are separate fields because the tailnet surface has irreversible
278    /// operations that are not self-severing but still require it.
279    pub self_severing: bool,
280    /// Whether this tool severs the connection when its *target* is this node.
281    ///
282    /// Where [`Self::self_severing`] is true of every call a tool makes, this
283    /// is true of some of them: `tailnet_device_delete` is an ordinary
284    /// destructive call against somebody else's device and a cut cable
285    /// against this one, and only the argument tells them apart. So it cannot
286    /// imply [`Self::requires_confirmation`] — a caller managing another
287    /// device would be made to confirm something that cannot happen — and the
288    /// confirmation lives in the tool's own parameters, where the handler can
289    /// ask for it only when the target turns out to be us (Q83).
290    pub severs_local_node: bool,
291    /// Whether the caller must state intent in the call itself. No flag can
292    /// pre-authorise this.
293    pub requires_confirmation: bool,
294    /// Repeating the call has the same effect as making it once.
295    pub idempotent: bool,
296    /// Whether [`Self::tier`] is a floor rather than the whole truth.
297    ///
298    /// Set by the rows whose risk is decided by the arguments they are given
299    /// rather than by the row: the passthrough, `tailnet_device_authorize` and
300    /// `tailnet_service_approval_set`, the last two by Q70. The gate still
301    /// reads the tier, so such a tool is offered as soon as its floor is
302    /// permitted, and the handler refuses anything above what the session
303    /// allows. The annotations state the worst case, because a client reading
304    /// `read_only` has no way to know that this one is conditional.
305    ///
306    /// `the_tier_is_a_floor_only_where_it_is_documented` pins that list, so a
307    /// fourth row adopting the flag is a change somebody has to write down.
308    pub varying_tier: bool,
309    /// The lowest `tailscale` version that accepts this command, where the
310    /// command is newer than our supported floor.
311    pub min_version: Option<&'static str>,
312    /// The operating systems the command exists on, when it does not exist on
313    /// all of them. Values are [`std::env::consts::OS`] spellings.
314    ///
315    /// A restricted tool is still listed everywhere. The table is the same on
316    /// every platform so that the documentation, the contract tests and the
317    /// `tools` subcommand agree wherever they run, and so that a caller asking
318    /// for something macOS-only on Linux is told *why* rather than finding a
319    /// tool that does not exist.
320    pub platforms: Option<&'static [&'static str]>,
321}
322
323impl ToolMeta {
324    pub const fn surface(&self) -> Surface {
325        self.toolset.surface()
326    }
327
328    /// Whether the command behind this tool exists on the machine we are on.
329    pub fn runs_here(&self) -> bool {
330        self.platforms
331            .is_none_or(|allowed| allowed.contains(&std::env::consts::OS))
332    }
333
334    /// Whether this tool exposes a `confirm` argument to the caller.
335    ///
336    /// Three fields put one there and they mean different things — the row
337    /// demands it, every call severs, or a call severs when its target turns
338    /// out to be this node — but a caller sees the same argument for all
339    /// three. Anything reasoning about what a session shows a model wants this
340    /// question, not the three underneath it.
341    pub const fn takes_confirmation(&self) -> bool {
342        self.requires_confirmation || self.self_severing || self.severs_local_node
343    }
344
345    /// Annotations are derived, not stored, so that a tool cannot claim to be
346    /// read-only while sitting at the destructive tier.
347    pub const fn annotations(&self) -> Annotations {
348        Annotations {
349            read_only: !self.varying_tier && matches!(self.tier, Tier::Read),
350            destructive: self.varying_tier || matches!(self.tier, Tier::Destructive),
351            idempotent: self.idempotent,
352            // Both surfaces reach a network the server does not control.
353            open_world: true,
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn toolset_all_covers_every_variant() {
364        // A cheap stand-in for exhaustiveness: every name round-trips, and the
365        // list has no duplicates. A new variant missing from ALL fails the
366        // count assertions in the registry tests.
367        let mut names: Vec<&str> = Toolset::ALL.iter().map(|t| t.as_str()).collect();
368        names.sort_unstable();
369        let before = names.len();
370        names.dedup();
371        assert_eq!(before, names.len(), "duplicate toolset name");
372
373        for t in Toolset::ALL {
374            assert_eq!(Toolset::parse(t.as_str()), Some(*t));
375        }
376        Toolset::all_is_exhaustive();
377    }
378
379    #[test]
380    fn toolset_names_are_prefixed_by_surface() {
381        for t in Toolset::ALL {
382            let expected = match t.surface() {
383                Surface::Local => "local-",
384                Surface::Tailnet => "tailnet-",
385            };
386            assert!(
387                t.as_str().starts_with(expected),
388                "{t} does not carry its surface prefix"
389            );
390        }
391    }
392
393    #[test]
394    fn tiers_order_from_least_to_most_dangerous() {
395        assert!(Tier::Read < Tier::Write);
396        assert!(Tier::Write < Tier::Destructive);
397    }
398
399    #[test]
400    fn annotations_follow_the_tier() {
401        let read = ToolMeta {
402            name: "tailscale_status",
403            toolset: Toolset::LocalStatus,
404            tier: Tier::Read,
405            summary: "",
406            self_severing: false,
407            severs_local_node: false,
408            requires_confirmation: false,
409            idempotent: true,
410            varying_tier: false,
411            min_version: None,
412            platforms: None,
413        };
414        assert!(read.annotations().read_only);
415        assert!(!read.annotations().destructive);
416        assert!(read.annotations().open_world);
417
418        let destructive = ToolMeta {
419            tier: Tier::Destructive,
420            ..read
421        };
422        assert!(!destructive.annotations().read_only);
423        assert!(destructive.annotations().destructive);
424    }
425
426    #[test]
427    fn a_varying_tier_is_annotated_at_its_worst_case() {
428        // The passthrough sits at the read tier so that a read-only session can
429        // still reach the commands it may run, but a client must not be told
430        // that calling it changes nothing.
431        let passthrough = ToolMeta {
432            name: "tailscale_run",
433            toolset: Toolset::LocalPassthrough,
434            tier: Tier::Read,
435            summary: "",
436            self_severing: false,
437            severs_local_node: false,
438            requires_confirmation: false,
439            idempotent: false,
440            varying_tier: true,
441            min_version: None,
442            platforms: None,
443        };
444        assert!(!passthrough.annotations().read_only);
445        assert!(passthrough.annotations().destructive);
446    }
447}