turul_mcp_protocol_2025_06_18/
roots.rs

1//! MCP Roots Protocol Types
2//!
3//! This module defines types for root directory listing in MCP.
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::HashMap;
8
9/// Root directory entry (per MCP spec)
10#[derive(Debug, Clone, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct Root {
13    /// URI of the root (must start with "file://" currently)
14    pub uri: String,
15    /// Optional human-readable name
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub name: Option<String>,
18    /// Optional metadata
19    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
20    pub meta: Option<HashMap<String, Value>>,
21}
22
23impl Root {
24    pub fn new(uri: impl Into<String>) -> Self {
25        Self {
26            uri: uri.into(),
27            name: None,
28            meta: None,
29        }
30    }
31
32    pub fn with_name(mut self, name: impl Into<String>) -> Self {
33        self.name = Some(name.into());
34        self
35    }
36
37    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
38        self.meta = Some(meta);
39        self
40    }
41
42    /// Validate that the URI follows MCP requirements
43    pub fn validate(&self) -> Result<(), String> {
44        if !self.uri.starts_with("file://") {
45            return Err("Root URI must start with 'file://'".to_string());
46        }
47        Ok(())
48    }
49}
50
51/// Parameters for roots/list request (per MCP spec - no params required but can have _meta)
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct ListRootsParams {
55    /// Meta information (optional _meta field inside params)
56    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
57    pub meta: Option<HashMap<String, Value>>,
58}
59
60/// Complete roots/list request (matches TypeScript ListRootsRequest interface)
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct ListRootsRequest {
64    /// Method name (always "roots/list")
65    pub method: String,
66    /// Optional parameters (can be None since no actual params needed, but _meta can be present)
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub params: Option<ListRootsParams>,
69}
70
71
72/// Response for roots/list (per MCP spec)
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct ListRootsResult {
76    /// Available roots
77    pub roots: Vec<Root>,
78    /// Meta information (follows MCP Result interface)
79    #[serde(
80        default,
81        skip_serializing_if = "Option::is_none",
82        alias = "_meta",
83        rename = "_meta"
84    )]
85    pub meta: Option<HashMap<String, Value>>,
86}
87
88/// Parameters for roots list changed notification (per MCP spec - optional _meta only)
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct RootsListChangedParams {
92    /// Meta information (optional _meta field inside params)
93    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
94    pub meta: Option<HashMap<String, Value>>,
95}
96
97/// Notification for when roots list changes (matches TypeScript RootsListChangedNotification interface)
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct RootsListChangedNotification {
101    /// Method name (always "notifications/roots/list_changed")
102    pub method: String,
103    /// Optional parameters (can be None, but _meta can be present)
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub params: Option<RootsListChangedParams>,
106}
107
108impl ListRootsParams {
109    pub fn new() -> Self {
110        Self {
111            meta: None,
112        }
113    }
114
115    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
116        self.meta = Some(meta);
117        self
118    }
119}
120
121impl Default for ListRootsParams {
122    fn default() -> Self {
123        Self::new()
124    }
125}
126
127impl ListRootsRequest {
128    pub fn new() -> Self {
129        Self {
130            method: "roots/list".to_string(),
131            params: None,
132        }
133    }
134
135    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
136        self.params = Some(ListRootsParams::new().with_meta(meta));
137        self
138    }
139}
140
141
142impl ListRootsResult {
143    pub fn new(roots: Vec<Root>) -> Self {
144        Self { 
145            roots,
146            meta: None,
147        }
148    }
149
150    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
151        self.meta = Some(meta);
152        self
153    }
154}
155
156impl RootsListChangedParams {
157    pub fn new() -> Self {
158        Self {
159            meta: None,
160        }
161    }
162
163    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
164        self.meta = Some(meta);
165        self
166    }
167}
168
169impl Default for RootsListChangedParams {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175impl RootsListChangedNotification {
176    pub fn new() -> Self {
177        Self {
178            method: "notifications/roots/list_changed".to_string(),
179            params: None,
180        }
181    }
182
183    pub fn with_meta(mut self, meta: HashMap<String, Value>) -> Self {
184        self.params = Some(RootsListChangedParams::new().with_meta(meta));
185        self
186    }
187}
188
189// Trait implementations for protocol compliance
190use crate::traits::*;
191
192impl Params for ListRootsParams {}
193impl Params for RootsListChangedParams {}
194
195impl HasMetaParam for ListRootsParams {
196    fn meta(&self) -> Option<&HashMap<String, Value>> {
197        self.meta.as_ref()
198    }
199}
200
201impl HasMetaParam for RootsListChangedParams {
202    fn meta(&self) -> Option<&HashMap<String, Value>> {
203        self.meta.as_ref()
204    }
205}
206
207impl HasMethod for ListRootsRequest {
208    fn method(&self) -> &str {
209        &self.method
210    }
211}
212
213impl HasParams for ListRootsRequest {
214    fn params(&self) -> Option<&dyn Params> {
215        self.params.as_ref().map(|p| p as &dyn Params)
216    }
217}
218
219impl HasMethod for RootsListChangedNotification {
220    fn method(&self) -> &str {
221        &self.method
222    }
223}
224
225impl HasParams for RootsListChangedNotification {
226    fn params(&self) -> Option<&dyn Params> {
227        self.params.as_ref().map(|p| p as &dyn Params)
228    }
229}
230
231impl HasData for ListRootsResult {
232    fn data(&self) -> HashMap<String, Value> {
233        let mut data = HashMap::new();
234        data.insert("roots".to_string(), serde_json::to_value(&self.roots).unwrap_or(Value::Null));
235        data
236    }
237}
238
239impl HasMeta for ListRootsResult {
240    fn meta(&self) -> Option<HashMap<String, Value>> {
241        self.meta.clone()
242    }
243}
244
245impl RpcResult for ListRootsResult {}
246
247// ===========================================
248// === Fine-Grained Roots Traits ===
249// ===========================================
250
251/// Trait for root metadata (URI, name, path info)
252pub trait HasRootMetadata {
253    /// The root URI (must start with "file://")
254    fn uri(&self) -> &str;
255    
256    /// Optional human-readable name
257    fn name(&self) -> Option<&str> {
258        None
259    }
260    
261    /// Optional description or additional metadata
262    fn description(&self) -> Option<&str> {
263        None
264    }
265}
266
267/// Trait for root permissions and security
268pub trait HasRootPermissions {
269    /// Check if read access is allowed for this path
270    fn can_read(&self, _path: &str) -> bool {
271        true
272    }
273    
274    /// Check if write access is allowed for this path
275    fn can_write(&self, _path: &str) -> bool {
276        false // Default: read-only
277    }
278    
279    /// Get maximum depth for directory traversal
280    fn max_depth(&self) -> Option<usize> {
281        None // No limit by default
282    }
283}
284
285/// Trait for root filtering and exclusions
286pub trait HasRootFiltering {
287    /// File extensions to include (None = all)
288    fn allowed_extensions(&self) -> Option<&[String]> {
289        None
290    }
291    
292    /// File patterns to exclude (glob patterns)
293    fn excluded_patterns(&self) -> Option<&[String]> {
294        None
295    }
296    
297    /// Check if a file should be included
298    fn should_include(&self, path: &str) -> bool {
299        // Default: include everything unless filtered
300        if let Some(patterns) = self.excluded_patterns() {
301            for pattern in patterns {
302                if path.contains(pattern) {
303                    return false;
304                }
305            }
306        }
307        
308        if let Some(extensions) = self.allowed_extensions() {
309            if let Some(ext) = path.split('.').last() {
310                return extensions.contains(&ext.to_string());
311            }
312            return false;
313        }
314        
315        true
316    }
317}
318
319/// Trait for root annotations and custom metadata
320pub trait HasRootAnnotations {
321    /// Get custom metadata
322    fn annotations(&self) -> Option<&HashMap<String, Value>> {
323        None
324    }
325    
326    /// Get root-specific tags or labels
327    fn tags(&self) -> Option<&[String]> {
328        None
329    }
330}
331
332/// Composed root definition trait (automatically implemented via blanket impl)
333pub trait RootDefinition: 
334    HasRootMetadata + 
335    HasRootPermissions + 
336    HasRootFiltering + 
337    HasRootAnnotations 
338{
339    /// Convert this root definition to a protocol Root
340    fn to_root(&self) -> Root {
341        let mut root = Root::new(self.uri());
342        if let Some(name) = self.name() {
343            root = root.with_name(name);
344        }
345        if let Some(annotations) = self.annotations() {
346            root = root.with_meta(annotations.clone());
347        }
348        root
349    }
350    
351    /// Validate this root definition
352    fn validate(&self) -> Result<(), String> {
353        if !self.uri().starts_with("file://") {
354            return Err("Root URI must start with 'file://'".to_string());
355        }
356        Ok(())
357    }
358}
359
360// Blanket implementation: any type implementing the fine-grained traits automatically gets RootDefinition
361impl<T> RootDefinition for T 
362where 
363    T: HasRootMetadata + HasRootPermissions + HasRootFiltering + HasRootAnnotations 
364{}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use serde_json::json;
370
371    #[test]
372    fn test_root_creation() {
373        let mut root = Root::new("file:///home/user/project")
374            .with_name("My Project");
375        
376        let meta = HashMap::from([
377            ("version".to_string(), json!("1.0")),
378            ("type".to_string(), json!("workspace")),
379        ]);
380        root = root.with_meta(meta.clone());
381        
382        assert_eq!(root.uri, "file:///home/user/project");
383        assert_eq!(root.name, Some("My Project".to_string()));
384        assert_eq!(root.meta, Some(meta));
385    }
386
387    #[test]
388    fn test_root_validation() {
389        let valid_root = Root::new("file:///valid/path");
390        assert!(valid_root.validate().is_ok());
391        
392        let invalid_root = Root::new("http://invalid/path");
393        assert!(invalid_root.validate().is_err());
394    }
395
396    #[test]
397    fn test_list_roots_request() {
398        let request = ListRootsRequest::new();
399        assert_eq!(request.method, "roots/list");
400    }
401
402    #[test]
403    fn test_list_roots_result() {
404        let roots = vec![
405            Root::new("file:///path1").with_name("Root 1"),
406            Root::new("file:///path2").with_name("Root 2"),
407        ];
408        
409        let result = ListRootsResult::new(roots.clone());
410        assert_eq!(result.roots.len(), 2);
411        assert_eq!(result.roots[0].name, Some("Root 1".to_string()));
412    }
413
414    #[test]
415    fn test_roots_list_changed_notification() {
416        let notification = RootsListChangedNotification::new();
417        assert_eq!(notification.method, "notifications/roots/list_changed");
418    }
419
420    #[test]
421    fn test_serialization() {
422        let root = Root::new("file:///test/path").with_name("Test Root");
423        let json = serde_json::to_string(&root).unwrap();
424        assert!(json.contains("file:///test/path"));
425        assert!(json.contains("Test Root"));
426        
427        let parsed: Root = serde_json::from_str(&json).unwrap();
428        assert_eq!(parsed.uri, "file:///test/path");
429        assert_eq!(parsed.name, Some("Test Root".to_string()));
430    }
431
432    #[test]
433    fn test_list_roots_request_matches_typescript_spec() {
434        // Test ListRootsRequest matches: { method: string, params?: { _meta?: {...} } }
435        let mut meta = HashMap::new();
436        meta.insert("requestId".to_string(), json!("req-123"));
437        
438        let request = ListRootsRequest::new()
439            .with_meta(meta);
440        
441        let json_value = serde_json::to_value(&request).unwrap();
442        
443        assert_eq!(json_value["method"], "roots/list");
444        assert!(json_value["params"].is_object());
445        assert_eq!(json_value["params"]["_meta"]["requestId"], "req-123");
446    }
447
448    #[test]
449    fn test_list_roots_result_matches_typescript_spec() {
450        // Test ListRootsResult matches: { roots: Root[], _meta?: {...} }
451        let mut meta = HashMap::new();
452        meta.insert("totalCount".to_string(), json!(2));
453        
454        let roots = vec![
455            Root::new("file:///path1").with_name("Root 1"),
456            Root::new("file:///path2").with_name("Root 2"),
457        ];
458        
459        let result = ListRootsResult::new(roots)
460            .with_meta(meta);
461        
462        let json_value = serde_json::to_value(&result).unwrap();
463        
464        assert!(json_value["roots"].is_array());
465        assert_eq!(json_value["roots"].as_array().unwrap().len(), 2);
466        assert_eq!(json_value["roots"][0]["uri"], "file:///path1");
467        assert_eq!(json_value["roots"][0]["name"], "Root 1");
468        assert_eq!(json_value["_meta"]["totalCount"], 2);
469    }
470
471    #[test]
472    fn test_roots_list_changed_notification_matches_typescript_spec() {
473        // Test RootsListChangedNotification matches: { method: string, params?: { _meta?: {...} } }
474        let mut meta = HashMap::new();
475        meta.insert("timestamp".to_string(), json!("2025-01-01T00:00:00Z"));
476        
477        let notification = RootsListChangedNotification::new()
478            .with_meta(meta);
479        
480        let json_value = serde_json::to_value(&notification).unwrap();
481        
482        assert_eq!(json_value["method"], "notifications/roots/list_changed");
483        assert!(json_value["params"].is_object());
484        assert_eq!(json_value["params"]["_meta"]["timestamp"], "2025-01-01T00:00:00Z");
485    }
486
487    #[test]
488    fn test_optional_params_serialization() {
489        // Test that requests without _meta don't serialize params when None
490        let request = ListRootsRequest::new();
491        let json_value = serde_json::to_value(&request).unwrap();
492        
493        assert_eq!(json_value["method"], "roots/list");
494        // params should be absent since it's None
495        assert!(json_value["params"].is_null() || !json_value.as_object().unwrap().contains_key("params"));
496        
497        // Similar test for notification
498        let notification = RootsListChangedNotification::new();
499        let json_value = serde_json::to_value(&notification).unwrap();
500        
501        assert_eq!(json_value["method"], "notifications/roots/list_changed");
502        assert!(json_value["params"].is_null() || !json_value.as_object().unwrap().contains_key("params"));
503    }
504}