tailscale_mcp/tools/common.rs
1//! The small things every toolset does the same way.
2//!
3//! Building an argument, reporting what a command said on standard error, and
4//! turning a report struct into the value a tool answers with. None of it is
5//! interesting on its own; it lives here so that three modules cannot drift
6//! into three spellings of it.
7
8use std::path::Path;
9use std::time::Duration;
10
11use serde::Serialize;
12use serde_json::{Value, json};
13use tailscale_cli::{Invocation, Output, SecretFile};
14
15use crate::cli;
16use crate::context::ToolContext;
17use crate::error::{ErrorCode, ToolError, ToolResult};
18use crate::meta::ToolMeta;
19
20/// Render a boolean flag the way Go's flag package needs it: joined to its
21/// value, so that it cannot be mistaken for a positional argument.
22pub fn flag(name: &str, value: bool) -> String {
23 format!("--{name}={value}")
24}
25
26/// Add `--name=value` when the caller gave a value, and nothing when it did
27/// not. This is what keeps a call that changes one thing from restating the
28/// rest.
29pub fn push_bool(args: &mut Vec<String>, name: &str, value: Option<bool>) {
30 if let Some(value) = value {
31 args.push(flag(name, value));
32 }
33}
34
35pub fn push_text(args: &mut Vec<String>, name: &str, value: Option<&str>) {
36 if let Some(value) = value {
37 args.push(format!("--{name}={value}"));
38 }
39}
40
41/// The CLI takes repeated values as one comma-separated argument.
42pub fn push_list(args: &mut Vec<String>, name: &str, value: Option<&[String]>) {
43 if let Some(values) = value {
44 args.push(format!("--{name}={}", values.join(",")));
45 }
46}
47
48/// Whatever a command said on standard error, when it said anything.
49///
50/// Carried on the answer rather than raised, because a command that succeeded
51/// while warning about something has not failed — `netcheck` and `debug
52/// hostinfo` both talk on standard error as a matter of course.
53pub fn note(ctx: &ToolContext, stderr: &str) -> Option<String> {
54 let redacted = ctx.redactor.apply(stderr);
55 let trimmed = redacted.trim();
56 (!trimmed.is_empty()).then(|| trimmed.to_owned())
57}
58
59/// The text a command printed, with blank lines and comment lines dropped.
60/// A JSON value as the text a person reads: indented, so the shape shows.
61///
62/// The fallback cannot happen for a value that came from a parser, and is a
63/// fallback rather than an `unwrap` because `unwrap` is denied here.
64pub fn pretty(value: &serde_json::Value) -> String {
65 serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())
66}
67
68/// Everything the client printed, redacted, standard output first.
69///
70/// Which stream a command reports on is not a property a caller should have to
71/// know: `serve` names the new address on standard output and warns on
72/// standard error, `file cp` reports progress on either depending on the
73/// version, and a caller wants whichever it was.
74pub fn printed(ctx: &ToolContext, output: &Output) -> Option<String> {
75 let stdout = output.stdout_str();
76 let joined = [stdout.as_ref(), output.stderr.as_str()]
77 .iter()
78 .map(|part| part.trim())
79 .filter(|part| !part.is_empty())
80 .collect::<Vec<_>>()
81 .join("\n");
82 note(ctx, &joined)
83}
84
85/// How much longer the client is given than the caller asked to wait, so that
86/// a timeout is this server's own and not a command killed mid-sentence.
87const GRACE: u64 = 5;
88
89/// Bound a caller's wait, and give the client a little longer than we wait.
90///
91/// Returns the bound the caller is told about and the one the child is given,
92/// which differ by the grace above: the caller is answered in its own terms.
93pub fn bounded_wait(requested: Option<u64>, default: u64, longest: u64) -> (u64, Duration) {
94 let seconds = requested.unwrap_or(default).clamp(1, longest);
95 (seconds, Duration::from_secs(seconds + GRACE))
96}
97
98pub fn lines(text: &str) -> impl DoubleEndedIterator<Item = &str> {
99 text.lines()
100 .map(str::trim)
101 .filter(|line| !line.is_empty() && !line.starts_with('#'))
102}
103
104/// Every whitespace-separated token beginning with one of `prefixes`, in the
105/// order the text printed them and with trailing sentence punctuation removed.
106///
107/// The client hands back its products inside prose written for a person: a
108/// login URL in a sentence, the disablement secrets `lock init` just minted,
109/// the auth key `lock sign` countersigned. Each of those is recognisable by its
110/// own prefix, which is part of the value's format, whereas the sentence around
111/// it is not and changes between releases. So this looks for the prefix and
112/// ignores the prose.
113///
114/// Whatever it finds is always offered *alongside* the client's own text rather
115/// than instead of it, so a layout change costs the caller nothing.
116pub fn tokens_with_prefix(text: &str, prefixes: &[&str]) -> Vec<String> {
117 text.split_whitespace()
118 .filter(|word| prefixes.iter().any(|prefix| word.starts_with(prefix)))
119 .map(|word| word.trim_end_matches(['.', ',']).to_owned())
120 .collect()
121}
122
123/// The first URL in a block of text.
124///
125/// Three commands hand a person something to open rather than doing it
126/// themselves: `login` and an interactive `up` print a login URL, and `serve`
127/// prints the address the handler is now reachable at.
128pub fn find_url(text: &str) -> Option<String> {
129 tokens_with_prefix(text, &["https://", "http://"])
130 .into_iter()
131 .next()
132}
133
134/// Refuse a path the caller cannot have meant, naming which one it was.
135///
136/// `-` is the client's spelling of "a stream rather than a file": the private
137/// key on standard output for `cert`, standard input for `file cp`, the
138/// disablement secret typed at a prompt for `lock disable`. A tool call has
139/// none of those, so every command that offers the spelling is refused it here.
140///
141/// The configured [`PathPolicy`](crate::context::PathPolicy) is asked last, so
142/// that a caller who named something impossible is told that rather than told
143/// about the policy.
144pub fn real_path(ctx: &ToolContext, what: &str, path: &str) -> ToolResult<String> {
145 let trimmed = path.trim();
146 if trimmed.is_empty() {
147 return Err(ToolError::invalid_args(format!("`{what}` cannot be empty")));
148 }
149 if trimmed == "-" {
150 return Err(ToolError::invalid_args(format!(
151 "`{what}` has to be a path on this machine; `-` means a stream, which a tool call has none of"
152 )));
153 }
154 if !ctx.paths.permits(Path::new(trimmed)) {
155 return Err(ToolError::new(
156 ErrorCode::NotPermitted,
157 format!("`{what}` is outside the paths this server may use"),
158 )
159 .with_hint("Name a path under one of the server's configured roots."));
160 }
161 // The validated spelling, so that what was checked is what runs: a path
162 // the caller padded with a newline is the path it meant.
163 Ok(trimmed.to_owned())
164}
165
166/// A secret on its way to the CLI, held open for as long as the call takes.
167///
168/// Answers with the *value* the argument should carry and the file keeping it
169/// alive; how that value is spelled is the caller's business, because the
170/// commands disagree — `set` wants `--auth-key=<value>` and `lock sign` wants a
171/// bare positional. Drop the file once the command has returned, not before.
172///
173/// A value that is already a `file:` reference is passed through: it is a path,
174/// not a secret, and re-copying it would gain nothing. Anything else is written
175/// to a private temporary file, so that the secret itself never reaches an
176/// argument list that `ps` can read.
177pub fn secret_value(what: &str, value: &str) -> ToolResult<(String, Option<SecretFile>)> {
178 if value.starts_with("file:") {
179 return Ok((value.to_owned(), None));
180 }
181 let file = SecretFile::new(value).map_err(|e| {
182 ToolError::new(
183 ErrorCode::CliFailed,
184 format!("the {what} could not be written to a private file: {e}"),
185 )
186 })?;
187 Ok((file.arg(), Some(file)))
188}
189
190/// Refuse a setting that does not exist on this operating system, naming both
191/// the setting and where we are (DECISIONS Q20).
192pub fn only_on(setting: &str, platforms: &[&str]) -> ToolResult<()> {
193 if platforms.contains(&std::env::consts::OS) {
194 return Ok(());
195 }
196 Err(ToolError::new(
197 ErrorCode::UnsupportedPlatform,
198 format!(
199 "`{setting}` is a {} preference, and this node runs {}",
200 platforms.join(" or "),
201 std::env::consts::OS
202 ),
203 ))
204}
205
206/// A caller's identifier, checked before it becomes part of a URL.
207///
208/// The control plane accepts a device by either of two spellings — the node id
209/// `n1234567CNTRL` or the numeric one — and an attribute by a `custom:`-
210/// prefixed key, so the tools take whatever the caller has rather than making
211/// it convert. What they will not take is anything that could change which
212/// endpoint is being called: a segment carrying `/`, `?`, `#` or a space is a
213/// path being written by the caller rather than an identifier, and is refused
214/// here rather than escaped, because there is no legitimate identifier this
215/// rejects and an escaped one would only fail further away.
216pub fn path_segment(what: &str, value: &str) -> ToolResult<String> {
217 let trimmed = value.trim();
218 if trimmed.is_empty() {
219 return Err(ToolError::invalid_args(format!("`{what}` cannot be empty")));
220 }
221 let allowed = |c: char| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '@');
222 if let Some(bad) = trimmed.chars().find(|c| !allowed(*c)) {
223 return Err(ToolError::invalid_args(format!(
224 "`{what}` contains {bad:?}, which cannot appear in an identifier"
225 )));
226 }
227 // `.` is in the allow-list because identifiers contain it, and `.` and `..`
228 // are made of nothing else. A dot segment does not sit in the path: it
229 // rewrites it when the URL is parsed, so `/api/v2/device/../routes`
230 // reaches `/api/v2/routes` and a deletion aimed at `..` reaches
231 // `/api/v2/`. That is the whole failure this guard exists to prevent, and
232 // it is the one spelling the character check lets through.
233 if trimmed.chars().all(|c| c == '.') {
234 return Err(ToolError::invalid_args(format!(
235 "`{what}` is `{trimmed}`, which is not an identifier"
236 )));
237 }
238 Ok(trimmed.to_owned())
239}
240
241/// What an endpoint that answers with nothing answers with here.
242///
243/// The control plane sends an empty body for a deletion and for six of the
244/// writes, which reaches a tool as `null`. A caller cannot tell that apart
245/// from a tool that lost its answer, so the tool says what it did instead
246/// (Q67). Deliberately not shaped like a resource: `done` is a phrase for a
247/// reader rather than a status to branch on, and `about` carries only the
248/// identifiers the call was given, so nothing here can be mistaken for
249/// something the control plane said.
250#[derive(Debug, Serialize)]
251pub struct Done {
252 done: &'static str,
253 #[serde(flatten)]
254 about: serde_json::Map<String, Value>,
255}
256
257impl Done {
258 pub fn new(done: &'static str) -> Self {
259 Self {
260 done,
261 about: serde_json::Map::new(),
262 }
263 }
264
265 pub fn about(mut self, name: &str, value: impl Into<Value>) -> Self {
266 self.about.insert(name.to_owned(), value.into());
267 self
268 }
269}
270
271/// Hold a value to a documented string's known values, or refuse quoting them.
272///
273/// Q60 keeps the description's enumerations as `&[&str]` constants beside the
274/// models rather than as Rust enums, and the drift test holds each constant to
275/// the description — so a value this accepts is a value the description knows,
276/// and a refusal names today's whole list. Checked here rather than left to the
277/// control plane because a 400 with no vocabulary in it tells a caller nothing
278/// about what to send instead.
279///
280/// `what` is the caller's own parameter name, so a refusal points at the
281/// argument they wrote rather than at whatever the description calls it.
282pub fn one_of(what: &str, value: &str, allowed: &[&str]) -> ToolResult<String> {
283 if allowed.contains(&value) {
284 return Ok(value.to_owned());
285 }
286 Err(ToolError::invalid_args(format!(
287 "`{what}` is one of {}; `{value}` is none of them",
288 allowed.join(", ")
289 )))
290}
291
292/// Every entry of a list, trimmed, with nothing blank among them.
293///
294/// A blank entry is not an empty list: several endpoints here take an empty
295/// list as a documented instruction to remove everything, while `[""]` is a
296/// caller that meant that and got it wrong — and the control plane answers it
297/// with a 400 naming no entry.
298pub fn each_present(what: &str, given: Vec<String>) -> ToolResult<Vec<String>> {
299 given
300 .into_iter()
301 .map(|entry| {
302 let trimmed = entry.trim();
303 if trimmed.is_empty() {
304 return Err(ToolError::invalid_args(format!(
305 "`{what}` has an empty entry; send `[]` to remove everything"
306 )));
307 }
308 Ok(trimmed.to_owned())
309 })
310 .collect()
311}
312
313/// Forward what the control plane answered, or say what was done if it said
314/// nothing.
315///
316/// Several control-plane writes are documented as answering with the resource
317/// as it now stands, and answer with an empty body in practice. Forwarding the
318/// answer is worth more to a caller than a report that the call worked, so the
319/// answer wins when there is one; an empty body falls back to the report the
320/// rest of this surface uses (Q67), rather than to `null`.
321pub fn answered_or(answer: Value, otherwise: Done) -> ToolResult<Value> {
322 match answer {
323 Value::Null => report(otherwise),
324 answer => Ok(answer),
325 }
326}
327
328/// Turn a serialisable report into the value a tool answers with.
329///
330/// Serialisation of a plain struct of owned strings and numbers cannot fail, so
331/// the fallback is unreachable; it exists because a panic in a tool handler
332/// would take the whole session down.
333pub fn report(value: impl Serialize) -> ToolResult<Value> {
334 serde_json::to_value(value).map_err(|e| {
335 ToolError::new(
336 ErrorCode::CliFailed,
337 format!("the report did not build: {e}"),
338 )
339 })
340}
341
342/// Run a command that prints a JSON document, and return the document.
343///
344/// Tolerant of a non-zero exit *when a document still came back*: `status`
345/// prints a complete report while telling the shell that the node is not
346/// running. A refusal with nothing to parse is read as an ordinary failure.
347///
348/// Only standard output is parsed. `netcheck` and several `debug` commands
349/// write log lines to standard error while writing clean JSON to stdout.
350pub async fn document(
351 ctx: &ToolContext,
352 meta: &ToolMeta,
353 invocation: Invocation,
354) -> ToolResult<Value> {
355 let display = invocation.display();
356 let output = cli::run_tolerant(ctx, meta, invocation).await?;
357 let stdout = output.stdout_str();
358 match serde_json::from_str::<Value>(stdout.trim()) {
359 Ok(value) => Ok(value),
360 Err(_) if !output.success() => Err(cli::command_failure(ctx, meta, &display, &output)),
361 Err(e) => Err(ToolError::new(
362 ErrorCode::CliFailed,
363 format!("`{display}` did not print JSON: {e}"),
364 )),
365 }
366}
367
368/// A document that is an object, forwarded as it stands.
369///
370/// A document that is not an object is wrapped rather than rejected: a client
371/// destructures the answer, and a bare array or string would leave it nothing
372/// to destructure.
373pub async fn object(
374 ctx: &ToolContext,
375 meta: &ToolMeta,
376 invocation: Invocation,
377) -> ToolResult<Value> {
378 let value = document(ctx, meta, invocation).await?;
379 Ok(if value.is_object() {
380 value
381 } else {
382 json!({ "document": value })
383 })
384}
385
386/// A subcommand this server does not offer, and why.
387///
388/// The reason is not decoration. It is what the passthrough tells a caller that
389/// asked for one of these, and what a later reader of such a list has to argue
390/// with before adding one back.
391///
392/// It lives here rather than beside either list because there are two:
393/// [`crate::tools::local_debug::EXCLUDED`] holds the hidden `debug` subcommands
394/// that never become tools, and the passthrough's own `EXCLUDED` holds the
395/// documented commands this server will not run at all. The passthrough refuses
396/// both from one iterator — [`crate::tools::passthrough::excluded`] — which
397/// needs them to be one type.
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
399pub struct Excluded {
400 /// The subcommand as it is written after `tailscale`, words separated by
401 /// spaces, so that a caller's argument list can be matched against it.
402 pub path: &'static str,
403 /// Why it is not offered, phrased to be read by whoever asked for it.
404 pub reason: &'static str,
405}
406
407/// Refuse an argument that raises a call above the session's tier.
408///
409/// For the tools whose row is a floor rather than the whole truth (`varying:
410/// true`): the gate has already let the call through on the row's tier, and
411/// this asks the same question the gate asked, about the argument it could not
412/// see (Q70).
413pub fn require_destructive(ctx: &ToolContext, what: &str) -> ToolResult<()> {
414 if ctx.max_tier < crate::meta::Tier::Destructive {
415 return Err(ToolError::not_permitted(what, "--allow-destructive"));
416 }
417 Ok(())
418}
419
420/// The confirmation a call aimed at this node has to carry.
421///
422/// Flattened into the parameters of every tool whose row says
423/// `severs_local: true`, so that the field, its documentation and its meaning
424/// are written once rather than six times. The registry refuses to build a
425/// table where a tool claims to sever the local node without offering this,
426/// which is what keeps the flag and the schema in step.
427#[derive(Debug, Default, serde::Deserialize, rmcp::schemars::JsonSchema)]
428pub struct SelfConfirmation {
429 /// Set to `true` to confirm an operation on the device this server runs
430 /// on, which would cut the connection this call is being made over.
431 /// Needed only then: any other device is an ordinary call and ignores it.
432 #[serde(default)]
433 pub confirm: Option<bool>,
434}
435
436/// Refuse a call aimed at this node that did not say it meant to be.
437///
438/// `what` names the operation as the refusal should read it — "deleting this
439/// node's own device", not the tool name — because the caller is being told
440/// what would happen, not which function it reached.
441///
442/// Identity is read from local status and refreshed as it ages, and a session
443/// with no local surface has none: `SelfIdentity::default()` matches nothing,
444/// so the call is treated as ordinary. That is deliberate. The alternative is
445/// refusing every device operation on a suspicion the server cannot check,
446/// which would make the tailnet surface unusable on its own for the sake of a
447/// guess (Q83).
448pub async fn not_at_ourselves(
449 ctx: &ToolContext,
450 what: &str,
451 target: &str,
452 confirmation: &SelfConfirmation,
453) -> ToolResult<()> {
454 if confirmation.confirm == Some(true) || !ctx.names_us(target).await {
455 return Ok(());
456 }
457 Err(ToolError::new(
458 ErrorCode::ConfirmationRequired,
459 format!(
460 "`{target}` is the device this server runs on, so {what} can cut this \
461 session off from it, and this call did not say it meant to"
462 ),
463 )
464 .with_hint("Pass `confirm: true` to do it anyway."))
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470
471 #[test]
472 fn a_dot_segment_is_not_an_identifier() {
473 // The one spelling the character allow-list lets through, and the
474 // worst: `..` is normalised away when the URL is parsed, so it does
475 // not address a device — it addresses a different endpoint.
476 for bad in [".", "..", "...", " .. "] {
477 let error = path_segment("device_id", bad).expect_err("a dot segment");
478 assert_eq!(
479 serde_json::to_value(&error).expect("reportable")["code"],
480 serde_json::json!("invalid_args"),
481 "{bad:?}"
482 );
483 }
484 }
485
486 #[test]
487 fn an_identifier_containing_a_dot_is_still_an_identifier() {
488 // MagicDNS names reach these tools too, and dropping every dot would
489 // reject the commonest way a person names a device.
490 for good in [
491 "n1111111CNTRL",
492 "123456789",
493 "laptop.example-tailnet.ts.net",
494 "custom:a.b",
495 ] {
496 assert_eq!(
497 path_segment("device_id", good).expect("a valid identifier"),
498 good
499 );
500 }
501 }
502}