Skip to main content

tailscale_mcp/
config.rs

1//! Turning flags and environment variables into a settled configuration.
2//!
3//! Environment matters more here than in most command-line programs: an MCP
4//! client launches this server from a JSON configuration file that sets
5//! environment variables, and often cannot pass arguments at all. So every
6//! setting has both forms, the command line wins, and the resolution is a
7//! plain function over a source of variables rather than something clap does
8//! behind our back — which is what makes it testable without mutating the
9//! environment of a running test binary.
10
11use std::collections::BTreeSet;
12use std::net::{SocketAddr, ToSocketAddrs};
13use std::path::PathBuf;
14
15use clap::Parser;
16
17use crate::gating::{ConfigError, Preset, apply_toolset_modifiers};
18use crate::meta::{Surface, Tier, Toolset};
19
20/// The default ceiling on a single tool result, in bytes.
21pub const DEFAULT_MAX_RESULT_BYTES: usize = 1 << 20;
22
23/// What is logged when nothing says otherwise.
24pub const DEFAULT_LOG_FILTER: &str = "warn,tailscale_mcp=info";
25
26/// The most the MCP SDK is allowed to say unless it is asked for by name.
27///
28/// `rmcp` traces whole JSON-RPC messages at `TRACE` and `DEBUG`, results
29/// included — so an operator who set `--log=debug` to follow this server's own
30/// work would also write every minted auth key, every OAuth client secret and
31/// every invite URL to standard error, without asking for any of that (Q79).
32///
33/// So the SDK is capped at `info` on top of whatever was asked for. An
34/// operator who genuinely wants the wire can still have it, by naming the
35/// target — `--log=info,rmcp=trace` — which is a deliberate act rather than a
36/// side effect of turning up the volume.
37const SDK_CAP: &str = "rmcp=info";
38
39/// Whatever was asked for, with `rmcp=info` on the end.
40///
41/// Left alone if the filter already mentions the SDK: that is an operator who
42/// has said what they want from it, and overriding a deliberate choice with a
43/// default would be worse than the exposure this prevents.
44pub fn bounded_log_filter(requested: &str) -> String {
45    if requested.split(',').any(|directive| {
46        directive
47            .split('=')
48            .next()
49            .is_some_and(|target| target.trim() == "rmcp")
50    }) {
51        return requested.to_owned();
52    }
53    format!("{requested},{SDK_CAP}")
54}
55
56pub const PRESET_ENV: &str = "TAILSCALE_MCP_PRESET";
57pub const TOOLSETS_ENV: &str = "TAILSCALE_MCP_TOOLSETS";
58pub const ALLOW_WRITE_ENV: &str = "TAILSCALE_MCP_ALLOW_WRITE";
59pub const ALLOW_DESTRUCTIVE_ENV: &str = "TAILSCALE_MCP_ALLOW_DESTRUCTIVE";
60pub const NO_LOCAL_ENV: &str = "TAILSCALE_MCP_NO_LOCAL";
61pub const NO_TAILNET_ENV: &str = "TAILSCALE_MCP_NO_TAILNET";
62pub const CLI_PATH_ENV: &str = "TAILSCALE_MCP_CLI_PATH";
63pub const MAX_RESULT_BYTES_ENV: &str = "TAILSCALE_MCP_MAX_RESULT_BYTES";
64pub const LOG_ENV: &str = "TAILSCALE_MCP_LOG";
65/// The bearer token the HTTP transport requires.
66///
67/// A variable and no flag, because a token on a command line is a token in
68/// every process listing on the machine.
69pub const HTTP_TOKEN_ENV: &str = "TAILSCALE_MCP_HTTP_TOKEN";
70/// Serve HTTP with no token at all.
71pub const HTTP_NO_AUTH_ENV: &str = "TAILSCALE_MCP_HTTP_NO_AUTH";
72/// Serve HTTP without sessions.
73pub const HTTP_STATELESS_ENV: &str = "TAILSCALE_MCP_HTTP_STATELESS";
74/// Where the control-plane calls go. Deliberately without a command-line form:
75/// it exists so the tests can point at a fake and so a staging control plane
76/// can be reached, and neither belongs among the flags. It is documented all
77/// the same — a setting that redirects every credential this server holds is
78/// worse hidden than explained.
79pub const API_BASE_URL_ENV: &str = "TAILSCALE_MCP_API_BASE_URL";
80
81/// Every variable this module reads, for the diagnosis subcommand and the
82/// documentation to stay in step with the code.
83pub const ENV_VARS: &[&str] = &[
84    PRESET_ENV,
85    TOOLSETS_ENV,
86    ALLOW_WRITE_ENV,
87    ALLOW_DESTRUCTIVE_ENV,
88    NO_LOCAL_ENV,
89    NO_TAILNET_ENV,
90    CLI_PATH_ENV,
91    MAX_RESULT_BYTES_ENV,
92    LOG_ENV,
93    HTTP_TOKEN_ENV,
94    HTTP_NO_AUTH_ENV,
95    HTTP_STATELESS_ENV,
96    API_BASE_URL_ENV,
97];
98
99const LONG_ABOUT: &str = "\
100An MCP server for Tailscale. The local node is driven through the `tailscale`
101command-line interface; the tailnet is driven through the control-plane REST
102API. Tools are read-only unless a tier is permitted, and hidden rather than
103refused when they are not.
104
105Every option below can also be set through the environment, which is what an
106MCP client configuration usually does. An option given on the command line
107wins over the matching variable.
108
109  TAILSCALE_MCP_PRESET             --preset
110  TAILSCALE_MCP_TOOLSETS           --toolsets
111  TAILSCALE_MCP_ALLOW_WRITE        --allow-write
112  TAILSCALE_MCP_ALLOW_DESTRUCTIVE  --allow-destructive
113  TAILSCALE_MCP_NO_LOCAL           --no-local
114  TAILSCALE_MCP_NO_TAILNET         --no-tailnet
115  TAILSCALE_MCP_CLI_PATH           --cli-path
116  TAILSCALE_MCP_MAX_RESULT_BYTES   --max-result-bytes
117  TAILSCALE_MCP_LOG                --log
118  TAILSCALE_MCP_HTTP_NO_AUTH       --http-no-auth
119  TAILSCALE_MCP_HTTP_STATELESS     --http-stateless
120
121--http serves over Streamable HTTP instead of stdio, on 127.0.0.1:8449 unless
122given an address, with --http-allow-host and --http-allow-origin to widen what
123it answers for. TAILSCALE_MCP_HTTP_TOKEN is the bearer token callers must
124present; it has no command-line form, because an argument is readable by every
125process on this machine. A bind anywhere but loopback needs either that variable
126or --http-no-auth.
127
128TAILSCALE_TAILNET names the tailnet the control-plane tools act on; without it
129they act on the one the credential belongs to. TAILSCALE_MCP_API_BASE_URL sends
130the control-plane calls somewhere other than https://api.tailscale.com. It is
131there so the test suite can reach a fake on this machine, and has no
132command-line form. An address is accepted only over https or to this machine,
133and never with a username or password in it.
134
135Credentials for the tailnet surface are read from TAILSCALE_API_KEY, or from
136TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET, or from
137TAILSCALE_OAUTH_JWT_FILE, in that order. They have no command-line form: a
138secret on an argument list is visible to every process on the machine.";
139
140/// The command line, before the environment has been folded in.
141#[derive(Debug, Clone, Default, Parser)]
142#[command(name = "tailscale-mcp", version, about, long_about = LONG_ABOUT)]
143pub struct Cli {
144    /// Which group of toolsets to start from: minimal, core or full.
145    #[arg(long, global = true, value_name = "NAME")]
146    pub preset: Option<String>,
147
148    /// Toolsets to offer. A bare list replaces the preset's selection; a list
149    /// where every entry begins with `+` or `-` adjusts it.
150    #[arg(long, global = true, value_name = "LIST", allow_hyphen_values = true)]
151    pub toolsets: Option<String>,
152
153    /// Permit tools that change configuration.
154    #[arg(long, global = true)]
155    pub allow_write: bool,
156
157    /// Permit tools that remove or expose something. Implies --allow-write.
158    #[arg(long, global = true)]
159    pub allow_destructive: bool,
160
161    /// Do not offer the local surface, even if the CLI is present.
162    #[arg(long, global = true)]
163    pub no_local: bool,
164
165    /// Do not offer the tailnet surface, even if a credential is present.
166    #[arg(long, global = true)]
167    pub no_tailnet: bool,
168
169    /// Where the `tailscale` binary is, when it is not on the path.
170    #[arg(long, global = true, value_name = "PATH")]
171    pub cli_path: Option<PathBuf>,
172
173    /// Refuse a tool result larger than this many bytes.
174    #[arg(long, global = true, value_name = "BYTES")]
175    pub max_result_bytes: Option<usize>,
176
177    /// Logging filter, in the `tracing` syntax. Logs go to standard error.
178    #[arg(long, global = true, value_name = "FILTER")]
179    pub log: Option<String>,
180
181    /// Serve MCP over HTTP at this address instead of over stdio. Defaults to
182    /// 127.0.0.1:8449 when the flag is given with no address.
183    #[arg(long, value_name = "ADDR", num_args = 0..=1, default_missing_value = crate::http::DEFAULT_BIND)]
184    pub http: Option<String>,
185
186    /// Serve HTTP with no token at all. Required to bind anywhere but
187    /// loopback without one.
188    #[arg(long)]
189    pub http_no_auth: bool,
190
191    /// Also answer for this Host header. Repeatable. Loopback and this node's
192    /// own tailnet names are always allowed.
193    #[arg(long, value_name = "HOST")]
194    pub http_allow_host: Vec<String>,
195
196    /// Answer requests from this browser origin. Repeatable. Without it, a
197    /// request carrying any Origin is refused.
198    #[arg(long, value_name = "ORIGIN")]
199    pub http_allow_origin: Vec<String>,
200
201    /// Serve HTTP without sessions, where the negotiated protocol version
202    /// still has them. From 2026-07-28 the protocol has no sessions and this
203    /// changes nothing.
204    #[arg(long)]
205    pub http_stateless: bool,
206
207    /// Ask this binary a question instead of serving. Without one it serves.
208    #[command(subcommand)]
209    pub command: Option<Command>,
210}
211
212/// The things this binary does other than serve.
213#[derive(Debug, Clone, clap::Subcommand)]
214pub enum Command {
215    /// Check the CLI, the credential and the control plane, and report each.
216    ///
217    /// Exits non-zero when a check fails, so that a script can act on it.
218    Diagnose {
219        /// Report as JSON rather than as a table.
220        #[arg(long)]
221        json: bool,
222    },
223    /// Print what this preset and tier would offer, without serving.
224    Tools {
225        /// Report as JSON rather than as a table.
226        #[arg(long)]
227        json: bool,
228    },
229    /// Print this server's version and the protocol versions it speaks.
230    Version,
231    /// Print a configuration snippet for an MCP client. Writes nothing.
232    Setup {
233        /// Which client the snippet is for.
234        #[arg(value_enum)]
235        client: Client,
236    },
237    /// Validate a policy file, or deploy it. Quiet on success.
238    Policy {
239        #[command(subcommand)]
240        action: PolicyCommand,
241    },
242}
243
244/// What to do with a policy file.
245#[derive(Debug, Clone, clap::Subcommand)]
246pub enum PolicyCommand {
247    /// Check that the control plane would accept this file. Changes nothing.
248    Check {
249        /// The policy file, HuJSON or JSON.
250        file: PathBuf,
251    },
252    /// Write this file as the tailnet policy, guarded by the version
253    /// identifier read immediately before.
254    Deploy {
255        /// The policy file, HuJSON or JSON.
256        file: PathBuf,
257    },
258}
259
260/// The clients `setup` knows how to write a snippet for.
261///
262/// One enum and not two: clap derives the parsing and the list of variants
263/// from it, so a sixth client is offered, parsed and tested from one place
264/// rather than from four (Q95).
265#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
266pub enum Client {
267    ClaudeCode,
268    ClaudeDesktop,
269    Vscode,
270    Cursor,
271    Zed,
272}
273
274/// How to serve MCP over HTTP, once the operator has asked for it.
275#[derive(Debug, Clone)]
276pub struct HttpConfig {
277    /// Where to listen.
278    pub bind: SocketAddr,
279    /// The bearer token, or `None` when the operator said there is none.
280    pub token: Option<tailscale_rest::Secret>,
281    /// Host headers to answer for beyond the ones always allowed.
282    pub allow_hosts: Vec<String>,
283    /// Browser origins to answer for.
284    pub allow_origins: Vec<String>,
285    /// Whether to keep sessions.
286    pub stateful: bool,
287}
288
289/// The configuration the server actually runs with.
290#[derive(Debug, Clone)]
291pub struct Config {
292    /// The preset the selection started from, kept for diagnostics.
293    pub preset: Preset,
294    /// The toolsets to offer, after any adjustment.
295    pub toolsets: BTreeSet<Toolset>,
296    /// The most dangerous tier permitted.
297    pub max_tier: Tier,
298    /// Surfaces the operator switched off. A surface can also be unavailable
299    /// because its backend is missing, which is decided at startup, not here.
300    pub disabled: BTreeSet<Surface>,
301    pub cli_path: Option<PathBuf>,
302    pub max_result_bytes: usize,
303    /// Where the control-plane calls go. The real control plane unless a test
304    /// or a staging environment says otherwise; validated when the client is
305    /// built, because that is where the rule about what is safe lives.
306    pub api_base_url: String,
307    /// The tailnet a control-plane path means when the caller does not name
308    /// one.
309    pub tailnet: String,
310    pub log_filter: String,
311    /// Serve over HTTP instead of stdio, when the operator asked for it.
312    pub http: Option<HttpConfig>,
313}
314
315impl Config {
316    /// Resolve the command line against the process environment.
317    pub fn resolve(cli: Cli) -> Result<Self, ConfigError> {
318        Self::resolve_with(cli, |key| std::env::var(key).ok())
319    }
320
321    /// Resolve the command line against an arbitrary source of variables.
322    pub fn resolve_with(
323        cli: Cli,
324        source: impl Fn(&str) -> Option<String>,
325    ) -> Result<Self, ConfigError> {
326        let get = |key: &str| {
327            source(key)
328                .map(|v| v.trim().to_owned())
329                .filter(|v| !v.is_empty())
330        };
331        let flag = |set: bool, key: &str| -> Result<bool, ConfigError> {
332            if set {
333                return Ok(true);
334            }
335            match get(key) {
336                None => Ok(false),
337                Some(raw) => parse_bool(&raw).ok_or_else(|| ConfigError::InvalidValue {
338                    setting: key.to_owned(),
339                    value: raw,
340                    expected: "a boolean (1, true, yes, on, or their negatives)",
341                }),
342            }
343        };
344
345        let preset = match cli.preset.or_else(|| get(PRESET_ENV)) {
346            Some(name) => Preset::parse(&name)?,
347            None => Preset::default(),
348        };
349
350        let spec = cli.toolsets.or_else(|| get(TOOLSETS_ENV));
351        let toolsets = match spec.as_deref() {
352            Some(spec) => apply_toolset_modifiers(preset.toolsets(), spec)?,
353            None => preset.toolsets(),
354        };
355
356        let allow_destructive = flag(cli.allow_destructive, ALLOW_DESTRUCTIVE_ENV)?;
357        let allow_write = allow_destructive || flag(cli.allow_write, ALLOW_WRITE_ENV)?;
358        let max_tier = match (allow_destructive, allow_write) {
359            (true, _) => Tier::Destructive,
360            (_, true) => Tier::Write,
361            _ => Tier::Read,
362        };
363
364        let mut disabled = BTreeSet::new();
365        if flag(cli.no_local, NO_LOCAL_ENV)? {
366            disabled.insert(Surface::Local);
367        }
368        if flag(cli.no_tailnet, NO_TAILNET_ENV)? {
369            disabled.insert(Surface::Tailnet);
370        }
371
372        let max_result_bytes = match cli.max_result_bytes {
373            // Judged the same as the variable below it: a cap of zero rejects
374            // every answer, and does it from inside the control-plane client,
375            // where it reads as the server being misconfigured rather than as
376            // the number the operator typed.
377            Some(0) => {
378                return Err(ConfigError::InvalidValue {
379                    setting: "--max-result-bytes".to_owned(),
380                    value: "0".to_owned(),
381                    expected: "a positive number of bytes",
382                });
383            }
384            Some(n) => n,
385            None => match get(MAX_RESULT_BYTES_ENV) {
386                Some(raw) => raw
387                    .parse::<usize>()
388                    .ok()
389                    .filter(|n| *n > 0)
390                    .ok_or_else(|| ConfigError::InvalidValue {
391                        setting: MAX_RESULT_BYTES_ENV.to_owned(),
392                        value: raw,
393                        expected: "a positive number of bytes",
394                    })?,
395                None => DEFAULT_MAX_RESULT_BYTES,
396            },
397        };
398
399        let http = match cli.http {
400            None => None,
401            Some(address) => Some(checked_http(
402                HttpConfig {
403                    bind: http_bind(&address)?,
404                    // No flag for the token, deliberately: an argument is
405                    // visible to every process on the machine, and this server
406                    // has never had a way to put a secret on its own command
407                    // line (Q91).
408                    token: get(HTTP_TOKEN_ENV).map(tailscale_rest::Secret::new),
409                    allow_hosts: cli.http_allow_host,
410                    allow_origins: cli.http_allow_origin,
411                    stateful: !flag(cli.http_stateless, HTTP_STATELESS_ENV)?,
412                },
413                &address,
414                flag(cli.http_no_auth, HTTP_NO_AUTH_ENV)?,
415            )?),
416        };
417
418        Ok(Self {
419            preset,
420            toolsets,
421            max_tier,
422            disabled,
423            cli_path: cli
424                .cli_path
425                .or_else(|| get(CLI_PATH_ENV).map(PathBuf::from)),
426            max_result_bytes,
427            api_base_url: get(API_BASE_URL_ENV)
428                .unwrap_or_else(|| tailscale_rest::DEFAULT_BASE_URL.to_owned()),
429            tailnet: tailscale_rest::credentials::tailnet_from_source(&source),
430            log_filter: bounded_log_filter(
431                &cli.log
432                    .or_else(|| get(LOG_ENV))
433                    .unwrap_or_else(|| DEFAULT_LOG_FILTER.to_owned()),
434            ),
435            http,
436        })
437    }
438
439    /// Whether the operator switched this surface off.
440    pub fn is_disabled(&self, surface: Surface) -> bool {
441        self.disabled.contains(&surface)
442    }
443
444    /// The settings that differ from what this server would do on its own, as
445    /// the variables that would reproduce them.
446    ///
447    /// This is `resolve` run backwards, which is why it lives beside it: every
448    /// field it reads and every variable it names is this module's, and a
449    /// setting added to one without the other is a snippet that does not
450    /// reproduce the server it was printed from.
451    ///
452    /// Only what the operator changed. A snippet spelling out every default
453    /// would be a snippet nobody reads, and every value in it would go stale
454    /// the day a default changed.
455    pub fn changed_settings(&self) -> Vec<(&'static str, String)> {
456        let mut chosen = Vec::new();
457        if self.preset != Preset::default() {
458            chosen.push((PRESET_ENV, self.preset.as_str().to_owned()));
459        }
460        if self.toolsets != self.preset.toolsets() {
461            chosen.push((
462                TOOLSETS_ENV,
463                self.toolsets
464                    .iter()
465                    .map(|toolset| toolset.as_str())
466                    .collect::<Vec<_>>()
467                    .join(","),
468            ));
469        }
470        match self.max_tier {
471            Tier::Destructive => chosen.push((ALLOW_DESTRUCTIVE_ENV, "true".to_owned())),
472            Tier::Write => chosen.push((ALLOW_WRITE_ENV, "true".to_owned())),
473            Tier::Read => {}
474        }
475        if self.is_disabled(Surface::Local) {
476            chosen.push((NO_LOCAL_ENV, "true".to_owned()));
477        }
478        if self.is_disabled(Surface::Tailnet) {
479            chosen.push((NO_TAILNET_ENV, "true".to_owned()));
480        }
481        if let Some(path) = &self.cli_path {
482            chosen.push((CLI_PATH_ENV, path.display().to_string()));
483        }
484        if self.max_result_bytes != DEFAULT_MAX_RESULT_BYTES {
485            chosen.push((MAX_RESULT_BYTES_ENV, self.max_result_bytes.to_string()));
486        }
487        // Against the bounded default, not the raw one: `resolve` bounds every
488        // filter it is given, including the default, so comparing with the raw
489        // string would report the default as a change on every run.
490        if self.log_filter != bounded_log_filter(DEFAULT_LOG_FILTER) {
491            chosen.push((LOG_ENV, self.log_filter.clone()));
492        }
493        chosen
494    }
495}
496
497/// Variables `changed_settings` deliberately never carries into a snippet.
498///
499/// The three HTTP ones because a client launches this binary and talks to it
500/// over stdio, so a snippet that turned on the HTTP transport would describe a
501/// server the client cannot reach; and the base address because it exists for
502/// the test suite to point at a fake and has no place in an operator's
503/// configuration (Q96).
504pub const NOT_IN_A_SNIPPET: &[&str] = &[
505    HTTP_TOKEN_ENV,
506    HTTP_NO_AUTH_ENV,
507    HTTP_STATELESS_ENV,
508    API_BASE_URL_ENV,
509];
510
511/// Resolve the HTTP options, refusing the combination that would publish an
512/// unauthenticated control plane by accident.
513///
514/// A token is optional on loopback, where the operating system has already
515/// decided who can reach the socket, and required everywhere else. The
516/// no-authentication flag is the only way past that, and is a flag rather than
517/// an omission on purpose: serving a tailnet address to anyone who asks should
518/// be something an operator did, not something that happened.
519fn checked_http(
520    settings: HttpConfig,
521    address: &str,
522    no_auth: bool,
523) -> Result<HttpConfig, ConfigError> {
524    if no_auth {
525        return Ok(HttpConfig {
526            token: None,
527            ..settings
528        });
529    }
530    // The unspecified address — `0.0.0.0` or `::` — is emphatically not
531    // loopback: it is every interface the machine has, which is the case the
532    // token exists for.
533    if settings.token.is_none() && !settings.bind.ip().is_loopback() {
534        return Err(ConfigError::InvalidValue {
535            setting: "--http".to_owned(),
536            value: address.to_owned(),
537            // The template this renders into reads "…which is not {expected}",
538            // so what follows has to be the one thing the address failed to
539            // be. The two ways past it are remedies rather than alternative
540            // things the address could have been, and reading as though they
541            // were made the sentence say an address is not a token.
542            expected: "a loopback address \u{2014} set a token in \
543                       TAILSCALE_MCP_HTTP_TOKEN, or pass --http-no-auth to say \
544                       the address really should answer anyone who asks",
545        });
546    }
547    Ok(settings)
548}
549
550/// The address `--http` was given, resolved.
551fn http_bind(address: &str) -> Result<SocketAddr, ConfigError> {
552    address
553        .to_socket_addrs()
554        .ok()
555        .and_then(|mut found| found.next())
556        .ok_or_else(|| ConfigError::InvalidValue {
557            setting: "--http".to_owned(),
558            value: address.to_owned(),
559            expected: "an address and port, such as 127.0.0.1:8449",
560        })
561}
562
563/// The spellings people actually write in a container environment file.
564fn parse_bool(raw: &str) -> Option<bool> {
565    match raw.trim().to_ascii_lowercase().as_str() {
566        "1" | "true" | "yes" | "on" => Some(true),
567        "0" | "false" | "no" | "off" | "" => Some(false),
568        _ => None,
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use std::collections::HashMap;
575
576    use super::*;
577
578    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
579        let map: HashMap<String, String> = pairs
580            .iter()
581            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
582            .collect();
583        move |key| map.get(key).cloned()
584    }
585
586    fn resolve(cli: Cli, pairs: &[(&str, &str)]) -> Result<Config, ConfigError> {
587        Config::resolve_with(cli, env(pairs))
588    }
589
590    #[test]
591    fn the_defaults_are_the_core_preset_and_read_only() {
592        let config = resolve(Cli::default(), &[]).expect("defaults resolve");
593        assert_eq!(config.preset, Preset::Core);
594        assert_eq!(config.toolsets, Preset::Core.toolsets());
595        assert_eq!(config.max_tier, Tier::Read);
596        assert!(config.disabled.is_empty());
597        assert_eq!(config.max_result_bytes, DEFAULT_MAX_RESULT_BYTES);
598    }
599
600    #[test]
601    fn the_environment_configures_a_client_that_cannot_pass_arguments() {
602        let config = resolve(
603            Cli::default(),
604            &[
605                (PRESET_ENV, "full"),
606                (ALLOW_WRITE_ENV, "true"),
607                (NO_TAILNET_ENV, "1"),
608                (MAX_RESULT_BYTES_ENV, "4096"),
609                (LOG_ENV, "debug"),
610            ],
611        )
612        .expect("resolves");
613        assert_eq!(config.preset, Preset::Full);
614        assert_eq!(config.max_tier, Tier::Write);
615        assert!(config.is_disabled(Surface::Tailnet));
616        assert!(!config.is_disabled(Surface::Local));
617        assert_eq!(config.max_result_bytes, 4096);
618        assert_eq!(config.log_filter, "debug,rmcp=info");
619    }
620
621    #[test]
622    fn the_command_line_wins_over_the_environment() {
623        let cli = Cli {
624            preset: Some("minimal".to_owned()),
625            log: Some("trace".to_owned()),
626            max_result_bytes: Some(1024),
627            ..Cli::default()
628        };
629        let config = resolve(
630            cli,
631            &[
632                (PRESET_ENV, "full"),
633                (LOG_ENV, "debug"),
634                (MAX_RESULT_BYTES_ENV, "4096"),
635            ],
636        )
637        .expect("resolves");
638        assert_eq!(config.preset, Preset::Minimal);
639        assert_eq!(config.log_filter, "trace,rmcp=info");
640        assert_eq!(config.max_result_bytes, 1024);
641    }
642
643    #[test]
644    fn a_flag_set_on_the_command_line_is_not_undone_by_the_environment() {
645        // The environment cannot say "false" loudly enough to override an
646        // explicit flag: the flag's presence is the operator's intent.
647        let cli = Cli {
648            allow_write: true,
649            ..Cli::default()
650        };
651        let config = resolve(cli, &[(ALLOW_WRITE_ENV, "false")]).expect("resolves");
652        assert_eq!(config.max_tier, Tier::Write);
653    }
654
655    #[test]
656    fn permitting_destruction_permits_writing() {
657        let cli = Cli {
658            allow_destructive: true,
659            ..Cli::default()
660        };
661        let config = resolve(cli, &[]).expect("resolves");
662        assert_eq!(config.max_tier, Tier::Destructive);
663        assert!(config.max_tier >= Tier::Write);
664    }
665
666    #[test]
667    fn toolsets_adjust_the_preset() {
668        let cli = Cli {
669            toolsets: Some("+local-debug,-tailnet-dns".to_owned()),
670            ..Cli::default()
671        };
672        let config = resolve(cli, &[]).expect("resolves");
673        assert!(config.toolsets.contains(&Toolset::LocalDebug));
674        assert!(!config.toolsets.contains(&Toolset::TailnetDns));
675        assert!(config.toolsets.contains(&Toolset::LocalStatus));
676    }
677
678    #[test]
679    fn a_bare_toolset_list_replaces_the_preset() {
680        let config = resolve(
681            Cli::default(),
682            &[(PRESET_ENV, "full"), (TOOLSETS_ENV, "local-status")],
683        )
684        .expect("resolves");
685        assert_eq!(config.toolsets, BTreeSet::from([Toolset::LocalStatus]));
686        // The preset is still reported, because it is what the selection
687        // started from and diagnostics should say so.
688        assert_eq!(config.preset, Preset::Full);
689    }
690
691    #[test]
692    fn a_setting_that_cannot_be_understood_stops_the_server() {
693        let cases: [(&str, &str); 4] = [
694            (PRESET_ENV, "everything"),
695            (TOOLSETS_ENV, "not-a-toolset"),
696            (ALLOW_WRITE_ENV, "perhaps"),
697            (MAX_RESULT_BYTES_ENV, "0"),
698        ];
699        for (key, value) in cases {
700            let err = resolve(Cli::default(), &[(key, value)])
701                .expect_err("{key}={value} should be refused");
702            let message = err.to_string();
703            assert!(
704                message.contains(value) || message.contains("unknown"),
705                "{key}={value} produced {message}"
706            );
707        }
708    }
709
710    #[test]
711    fn a_blank_variable_is_the_same_as_an_unset_one() {
712        let config = resolve(
713            Cli::default(),
714            &[(PRESET_ENV, "  "), (LOG_ENV, ""), (TOOLSETS_ENV, " ")],
715        )
716        .expect("resolves");
717        assert_eq!(config.preset, Preset::Core);
718        assert_eq!(config.log_filter, bounded_log_filter(DEFAULT_LOG_FILTER));
719    }
720
721    #[test]
722    fn turning_up_the_volume_does_not_turn_on_the_wire() {
723        // The SDK traces whole messages, results included, so `debug` alone
724        // would write every minted secret to standard error (Q79).
725        assert_eq!(bounded_log_filter("debug"), "debug,rmcp=info");
726        assert_eq!(
727            bounded_log_filter(DEFAULT_LOG_FILTER),
728            "warn,tailscale_mcp=info,rmcp=info"
729        );
730        // Unless it is asked for by name, which is a deliberate act.
731        assert_eq!(bounded_log_filter("info,rmcp=trace"), "info,rmcp=trace");
732        assert_eq!(bounded_log_filter("rmcp=debug"), "rmcp=debug");
733    }
734
735    #[test]
736    fn the_boolean_spellings_people_write_are_all_understood() {
737        for raw in ["1", "true", "TRUE", "yes", "on"] {
738            let config = resolve(Cli::default(), &[(ALLOW_WRITE_ENV, raw)]).expect("resolves");
739            assert_eq!(config.max_tier, Tier::Write, "{raw}");
740        }
741        for raw in ["0", "false", "no", "off"] {
742            let config = resolve(Cli::default(), &[(ALLOW_WRITE_ENV, raw)]).expect("resolves");
743            assert_eq!(config.max_tier, Tier::Read, "{raw}");
744        }
745    }
746
747    /// This module's own source, so that the test below can hold the list to
748    /// the code rather than to a number somebody has to remember to change.
749    const SOURCE: &str = include_str!("config.rs");
750
751    #[test]
752    fn the_documented_variables_are_the_ones_that_are_read() {
753        // A variable added to the resolution but not to the list would be
754        // invisible to the diagnosis subcommand and to the help text. The
755        // previous version of this test pinned `ENV_VARS.len()` to a number,
756        // which is a check that passes while the list is wrong: three
757        // variables were read and undocumented and it said nothing.
758        for var in ENV_VARS {
759            assert!(
760                LONG_ABOUT.contains(var),
761                "{var} is read but not documented in the help"
762            );
763        }
764
765        // Constant name to the variable it names, from the declarations above.
766        let declared: std::collections::HashMap<&str, &str> = SOURCE
767            .lines()
768            .filter_map(|line| {
769                let line = line.trim().strip_prefix("pub const ")?;
770                let (name, rest) = line.split_once(": &str = \"")?;
771                name.ends_with("_ENV")
772                    .then(|| (name, rest.split('"').next().unwrap_or_default()))
773            })
774            .collect();
775        assert!(
776            declared.len() >= ENV_VARS.len(),
777            "every listed variable should have a declaration to be found"
778        );
779
780        // Every `*_ENV` constant the resolution actually reads.
781        let resolution = SOURCE
782            .split_once("pub fn resolve_with(")
783            .and_then(|(_, rest)| rest.split_once("\n    /// Whether the operator switched"))
784            .map_or(SOURCE, |(body, _)| body);
785        for (name, var) in &declared {
786            let read = resolution.contains(&format!("{name})"))
787                || resolution.contains(&format!("{name},"));
788            if read {
789                assert!(
790                    ENV_VARS.contains(var),
791                    "{name} is read by the resolution but is not in ENV_VARS, so nothing \
792                     documents it and the diagnosis subcommand will not check it"
793                );
794            }
795        }
796    }
797
798    #[test]
799    fn a_leading_minus_in_a_toolset_list_is_not_read_as_a_flag() {
800        let cli = Cli::try_parse_from(["tailscale-mcp", "--toolsets", "-tailnet-dns"])
801            .expect("a removal parses");
802        assert_eq!(cli.toolsets.as_deref(), Some("-tailnet-dns"));
803    }
804
805    #[test]
806    fn the_command_line_offers_no_way_to_pass_a_secret() {
807        use clap::CommandFactory as _;
808        for arg in Cli::command().get_arguments() {
809            let name = arg.get_id().as_str();
810            assert!(
811                !["key", "secret", "token", "password"]
812                    .iter()
813                    .any(|bad| name.contains(bad)),
814                "`--{name}` would put a secret on the argument list"
815            );
816        }
817    }
818
819    /// Every `InvalidValue` reads as one sentence.
820    ///
821    /// `ConfigError::InvalidValue` renders as "… which is not {expected}", so
822    /// `expected` has to be the thing the value failed to be. The HTTP guard
823    /// listed its two remedies there instead — a token and a flag — and the
824    /// sentence came out saying an address was not an environment variable.
825    /// A remedy belongs after the clause ends, which is what the dash marks.
826    #[test]
827    fn an_invalid_value_says_what_the_value_should_have_been() {
828        let phrases = [
829            checked_http(
830                HttpConfig {
831                    bind: "0.0.0.0:8449".parse().expect("an address"),
832                    token: None,
833                    allow_hosts: Vec::new(),
834                    allow_origins: Vec::new(),
835                    stateful: true,
836                },
837                "0.0.0.0:8449",
838                false,
839            )
840            .expect_err("an unauthenticated public bind is refused"),
841            http_bind("not-an-address").expect_err("a bad address is refused"),
842        ];
843        for error in phrases {
844            let rendered = error.to_string();
845            let (_, expected) = rendered
846                .split_once("which is not ")
847                .unwrap_or_else(|| panic!("the template should be intact: {rendered}"));
848            // Where the clause ends, the remedies may begin; before that, the
849            // sentence is still saying what the value was not.
850            let clause = expected
851                .split(['\u{2014}', ';'])
852                .next()
853                .expect("a first clause");
854            assert!(
855                !clause.contains(" or "),
856                "`which is not {clause}` offers alternatives where it should name \
857                 one thing; put the remedies after a dash: {rendered}"
858            );
859            assert!(
860                !clause.contains("--") && !clause.contains("TAILSCALE_"),
861                "`which is not {clause}` says the value is not a flag or a variable, \
862                 which is not what failed: {rendered}"
863            );
864        }
865    }
866}