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. A variant missing here cannot be parsed
171    /// from `--toolsets`, so the contract tests, which enable each tool's
172    /// toolset by name, fail on it.
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
243impl fmt::Display for Toolset {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        f.write_str(self.as_str())
246    }
247}
248
249/// The hints a client sees on a tool.
250///
251/// These are advisory in the protocol but load-bearing for a planning model, so
252/// they are derived from the tier wherever the tier determines them and stated
253/// explicitly only where it does not.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub struct Annotations {
256    pub read_only: bool,
257    pub destructive: bool,
258    pub idempotent: bool,
259    pub open_world: bool,
260}
261
262/// One row of the tool table.
263#[derive(Debug, Clone, Copy)]
264pub struct ToolMeta {
265    /// The tool name as the client sees it, including its surface prefix.
266    pub name: &'static str,
267    pub toolset: Toolset,
268    pub tier: Tier,
269    /// One sentence, shown to the model. The full description lives on the
270    /// generated schema; this is what the tool table prints.
271    pub summary: &'static str,
272    /// Whether calling this can cut the server off from the tailnet or from the
273    /// client it serves. Self-severing tools always require confirmation; the
274    /// two are separate fields because the tailnet surface has irreversible
275    /// operations that are not self-severing but still require it.
276    pub self_severing: bool,
277    /// Whether this tool severs the connection when its *target* is this node.
278    ///
279    /// Where [`Self::self_severing`] is true of every call a tool makes, this
280    /// is true of some of them: `tailnet_device_delete` is an ordinary
281    /// destructive call against somebody else's device and a cut cable
282    /// against this one, and only the argument tells them apart. So it cannot
283    /// imply [`Self::requires_confirmation`] — a caller managing another
284    /// device would be made to confirm something that cannot happen — and the
285    /// confirmation lives in the tool's own parameters, where the handler can
286    /// ask for it only when the target turns out to be us (Q83).
287    pub severs_local_node: bool,
288    /// Whether the caller must state intent in the call itself. No flag can
289    /// pre-authorise this.
290    pub requires_confirmation: bool,
291    /// Repeating the call has the same effect as making it once.
292    pub idempotent: bool,
293    /// Whether [`Self::tier`] is a floor rather than the whole truth.
294    ///
295    /// Set by the rows whose risk is decided by the arguments they are given
296    /// rather than by the row: the passthrough, `tailnet_device_authorize` and
297    /// `tailnet_service_approval_set`, the last two by Q70. The gate still
298    /// reads the tier, so such a tool is offered as soon as its floor is
299    /// permitted, and the handler refuses anything above what the session
300    /// allows. The annotations state the worst case, because a client reading
301    /// `read_only` has no way to know that this one is conditional.
302    ///
303    /// `the_tier_is_a_floor_only_where_it_is_documented` pins that list, so a
304    /// fourth row adopting the flag is a change somebody has to write down.
305    pub varying_tier: bool,
306    /// The lowest `tailscale` version that accepts this command, where the
307    /// command is newer than our supported floor.
308    pub min_version: Option<&'static str>,
309    /// The operating systems the command exists on, when it does not exist on
310    /// all of them. Values are [`std::env::consts::OS`] spellings.
311    ///
312    /// A restricted tool is still listed everywhere. The table is the same on
313    /// every platform so that the documentation, the contract tests and the
314    /// `tools` subcommand agree wherever they run, and so that a caller asking
315    /// for something macOS-only on Linux is told *why* rather than finding a
316    /// tool that does not exist.
317    pub platforms: Option<&'static [&'static str]>,
318}
319
320impl ToolMeta {
321    pub const fn surface(&self) -> Surface {
322        self.toolset.surface()
323    }
324
325    /// Whether the command behind this tool exists on the machine we are on.
326    pub fn runs_here(&self) -> bool {
327        self.platforms
328            .is_none_or(|allowed| allowed.contains(&std::env::consts::OS))
329    }
330
331    /// Whether this tool exposes a `confirm` argument to the caller.
332    ///
333    /// Three fields put one there and they mean different things — the row
334    /// demands it, every call severs, or a call severs when its target turns
335    /// out to be this node — but a caller sees the same argument for all
336    /// three. Anything reasoning about what a session shows a model wants this
337    /// question, not the three underneath it.
338    pub const fn takes_confirmation(&self) -> bool {
339        self.requires_confirmation || self.self_severing || self.severs_local_node
340    }
341
342    /// Annotations are derived, not stored, so that a tool cannot claim to be
343    /// read-only while sitting at the destructive tier.
344    pub const fn annotations(&self) -> Annotations {
345        Annotations {
346            read_only: !self.varying_tier && matches!(self.tier, Tier::Read),
347            destructive: self.varying_tier || matches!(self.tier, Tier::Destructive),
348            idempotent: self.idempotent,
349            // Both surfaces reach a network the server does not control.
350            open_world: true,
351        }
352    }
353}