tailscale_mcp/subcommands/
mod.rs1pub 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#[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 #[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#[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 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#[derive(Debug, Clone, Serialize)]
106pub struct Check {
107 pub what: &'static str,
109 pub state: State,
111 pub detail: String,
113 #[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 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
149pub 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 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
241async 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 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#[derive(Debug, Serialize)]
293struct Listed {
294 name: &'static str,
295 toolset: &'static str,
296 surface: &'static str,
297 tier: &'static str,
298 #[serde(skip_serializing_if = "std::ops::Not::not")]
302 tier_is_a_floor: bool,
303 summary: &'static str,
304}
305
306pub 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 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 "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 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
388fn 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
424pub fn version() -> Report {
426 let latest = rmcp::model::ProtocolVersion::LATEST;
427 let others: Vec<String> = rmcp::model::ProtocolVersion::KNOWN_VERSIONS
432 .iter()
433 .filter(|known| **known != latest)
434 .map(ToString::to_string)
435 .collect();
436 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
450pub const RMCP_VERSION: &str = "3.2.0";