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 get(&self, name: &str) -> Option<&ToolEntry> {
156 self.by_name.get(name).map(|index| &self.entries[*index])
157 }
158
159 pub fn len(&self) -> usize {
160 self.entries.len()
161 }
162
163 pub fn is_empty(&self) -> bool {
164 self.entries.is_empty()
165 }
166
167 pub fn visible(&self, gate: &Gate) -> Vec<&ToolEntry> {
172 let mut visible: Vec<&ToolEntry> = self
173 .entries
174 .iter()
175 .filter(|entry| gate.permits(&entry.meta))
176 .collect();
177 visible.sort_by_key(|entry| entry.meta.name);
178 visible
179 }
180
181 pub fn resolve(
186 &self,
187 name: &str,
188 mut args: JsonObject,
189 gate: &Gate,
190 ) -> ToolResult<(&ToolEntry, JsonObject)> {
191 let entry = self
192 .get(name)
193 .ok_or_else(|| ToolError::not_found(&format!("the tool `{name}`")))?;
194
195 if !gate.permits(&entry.meta) {
196 return Err(ToolError::not_permitted(name, &gate.needs(&entry.meta)));
197 }
198
199 if entry.meta.requires_confirmation {
200 let confirmed = match args.remove(CONFIRM_PARAM) {
201 Some(Value::Bool(confirmed)) => confirmed,
202 None | Some(Value::Null) => false,
203 Some(other) => {
204 return Err(ToolError::invalid_args(format!(
205 "`{CONFIRM_PARAM}` must be true or false, not {other}"
206 )));
207 }
208 };
209 if !confirmed {
210 return Err(ToolError::confirmation_required(
211 name,
212 confirmation_consequence(&entry.meta),
213 ));
214 }
215 }
216
217 Ok((entry, args))
218 }
219}
220
221fn confirmation_consequence(meta: &crate::meta::ToolMeta) -> &'static str {
223 if meta.self_severing {
224 "can disconnect the node this server runs on, so it needs `confirm: true`"
225 } else {
226 "cannot be undone, so it needs `confirm: true`"
227 }
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
232pub enum RegistryError {
233 #[error("two tools are both named `{0}`")]
234 DuplicateName(&'static str),
235
236 #[error("the tool name `{name}` {reason}")]
237 BadName {
238 name: &'static str,
239 reason: &'static str,
240 },
241
242 #[error("`{0}` is self-severing but does not require confirmation")]
243 SelfSeveringWithoutConfirmation(&'static str),
244
245 #[error("`{0}` severs the local node but its parameters carry no `confirm`")]
246 SeversLocalWithoutConfirmParameter(&'static str),
247
248 #[error("the parameters of `{name}` do not make a valid input schema: {reason}")]
249 BadSchema { name: &'static str, reason: String },
250}
251
252fn validate_name(name: &'static str) -> Result<(), RegistryError> {
254 let bad = |reason| Err(RegistryError::BadName { name, reason });
255 if name.is_empty() {
256 return bad("is empty");
257 }
258 if name.len() > MAX_NAME_LEN {
259 return bad("is longer than the 128 characters the protocol allows");
260 }
261 if !name
262 .bytes()
263 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.')
264 {
265 return bad("contains a character outside [A-Za-z0-9_.-]");
266 }
267 if !name.starts_with("tailscale_") && !name.starts_with("tailnet_") {
268 return bad("does not begin with `tailscale_` or `tailnet_`");
269 }
270 Ok(())
271}
272
273#[macro_export]
292macro_rules! tools {
293 (
294 $(
295 $(#[doc = $summary:literal])+
296 $name:ident => $params:ty, $handler:path,
297 toolset: $toolset:ident,
298 tier: $tier:ident
299 $(, idempotent: $idempotent:literal)?
300 $(, confirm: $confirm:literal)?
301 $(, severing: $severing:literal)?
302 $(, severs_local: $severs_local:literal)?
303 $(, varying: $varying:literal)?
304 $(, since: $since:literal)?
305 $(, platforms: [$($platform:literal),+ $(,)?])?
306 $(,)?
307 ;
308 )*
309 ) => {
310 pub mod metas {
315 #![allow(non_upper_case_globals)]
316
317 $(
318 #[doc = ::std::concat!($($summary),*)]
319 pub const $name: $crate::meta::ToolMeta = {
320 #[allow(unused_mut, unused_assignments)]
321 let mut severing = false;
322 $( severing = $severing; )?
323 #[allow(unused_mut, unused_assignments)]
324 let mut confirm = severing;
325 $( confirm = $confirm || severing; )?
326 #[allow(unused_mut, unused_assignments)]
327 let mut severs_local_node = false;
328 $( severs_local_node = $severs_local; )?
329 #[allow(unused_mut, unused_assignments)]
330 let mut idempotent = false;
331 $( idempotent = $idempotent; )?
332 #[allow(unused_mut, unused_assignments)]
333 let mut varying_tier = false;
334 $( varying_tier = $varying; )?
335 #[allow(unused_mut, unused_assignments)]
336 let mut since: ::std::option::Option<&'static str> =
337 ::std::option::Option::None;
338 $( since = ::std::option::Option::Some($since); )?
339 #[allow(unused_mut, unused_assignments)]
340 let mut platforms: ::std::option::Option<&'static [&'static str]> =
341 ::std::option::Option::None;
342 $( platforms = ::std::option::Option::Some(&[$($platform),+]); )?
343
344 $crate::meta::ToolMeta {
345 name: ::std::stringify!($name),
346 toolset: $crate::meta::Toolset::$toolset,
347 tier: $crate::meta::Tier::$tier,
348 summary: ::std::concat!($($summary),*).trim_ascii(),
349 self_severing: severing,
350 severs_local_node,
351 requires_confirmation: confirm,
352 idempotent,
353 varying_tier,
354 min_version: since,
355 platforms,
356 }
357 };
358 )*
359 }
360
361 pub fn entries() -> ::std::vec::Vec<$crate::registry::ToolEntry> {
363 ::std::vec![
364 $(
365 {
366 fn invoke(
370 ctx: ::std::sync::Arc<$crate::context::ToolContext>,
371 args: ::rmcp::model::JsonObject,
372 ) -> ::tailscale_cli::BoxFuture<
373 'static,
374 $crate::error::ToolResult<::serde_json::Value>,
375 > {
376 ::std::boxed::Box::pin(async move {
377 let params: $params = $crate::registry::parse_params(
378 ::std::stringify!($name),
379 args,
380 )?;
381 $handler(&ctx, params).await
382 })
383 }
384
385 $crate::registry::ToolEntry {
386 meta: self::metas::$name,
387 schema: || {
388 ::rmcp::handler::server::tool::schema_for_input::<$params>()
389 },
390 invoke,
391 }
392 },
393 )*
394 ]
395 }
396 };
397}
398
399pub fn parse_params<T: serde::de::DeserializeOwned + schemars::JsonSchema + 'static>(
410 tool: &str,
411 args: JsonObject,
412) -> ToolResult<T> {
413 let args = Value::Object(args);
414 if let Ok(schema) = rmcp::handler::server::tool::schema_for_input::<T>() {
415 let root = Value::Object(schema.as_ref().clone());
416 if let Some((path, accepted)) = unknown_argument_in(&root, &args) {
417 let takes = if accepted.is_empty() {
418 "no arguments".to_owned()
419 } else {
420 accepted.join(", ")
421 };
422 return Err(ToolError::invalid_args(format!(
423 "`{tool}` takes no argument `{path}`; it takes {takes}"
424 )));
425 }
426 }
427 serde_json::from_value(args).map_err(|e| {
428 ToolError::invalid_args(format!("`{tool}` was called with unusable arguments: {e}"))
429 })
430}
431
432pub(crate) fn unknown_argument_in(schema: &Value, args: &Value) -> Option<(String, Vec<String>)> {
435 unknown_argument(schema, schema, args, "")
436}
437
438fn unknown_argument(
441 root: &Value,
442 schema: &Value,
443 value: &Value,
444 path: &str,
445) -> Option<(String, Vec<String>)> {
446 let branches = branches(root, schema);
447 match value {
448 Value::Object(fields) => {
449 let open = branches.iter().any(|b| {
450 b.get("additionalProperties")
451 .is_some_and(|extra| extra != &Value::Bool(false))
452 });
453 let properties: Vec<&JsonObject> = branches
454 .iter()
455 .filter_map(|b| b.get("properties")?.as_object())
456 .collect();
457 let declared = !properties.is_empty() || branches.iter().any(|b| is_object_type(b));
460 if open || !declared {
461 return None;
462 }
463 fields.iter().find_map(|(key, field)| {
464 let here = if path.is_empty() {
465 key.clone()
466 } else {
467 format!("{path}.{key}")
468 };
469 match properties.iter().find_map(|p| p.get(key)) {
470 Some(property) => unknown_argument(root, property, field, &here),
471 None => {
472 let mut accepted: Vec<String> = properties
473 .iter()
474 .flat_map(|p| p.keys().map(|k| format!("`{k}`")))
475 .collect();
476 accepted.sort();
477 accepted.dedup();
478 Some((here, accepted))
479 }
480 }
481 })
482 }
483 Value::Array(items) => {
484 let item = branches.iter().find_map(|b| b.get("items"))?;
485 items.iter().enumerate().find_map(|(i, element)| {
486 unknown_argument(root, item, element, &format!("{path}[{i}]"))
487 })
488 }
489 _ => None,
490 }
491}
492
493fn is_object_type(schema: &JsonObject) -> bool {
495 match schema.get("type") {
496 Some(Value::String(kind)) => kind == "object",
497 Some(Value::Array(kinds)) => kinds.iter().any(|kind| kind == "object"),
498 _ => false,
499 }
500}
501
502fn branches<'a>(root: &'a Value, schema: &'a Value) -> Vec<&'a JsonObject> {
504 let Some(object) = schema.as_object() else {
505 return Vec::new();
506 };
507 if let Some(target) = object.get("$ref").and_then(Value::as_str) {
508 let resolved = target
509 .strip_prefix("#/")
510 .and_then(|pointer| root.pointer(&format!("/{pointer}")));
511 return resolved.map_or_else(Vec::new, |r| branches(root, r));
512 }
513 let mut all = vec![object];
514 for key in ["allOf", "anyOf", "oneOf"] {
515 if let Some(alternatives) = object.get(key).and_then(Value::as_array) {
516 all.extend(alternatives.iter().flat_map(|a| branches(root, a)));
517 }
518 }
519 all
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525 use crate::meta::{Tier, Toolset};
526 use schemars::JsonSchema;
527 use serde::Deserialize;
528 use std::collections::BTreeSet;
529
530 #[derive(Debug, Deserialize, JsonSchema)]
531 struct Empty {}
532
533 #[allow(dead_code)]
538 #[derive(Debug, Deserialize, JsonSchema)]
539 struct MixedParams {
540 include_peers: bool,
542 #[serde(rename = "keyExpiryDisabled")]
544 key_expiry_disabled: bool,
545 }
546
547 async fn ok_handler<P>(_ctx: &ToolContext, _params: P) -> ToolResult<Value> {
548 Ok(Value::Null)
549 }
550
551 mod declared {
552 use super::*;
553
554 crate::tools! {
555 tailscale_status => Empty, super::ok_handler,
557 toolset: LocalStatus, tier: Read, idempotent: true;
558
559 tailscale_down => Empty, super::ok_handler,
561 toolset: LocalPrefs, tier: Destructive, severing: true;
562
563 tailnet_device_delete => MixedParams, super::ok_handler,
565 toolset: TailnetDevices, tier: Destructive, confirm: true, since: "1.60";
566 }
567 }
568
569 fn registry() -> Registry {
570 Registry::new(declared::entries()).expect("a well-formed table")
571 }
572
573 fn open_gate() -> Gate {
574 Gate::unchecked(
575 Toolset::ALL.iter().copied().collect(),
576 Tier::Destructive,
577 BTreeSet::new(),
578 )
579 }
580
581 #[test]
582 fn a_declaration_produces_its_metadata_row() {
583 let registry = registry();
584 assert_eq!(registry.len(), 3);
585
586 let status = registry.get("tailscale_status").expect("declared");
587 assert_eq!(status.meta.toolset, Toolset::LocalStatus);
588 assert_eq!(status.meta.tier, Tier::Read);
589 assert_eq!(status.meta.summary, "Report the state of the local node.");
590 assert!(status.meta.idempotent);
591 assert!(!status.meta.requires_confirmation);
592 assert_eq!(status.meta.min_version, None);
593 }
594
595 #[test]
596 fn a_tool_that_severs_the_local_node_must_offer_somewhere_to_say_so() {
597 mod without {
598 use crate::registry::tests::Empty;
599
600 crate::tools! {
601 tailnet_device_delete => Empty, super::ok_handler,
603 toolset: TailnetDevices, tier: Destructive, severs_local: true;
604 }
605 }
606 assert_eq!(
609 Registry::new(without::entries()).expect_err("no `confirm` to give"),
610 RegistryError::SeversLocalWithoutConfirmParameter("tailnet_device_delete")
611 );
612 }
613
614 #[test]
615 fn optional_settings_land_where_they_are_given() {
616 let delete = registry()
617 .get("tailnet_device_delete")
618 .expect("declared")
619 .meta;
620 assert!(delete.requires_confirmation);
621 assert!(!delete.self_severing);
622 assert_eq!(delete.min_version, Some("1.60"));
623 }
624
625 #[test]
626 fn a_duplicate_name_is_refused_at_construction() {
627 let mut entries = declared::entries();
628 entries.push(entries[0].clone());
629 assert_eq!(
630 Registry::new(entries).err(),
631 Some(RegistryError::DuplicateName("tailscale_status"))
632 );
633 }
634
635 #[test]
636 fn a_name_the_protocol_or_our_prefixes_would_not_allow_is_refused() {
637 let too_long: &'static str =
638 Box::leak(format!("tailscale_{}", "a".repeat(MAX_NAME_LEN)).into_boxed_str());
639 for (name, reason) in [
640 ("", "empty"),
641 (too_long, "128 characters"),
642 ("tailscale_ping peer", "outside [A-Za-z0-9_.-]"),
643 ("ping", "does not begin"),
644 ("tailscaleping", "does not begin"),
645 ] {
646 match validate_name(name) {
647 Err(RegistryError::BadName {
648 name: refused,
649 reason: said,
650 }) => {
651 assert_eq!(refused, name);
652 assert!(said.contains(reason), "`{name}` {said}");
653 }
654 other => panic!("`{name}` should be refused: {other:?}"),
655 }
656 }
657 assert_eq!(validate_name("tailnet_split-dns.v2"), Ok(()));
658 }
659
660 #[test]
661 fn a_confirming_tool_refuses_until_the_caller_says_so() {
662 let registry = registry();
663 let gate = open_gate();
664
665 let err = registry
666 .resolve("tailscale_down", JsonObject::new(), &gate)
667 .expect_err("must not run unconfirmed");
668 assert_eq!(err.code, crate::error::ErrorCode::ConfirmationRequired);
669 assert!(err.message.contains("disconnect"), "{}", err.message);
670
671 let mut args = JsonObject::new();
672 args.insert(CONFIRM_PARAM.to_owned(), Value::Bool(true));
673 let (entry, rest) = registry
674 .resolve("tailscale_down", args, &gate)
675 .expect("a confirmed call runs");
676 assert_eq!(entry.meta.name, "tailscale_down");
677 assert!(
678 !rest.contains_key(CONFIRM_PARAM),
679 "the handler should not see the flag"
680 );
681 }
682
683 #[test]
684 fn a_confirmation_flag_of_the_wrong_shape_is_a_bad_argument() {
685 let mut args = JsonObject::new();
686 args.insert(CONFIRM_PARAM.to_owned(), Value::String("yes".to_owned()));
687 let err = registry()
688 .resolve("tailscale_down", args, &open_gate())
689 .expect_err("`yes` is not a boolean");
690 assert_eq!(err.code, crate::error::ErrorCode::InvalidArgs);
691 }
692}