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(McpError::io)?;
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).await.map_err(McpError::io)?;
361
362            while let Some(entry) = dir.next_entry().await.map_err(McpError::io)? {
363                let path = entry.path();
364
365                if path.is_dir() {
366                    stack.push(path);
367                } else if self.is_allowed_file(&path) {
368                    let relative_path = path
369                        .strip_prefix(&self.base_path)
370                        .map_err(|_| McpError::internal("Path computation error"))?;
371
372                    let uri = format!("file://{}", relative_path.display());
373                    let name = path
374                        .file_name()
375                        .and_then(|n| n.to_str())
376                        .unwrap_or("unnamed")
377                        .to_string();
378
379                    resources.push(ResourceInfo {
380                        uri,
381                        name,
382                        description: None,
383                        mime_type: Some(self.get_mime_type(&path)),
384                    });
385                }
386            }
387        }
388
389        Ok(resources)
390    }
391}
392
393/// Builder for creating resources with fluent API
394pub struct ResourceBuilder {
395    uri: String,
396    name: String,
397    description: Option<String>,
398    mime_type: Option<String>,
399}
400
401impl ResourceBuilder {
402    /// Create a new resource builder
403    pub fn new<S: Into<String>>(uri: S, name: S) -> Self {
404        Self {
405            uri: uri.into(),
406            name: name.into(),
407            description: None,
408            mime_type: None,
409        }
410    }
411
412    /// Set the resource description
413    pub fn description<S: Into<String>>(mut self, description: S) -> Self {
414        self.description = Some(description.into());
415        self
416    }
417
418    /// Set the MIME type
419    pub fn mime_type<S: Into<String>>(mut self, mime_type: S) -> Self {
420        self.mime_type = Some(mime_type.into());
421        self
422    }
423
424    /// Build the resource with the given handler
425    pub fn build<H>(self, handler: H) -> Resource
426    where
427        H: ResourceHandler + 'static,
428    {
429        let info = ResourceInfo {
430            uri: self.uri,
431            name: self.name,
432            description: self.description,
433            mime_type: self.mime_type,
434        };
435
436        Resource::new(info, handler)
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[tokio::test]
445    async fn test_text_resource() {
446        let resource =
447            TextResource::new("Hello, World!".to_string(), Some("text/plain".to_string()));
448        let params = HashMap::new();
449
450        let content = resource.read("test://resource", &params).await.unwrap();
451        assert_eq!(content.len(), 1);
452        assert_eq!(content[0].text, Some("Hello, World!".to_string()));
453        assert_eq!(content[0].mime_type, Some("text/plain".to_string()));
454    }
455
456    #[test]
457    fn test_resource_creation() {
458        let info = ResourceInfo {
459            uri: "test://resource".to_string(),
460            name: "Test Resource".to_string(),
461            description: Some("A test resource".to_string()),
462            mime_type: Some("text/plain".to_string()),
463        };
464
465        let resource = Resource::new(info.clone(), TextResource::new("test".to_string(), None));
466        assert_eq!(resource.info, info);
467        assert!(resource.is_enabled());
468    }
469
470    #[test]
471    fn test_resource_template() {
472        let template = ResourceTemplate {
473            uri_template: "test://resource/{id}".to_string(),
474            name: "Test Template".to_string(),
475            description: Some("A test template".to_string()),
476            mime_type: Some("text/plain".to_string()),
477        };
478
479        let resource = Resource::with_template(
480            template.clone(),
481            TextResource::new("test".to_string(), None),
482        );
483        assert_eq!(resource.template, Some(template));
484    }
485
486    #[test]
487    fn test_resource_uri_matching() {
488        let template = ResourceTemplate {
489            uri_template: "test://resource/{id}".to_string(),
490            name: "Test Template".to_string(),
491            description: None,
492            mime_type: None,
493        };
494
495        let resource =
496            Resource::with_template(template, TextResource::new("test".to_string(), None));
497
498        // Simple test - real implementation would need proper URI template matching
499        assert!(resource.matches_uri("test://resource/123"));
500        assert!(!resource.matches_uri("other://resource/123"));
501    }
502
503    #[test]
504    fn test_resource_builder() {
505        let resource = ResourceBuilder::new("test://resource", "Test Resource")
506            .description("A test resource")
507            .mime_type("text/plain")
508            .build(TextResource::new("test".to_string(), None));
509
510        assert_eq!(resource.info.uri, "test://resource");
511        assert_eq!(resource.info.name, "Test Resource");
512        assert_eq!(
513            resource.info.description,
514            Some("A test resource".to_string())
515        );
516        assert_eq!(resource.info.mime_type, Some("text/plain".to_string()));
517    }
518}