Skip to main content

tailscale_mcp/
server.rs

1//! The MCP handler, and what has to be true before it can be built.
2//!
3//! Startup is where the two surfaces are discovered. Neither is required: a
4//! machine with the CLI and no credential offers the local tools, a container
5//! with a credential and no CLI offers the tailnet tools, and both are ordinary
6//! configurations rather than degraded ones. What is not permitted is offering
7//! nothing, which is a configuration mistake and is reported as one.
8
9use std::sync::Arc;
10
11use rmcp::model::{
12    CallToolRequestParams, CallToolResponse, CallToolResult, CompleteRequestParams, CompleteResult,
13    GetPromptRequestParams, GetPromptResponse, GetPromptResult, Implementation, InitializeResult,
14    ListPromptsResult, ListResourceTemplatesResult, ListResourcesResult, ListToolsResult,
15    PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResponse, ServerCapabilities,
16    ServerInfo, Tool,
17};
18use rmcp::service::{RequestContext, RoleServer};
19use rmcp::{ErrorData as McpError, ServerHandler};
20use serde_json::Value;
21use tailscale_cli::{CliBackend, LocalBackend, Unavailable};
22use tailscale_rest::Credentials;
23
24use crate::completion;
25use crate::config::Config;
26use crate::context::{Identity, PathPolicy, SelfIdentity, ToolContext};
27use crate::error::{ToolError, ToolResult};
28use crate::gating::{ConfigError, Gate};
29use crate::meta::Surface;
30use crate::registry::{Registry, RegistryError, ToolEntry};
31use crate::version::SUPPORTED_FLOOR;
32use crate::{cli, instructions, resources};
33
34/// The server could not be built.
35#[derive(Debug, thiserror::Error)]
36pub enum StartupError {
37    #[error(transparent)]
38    Config(#[from] ConfigError),
39    #[error(transparent)]
40    Registry(#[from] RegistryError),
41    /// A control-plane client that cannot be built is a startup failure rather
42    /// than a note. In practice this is the base URL having been set to
43    /// somewhere a credential must not be sent, and carrying on would mean
44    /// running with the tailnet surface silently missing.
45    #[error(transparent)]
46    ControlPlane(#[from] tailscale_rest::ApiError),
47    /// The selection would offer tools, but every surface it needs is missing.
48    /// The repair is a binary or a credential rather than a wider selection,
49    /// so the notes that name them are the message.
50    #[error("no tools can be offered. {}", .0.join(" "))]
51    NoSurface(Vec<String>),
52}
53
54/// What the two surfaces turned out to be.
55///
56/// Separated from [`build`] so that the tests can supply both without a binary
57/// on the machine or a credential in the environment.
58#[derive(Debug)]
59pub struct Backends {
60    pub local: Arc<dyn LocalBackend>,
61    /// Whether a usable local backend was found. An absent one is still a
62    /// backend — it reports the binary as missing — so this is what says
63    /// whether the surface should be offered.
64    pub local_available: bool,
65    pub credentials: Option<Credentials>,
66}
67
68impl Backends {
69    /// Look for both surfaces on this machine.
70    pub fn discover(config: &Config) -> Self {
71        let (local, local_available): (Arc<dyn LocalBackend>, bool) =
72            if config.is_disabled(Surface::Local) {
73                (Arc::new(Unavailable::new("disabled by --no-local")), false)
74            } else {
75                match CliBackend::discover_with(config.cli_path.as_ref().map(AsRef::as_ref)) {
76                    Ok(backend) => (Arc::new(backend), true),
77                    Err(e) => (Arc::new(Unavailable::new(e.to_string())), false),
78                }
79            };
80        let credentials = if config.is_disabled(Surface::Tailnet) {
81            None
82        } else {
83            Credentials::from_env()
84        };
85        Self {
86            local,
87            local_available,
88            credentials,
89        }
90    }
91}
92
93/// A built server, and what the operator should be told about how it was built.
94#[derive(Debug)]
95pub struct Startup {
96    pub server: TailscaleMcpServer,
97    /// What this node calls the nodes at these addresses, read from the same
98    /// status the identity came from. The HTTP transport names its callers
99    /// with it; a stdio session has one caller and no use for it.
100    pub peers: std::collections::HashMap<std::net::IpAddr, String>,
101    /// Lines for standard error. Never standard output: on the stdio transport
102    /// that is the protocol stream, and a stray line there ends the session.
103    pub notes: Vec<String>,
104}
105
106/// Build a server from a settled configuration and the tool table.
107pub async fn build(
108    config: &Config,
109    entries: Vec<ToolEntry>,
110    backends: Backends,
111) -> Result<Startup, StartupError> {
112    let registry = Registry::new(entries)?;
113    let mut notes = Vec::new();
114
115    // A surface the operator switched off is hidden whether or not the machine
116    // could have offered it. The check is here rather than only in `discover`
117    // so that a caller assembling `Backends` itself cannot bypass the flag.
118    let mut unavailable = std::collections::BTreeSet::new();
119    if config.is_disabled(Surface::Local) {
120        unavailable.insert(Surface::Local);
121        notes.push(
122            "The local surface is switched off; the tools that drive this node are hidden."
123                .to_owned(),
124        );
125    } else if !backends.local_available {
126        unavailable.insert(Surface::Local);
127        notes.push(
128            "No `tailscale` binary was found, so the tools that drive this node are hidden. \
129             Set TAILSCALE_MCP_CLI_PATH or pass --cli-path to point at one."
130                .to_owned(),
131        );
132    }
133    if config.is_disabled(Surface::Tailnet) {
134        unavailable.insert(Surface::Tailnet);
135        notes.push(
136            "The tailnet surface is switched off; the tools that act on the tailnet are hidden."
137                .to_owned(),
138        );
139    } else if backends.credentials.is_none() {
140        unavailable.insert(Surface::Tailnet);
141        notes.push(
142            "No control-plane credential was found, so the tools that act on the tailnet are \
143             hidden. Set TAILSCALE_API_KEY, or TAILSCALE_OAUTH_CLIENT_ID and \
144             TAILSCALE_OAUTH_CLIENT_SECRET."
145                .to_owned(),
146        );
147    }
148
149    // Probing a node the operator asked us to leave alone would be a surprise,
150    // so both probes are skipped when the local surface is not on offer.
151    let local_offered = backends.local_available && !config.is_disabled(Surface::Local);
152    let (cli_version, identity, peers) = if local_offered {
153        let version = cli::probe_version(backends.local.as_ref()).await;
154        match version {
155            Some(found) if found < SUPPORTED_FLOOR && !found.is_unstable() => notes.push(format!(
156                "The `tailscale` binary reports {found}, which is older than {SUPPORTED_FLOOR}, \
157                 the oldest release this server is written against. Nothing is hidden; commands \
158                 this build does not have will report the version they need."
159            )),
160            None => notes.push(
161                "Could not read the version of the `tailscale` binary; commands will be attempted \
162                 regardless."
163                    .to_owned(),
164            ),
165            Some(_) => {}
166        }
167        let (identity, peers) = cli::probe_node(backends.local.as_ref()).await;
168        (
169            version,
170            // Live: it may be read again as it ages, because a node can be
171            // renamed or moved onto a different address under a running
172            // server (Q87).
173            Identity::probed(identity),
174            peers,
175        )
176    } else {
177        // Nothing was read, so there is nothing to re-read: a fixed identity
178        // that matches nothing rather than a timer asking a missing binary.
179        (
180            None,
181            Identity::fixed(SelfIdentity::default()),
182            std::collections::HashMap::new(),
183        )
184    };
185
186    // Only when the surface is on offer: a credential in the environment is
187    // not a reason to build a client for tools nobody can call. The address is
188    // judged either way, so that an operator who has redirected the control
189    // plane hears about it now rather than on the day they add a credential.
190    let tailnet = if unavailable.contains(&Surface::Tailnet) {
191        None
192    } else {
193        tailscale_rest::checked_base_url(&config.api_base_url)?;
194        match &backends.credentials {
195            Some(credentials) => {
196                let mut client = tailscale_rest::ClientConfig::new(credentials.clone());
197                client.base_url = config.api_base_url.clone();
198                client.tailnet = config.tailnet.clone();
199                client.max_response_bytes = config.max_result_bytes;
200                // One number for both surfaces: a control-plane call is a tool
201                // call, and a tool call has one timeout.
202                client.budget = tailscale_cli::DEFAULT_TIMEOUT;
203                Some(tailscale_rest::Client::new(client)?)
204            }
205            None => None,
206        }
207    };
208
209    let metas = registry.metas();
210    let gate = match Gate::new(
211        config.toolsets.clone(),
212        config.max_tier,
213        unavailable,
214        &metas,
215    ) {
216        Err(ConfigError::NoToolsEnabled)
217            if Gate::new(
218                config.toolsets.clone(),
219                config.max_tier,
220                std::collections::BTreeSet::new(),
221                &metas,
222            )
223            .is_ok() =>
224        {
225            return Err(StartupError::NoSurface(notes));
226        }
227        gate => gate?,
228    };
229
230    let ctx = ToolContext {
231        local: Arc::clone(&backends.local),
232        tailnet,
233        redactor: crate::error::Redactor::for_credentials(backends.credentials.as_ref()),
234        max_result_bytes: config.max_result_bytes,
235        identity,
236        cli_version,
237        paths: PathPolicy::default(),
238        devices: Default::default(),
239        max_tier: config.max_tier,
240    };
241
242    let visible = registry.visible(&gate).len();
243    notes.push(format!(
244        "Offering {visible} tools: {} at the {} tier and above.",
245        describe_toolsets(&gate),
246        gate.max_tier()
247    ));
248
249    Ok(Startup {
250        server: TailscaleMcpServer::new(Arc::new(registry), gate, Arc::new(ctx)),
251        peers,
252        notes,
253    })
254}
255
256/// The toolsets this session actually offers something from.
257///
258/// Selected is not the same as offered: a toolset whose surface has no backend
259/// — no `tailscale` binary, no control-plane credential — contributes no tools,
260/// and naming it here beside a count that excludes it describes a session
261/// nobody is in. [`Gate::offers`] is the question the instructions already ask
262/// for exactly this reason, so the note asks it too.
263fn describe_toolsets(gate: &Gate) -> String {
264    gate.offered_toolsets()
265        .map(|toolset| toolset.as_str())
266        .collect::<Vec<_>>()
267        .join(", ")
268}
269
270/// The MCP server.
271#[derive(Debug, Clone)]
272pub struct TailscaleMcpServer {
273    registry: Arc<Registry>,
274    gate: Gate,
275    ctx: Arc<ToolContext>,
276    /// How fast this session may ask for completions. Per session, because
277    /// that is the thing being protected: one client typing quickly should not
278    /// spend another's budget.
279    completions: completion::Limiter,
280}
281
282impl TailscaleMcpServer {
283    pub fn new(registry: Arc<Registry>, gate: Gate, ctx: Arc<ToolContext>) -> Self {
284        Self {
285            registry,
286            gate,
287            ctx,
288            completions: completion::Limiter::new(),
289        }
290    }
291
292    pub fn context(&self) -> &Arc<ToolContext> {
293        &self.ctx
294    }
295
296    /// The tools this server offers, in listing order.
297    pub fn tools(&self) -> Result<Vec<Tool>, McpError> {
298        self.registry
299            .visible(&self.gate)
300            .into_iter()
301            .map(|entry| {
302                entry.describe().map_err(|reason| {
303                    McpError::internal_error(
304                        format!("the schema for `{}` is invalid: {reason}", entry.meta.name),
305                        None,
306                    )
307                })
308            })
309            .collect()
310    }
311
312    /// Run one tool, all policy applied.
313    async fn dispatch(&self, request: CallToolRequestParams) -> ToolResult<Value> {
314        let arguments = request.arguments.unwrap_or_default();
315        let (entry, arguments) = self
316            .registry
317            .resolve(&request.name, arguments, &self.gate)?;
318        if entry.meta.surface() == Surface::Local {
319            if !entry.meta.runs_here() {
320                return Err(ToolError::unsupported_platform(
321                    entry.meta.name,
322                    std::env::consts::OS,
323                ));
324            }
325            cli::version_permits(&self.ctx, &entry.meta)?;
326        }
327        let invoke = entry.invoke;
328        let value = invoke(Arc::clone(&self.ctx), arguments).await?;
329        self.check_size(&value)?;
330        Ok(value)
331    }
332
333    /// Refuse a result too large to be useful, rather than truncating it into
334    /// something that looks complete and is not.
335    fn check_size(&self, value: &Value) -> ToolResult<()> {
336        let size = serde_json::to_vec(value).map_or(0, |v| v.len());
337        if size > self.ctx.max_result_bytes {
338            return Err(ToolError::result_too_large(size, self.ctx.max_result_bytes));
339        }
340        Ok(())
341    }
342}
343
344impl ServerHandler for TailscaleMcpServer {
345    fn get_info(&self) -> ServerInfo {
346        let mut info = InitializeResult::new(
347            ServerCapabilities::builder()
348                .enable_tools()
349                // Resources and prompts, but not `enable_resources_subscribe`:
350                // `spec.md` puts subscriptions out of scope, and advertising
351                // one this server does not serve is a lie a client acts on.
352                .enable_resources()
353                .enable_prompts()
354                // Only the server declares this one — there is no client half
355                // of the capability. Without it, a client that supports
356                // completion has no way to know this server does, and rmcp's
357                // default handler would answer `completion/complete` with an
358                // empty success anyway: a method advertised nowhere and
359                // answered emptily is the worst of both.
360                .enable_completions()
361                .build(),
362        )
363        .with_instructions(instructions::render(
364            &self.gate,
365            &self.ctx,
366            instructions::Offered::new(&self.registry, &self.gate),
367        ));
368        // Not `from_build_env`: that reads the *SDK*'s package metadata.
369        info.server_info = Implementation::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
370            .with_title("Tailscale")
371            .with_website_url("https://github.com/tailscale-mcp/tailscale-mcp");
372        info
373    }
374
375    async fn list_tools(
376        &self,
377        _request: Option<PaginatedRequestParams>,
378        _context: RequestContext<RoleServer>,
379    ) -> Result<ListToolsResult, McpError> {
380        // Not paginated: the largest possible listing is under two hundred
381        // tools, and a client that has to page through its own tool list is
382        // worse off than one that receives it in a single message.
383        Ok(ListToolsResult::with_all_items(self.tools()?))
384    }
385
386    fn get_tool(&self, name: &str) -> Option<Tool> {
387        // Only ever a visible tool: this is used to validate a call, and a
388        // hidden tool must not become discoverable through it.
389        self.registry
390            .visible(&self.gate)
391            .into_iter()
392            .find(|entry| entry.meta.name == name)
393            .and_then(|entry| entry.describe().ok())
394    }
395
396    async fn list_resources(
397        &self,
398        _request: Option<PaginatedRequestParams>,
399        _context: RequestContext<RoleServer>,
400    ) -> Result<ListResourcesResult, McpError> {
401        // A resource whose surface is not offered is absent, not listed and
402        // refused — the same rule the tool listing follows, for the same
403        // reason: a client should not be shown something it cannot have.
404        Ok(ListResourcesResult::with_all_items(
405            resources::all()
406                .iter()
407                .filter(|entry| !entry.templated && self.gate.offers(entry.surface))
408                .map(resources::ResourceEntry::describe)
409                .collect(),
410        ))
411    }
412
413    async fn list_resource_templates(
414        &self,
415        _request: Option<PaginatedRequestParams>,
416        _context: RequestContext<RoleServer>,
417    ) -> Result<ListResourceTemplatesResult, McpError> {
418        Ok(ListResourceTemplatesResult::with_all_items(
419            resources::all()
420                .iter()
421                .filter(|entry| entry.templated && self.gate.offers(entry.surface))
422                .map(resources::ResourceEntry::describe_template)
423                .collect(),
424        ))
425    }
426
427    async fn read_resource(
428        &self,
429        request: ReadResourceRequestParams,
430        _context: RequestContext<RoleServer>,
431    ) -> Result<ReadResourceResponse, McpError> {
432        let read = resources::read(&self.ctx, |surface| self.gate.offers(surface), &request.uri);
433        match read.await {
434            Ok(result) => Ok(ReadResourceResponse::Complete(result)),
435            // Unlike a tool call, a resource read has no result shape to carry
436            // a failure in, so this one really is a protocol error.
437            Err(error) => Err(McpError::resource_not_found(
438                error.message.clone(),
439                Some(serde_json::json!({"uri": request.uri, "error": error.to_value()})),
440            )),
441        }
442    }
443
444    async fn list_prompts(
445        &self,
446        _request: Option<PaginatedRequestParams>,
447        _context: RequestContext<RoleServer>,
448    ) -> Result<ListPromptsResult, McpError> {
449        // The rule the two listings above follow, for the third listing that
450        // needs it: a prompt whose surface is missing is a numbered procedure
451        // every step of which this session would refuse.
452        Ok(ListPromptsResult::with_all_items(
453            resources::prompts()
454                .iter()
455                .filter(|entry| self.gate.offers(entry.surface))
456                .map(resources::PromptEntry::describe)
457                .collect(),
458        ))
459    }
460
461    async fn get_prompt(
462        &self,
463        request: GetPromptRequestParams,
464        _context: RequestContext<RoleServer>,
465    ) -> Result<GetPromptResponse, McpError> {
466        let prompts = resources::prompts();
467        let prompt = prompts
468            .iter()
469            .find(|prompt| prompt.name == request.name)
470            .ok_or_else(|| {
471                McpError::invalid_params(format!("`{}` is not a prompt", request.name), None)
472            })?;
473        // Asking for one by name is asking for something this session never
474        // listed, and the answer says which of the two it is: a name that is
475        // not a prompt anywhere, or a prompt this server has no surface for.
476        if !self.gate.offers(prompt.surface) {
477            return Err(McpError::invalid_params(
478                format!(
479                    "`{}` is not offered, because this server has no {} surface",
480                    request.name,
481                    prompt.surface.as_str()
482                ),
483                None,
484            ));
485        }
486        let (argument, _) = prompt.argument;
487        let given = request
488            .arguments
489            .as_ref()
490            .and_then(|arguments| arguments.get(argument))
491            .and_then(Value::as_str);
492        let surfaces = resources::Surfaces::new(|surface| self.gate.offers(surface));
493        Ok(GetPromptResponse::Complete(
494            GetPromptResult::new(prompt.expand(given, surfaces))
495                .with_description(prompt.description),
496        ))
497    }
498
499    /// Values for the four slots that have a knowable set.
500    ///
501    /// Never an error. A completion is a popup: the client shows what comes
502    /// back and moves on, so a failure it would have to explain buys nothing
503    /// that an empty list does not. [`completion`] says what each slot draws
504    /// from and why.
505    async fn complete(
506        &self,
507        request: CompleteRequestParams,
508        _context: RequestContext<RoleServer>,
509    ) -> Result<CompleteResult, McpError> {
510        Ok(completion::complete(&self.ctx, &self.gate, &self.completions, &request).await)
511    }
512
513    async fn call_tool(
514        &self,
515        request: CallToolRequestParams,
516        _context: RequestContext<RoleServer>,
517    ) -> Result<CallToolResponse, McpError> {
518        // A tool that ran and failed is a result, not a protocol error: the
519        // model has to see it to choose what to do next. Protocol errors are
520        // reserved for requests that could not be routed at all, and the
521        // registry does not produce those.
522        let result = match self.dispatch(request).await {
523            Ok(value) => CallToolResult::structured(value),
524            Err(error) => CallToolResult::structured_error(error.to_value()),
525        };
526        Ok(CallToolResponse::Complete(result))
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use rmcp::schemars::JsonSchema;
533    use serde::Deserialize;
534    use serde_json::json;
535    use tailscale_rest::Secret;
536
537    use super::*;
538    use crate::config::Cli;
539    use crate::error::ErrorCode;
540    use crate::testing::{Reply, StubBackend};
541
542    /// A table standing in for the real one, so that these tests exercise the
543    /// gate, the confirmation rule and both surfaces before the toolsets that
544    /// will fill them have been written. It is deliberately small and covers
545    /// one tool per case that the server itself has to get right.
546    mod fixture {
547        use super::{Deserialize, JsonSchema, ToolContext, ToolResult, Value};
548        use tailscale_cli::Invocation;
549
550        #[derive(Debug, Deserialize, JsonSchema)]
551        pub struct NoParams {}
552
553        crate::tools! {
554            /// Read something about this node.
555            tailscale_fixture_read => NoParams, run_local,
556                toolset: LocalStatus, tier: Read, idempotent: true;
557
558            /// Something only a much newer binary has.
559            tailscale_fixture_new => NoParams, run_local,
560                toolset: LocalStatus, tier: Read, since: "1.94";
561
562            /// Something the debug toolset offers, which no preset includes.
563            tailscale_fixture_debug => NoParams, run_local,
564                toolset: LocalDebug, tier: Read;
565
566            /// Read something about the tailnet.
567            tailnet_fixture_read => NoParams, run_tailnet,
568                toolset: TailnetDevices, tier: Read, idempotent: true;
569        }
570
571        async fn run_local(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
572            let text = crate::cli::run_text(
573                ctx,
574                &metas::tailscale_fixture_read,
575                Invocation::read(["fixture"]),
576            )
577            .await?;
578            Ok(serde_json::json!({ "text": text.trim() }))
579        }
580
581        async fn run_tailnet(_ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
582            Ok(serde_json::json!({ "ok": true }))
583        }
584    }
585
586    fn config(cli: Cli) -> Config {
587        Config::resolve_with(cli, |_| None).expect("test configuration resolves")
588    }
589
590    fn backends(local: Option<StubBackend>, credentialled: bool) -> Backends {
591        let local_available = local.is_some();
592        Backends {
593            local: local.map_or_else(
594                || Arc::new(Unavailable::default()) as Arc<dyn LocalBackend>,
595                |b| Arc::new(b) as Arc<dyn LocalBackend>,
596            ),
597            local_available,
598            credentials: credentialled
599                .then(|| Credentials::ApiKey(Secret::new("tskey-api-example"))),
600        }
601    }
602
603    /// A stub that answers the startup probes the way a healthy node does.
604    fn healthy_node() -> StubBackend {
605        StubBackend::ok("")
606            .on(["version"], Reply::ok("1.102.2\n"))
607            .on(
608                ["status", "--json"],
609                Reply::ok(
610                    json!({
611                        "Self": {
612                            "ID": "n1234567CNTRL",
613                            "PublicKey": "nodekey:aaa",
614                            "TailscaleIPs": ["100.64.0.1", "fd7a::1"],
615                            "DNSName": "workstation.example-tailnet.ts.net."
616                        }
617                    })
618                    .to_string(),
619                ),
620            )
621    }
622
623    async fn server(cli: Cli, backends: Backends) -> Startup {
624        build(&config(cli), fixture::entries(), backends)
625            .await
626            .expect("the server builds")
627    }
628
629    fn names(startup: &Startup) -> Vec<String> {
630        startup
631            .server
632            .tools()
633            .expect("tools describe")
634            .iter()
635            .map(|t| t.name.to_string())
636            .collect()
637    }
638
639    async fn call(server: &TailscaleMcpServer, name: &str, arguments: Value) -> CallToolResult {
640        let arguments = arguments
641            .as_object()
642            .cloned()
643            .expect("arguments must be an object");
644        let response = server
645            .respond(CallToolRequestParams::new(name.to_owned()).with_arguments(arguments))
646            .await;
647        match response {
648            CallToolResponse::Complete(result) => result,
649            other => panic!("expected a completed call, got {other:?}"),
650        }
651    }
652
653    fn error_of(result: &CallToolResult) -> Value {
654        assert_eq!(result.is_error, Some(true), "expected a failed call");
655        result
656            .structured_content
657            .clone()
658            .expect("a failed call carries structured content")
659    }
660
661    #[tokio::test]
662    async fn without_a_binary_the_local_tools_are_hidden_and_the_tailnet_tools_remain() {
663        let startup = server(Cli::default(), backends(None, true)).await;
664        let names = names(&startup);
665        assert!(
666            !names.is_empty(),
667            "the tailnet surface should still be offered"
668        );
669        assert!(
670            names.iter().all(|n| n.starts_with("tailnet_")),
671            "a local tool survived a missing binary: {names:?}"
672        );
673        assert!(
674            startup
675                .notes
676                .iter()
677                .any(|n| n.contains("`tailscale` binary")),
678            "{:?}",
679            startup.notes
680        );
681    }
682
683    #[tokio::test]
684    async fn a_surface_switched_off_stays_off_even_when_it_could_have_worked() {
685        // The flag is the operator's decision, not a description of the machine.
686        let startup = server(
687            Cli {
688                no_tailnet: true,
689                ..Cli::default()
690            },
691            backends(Some(healthy_node()), true),
692        )
693        .await;
694        let names = names(&startup);
695        assert!(!names.is_empty());
696        assert!(
697            names.iter().all(|n| n.starts_with("tailscale_")),
698            "a tailnet tool survived --no-tailnet: {names:?}"
699        );
700    }
701
702    #[tokio::test]
703    async fn a_local_surface_switched_off_is_not_probed() {
704        let backend = Arc::new(healthy_node());
705        let startup = build(
706            &config(Cli {
707                no_local: true,
708                ..Cli::default()
709            }),
710            fixture::entries(),
711            Backends {
712                local: Arc::clone(&backend) as Arc<dyn LocalBackend>,
713                local_available: true,
714                credentials: Some(Credentials::ApiKey(Secret::new("tskey-api-example"))),
715            },
716        )
717        .await
718        .expect("the tailnet surface still starts");
719        assert!(backend.calls().is_empty(), "{:?}", backend.calls());
720        assert_eq!(startup.server.context().cli_version, None);
721    }
722
723    #[tokio::test]
724    async fn neither_surface_is_a_startup_error() {
725        let err = build(
726            &config(Cli::default()),
727            fixture::entries(),
728            backends(None, false),
729        )
730        .await
731        .expect_err("a server that can do nothing should not start");
732        // The repair is a binary or a credential, not a wider selection, so the
733        // error has to say which: a container without either is the usual case.
734        let said = err.to_string();
735        for repair in ["--cli-path", "TAILSCALE_API_KEY"] {
736            assert!(said.contains(repair), "{said}");
737        }
738
739        // Whereas a selection that offers nothing on a working node is the
740        // selection's fault, and says so.
741        let err = build(
742            &config(Cli {
743                toolsets: Some("local-serve".to_owned()),
744                ..Cli::default()
745            }),
746            fixture::entries(),
747            backends(Some(healthy_node()), true),
748        )
749        .await
750        .expect_err("a selection with nothing in it should not start");
751        assert!(
752            matches!(err, StartupError::Config(ConfigError::NoToolsEnabled)),
753            "{err}"
754        );
755    }
756
757    /// A server pointed somewhere in particular, so that the base URL is the
758    /// only thing the pair below varies. Everything else is a healthy node
759    /// with a credential, which is the state in which the address matters.
760    async fn build_pointed_at(base_url: &str) -> Result<Startup, StartupError> {
761        let config = Config::resolve_with(Cli::default(), |key| {
762            (key == crate::config::API_BASE_URL_ENV).then(|| base_url.to_owned())
763        })
764        .expect("the configuration itself resolves");
765
766        build(
767            &config,
768            fixture::entries(),
769            backends(Some(healthy_node()), true),
770        )
771        .await
772    }
773
774    #[tokio::test]
775    async fn a_base_url_that_is_not_the_control_plane_stops_the_server_starting() {
776        // Not a note and not a hidden failure: the only way to reach this is
777        // to have redirected every credential this server holds, and starting
778        // anyway would mean running with the tailnet surface quietly missing.
779        let error = build_pointed_at("http://control.example.com")
780            .await
781            .expect_err("plaintext to another host is refused");
782
783        let reported = error.to_string();
784        assert!(
785            reported.contains("loopback") && reported.contains("control.example.com"),
786            "the failure should say what was wrong and with what: {reported}"
787        );
788    }
789
790    #[tokio::test]
791    async fn an_unstable_build_is_not_warned_about() {
792        let unstable = StubBackend::ok("").on(["version"], Reply::ok("1.77.0\n"));
793        let startup = server(Cli::default(), backends(Some(unstable), true)).await;
794        assert!(
795            !startup.notes.iter().any(|n| n.contains("older than")),
796            "{:?}",
797            startup.notes
798        );
799    }
800
801    #[tokio::test]
802    async fn an_unreadable_version_is_noted_and_blocks_nothing() {
803        let odd = StubBackend::ok("").on(["version"], Reply::failed(1, "no"));
804        let startup = server(Cli::default(), backends(Some(odd), true)).await;
805        assert!(
806            startup
807                .notes
808                .iter()
809                .any(|n| n.contains("Could not read the version")),
810            "{:?}",
811            startup.notes
812        );
813        assert!(names(&startup).iter().any(|n| n.starts_with("tailscale_")));
814    }
815
816    #[tokio::test]
817    async fn without_a_local_surface_we_claim_no_identity() {
818        let startup = server(Cli::default(), backends(None, true)).await;
819        let identity = startup.server.context().identity.last_known();
820        assert!(!identity.matches("n1234567CNTRL"));
821        assert_eq!(startup.server.context().cli_version, None);
822    }
823
824    #[tokio::test]
825    async fn a_hidden_tool_is_not_reachable_by_name() {
826        let startup = server(Cli::default(), backends(Some(healthy_node()), true)).await;
827        // The debug toolset is outside every preset.
828        assert!(startup.server.get_tool("tailscale_fixture_debug").is_none());
829        let error = error_of(&call(&startup.server, "tailscale_fixture_debug", json!({})).await);
830        assert_eq!(error["code"], ErrorCode::NotPermitted.as_str());
831        assert!(
832            error["hint"]
833                .as_str()
834                .is_some_and(|h| h.contains("local-debug")),
835            "the refusal should say what to change: {error}"
836        );
837    }
838
839    #[tokio::test]
840    async fn a_tool_newer_than_the_binary_reports_the_version_it_needs() {
841        let old = StubBackend::ok("")
842            .on(["version"], Reply::ok("1.80.0\n"))
843            .on(["status", "--json"], Reply::ok("{}"));
844        let startup = server(Cli::default(), backends(Some(old), true)).await;
845        let error = error_of(&call(&startup.server, "tailscale_fixture_new", json!({})).await);
846        assert_eq!(error["code"], ErrorCode::UnsupportedVersion.as_str());
847        assert!(
848            error["message"]
849                .as_str()
850                .is_some_and(|m| m.contains("1.94")),
851            "{error}"
852        );
853    }
854
855    #[tokio::test]
856    async fn a_result_larger_than_the_cap_is_refused() {
857        let startup = server(
858            Cli {
859                max_result_bytes: Some(4),
860                ..Cli::default()
861            },
862            backends(Some(healthy_node()), true),
863        )
864        .await;
865        let error = error_of(&call(&startup.server, "tailnet_fixture_read", json!({})).await);
866        assert_eq!(error["code"], ErrorCode::ResultTooLarge.as_str());
867    }
868
869    /// The count and the list are one sentence and have to agree.
870    ///
871    /// With no credential the tailnet toolsets are still *selected* — the
872    /// preset picks them and nothing in the configuration says otherwise — but
873    /// they contribute nothing, and the count beside them already knows that.
874    /// Naming them anyway told a reader that `tailnet-devices` was on offer in
875    /// a session where every call to it would be refused, which is the one
876    /// thing a startup note exists to prevent.
877    #[tokio::test]
878    async fn the_summary_names_no_toolset_that_offers_nothing() {
879        let startup = server(Cli::default(), backends(Some(healthy_node()), false)).await;
880        let summary = startup
881            .notes
882            .last()
883            .expect("a summary is always the last note");
884        assert!(
885            summary.contains("local-status"),
886            "the local surface is there and should be named: {summary}"
887        );
888        assert!(
889            !summary.contains("tailnet-"),
890            "no credential, so no tailnet toolset offers anything; naming one \
891             contradicts the count in the same sentence: {summary}"
892        );
893    }
894
895    /// `call_tool` without a [`RequestContext`], which only a live session can
896    /// produce. Every policy the handler applies lives in `dispatch`, so this
897    /// exercises the same path.
898    impl TailscaleMcpServer {
899        async fn respond(&self, request: CallToolRequestParams) -> CallToolResponse {
900            CallToolResponse::Complete(match self.dispatch(request).await {
901                Ok(value) => CallToolResult::structured(value),
902                Err(error) => CallToolResult::structured_error(error.to_value()),
903            })
904        }
905    }
906}