1use 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#[derive(Debug, thiserror::Error)]
36pub enum StartupError {
37 #[error(transparent)]
38 Config(#[from] ConfigError),
39 #[error(transparent)]
40 Registry(#[from] RegistryError),
41 #[error(transparent)]
46 ControlPlane(#[from] tailscale_rest::ApiError),
47 #[error("no tools can be offered. {}", .0.join(" "))]
51 NoSurface(Vec<String>),
52}
53
54#[derive(Debug)]
59pub struct Backends {
60 pub local: Arc<dyn LocalBackend>,
61 pub local_available: bool,
65 pub credentials: Option<Credentials>,
66}
67
68impl Backends {
69 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#[derive(Debug)]
95pub struct Startup {
96 pub server: TailscaleMcpServer,
97 pub peers: std::collections::HashMap<std::net::IpAddr, String>,
101 pub notes: Vec<String>,
104}
105
106pub 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 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 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 Identity::probed(identity),
174 peers,
175 )
176 } else {
177 (
180 None,
181 Identity::fixed(SelfIdentity::default()),
182 std::collections::HashMap::new(),
183 )
184 };
185
186 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 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
256fn describe_toolsets(gate: &Gate) -> String {
264 gate.offered_toolsets()
265 .map(|toolset| toolset.as_str())
266 .collect::<Vec<_>>()
267 .join(", ")
268}
269
270#[derive(Debug, Clone)]
272pub struct TailscaleMcpServer {
273 registry: Arc<Registry>,
274 gate: Gate,
275 ctx: Arc<ToolContext>,
276 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 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 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 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 .enable_resources()
353 .enable_prompts()
354 .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 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 Ok(ListToolsResult::with_all_items(self.tools()?))
384 }
385
386 fn get_tool(&self, name: &str) -> Option<Tool> {
387 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 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 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 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 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 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 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 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 tailscale_fixture_read => NoParams, run_local,
556 toolset: LocalStatus, tier: Read, idempotent: true;
557
558 tailscale_fixture_new => NoParams, run_local,
560 toolset: LocalStatus, tier: Read, since: "1.94";
561
562 tailscale_fixture_debug => NoParams, run_local,
564 toolset: LocalDebug, tier: Read;
565
566 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 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 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 let said = err.to_string();
735 for repair in ["--cli-path", "TAILSCALE_API_KEY"] {
736 assert!(said.contains(repair), "{said}");
737 }
738
739 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 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 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 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 #[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 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}