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}