Skip to main content

tailscale_mcp/subcommands/
mod.rs

1//! The things this binary does other than serve.
2//!
3//! An MCP server spends its life talking to a program, which makes it awkward
4//! to ask a question of. These are the questions worth asking from a terminal:
5//! *is my setup broken*, *what would this actually offer*, *what version is
6//! this*, *what do I paste into my client*, and *would the control plane take
7//! this policy*.
8//!
9//! Five jobs and three files, split by what makes each change: a new
10//! credential kind changes the diagnosis, a new editor changes [`setup()`], and
11//! a policy tool being renamed changes [`policy()`]. This module holds the three
12//! that share the metadata table and the [`Report`].
13//!
14//! **What needs a credential.** [`diagnose`] and [`policy()`] do; the latter
15//! also builds a server, because sending a policy the way a tool sends one
16//! means using the client a session would have used. [`tools`], [`version`]
17//! and [`setup()`] read compiled-in data and touch nothing — no credential, no
18//! `tailscale` binary, no network — which is the criterion "no subcommand
19//! requires credentials except the ones that check them".
20//!
21//! **Exit codes are the contract.** Diagnosis exits non-zero when a check
22//! fails, because the shape it is meant for is a shell script or a container
23//! health check, and a diagnosis that always succeeds is a diagnosis nobody
24//! can automate.
25
26pub mod policy;
27pub mod setup;
28
29use std::collections::BTreeSet;
30use std::process::ExitCode;
31
32use serde::Serialize;
33
34use crate::config::Config;
35use crate::gating::Gate;
36use crate::meta::{Surface, ToolMeta};
37use crate::server::Backends;
38use crate::tools::common::pretty;
39use crate::version::SUPPORTED_FLOOR;
40use tailscale_rest::credentials::Credentials;
41
42pub use policy::policy;
43pub use setup::setup;
44
45/// What a subcommand printed, and whether it was bad news.
46///
47/// Returned rather than exiting, so that a test can call these directly rather
48/// than spawning the binary to find out what it said (spec: "Tests
49/// preferentially live here").
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Report {
52    pub text: String,
53    pub ok: bool,
54}
55
56impl Report {
57    fn ok(text: impl Into<String>) -> Self {
58        Self {
59            text: text.into(),
60            ok: true,
61        }
62    }
63
64    /// Print it and become an exit code.
65    #[allow(clippy::print_stdout, reason = "a subcommand's output is its point")]
66    pub fn emit(&self) -> ExitCode {
67        print!("{}", self.text);
68        if self.ok {
69            ExitCode::SUCCESS
70        } else {
71            ExitCode::FAILURE
72        }
73    }
74}
75
76/// How a check came out.
77///
78/// Three states and not two: a check the operator switched off did not pass,
79/// it did not happen, and reporting it as a pass would be telling them their
80/// credential is fine when nothing looked at it.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
82#[serde(rename_all = "lowercase")]
83pub enum State {
84    Passed,
85    Skipped,
86    Failed,
87}
88
89impl State {
90    /// Whether this state should stop the exit code being zero.
91    const fn is_failure(self) -> bool {
92        matches!(self, Self::Failed)
93    }
94
95    const fn label(self) -> &'static str {
96        match self {
97            Self::Passed => "ok  ",
98            Self::Skipped => "--  ",
99            Self::Failed => "FAIL",
100        }
101    }
102}
103
104/// One thing that can be wrong with a setup.
105#[derive(Debug, Clone, Serialize)]
106pub struct Check {
107    /// What was looked at.
108    pub what: &'static str,
109    /// How it came out.
110    pub state: State,
111    /// What was found, in one line.
112    pub detail: String,
113    /// What to do about it, when there is something to do.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub remedy: Option<String>,
116}
117
118impl Check {
119    fn passed(what: &'static str, detail: impl Into<String>) -> Self {
120        Self {
121            what,
122            state: State::Passed,
123            detail: detail.into(),
124            remedy: None,
125        }
126    }
127
128    /// A check the operator switched off, reported so that they can see it was
129    /// not made.
130    fn skipped(what: &'static str, detail: impl Into<String>) -> Self {
131        Self {
132            what,
133            state: State::Skipped,
134            detail: detail.into(),
135            remedy: None,
136        }
137    }
138
139    fn failed(what: &'static str, detail: impl Into<String>, remedy: impl Into<String>) -> Self {
140        Self {
141            what,
142            state: State::Failed,
143            detail: detail.into(),
144            remedy: Some(remedy.into()),
145        }
146    }
147}
148
149/// Check everything a broken setup is usually broken in.
150///
151/// Each check is reported whatever the others did: a missing `tailscale`
152/// binary must not stop the credential from being checked, because an operator
153/// running this wants the whole list, not the first thing to go wrong.
154pub async fn diagnose(config: &Config, backends: Backends, json: bool) -> Report {
155    let mut checks = Vec::new();
156
157    checks.push(if backends.local_available {
158        match crate::cli::probe_version(backends.local.as_ref()).await {
159            Some(version) if version < SUPPORTED_FLOOR && !version.is_unstable() => Check::failed(
160                "tailscale CLI",
161                format!("found, reporting {version}"),
162                format!(
163                    "upgrade to {} or newer; older commands are still attempted, and \
164                         report the version they need",
165                    SUPPORTED_FLOOR
166                ),
167            ),
168            Some(version) => Check::passed("tailscale CLI", format!("found, reporting {version}")),
169            None => Check::failed(
170                "tailscale CLI",
171                "found, but its version could not be read",
172                "run `tailscale version` and check it prints a version",
173            ),
174        }
175    } else if config.is_disabled(Surface::Local) {
176        Check::skipped("tailscale CLI", "not looked for: --no-local was given")
177    } else {
178        Check::failed(
179            "tailscale CLI",
180            "not found on the path",
181            "install Tailscale, or point --cli-path at the binary",
182        )
183    });
184
185    checks.push(
186        match (&backends.credentials, config.is_disabled(Surface::Tailnet)) {
187            (_, true) => Check::skipped(
188                "control-plane credential",
189                "not looked for: --no-tailnet was given",
190            ),
191            (Some(credentials), _) => Check::passed(
192                "control-plane credential",
193                // Named exhaustively, with no wildcard: a fourth kind of
194                // credential should stop this compiling rather than be
195                // reported as whichever one the wildcard happened to name.
196                // The glossary reserves "API key"; it is an access token.
197                match credentials {
198                    Credentials::ApiKey(_) => "an API access token, from TAILSCALE_API_KEY",
199                    Credentials::OauthClient { .. } => {
200                        "an OAuth client, from TAILSCALE_OAUTH_CLIENT_ID and _SECRET"
201                    }
202                    Credentials::Federated { .. } => {
203                        "a workload identity, from the platform's own token file"
204                    }
205                },
206            ),
207            (None, _) => Check::failed(
208                "control-plane credential",
209                "none found",
210                "set TAILSCALE_API_KEY, or TAILSCALE_OAUTH_CLIENT_ID and \
211             TAILSCALE_OAUTH_CLIENT_SECRET",
212            ),
213        },
214    );
215
216    checks.push(reachability(config, &backends).await);
217
218    let ok = !checks.iter().any(|check| check.state.is_failure());
219    if json {
220        return Report {
221            text: pretty(&serde_json::json!({"ok": ok, "checks": checks})),
222            ok,
223        };
224    }
225
226    let mut text = String::new();
227    for check in &checks {
228        text.push_str(&format!(
229            "{} {:<26} {}\n",
230            check.state.label(),
231            check.what,
232            check.detail
233        ));
234        if let Some(remedy) = &check.remedy {
235            text.push_str(&format!("     {remedy}\n"));
236        }
237    }
238    Report { text, ok }
239}
240
241/// Whether the control plane answers, which is the only check that leaves this
242/// machine.
243async fn reachability(config: &Config, backends: &Backends) -> Check {
244    if config.is_disabled(Surface::Tailnet) {
245        return Check::skipped("control plane", "not reached: --no-tailnet was given");
246    }
247    let Some(credentials) = backends.credentials.clone() else {
248        return Check::failed(
249            "control plane",
250            "not reached: there is no credential to reach it with",
251            "set a credential, then run this again",
252        );
253    };
254    let mut settings = tailscale_rest::ClientConfig::new(credentials);
255    settings.base_url = config.api_base_url.clone();
256    settings.tailnet = config.tailnet.clone();
257    settings.budget = tailscale_cli::DEFAULT_TIMEOUT;
258    let client = match tailscale_rest::Client::new(settings) {
259        Ok(client) => client,
260        Err(error) => {
261            return Check::failed(
262                "control plane",
263                format!("no client could be built: {error}"),
264                "check TAILSCALE_MCP_API_BASE_URL",
265            );
266        }
267    };
268    // The cheapest authenticated read there is: it says the credential works,
269    // the tailnet exists, and the network is there, in one round trip.
270    match client
271        .get(client.tailnet_path(None, "/devices"))
272        .send_as::<serde_json::Value>()
273        .await
274    {
275        Ok(_) => Check::passed("control plane", format!("{} answered", config.api_base_url)),
276        Err(error) => Check::failed(
277            "control plane",
278            format!("{} refused: {error}", config.api_base_url),
279            match error.status() {
280                Some(401 | 403) => {
281                    "the credential is not accepted; check it has not expired \
282                                    and has the scopes the tools need"
283                }
284                Some(404) => "the tailnet was not found; check TAILSCALE_TAILNET",
285                _ => "check network access to the control plane",
286            },
287        ),
288    }
289}
290
291/// One row of the tool listing.
292#[derive(Debug, Serialize)]
293struct Listed {
294    name: &'static str,
295    toolset: &'static str,
296    surface: &'static str,
297    tier: &'static str,
298    /// Whether `tier` is a floor rather than the whole truth, which for three
299    /// rows it is. Written only where it is true, so that a row without the
300    /// field means what it always meant.
301    #[serde(skip_serializing_if = "std::ops::Not::not")]
302    tier_is_a_floor: bool,
303    summary: &'static str,
304}
305
306/// What a preset and tier would offer, without starting anything.
307///
308/// Read from the metadata table, which `spec.md` names as "the single source
309/// for the tool-listing subcommand, the contract tests and the README's tool
310/// table" — so this counts what the server would offer rather than what
311/// anybody wrote down.
312pub fn tools(config: &Config, json: bool) -> Report {
313    let metas: Vec<ToolMeta> = crate::tools::entries()
314        .iter()
315        .map(|entry| entry.meta)
316        .collect();
317    // Nothing *discovered* is unavailable: this asks what the selection
318    // offers, not what this machine happens to have, so that the answer is the
319    // same everywhere. A surface the operator switched off is not that — it is
320    // part of the selection, like the preset and the toolsets, and this
321    // subcommand accepts `--no-local` and `--no-tailnet` and documents what
322    // they do. Leaving them out reported 57 tools where the server it is
323    // describing would have served 29, which `server::build` warns about in
324    // as many words: the check is not only in `discover` so that a caller
325    // assembling the surfaces itself cannot bypass the flag.
326    let switched_off: BTreeSet<Surface> = [Surface::Local, Surface::Tailnet]
327        .into_iter()
328        .filter(|surface| config.is_disabled(*surface))
329        .collect();
330    let gate = Gate::unchecked(config.toolsets.clone(), config.max_tier, switched_off);
331    let mut shown: Vec<&ToolMeta> = metas.iter().filter(|meta| gate.permits(meta)).collect();
332    shown.sort_by_key(|meta| (meta.toolset.as_str(), meta.name));
333
334    if json {
335        let rows: Vec<Listed> = shown.iter().map(|meta| row(meta)).collect();
336        return Report::ok(pretty(&serde_json::json!({
337            "preset": config.preset.as_str(),
338            "tier": config.max_tier.as_str(),
339            // The same list the `tools` array and `count` beside it are drawn
340            // from. Reporting the selection here put nine tailnet toolsets in
341            // a document whose every tool was local.
342            "toolsets": gate.offered_toolsets().map(|toolset| toolset.as_str()).collect::<Vec<_>>(),
343            "count": rows.len(),
344            "tools": rows,
345        })));
346    }
347
348    let mut text = format!(
349        "preset {}, tier {}, {} of {} tools\n\n",
350        config.preset.as_str(),
351        config.max_tier.as_str(),
352        shown.len(),
353        metas.len()
354    );
355    // The tier column shows the row's tier, and for three rows that is a floor
356    // rather than the whole truth. `docs/tools.md` has a notes column and says
357    // so there; this has neither the column nor the room, and a summary cut to
358    // its first sentence drops the very clause that would have said it. So the
359    // marker carries it, and appears only in a listing that has one to explain.
360    if shown.iter().any(|meta| meta.varying_tier) {
361        text.push_str(
362            "`+` marks a tier that is a floor: some arguments to that tool need \
363             a higher one.\n\n",
364        );
365    }
366    let widest = shown.iter().map(|m| m.name.len()).max().unwrap_or(0);
367    let mut toolset = None;
368    for meta in &shown {
369        if toolset != Some(meta.toolset) {
370            toolset = Some(meta.toolset);
371            text.push_str(&format!("{}\n", meta.toolset.as_str()));
372        }
373        let tier = if meta.varying_tier {
374            format!("{}+", meta.tier.as_str())
375        } else {
376            meta.tier.as_str().to_owned()
377        };
378        text.push_str(&format!(
379            "  {:<widest$}  {:<12}  {}\n",
380            meta.name,
381            tier,
382            first_sentence(meta.summary)
383        ));
384    }
385    Report::ok(text)
386}
387
388/// Enough of a summary for a table: the first sentence, and not much of it.
389///
390/// The whole summary is what a client is given and is as long as it needs to
391/// be; a terminal column is not the place for it.
392fn first_sentence(summary: &str) -> String {
393    const WIDEST: usize = 72;
394    let sentence = summary
395        .split_once(". ")
396        .map_or(summary, |(first, _)| first)
397        .trim_end_matches('.');
398    if sentence.chars().count() <= WIDEST {
399        return sentence.to_owned();
400    }
401    let cut = sentence
402        .char_indices()
403        .take(WIDEST - 1)
404        .last()
405        .map_or(0, |(at, _)| at);
406    let kept = sentence[..cut].trim_end();
407    format!(
408        "{}…",
409        kept.rsplit_once(' ').map_or(kept, |(before, _)| before)
410    )
411}
412
413fn row(meta: &ToolMeta) -> Listed {
414    Listed {
415        name: meta.name,
416        toolset: meta.toolset.as_str(),
417        surface: meta.toolset.surface().as_str(),
418        tier: meta.tier.as_str(),
419        tier_is_a_floor: meta.varying_tier,
420        summary: meta.summary,
421    }
422}
423
424/// This server's version, and what protocol versions it can speak.
425pub fn version() -> Report {
426    let latest = rmcp::model::ProtocolVersion::LATEST;
427    // `KNOWN_VERSIONS` contains the newest one too, and it is named by itself
428    // immediately before this list. "Also" is a claim about the others, so
429    // repeating it there said the server speaks a version in addition to
430    // itself.
431    let others: Vec<String> = rmcp::model::ProtocolVersion::KNOWN_VERSIONS
432        .iter()
433        .filter(|known| **known != latest)
434        .map(ToString::to_string)
435        .collect();
436    // A build where the newest is the only one is not a build that should
437    // print an empty parenthesis.
438    let rest = if others.is_empty() {
439        String::new()
440    } else {
441        format!(" (also speaks {})", others.join(", "))
442    };
443    Report::ok(format!(
444        "{} {}\nrmcp {RMCP_VERSION}\nMCP protocol {latest}{rest}\n",
445        env!("CARGO_PKG_NAME"),
446        env!("CARGO_PKG_VERSION"),
447    ))
448}
449
450/// The version of rmcp this is built against.
451///
452/// Written here and held to the manifest by a test, because there is no way to
453/// read a dependency's version at runtime: `DEP_*` variables reach only the
454/// build script of a crate that declares `links`, which rmcp does not, so the
455/// obvious `option_env!` would have been silently `None` for ever (Q97).
456pub const RMCP_VERSION: &str = "3.2.0";