Skip to main content

smg_mcp/
annotations.rs

1//! Tool annotations for approval decisions.
2//!
3//! We maintain [`ToolAnnotations`] separate from [`rmcp::model::ToolAnnotations`] because:
4//! - rmcp uses `Option<bool>` requiring unwrapping everywhere
5//! - We use `bool` with conservative defaults (destructive=true, read_only=false)
6
7use rmcp::model::ToolAnnotations as RmcpToolAnnotations;
8use serde::{Deserialize, Serialize};
9
10/// Tool behavior hints for approval decisions.
11#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
12pub struct ToolAnnotations {
13    pub read_only: bool,
14    pub destructive: bool,
15    pub idempotent: bool,
16    pub open_world: bool,
17}
18
19impl ToolAnnotations {
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Convert from rmcp's optional annotations with conservative defaults.
25    pub fn from_rmcp(rmcp: &RmcpToolAnnotations) -> Self {
26        Self {
27            read_only: rmcp.read_only_hint.unwrap_or(false),
28            destructive: rmcp.destructive_hint.unwrap_or(true),
29            idempotent: rmcp.idempotent_hint.unwrap_or(false),
30            open_world: rmcp.open_world_hint.unwrap_or(true),
31        }
32    }
33
34    pub fn from_rmcp_option(rmcp: Option<&RmcpToolAnnotations>) -> Self {
35        rmcp.map(Self::from_rmcp).unwrap_or_default()
36    }
37
38    #[must_use]
39    pub fn with_read_only(mut self, v: bool) -> Self {
40        self.read_only = v;
41        self
42    }
43
44    #[must_use]
45    pub fn with_destructive(mut self, v: bool) -> Self {
46        self.destructive = v;
47        self
48    }
49
50    #[must_use]
51    pub fn with_open_world(mut self, v: bool) -> Self {
52        self.open_world = v;
53        self
54    }
55
56    #[must_use]
57    pub fn should_require_approval(&self) -> bool {
58        self.destructive && !self.read_only
59    }
60}
61
62/// Annotation types for pattern matching in policy rules.
63#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
64pub enum AnnotationType {
65    Destructive,
66    ReadOnly,
67    Idempotent,
68    OpenWorld,
69}
70
71impl AnnotationType {
72    pub fn matches(&self, annotations: &ToolAnnotations) -> bool {
73        match self {
74            AnnotationType::Destructive => annotations.destructive,
75            AnnotationType::ReadOnly => annotations.read_only,
76            AnnotationType::Idempotent => annotations.idempotent,
77            AnnotationType::OpenWorld => annotations.open_world,
78        }
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_from_rmcp() {
88        let rmcp = RmcpToolAnnotations {
89            read_only_hint: Some(true),
90            destructive_hint: Some(false),
91            idempotent_hint: Some(true),
92            open_world_hint: Some(false),
93            title: None,
94        };
95        let ann = ToolAnnotations::from_rmcp(&rmcp);
96        assert!(ann.read_only);
97        assert!(!ann.destructive);
98    }
99
100    #[test]
101    fn test_conservative_defaults() {
102        let rmcp = RmcpToolAnnotations {
103            read_only_hint: None,
104            destructive_hint: None,
105            idempotent_hint: None,
106            open_world_hint: None,
107            title: None,
108        };
109        let ann = ToolAnnotations::from_rmcp(&rmcp);
110        assert!(!ann.read_only); // assume writes
111        assert!(ann.destructive); // assume dangerous
112        assert!(!ann.idempotent); // assume not safe to retry
113        assert!(ann.open_world); // assume external access
114    }
115
116    #[test]
117    fn test_should_require_approval() {
118        assert!(ToolAnnotations::new()
119            .with_destructive(true)
120            .should_require_approval());
121        assert!(!ToolAnnotations::new()
122            .with_destructive(true)
123            .with_read_only(true)
124            .should_require_approval());
125    }
126}