1use std::collections::BTreeMap;
13use std::sync::Arc;
14
15use rmcp::model::{JsonObject, Tool, ToolAnnotations};
16use serde_json::Value;
17use tailscale_cli::BoxFuture;
18
19use crate::context::ToolContext;
20use crate::error::{ToolError, ToolResult};
21use crate::gating::Gate;
22use crate::meta::ToolMeta;
23
24pub const CONFIRM_PARAM: &str = "confirm";
30
31pub const MAX_NAME_LEN: usize = 128;
33
34pub type InvokeFn = fn(Arc<ToolContext>, JsonObject) -> BoxFuture<'static, ToolResult<Value>>;
36
37#[derive(Clone)]
39pub struct ToolEntry {
40 pub meta: ToolMeta,
41 pub schema: fn() -> Result<Arc<JsonObject>, String>,
43 pub invoke: InvokeFn,
44}
45
46impl std::fmt::Debug for ToolEntry {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.debug_struct("ToolEntry")
49 .field("meta", &self.meta)
50 .finish_non_exhaustive()
51 }
52}
53
54impl ToolEntry {
55 pub fn describe(&self) -> Result<Tool, String> {
62 let mut schema = (self.schema)()?.as_ref().clone();
63 if self.meta.requires_confirmation {
64 add_confirm_property(&mut schema);
65 }
66
67 let a = self.meta.annotations();
68 Ok(
69 Tool::new(self.meta.name, self.meta.summary, Arc::new(schema)).with_annotations(
70 ToolAnnotations::new()
71 .read_only(a.read_only)
72 .destructive(a.destructive)
73 .idempotent(a.idempotent)
74 .open_world(a.open_world),
75 ),
76 )
77 }
78}
79
80fn add_confirm_property(schema: &mut JsonObject) {
82 let properties = schema
83 .entry("properties")
84 .or_insert_with(|| Value::Object(JsonObject::new()));
85 if let Some(properties) = properties.as_object_mut() {
86 properties.insert(
87 CONFIRM_PARAM.to_owned(),
88 serde_json::json!({
89 "type": "boolean",
90 "default": false,
91 "description":
92 "Set to true to confirm this operation. It is irreversible, \
93 or it can disconnect the node this server runs on, so it \
94 will not run without an explicit intent.",
95 }),
96 );
97 }
98}
99
100#[derive(Debug, Clone)]
106pub struct Registry {
107 entries: Vec<ToolEntry>,
108 by_name: BTreeMap<&'static str, usize>,
109}
110
111impl Registry {
112 pub fn new(entries: Vec<ToolEntry>) -> Result<Self, RegistryError> {
118 let mut by_name = BTreeMap::new();
119 for (index, entry) in entries.iter().enumerate() {
120 let name = entry.meta.name;
121 validate_name(name)?;
122 if by_name.insert(name, index).is_some() {
123 return Err(RegistryError::DuplicateName(name));
124 }
125 if entry.meta.self_severing && !entry.meta.requires_confirmation {
126 return Err(RegistryError::SelfSeveringWithoutConfirmation(name));
127 }
128 match (entry.schema)() {
129 Err(reason) => return Err(RegistryError::BadSchema { name, reason }),
130 Ok(schema) if entry.meta.severs_local_node => {
134 let asks = schema
135 .get("properties")
136 .and_then(Value::as_object)
137 .is_some_and(|properties| properties.contains_key(CONFIRM_PARAM));
138 if !asks {
139 return Err(RegistryError::SeversLocalWithoutConfirmParameter(name));
140 }
141 }
142 Ok(_) => {}
143 }
144 }
145 Ok(Self { entries, by_name })
146 }
147
148 pub fn metas(&self) -> Vec<ToolMeta> {
152 self.entries.iter().map(|e| e.meta).collect()
153 }
154
155 pub fn entries(&self) -> &[ToolEntry] {
156 &self.entries
157 }
158
159 pub fn get(&self, name: &str) -> Option<&ToolEntry> {
160 self.by_name.get(name).map(|index| &self.entries[*index])
161 }
162
163 pub fn len(&self) -> usize {
164 self.entries.len()
165 }
166
167 pub fn is_empty(&self) -> bool {
168 self.entries.is_empty()
169 }
170
171 pub fn visible(&self, gate: &Gate) -> Vec<&ToolEntry> {
176 let mut visible: Vec<&ToolEntry> = self
177 .entries
178 .iter()
179 .filter(|entry| gate.permits(&entry.meta))
180 .collect();
181 visible.sort_by_key(|entry| entry.meta.name);
182 visible
183 }
184
185 pub fn resolve(
190 &self,
191 name: &str,
192 mut args: JsonObject,
193 gate: &Gate,
194 ) -> ToolResult<(&ToolEntry, JsonObject)> {
195 let entry = self
196 .get(name)
197 .ok_or_else(|| ToolError::not_found(&format!("the tool `{name}`")))?;
198
199 if !gate.permits(&entry.meta) {
200 return Err(ToolError::not_permitted(name, &gate.needs(&entry.meta)));
201 }
202
203 if entry.meta.requires_confirmation {
204 let confirmed = match args.remove(CONFIRM_PARAM) {
205 Some(Value::Bool(confirmed)) => confirmed,
206 None | Some(Value::Null) => false,
207 Some(other) => {
208 return Err(ToolError::invalid_args(format!(
209 "`{CONFIRM_PARAM}` must be true or false, not {other}"
210 )));
211 }
212 };
213 if !confirmed {
214 return Err(ToolError::confirmation_required(
215 name,
216 confirmation_consequence(&entry.meta),
217 ));
218 }
219 }
220
221 Ok((entry, args))
222 }
223}
224
225fn confirmation_consequence(meta: &crate::meta::ToolMeta) -> &'static str {
227 if meta.self_severing {
228 "can disconnect the node this server runs on, so it needs `confirm: true`"
229 } else {
230 "cannot be undone, so it needs `confirm: true`"
231 }
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
236pub enum RegistryError {
237 #[error("two tools are both named `{0}`")]
238 DuplicateName(&'static str),
239
240 #[error("the tool name `{name}` {reason}")]
241 BadName {
242 name: &'static str,
243 reason: &'static str,
244 },
245
246 #[error("`{0}` is self-severing but does not require confirmation")]
247 SelfSeveringWithoutConfirmation(&'static str),
248
249 #[error("`{0}` severs the local node but its parameters carry no `confirm`")]
250 SeversLocalWithoutConfirmParameter(&'static str),
251
252 #[error("the parameters of `{name}` do not make a valid input schema: {reason}")]
253 BadSchema { name: &'static str, reason: String },
254}
255
256fn validate_name(name: &'static str) -> Result<(), RegistryError> {
258 let bad = |reason| Err(RegistryError::BadName { name, reason });
259 if name.is_empty() {
260 return bad("is empty");
261 }
262 if name.len() > MAX_NAME_LEN {
263 return bad("is longer than the 128 characters the protocol allows");
264 }
265 if !name
266 .bytes()
267 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
268 {
269 return bad("contains a character outside [A-Za-z0-9_.-]");
270 }
271 if !name.starts_with("tailscale_") && !name.starts_with("tailnet_") {
272 return bad("does not begin with `tailscale_` or `tailnet_`");
273 }
274 Ok(())
275}
276
277#[macro_export]
296macro_rules! tools {
297 (
298 $(
299 $(#[doc = $summary:literal])+
300 $name:ident => $params:ty, $handler:path,
301 toolset: $toolset:ident,
302 tier: $tier:ident
303 $(, idempotent: $idempotent:literal)?
304 $(, confirm: $confirm:literal)?
305 $(, severing: $severing:literal)?
306 $(, severs_local: $severs_local:literal)?
307 $(, varying: $varying:literal)?
308 $(, since: $since:literal)?
309 $(, platforms: [$($platform:literal),+ $(,)?])?
310 $(,)?
311 ;
312 )*
313 ) => {
314 pub mod metas {
319 #![allow(non_upper_case_globals)]
320
321 $(
322 #[doc = ::std::concat!($($summary),*)]
323 pub const $name: $crate::meta::ToolMeta = {
324 #[allow(unused_mut, unused_assignments)]
325 let mut severing = false;
326 $( severing = $severing; )?
327 #[allow(unused_mut, unused_assignments)]
328 let mut confirm = severing;
329 $( confirm = $confirm || severing; )?
330 #[allow(unused_mut, unused_assignments)]
331 let mut severs_local_node = false;
332 $( severs_local_node = $severs_local; )?
333 #[allow(unused_mut, unused_assignments)]
334 let mut idempotent = false;
335 $( idempotent = $idempotent; )?
336 #[allow(unused_mut, unused_assignments)]
337 let mut varying_tier = false;
338 $( varying_tier = $varying; )?
339 #[allow(unused_mut, unused_assignments)]
340 let mut since: ::std::option::Option<&'static str> =
341 ::std::option::Option::None;
342 $( since = ::std::option::Option::Some($since); )?
343 #[allow(unused_mut, unused_assignments)]
344 let mut platforms: ::std::option::Option<&'static [&'static str]> =
345 ::std::option::Option::None;
346 $( platforms = ::std::option::Option::Some(&[$($platform),+]); )?
347
348 $crate::meta::ToolMeta {
349 name: ::std::stringify!($name),
350 toolset: $crate::meta::Toolset::$toolset,
351 tier: $crate::meta::Tier::$tier,
352 summary: ::std::concat!($($summary),*).trim_ascii(),
353 self_severing: severing,
354 severs_local_node,
355 requires_confirmation: confirm,
356 idempotent,
357 varying_tier,
358 min_version: since,
359 platforms,
360 }
361 };
362 )*
363 }
364
365 pub fn entries() -> ::std::vec::Vec<$crate::registry::ToolEntry> {
367 ::std::vec![
368 $(
369 {
370 fn invoke(
374 ctx: ::std::sync::Arc<$crate::context::ToolContext>,
375 args: ::rmcp::model::JsonObject,
376 ) -> ::tailscale_cli::BoxFuture<
377 'static,
378 $crate::error::ToolResult<::serde_json::Value>,
379 > {
380 ::std::boxed::Box::pin(async move {
381 let params: $params = $crate::registry::parse_params(
382 ::std::stringify!($name),
383 args,
384 )?;
385 $handler(&ctx, params).await
386 })
387 }
388
389 $crate::registry::ToolEntry {
390 meta: self::metas::$name,
391 schema: || {
392 ::rmcp::handler::server::tool::schema_for_input::<$params>()
393 },
394 invoke,
395 }
396 },
397 )*
398 ]
399 }
400 };
401}
402
403pub fn parse_params<T: serde::de::DeserializeOwned>(tool: &str, args: JsonObject) -> ToolResult<T> {
409 serde_json::from_value(Value::Object(args)).map_err(|e| {
410 ToolError::invalid_args(format!("`{tool}` was called with unusable arguments: {e}"))
411 })
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use crate::meta::{Tier, Toolset};
418 use schemars::JsonSchema;
419 use serde::Deserialize;
420 use std::collections::BTreeSet;
421
422 #[derive(Debug, Deserialize, JsonSchema)]
423 struct Empty {}
424
425 #[allow(dead_code)]
430 #[derive(Debug, Deserialize, JsonSchema)]
431 struct MixedParams {
432 include_peers: bool,
434 #[serde(rename = "keyExpiryDisabled")]
436 key_expiry_disabled: bool,
437 }
438
439 async fn ok_handler<P>(_ctx: &ToolContext, _params: P) -> ToolResult<Value> {
440 Ok(Value::Null)
441 }
442
443 mod declared {
444 use super::*;
445
446 crate::tools! {
447 tailscale_status => Empty, super::ok_handler,
449 toolset: LocalStatus, tier: Read, idempotent: true;
450
451 tailscale_down => Empty, super::ok_handler,
453 toolset: LocalPrefs, tier: Destructive, severing: true;
454
455 tailnet_device_delete => MixedParams, super::ok_handler,
457 toolset: TailnetDevices, tier: Destructive, confirm: true, since: "1.60";
458 }
459 }
460
461 fn registry() -> Registry {
462 Registry::new(declared::entries()).expect("a well-formed table")
463 }
464
465 fn open_gate() -> Gate {
466 Gate::unchecked(
467 Toolset::ALL.iter().copied().collect(),
468 Tier::Destructive,
469 BTreeSet::new(),
470 )
471 }
472
473 #[test]
474 fn a_declaration_produces_its_metadata_row() {
475 let registry = registry();
476 assert_eq!(registry.len(), 3);
477
478 let status = registry.get("tailscale_status").expect("declared");
479 assert_eq!(status.meta.toolset, Toolset::LocalStatus);
480 assert_eq!(status.meta.tier, Tier::Read);
481 assert_eq!(status.meta.summary, "Report the state of the local node.");
482 assert!(status.meta.idempotent);
483 assert!(!status.meta.requires_confirmation);
484 assert_eq!(status.meta.min_version, None);
485 }
486
487 #[test]
488 fn a_tool_that_severs_the_local_node_must_offer_somewhere_to_say_so() {
489 mod without {
490 use crate::registry::tests::Empty;
491
492 crate::tools! {
493 tailnet_device_delete => Empty, super::ok_handler,
495 toolset: TailnetDevices, tier: Destructive, severs_local: true;
496 }
497 }
498 assert_eq!(
501 Registry::new(without::entries()).expect_err("no `confirm` to give"),
502 RegistryError::SeversLocalWithoutConfirmParameter("tailnet_device_delete")
503 );
504 }
505
506 #[test]
507 fn a_self_severing_tool_requires_confirmation_without_being_told_twice() {
508 let down = registry().get("tailscale_down").expect("declared").meta;
509 assert!(down.self_severing);
510 assert!(
511 down.requires_confirmation,
512 "severing must imply confirmation"
513 );
514 }
515
516 #[test]
517 fn optional_settings_land_where_they_are_given() {
518 let delete = registry()
519 .get("tailnet_device_delete")
520 .expect("declared")
521 .meta;
522 assert!(delete.requires_confirmation);
523 assert!(!delete.self_severing);
524 assert_eq!(delete.min_version, Some("1.60"));
525 }
526
527 #[test]
528 fn every_tool_has_exactly_one_row_and_a_usable_name() {
529 let registry = registry();
530 let mut names: Vec<&str> = registry.entries().iter().map(|e| e.meta.name).collect();
531 let before = names.len();
532 names.sort_unstable();
533 names.dedup();
534 assert_eq!(before, names.len(), "a tool appears twice");
535
536 for name in names {
537 assert!(!name.is_empty());
538 assert!(name.len() <= MAX_NAME_LEN, "{name} is too long");
539 assert!(
540 name.bytes()
541 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.'),
542 "{name} uses a character the protocol does not allow"
543 );
544 assert!(
545 name.starts_with("tailscale_") || name.starts_with("tailnet_"),
546 "{name} does not say which surface it acts on"
547 );
548 }
549 }
550
551 #[test]
552 fn a_duplicate_name_is_refused_at_construction() {
553 let mut entries = declared::entries();
554 entries.push(entries[0].clone());
555 assert_eq!(
556 Registry::new(entries).err(),
557 Some(RegistryError::DuplicateName("tailscale_status"))
558 );
559 }
560
561 #[test]
562 fn annotations_are_derived_from_the_tier_not_declared_beside_it() {
563 let registry = registry();
564 let status = registry
565 .get("tailscale_status")
566 .expect("declared")
567 .describe()
568 .expect("a valid schema");
569 let a = status.annotations.expect("annotations are set");
570 assert_eq!(a.read_only_hint, Some(true));
571 assert_eq!(a.destructive_hint, Some(false));
572 assert_eq!(a.idempotent_hint, Some(true));
573 assert_eq!(a.open_world_hint, Some(true));
574
575 let down = registry
576 .get("tailscale_down")
577 .expect("declared")
578 .describe()
579 .expect("a valid schema");
580 let a = down.annotations.expect("annotations are set");
581 assert_eq!(a.read_only_hint, Some(false));
582 assert_eq!(a.destructive_hint, Some(true));
583 }
584
585 #[test]
586 fn both_naming_conventions_survive_schema_generation() {
587 let tool = registry()
588 .get("tailnet_device_delete")
589 .expect("declared")
590 .describe()
591 .expect("a valid schema");
592 let properties = tool
593 .input_schema
594 .get("properties")
595 .and_then(Value::as_object)
596 .expect("an object schema with properties");
597
598 assert!(
600 properties.contains_key("include_peers"),
601 "server-owned parameters stay snake_case: {properties:?}"
602 );
603 assert!(
605 properties.contains_key("keyExpiryDisabled"),
606 "control-plane fields keep their own shape: {properties:?}"
607 );
608 }
609
610 #[test]
611 fn the_confirmation_flag_appears_only_where_it_is_required() {
612 let registry = registry();
613 let confirming = registry
614 .get("tailscale_down")
615 .expect("declared")
616 .describe()
617 .expect("a valid schema");
618 let properties = confirming
619 .input_schema
620 .get("properties")
621 .and_then(Value::as_object)
622 .expect("properties");
623 assert!(properties.contains_key(CONFIRM_PARAM));
624
625 let plain = registry
626 .get("tailscale_status")
627 .expect("declared")
628 .describe()
629 .expect("a valid schema");
630 let properties = plain
633 .input_schema
634 .get("properties")
635 .and_then(Value::as_object);
636 assert!(properties.is_none_or(|p| !p.contains_key(CONFIRM_PARAM)));
637 }
638
639 #[test]
640 fn a_confirming_tool_refuses_until_the_caller_says_so() {
641 let registry = registry();
642 let gate = open_gate();
643
644 let err = registry
645 .resolve("tailscale_down", JsonObject::new(), &gate)
646 .expect_err("must not run unconfirmed");
647 assert_eq!(err.code, crate::error::ErrorCode::ConfirmationRequired);
648 assert!(err.message.contains("disconnect"), "{}", err.message);
649
650 let mut args = JsonObject::new();
651 args.insert(CONFIRM_PARAM.to_owned(), Value::Bool(true));
652 let (entry, rest) = registry
653 .resolve("tailscale_down", args, &gate)
654 .expect("a confirmed call runs");
655 assert_eq!(entry.meta.name, "tailscale_down");
656 assert!(
657 !rest.contains_key(CONFIRM_PARAM),
658 "the handler should not see the flag"
659 );
660 }
661
662 #[test]
663 fn a_confirmation_flag_of_the_wrong_shape_is_a_bad_argument() {
664 let mut args = JsonObject::new();
665 args.insert(CONFIRM_PARAM.to_owned(), Value::String("yes".to_owned()));
666 let err = registry()
667 .resolve("tailscale_down", args, &open_gate())
668 .expect_err("`yes` is not a boolean");
669 assert_eq!(err.code, crate::error::ErrorCode::InvalidArgs);
670 }
671
672 #[test]
673 fn the_router_is_the_table_filtered_by_the_gate() {
674 let registry = registry();
675 let gate = Gate::unchecked(
676 BTreeSet::from([Toolset::LocalStatus, Toolset::LocalPrefs]),
677 Tier::Read,
678 BTreeSet::new(),
679 );
680 let visible: Vec<&str> = registry
681 .visible(&gate)
682 .iter()
683 .map(|e| e.meta.name)
684 .collect();
685 assert_eq!(visible, ["tailscale_status"]);
686
687 let err = registry
690 .resolve("tailscale_down", JsonObject::new(), &gate)
691 .expect_err("hidden tools do not run");
692 assert_eq!(err.code, crate::error::ErrorCode::NotPermitted);
693 assert!(
694 err.hint
695 .as_deref()
696 .is_some_and(|h| h.contains("--allow-destructive")),
697 "{err:?}"
698 );
699 }
700
701 #[test]
702 fn an_unknown_name_is_not_found() {
703 let err = registry()
704 .resolve("tailscale_nonesuch", JsonObject::new(), &open_gate())
705 .expect_err("no such tool");
706 assert_eq!(err.code, crate::error::ErrorCode::NotFound);
707 }
708
709 #[test]
710 fn visible_tools_are_listed_in_a_stable_order() {
711 let registry = registry();
712 let names: Vec<&str> = registry
713 .visible(&open_gate())
714 .iter()
715 .map(|e| e.meta.name)
716 .collect();
717 let mut sorted = names.clone();
718 sorted.sort_unstable();
719 assert_eq!(names, sorted);
720 }
721}