1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
//! MCP Roots Trait
//!
//! This module defines the high-level trait for implementing MCP roots functionality.
use async_trait::async_trait;
use std::path::PathBuf;
use turul_mcp_builders::prelude::*;
use turul_mcp_protocol::{
McpResult,
roots::{ListRootsRequest, ListRootsResult, RootsListChangedNotification},
};
/// File information for root directory listings
#[derive(Debug, Clone)]
pub struct FileInfo {
/// File path relative to root
pub path: String,
/// Whether this is a directory
pub is_directory: bool,
/// File size in bytes (for files)
pub size: Option<u64>,
/// Last modified timestamp (Unix timestamp)
pub modified: Option<u64>,
/// MIME type (for files)
pub mime_type: Option<String>,
}
/// Access level for files and directories
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessLevel {
None,
Read,
Write,
Full,
}
/// High-level trait for implementing MCP roots functionality
///
/// McpRoot extends RootDefinition with execution capabilities.
/// All metadata is provided by the RootDefinition trait, ensuring
/// consistency between concrete Root structs and dynamic implementations.
#[async_trait]
pub trait McpRoot: RootDefinition + Send + Sync {
/// List available roots (per MCP spec)
///
/// This method processes the roots/list request and returns
/// the complete list of available root directories.
async fn list_roots(&self, request: ListRootsRequest) -> McpResult<ListRootsResult>;
/// List files within a root directory
///
/// This method lists files and directories within the specified path,
/// respecting permissions and filtering rules.
async fn list_files(&self, path: &str) -> McpResult<Vec<FileInfo>>;
/// Check access level for a specific path
///
/// This method determines what access level the client has
/// for the specified file or directory path.
async fn check_access(&self, path: &str) -> McpResult<AccessLevel>;
/// Optional: Check if this root handler can manage the given path
///
/// This allows for conditional root handling based on path patterns,
/// URI schemes, or other factors.
fn can_handle(&self, path: &str) -> bool {
path.starts_with(&self.uri().replace("file://", ""))
}
/// Optional: Get root priority for request routing
///
/// Higher priority handlers are tried first when multiple handlers
/// can manage the same path.
fn priority(&self) -> u32 {
0
}
/// Optional: Validate a file path
///
/// This method can perform additional validation beyond basic access checks.
async fn validate_path(&self, path: &str) -> McpResult<()> {
// Basic validation - ensure path is within root
let root_path = self.uri().replace("file://", "");
let canonical_path = PathBuf::from(path);
let canonical_root = PathBuf::from(&root_path);
if !canonical_path.starts_with(&canonical_root) {
return Err(turul_mcp_protocol::McpError::validation(
"Path is outside root directory",
));
}
Ok(())
}
/// Optional: Watch for changes in root directories
///
/// This method can be used to monitor root directories for changes
/// and send RootsListChangedNotification when needed.
async fn start_watching(&self) -> McpResult<()> {
// Default: no-op for non-watching roots
Ok(())
}
/// Optional: Stop watching for changes
async fn stop_watching(&self) -> McpResult<()> {
// Default: no-op
Ok(())
}
/// Optional: Send a roots list changed notification
///
/// This method should be called when the list of roots changes
/// to notify clients about the update.
async fn notify_roots_changed(&self) -> McpResult<RootsListChangedNotification> {
Ok(RootsListChangedNotification::new())
}
/// Optional: Get file metadata
///
/// This method retrieves detailed metadata for a specific file or directory.
async fn get_file_info(&self, path: &str) -> McpResult<Option<FileInfo>> {
use std::fs;
use std::time::UNIX_EPOCH;
let full_path = self.uri().replace("file://", "") + "/" + path;
match fs::metadata(&full_path) {
Ok(metadata) => {
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_secs());
let info = FileInfo {
path: path.to_string(),
is_directory: metadata.is_dir(),
size: if metadata.is_file() {
Some(metadata.len())
} else {
None
},
modified,
mime_type: if metadata.is_file() {
// Simple MIME type detection based on extension
match path.split('.').next_back() {
Some("txt") => Some("text/plain".to_string()),
Some("json") => Some("application/json".to_string()),
Some("html") => Some("text/html".to_string()),
Some("md") => Some("text/markdown".to_string()),
_ => Some("application/octet-stream".to_string()),
}
} else {
None
},
};
Ok(Some(info))
}
Err(_) => Ok(None),
}
}
}
/// Convert an McpRoot trait object to a ListRootsRequest
///
/// This is a convenience function for creating protocol requests.
pub fn root_to_list_request(_root: &dyn McpRoot) -> ListRootsRequest {
ListRootsRequest::new()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
// HasRootMetadata, HasRootPermissions, etc.
struct TestRoot {
uri: String,
name: Option<String>,
read_only: bool,
}
// Implement fine-grained traits (MCP spec compliant)
impl HasRootMetadata for TestRoot {
fn uri(&self) -> &str {
&self.uri
}
fn name(&self) -> Option<&str> {
self.name.as_deref()
}
}
impl HasRootPermissions for TestRoot {
fn can_read(&self, _path: &str) -> bool {
true
}
fn can_write(&self, _path: &str) -> bool {
!self.read_only
}
fn max_depth(&self) -> Option<usize> {
Some(10) // Limit depth for testing
}
}
impl HasRootFiltering for TestRoot {
fn excluded_patterns(&self) -> Option<&[String]> {
// Example: exclude hidden files and build directories
static PATTERNS: &[String] = &[];
if PATTERNS.is_empty() {
None
} else {
Some(PATTERNS)
}
}
}
impl HasRootAnnotations for TestRoot {
fn annotations(&self) -> Option<&HashMap<std::string::String, serde_json::Value>> {
None
}
}
// RootDefinition automatically implemented via blanket impl!
#[async_trait]
impl McpRoot for TestRoot {
async fn list_roots(&self, _request: ListRootsRequest) -> McpResult<ListRootsResult> {
let root = self.to_root();
Ok(ListRootsResult::new(vec![root]))
}
async fn list_files(&self, path: &str) -> McpResult<Vec<FileInfo>> {
// Simulate file listing
if path.is_empty() || path == "/" {
Ok(vec![
FileInfo {
path: "README.md".to_string(),
is_directory: false,
size: Some(1024),
modified: Some(1640995200), // 2022-01-01
mime_type: Some("text/markdown".to_string()),
},
FileInfo {
path: "src".to_string(),
is_directory: true,
size: None,
modified: Some(1640995200),
mime_type: None,
},
])
} else {
Ok(vec![])
}
}
async fn check_access(&self, _path: &str) -> McpResult<AccessLevel> {
if self.read_only {
Ok(AccessLevel::Read)
} else {
Ok(AccessLevel::Full)
}
}
}
#[test]
fn test_root_trait() {
let root = TestRoot {
uri: "file:///home/user/project".to_string(),
name: Some("Test Project".to_string()),
read_only: false,
};
assert_eq!(root.uri(), "file:///home/user/project");
assert_eq!(root.name(), Some("Test Project"));
assert!(root.can_read("any/path"));
assert!(root.can_write("any/path"));
assert_eq!(root.max_depth(), Some(10));
}
#[tokio::test]
async fn test_root_validation() {
let root = TestRoot {
uri: "file:///home/user".to_string(),
name: None,
read_only: true,
};
let valid_result = root.validate_path("/home/user/project/file.txt").await;
assert!(valid_result.is_ok());
}
#[tokio::test]
async fn test_file_listing() {
let root = TestRoot {
uri: "file:///test".to_string(),
name: Some("Test Root".to_string()),
read_only: false,
};
let files = root.list_files("").await.unwrap();
assert_eq!(files.len(), 2);
assert_eq!(files[0].path, "README.md");
assert!(!files[0].is_directory);
assert_eq!(files[1].path, "src");
assert!(files[1].is_directory);
}
#[tokio::test]
async fn test_access_levels() {
let read_only_root = TestRoot {
uri: "file:///readonly".to_string(),
name: None,
read_only: true,
};
let full_access_root = TestRoot {
uri: "file:///writable".to_string(),
name: None,
read_only: false,
};
assert_eq!(
read_only_root.check_access("test").await.unwrap(),
AccessLevel::Read
);
assert_eq!(
full_access_root.check_access("test").await.unwrap(),
AccessLevel::Full
);
}
#[tokio::test]
async fn test_roots_changed_notification() {
let root = TestRoot {
uri: "file:///test".to_string(),
name: None,
read_only: false,
};
let notification = root.notify_roots_changed().await.unwrap();
assert_eq!(notification.method, "notifications/roots/list_changed");
}
}