mcp_protocol_sdk/core/
resource.rs

1//! Resource system for MCP servers
2//!
3//! This module provides the abstraction for implementing and managing resources in MCP servers.
4//! Resources represent data that can be read by clients, such as files, database records, or API endpoints.
5
6use async_trait::async_trait;
7use std::collections::HashMap;
8
9use crate::core::error::{McpError, McpResult};
10use crate::protocol::types::{ResourceContent, ResourceInfo};
11
12/// Template for parameterized resources
13#[derive(Debug, Clone, PartialEq)]
14pub struct ResourceTemplate {
15    /// URI template with parameter placeholders
16    pub uri_template: String,
17    /// Name of the resource template
18    pub name: String,
19    /// Description of the resource template
20    pub description: Option<String>,
21    /// MIME type of resources created from this template
22    pub mime_type: Option<String>,
23}
24
25/// Trait for implementing resource handlers
26#[async_trait]
27pub trait ResourceHandler: Send + Sync {
28    /// Read the content of a resource
29    ///
30    /// # Arguments
31    /// * `uri` - URI of the resource to read
32    /// * `params` - Additional parameters for the resource
33    ///
34    /// # Returns
35    /// Result containing the resource content or an error
36    async fn read(
37        &self,
38        uri: &str,
39        params: &HashMap<String, String>,
40    ) -> McpResult<Vec<ResourceContent>>;
41
42    /// List all available resources
43    ///
44    /// # Returns
45    /// Result containing a list of available resources or an error
46    async fn list(&self) -> McpResult<Vec<ResourceInfo>>;
47
48    /// Subscribe to changes in a resource (optional)
49    ///
50    /// # Arguments
51    /// * `uri` - URI of the resource to subscribe to
52    ///
53    /// # Returns
54    /// Result indicating success or an error
55    async fn subscribe(&self, uri: &str) -> McpResult<()> {
56        // Default implementation - subscription not supported
57        Err(McpError::protocol(format!(
58            "Subscription not supported for resource: {}",
59            uri
60        )))
61    }
62
63    /// Unsubscribe from changes in a resource (optional)
64    ///
65    /// # Arguments
66    /// * `uri` - URI of the resource to unsubscribe from
67    ///
68    /// # Returns
69    /// Result indicating success or an error
70    async fn unsubscribe(&self, uri: &str) -> McpResult<()> {
71        // Default implementation - subscription not supported
72        Err(McpError::protocol(format!(
73            "Subscription not supported for resource: {}",
74            uri
75        )))
76    }
77}
78
79/// A registered resource with its handler
80pub struct Resource {
81    /// Information about the resource
82    pub info: ResourceInfo,
83    /// Handler that implements the resource's functionality
84    pub handler: Box<dyn ResourceHandler>,
85    /// Optional template for parameterized resources
86    pub template: Option<ResourceTemplate>,
87    /// Whether the resource is currently enabled
88    pub enabled: bool,
89}
90
91impl Resource {
92    /// Create a new static resource
93    ///
94    /// # Arguments
95    /// * `info` - Information about the resource
96    /// * `handler` - Implementation of the resource's functionality
97    pub fn new<H>(info: ResourceInfo, handler: H) -> Self
98    where
99        H: ResourceHandler + 'static,
100    {
101        Self {
102            info,
103            handler: Box::new(handler),
104            template: None,
105            enabled: true,
106        }
107    }
108
109    /// Create a new templated resource
110    ///
111    /// # Arguments
112    /// * `template` - Template for the resource
113    /// * `handler` - Implementation of the resource's functionality
114    pub fn with_template<H>(template: ResourceTemplate, handler: H) -> Self
115    where
116        H: ResourceHandler + 'static,
117    {
118        let info = ResourceInfo {
119            uri: template.uri_template.clone(),
120            name: template.name.clone(),
121            description: template.description.clone(),
122            mime_type: template.mime_type.clone(),
123        };
124
125        Self {
126            info,
127            handler: Box::new(handler),
128            template: Some(template),
129            enabled: true,
130        }
131    }
132
133    /// Enable the resource
134    pub fn enable(&mut self) {
135        self.enabled = true;
136    }
137
138    /// Disable the resource
139    pub fn disable(&mut self) {
140        self.enabled = false;
141    }
142
143    /// Check if the resource is enabled
144    pub fn is_enabled(&self) -> bool {
145        self.enabled
146    }
147
148    /// Read the resource if it's enabled
149    ///
150    /// # Arguments
151    /// * `uri` - URI of the resource to read
152    /// * `params` - Additional parameters for the resource
153    ///
154    /// # Returns
155    /// Result containing the resource content or an error
156    pub async fn read(
157        &self,
158        uri: &str,
159        params: &HashMap<String, String>,
160    ) -> McpResult<Vec<ResourceContent>> {
161        if !self.enabled {
162            return Err(McpError::validation(format!(
163                "Resource '{}' is disabled",
164                self.info.name
165            )));
166        }
167
168        self.handler.read(uri, params).await
169    }
170
171    /// List resources from this handler
172    pub async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
173        if !self.enabled {
174            return Ok(vec![]);
175        }
176
177        self.handler.list().await
178    }
179
180    /// Subscribe to resource changes
181    pub async fn subscribe(&self, uri: &str) -> McpResult<()> {
182        if !self.enabled {
183            return Err(McpError::validation(format!(
184                "Resource '{}' is disabled",
185                self.info.name
186            )));
187        }
188
189        self.handler.subscribe(uri).await
190    }
191
192    /// Unsubscribe from resource changes
193    pub async fn unsubscribe(&self, uri: &str) -> McpResult<()> {
194        if !self.enabled {
195            return Err(McpError::validation(format!(
196                "Resource '{}' is disabled",
197                self.info.name
198            )));
199        }
200
201        self.handler.unsubscribe(uri).await
202    }
203
204    /// Check if this resource matches the given URI
205    pub fn matches_uri(&self, uri: &str) -> bool {
206        if let Some(template) = &self.template {
207            // Simple template matching - in a real implementation,
208            // you'd want more sophisticated URI template matching
209            uri.starts_with(&template.uri_template.replace("{id}", "").replace("{*}", ""))
210        } else {
211            self.info.uri == uri
212        }
213    }
214}
215
216impl std::fmt::Debug for Resource {
217    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218        f.debug_struct("Resource")
219            .field("info", &self.info)
220            .field("template", &self.template)
221            .field("enabled", &self.enabled)
222            .finish()
223    }
224}
225
226// Common resource implementations
227
228/// Simple text resource
229pub struct TextResource {
230    content: String,
231    mime_type: String,
232}
233
234impl TextResource {
235    /// Create a new text resource
236    pub fn new(content: String, mime_type: Option<String>) -> Self {
237        Self {
238            content,
239            mime_type: mime_type.unwrap_or_else(|| "text/plain".to_string()),
240        }
241    }
242}
243
244#[async_trait]
245impl ResourceHandler for TextResource {
246    async fn read(
247        &self,
248        uri: &str,
249        _params: &HashMap<String, String>,
250    ) -> McpResult<Vec<ResourceContent>> {
251        Ok(vec![ResourceContent {
252            uri: uri.to_string(),
253            mime_type: Some(self.mime_type.clone()),
254            text: Some(self.content.clone()),
255            blob: None,
256        }])
257    }
258
259    async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
260        // Static resources don't provide dynamic listing
261        Ok(vec![])
262    }
263}
264
265/// File system resource handler
266pub struct FileSystemResource {
267    base_path: std::path::PathBuf,
268    allowed_extensions: Option<Vec<String>>,
269}
270
271impl FileSystemResource {
272    /// Create a new file system resource handler
273    pub fn new<P: AsRef<std::path::Path>>(base_path: P) -> Self {
274        Self {
275            base_path: base_path.as_ref().to_path_buf(),
276            allowed_extensions: None,
277        }
278    }
279
280    /// Set allowed file extensions
281    pub fn with_extensions(mut self, extensions: Vec<String>) -> Self {
282        self.allowed_extensions = Some(extensions);
283        self
284    }
285
286    fn is_allowed_file(&self, path: &std::path::Path) -> bool {
287        if let Some(ref allowed) = self.allowed_extensions {
288            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
289                return allowed.contains(&ext.to_lowercase());
290            }
291            return false;
292        }
293        true
294    }
295
296    fn get_mime_type(&self, path: &std::path::Path) -> String {
297        match path.extension().and_then(|e| e.to_str()) {
298            Some("txt") => "text/plain".to_string(),
299            Some("json") => "application/json".to_string(),
300            Some("html") => "text/html".to_string(),
301            Some("css") => "text/css".to_string(),
302            Some("js") => "application/javascript".to_string(),
303            Some("md") => "text/markdown".to_string(),
304            Some("xml") => "application/xml".to_string(),
305            Some("yaml") | Some("yml") => "application/yaml".to_string(),
306            _ => "application/octet-stream".to_string(),
307        }
308    }
309}
310
311#[async_trait]
312impl ResourceHandler for FileSystemResource {
313    async fn read(
314        &self,
315        uri: &str,
316        _params: &HashMap<String, String>,
317    ) -> McpResult<Vec<ResourceContent>> {
318        // Extract file path from URI (assuming file:// scheme or relative path)
319        let file_path = if uri.starts_with("file://") {
320            uri.strip_prefix("file://").unwrap_or(uri)
321        } else {
322            uri
323        };
324
325        let full_path = self.base_path.join(file_path);
326
327        // Security check - ensure path is within base directory
328        let canonical_base = self.base_path.canonicalize().map_err(|e| McpError::io(e))?;
329        let canonical_target = full_path
330            .canonicalize()
331            .map_err(|_| McpError::ResourceNotFound(uri.to_string()))?;
332
333        if !canonical_target.starts_with(&canonical_base) {
334            return Err(McpError::validation("Path outside of allowed directory"));
335        }
336
337        if !self.is_allowed_file(&canonical_target) {
338            return Err(McpError::validation("File type not allowed"));
339        }
340
341        let content = tokio::fs::read_to_string(&canonical_target)
342            .await
343            .map_err(|_| McpError::ResourceNotFound(uri.to_string()))?;
344
345        let mime_type = self.get_mime_type(&canonical_target);
346
347        Ok(vec![ResourceContent {
348            uri: uri.to_string(),
349            mime_type: Some(mime_type),
350            text: Some(content),
351            blob: None,
352        }])
353    }
354
355    async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
356        let mut resources = Vec::new();
357        let mut stack = vec![self.base_path.clone()];
358
359        while let Some(dir_path) = stack.pop() {
360            let mut dir = tokio::fs::read_dir(&dir_path)
361                .await
362                .map_err(|e| McpError::io(e))?;
363
364            while let Some(entry) = dir.next_entry().await.map_err(|e| McpError::io(e))? {
365                let path = entry.path();
366
367                if path.is_dir() {
368                    stack.push(path);
369                } else if self.is_allowed_file(&path) {
370                    let relative_path = path
371                        .strip_prefix(&self.base_path)
372                        .map_err(|_| McpError::internal("Path computation error"))?;
373
374                    let uri = format!("file://{}", relative_path.display());
375                    let name = path
376                        .file_name()
377                        .and_then(|n| n.to_str())
378                        .unwrap_or("unnamed")
379                        .to_string();
380
381                    resources.push(ResourceInfo {
382                        uri,
383                        name,
384                        description: None,
385                        mime_type: Some(self.get_mime_type(&path)),
386                    });
387                }
388            }
389        }
390
391        Ok(resources)
392    }
393}
394
395/// Builder for creating resources with fluent API
396pub struct ResourceBuilder {
397    uri: String,
398    name: String,
399    description: Option<String>,
400    mime_type: Option<String>,
401}
402
403impl ResourceBuilder {
404    /// Create a new resource builder
405    pub fn new<S: Into<String>>(uri: S, name: S) -> Self {
406        Self {
407            uri: uri.into(),
408            name: name.into(),
409            description: None,
410            mime_type: None,
411        }
412    }
413
414    /// Set the resource description
415    pub fn description<S: Into<String>>(mut self, description: S) -> Self {
416        self.description = Some(description.into());
417        self
418    }
419
420    /// Set the MIME type
421    pub fn mime_type<S: Into<String>>(mut self, mime_type: S) -> Self {
422        self.mime_type = Some(mime_type.into());
423        self
424    }
425
426    /// Build the resource with the given handler
427    pub fn build<H>(self, handler: H) -> Resource
428    where
429        H: ResourceHandler + 'static,
430    {
431        let info = ResourceInfo {
432            uri: self.uri,
433            name: self.name,
434            description: self.description,
435            mime_type: self.mime_type,
436        };
437
438        Resource::new(info, handler)
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[tokio::test]
447    async fn test_text_resource() {
448        let resource =
449            TextResource::new("Hello, World!".to_string(), Some("text/plain".to_string()));
450        let params = HashMap::new();
451
452        let content = resource.read("test://resource", &params).await.unwrap();
453        assert_eq!(content.len(), 1);
454        assert_eq!(content[0].text, Some("Hello, World!".to_string()));
455        assert_eq!(content[0].mime_type, Some("text/plain".to_string()));
456    }
457
458    #[test]
459    fn test_resource_creation() {
460        let info = ResourceInfo {
461            uri: "test://resource".to_string(),
462            name: "Test Resource".to_string(),
463            description: Some("A test resource".to_string()),
464            mime_type: Some("text/plain".to_string()),
465        };
466
467        let resource = Resource::new(info.clone(), TextResource::new("test".to_string(), None));
468        assert_eq!(resource.info, info);
469        assert!(resource.is_enabled());
470    }
471
472    #[test]
473    fn test_resource_template() {
474        let template = ResourceTemplate {
475            uri_template: "test://resource/{id}".to_string(),
476            name: "Test Template".to_string(),
477            description: Some("A test template".to_string()),
478            mime_type: Some("text/plain".to_string()),
479        };
480
481        let resource = Resource::with_template(
482            template.clone(),
483            TextResource::new("test".to_string(), None),
484        );
485        assert_eq!(resource.template, Some(template));
486    }
487
488    #[test]
489    fn test_resource_uri_matching() {
490        let template = ResourceTemplate {
491            uri_template: "test://resource/{id}".to_string(),
492            name: "Test Template".to_string(),
493            description: None,
494            mime_type: None,
495        };
496
497        let resource =
498            Resource::with_template(template, TextResource::new("test".to_string(), None));
499
500        // Simple test - real implementation would need proper URI template matching
501        assert!(resource.matches_uri("test://resource/123"));
502        assert!(!resource.matches_uri("other://resource/123"));
503    }
504
505    #[test]
506    fn test_resource_builder() {
507        let resource = ResourceBuilder::new("test://resource", "Test Resource")
508            .description("A test resource")
509            .mime_type("text/plain")
510            .build(TextResource::new("test".to_string(), None));
511
512        assert_eq!(resource.info.uri, "test://resource");
513        assert_eq!(resource.info.name, "Test Resource");
514        assert_eq!(
515            resource.info.description,
516            Some("A test resource".to_string())
517        );
518        assert_eq!(resource.info.mime_type, Some("text/plain".to_string()));
519    }
520}