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}