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::{Resource as ResourceInfo, ResourceContents};
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<ResourceContents>>;
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: {uri}"
59        )))
60    }
61
62    /// Unsubscribe from changes in a resource (optional)
63    ///
64    /// # Arguments
65    /// * `uri` - URI of the resource to unsubscribe from
66    ///
67    /// # Returns
68    /// Result indicating success or an error
69    async fn unsubscribe(&self, uri: &str) -> McpResult<()> {
70        // Default implementation - subscription not supported
71        Err(McpError::protocol(format!(
72            "Subscription not supported for resource: {uri}"
73        )))
74    }
75}
76
77/// Legacy trait for backward compatibility with existing tests
78/// This should be used for simple text-based resources
79#[async_trait]
80pub trait LegacyResourceHandler: Send + Sync {
81    /// Read the content of a resource as a string
82    ///
83    /// # Arguments
84    /// * `uri` - URI of the resource to read
85    ///
86    /// # Returns
87    /// Result containing the resource content as a string or an error
88    async fn read(&self, uri: &str) -> McpResult<String>;
89
90    /// List all available resources
91    ///
92    /// # Returns
93    /// Result containing a list of available resources or an error
94    async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
95        // Default implementation returns empty list
96        Ok(vec![])
97    }
98}
99
100/// Adapter to convert LegacyResourceHandler to ResourceHandler
101pub struct LegacyResourceAdapter<T> {
102    inner: T,
103}
104
105impl<T> LegacyResourceAdapter<T>
106where
107    T: LegacyResourceHandler,
108{
109    pub fn new(handler: T) -> Self {
110        Self { inner: handler }
111    }
112}
113
114#[async_trait]
115impl<T> ResourceHandler for LegacyResourceAdapter<T>
116where
117    T: LegacyResourceHandler + Send + Sync,
118{
119    async fn read(
120        &self,
121        uri: &str,
122        _params: &HashMap<String, String>,
123    ) -> McpResult<Vec<ResourceContents>> {
124        let content = self.inner.read(uri).await?;
125        Ok(vec![ResourceContents::Text {
126            uri: uri.to_string(),
127            mime_type: Some("text/plain".to_string()),
128            text: content,
129            meta: None,
130        }])
131    }
132
133    async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
134        self.inner.list().await
135    }
136}
137
138/// A registered resource with its handler
139pub struct Resource {
140    /// Information about the resource
141    pub info: ResourceInfo,
142    /// Handler that implements the resource's functionality
143    pub handler: Box<dyn ResourceHandler>,
144    /// Optional template for parameterized resources
145    pub template: Option<ResourceTemplate>,
146    /// Whether the resource is currently enabled
147    pub enabled: bool,
148}
149
150impl Resource {
151    /// Create a new static resource
152    ///
153    /// # Arguments
154    /// * `info` - Information about the resource
155    /// * `handler` - Implementation of the resource's functionality
156    pub fn new<H>(info: ResourceInfo, handler: H) -> Self
157    where
158        H: ResourceHandler + 'static,
159    {
160        Self {
161            info,
162            handler: Box::new(handler),
163            template: None,
164            enabled: true,
165        }
166    }
167
168    /// Create a new templated resource
169    ///
170    /// # Arguments
171    /// * `template` - Template for the resource
172    /// * `handler` - Implementation of the resource's functionality
173    pub fn with_template<H>(template: ResourceTemplate, handler: H) -> Self
174    where
175        H: ResourceHandler + 'static,
176    {
177        let info = ResourceInfo {
178            uri: template.uri_template.clone(),
179            name: template.name.clone(),
180            description: template.description.clone(),
181            mime_type: template.mime_type.clone(),
182            annotations: None,
183            size: None,
184            title: None,
185            meta: None,
186        };
187
188        Self {
189            info,
190            handler: Box::new(handler),
191            template: Some(template),
192            enabled: true,
193        }
194    }
195
196    /// Enable the resource
197    pub fn enable(&mut self) {
198        self.enabled = true;
199    }
200
201    /// Disable the resource
202    pub fn disable(&mut self) {
203        self.enabled = false;
204    }
205
206    /// Check if the resource is enabled
207    pub fn is_enabled(&self) -> bool {
208        self.enabled
209    }
210
211    /// Read the resource if it's enabled
212    ///
213    /// # Arguments
214    /// * `uri` - URI of the resource to read
215    /// * `params` - Additional parameters for the resource
216    ///
217    /// # Returns
218    /// Result containing the resource content or an error
219    pub async fn read(
220        &self,
221        uri: &str,
222        params: &HashMap<String, String>,
223    ) -> McpResult<Vec<ResourceContents>> {
224        if !self.enabled {
225            let name = self.info.name.as_str();
226            return Err(McpError::validation(format!(
227                "Resource '{name}' is disabled"
228            )));
229        }
230
231        self.handler.read(uri, params).await
232    }
233
234    /// List resources from this handler
235    pub async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
236        if !self.enabled {
237            return Ok(vec![]);
238        }
239
240        self.handler.list().await
241    }
242
243    /// Subscribe to resource changes
244    pub async fn subscribe(&self, uri: &str) -> McpResult<()> {
245        if !self.enabled {
246            let name = self.info.name.as_str();
247            return Err(McpError::validation(format!(
248                "Resource '{name}' is disabled"
249            )));
250        }
251
252        self.handler.subscribe(uri).await
253    }
254
255    /// Unsubscribe from resource changes
256    pub async fn unsubscribe(&self, uri: &str) -> McpResult<()> {
257        if !self.enabled {
258            let name = self.info.name.as_str();
259            return Err(McpError::validation(format!(
260                "Resource '{name}' is disabled"
261            )));
262        }
263
264        self.handler.unsubscribe(uri).await
265    }
266
267    /// Check if this resource matches the given URI
268    pub fn matches_uri(&self, uri: &str) -> bool {
269        if let Some(template) = &self.template {
270            // Simple template matching - in a real implementation,
271            // you'd want more sophisticated URI template matching
272            uri.starts_with(&template.uri_template.replace("{id}", "").replace("{*}", ""))
273        } else {
274            self.info.uri == uri
275        }
276    }
277}
278
279impl std::fmt::Debug for Resource {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        f.debug_struct("Resource")
282            .field("info", &self.info)
283            .field("template", &self.template)
284            .field("enabled", &self.enabled)
285            .finish()
286    }
287}
288
289// Common resource implementations
290
291/// Simple text resource
292pub struct TextResource {
293    content: String,
294    mime_type: String,
295}
296
297impl TextResource {
298    /// Create a new text resource
299    pub fn new(content: String, mime_type: Option<String>) -> Self {
300        Self {
301            content,
302            mime_type: mime_type.unwrap_or_else(|| "text/plain".to_string()),
303        }
304    }
305}
306
307#[async_trait]
308impl ResourceHandler for TextResource {
309    async fn read(
310        &self,
311        uri: &str,
312        _params: &HashMap<String, String>,
313    ) -> McpResult<Vec<ResourceContents>> {
314        Ok(vec![ResourceContents::Text {
315            uri: uri.to_string(),
316            mime_type: Some(self.mime_type.clone()),
317            text: self.content.clone(),
318            meta: None,
319        }])
320    }
321
322    async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
323        // Static resources don't provide dynamic listing
324        Ok(vec![])
325    }
326}
327
328/// File system resource handler
329pub struct FileSystemResource {
330    base_path: std::path::PathBuf,
331    allowed_extensions: Option<Vec<String>>,
332}
333
334impl FileSystemResource {
335    /// Create a new file system resource handler
336    pub fn new<P: AsRef<std::path::Path>>(base_path: P) -> Self {
337        Self {
338            base_path: base_path.as_ref().to_path_buf(),
339            allowed_extensions: None,
340        }
341    }
342
343    /// Set allowed file extensions
344    pub fn with_extensions(mut self, extensions: Vec<String>) -> Self {
345        self.allowed_extensions = Some(extensions);
346        self
347    }
348
349    fn is_allowed_file(&self, path: &std::path::Path) -> bool {
350        if let Some(ref allowed) = self.allowed_extensions {
351            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
352                return allowed.contains(&ext.to_lowercase());
353            }
354            return false;
355        }
356        true
357    }
358
359    fn get_mime_type(&self, path: &std::path::Path) -> String {
360        match path.extension().and_then(|e| e.to_str()) {
361            Some("txt") => "text/plain".to_string(),
362            Some("json") => "application/json".to_string(),
363            Some("html") => "text/html".to_string(),
364            Some("css") => "text/css".to_string(),
365            Some("js") => "application/javascript".to_string(),
366            Some("md") => "text/markdown".to_string(),
367            Some("xml") => "application/xml".to_string(),
368            Some("yaml") | Some("yml") => "application/yaml".to_string(),
369            _ => "application/octet-stream".to_string(),
370        }
371    }
372}
373
374#[async_trait]
375impl ResourceHandler for FileSystemResource {
376    async fn read(
377        &self,
378        uri: &str,
379        _params: &HashMap<String, String>,
380    ) -> McpResult<Vec<ResourceContents>> {
381        // Extract file path from URI (assuming file:// scheme or relative path)
382        let file_path = if uri.starts_with("file://") {
383            uri.strip_prefix("file://").unwrap_or(uri)
384        } else {
385            uri
386        };
387
388        let full_path = self.base_path.join(file_path);
389
390        // Security check - ensure path is within base directory
391        let canonical_base = self.base_path.canonicalize().map_err(McpError::io)?;
392        let canonical_target = full_path
393            .canonicalize()
394            .map_err(|_| McpError::ResourceNotFound(uri.to_string()))?;
395
396        if !canonical_target.starts_with(&canonical_base) {
397            return Err(McpError::validation("Path outside of allowed directory"));
398        }
399
400        if !self.is_allowed_file(&canonical_target) {
401            return Err(McpError::validation("File type not allowed"));
402        }
403
404        let content = tokio::fs::read_to_string(&canonical_target)
405            .await
406            .map_err(|_| McpError::ResourceNotFound(uri.to_string()))?;
407
408        let mime_type = self.get_mime_type(&canonical_target);
409
410        Ok(vec![ResourceContents::Text {
411            uri: uri.to_string(),
412            mime_type: Some(mime_type),
413            text: content,
414            meta: None,
415        }])
416    }
417
418    async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
419        let mut resources = Vec::new();
420        let mut stack = vec![self.base_path.clone()];
421
422        while let Some(dir_path) = stack.pop() {
423            let mut dir = tokio::fs::read_dir(&dir_path).await.map_err(McpError::io)?;
424
425            while let Some(entry) = dir.next_entry().await.map_err(McpError::io)? {
426                let path = entry.path();
427
428                if path.is_dir() {
429                    stack.push(path);
430                } else if self.is_allowed_file(&path) {
431                    let relative_path = path
432                        .strip_prefix(&self.base_path)
433                        .map_err(|_| McpError::internal("Path computation error"))?;
434
435                    let path_display = relative_path.display();
436                    let uri = format!("file://{path_display}");
437                    let name = path
438                        .file_name()
439                        .and_then(|n| n.to_str())
440                        .unwrap_or("unnamed")
441                        .to_string();
442
443                    resources.push(ResourceInfo {
444                        uri,
445                        name,
446                        description: None,
447                        mime_type: Some(self.get_mime_type(&path)),
448                        annotations: None,
449                        size: None,
450                        title: None,
451                        meta: None,
452                    });
453                }
454            }
455        }
456
457        Ok(resources)
458    }
459}
460
461/// Builder for creating resources with fluent API
462pub struct ResourceBuilder {
463    uri: String,
464    name: String,
465    description: Option<String>,
466    mime_type: Option<String>,
467}
468
469impl ResourceBuilder {
470    /// Create a new resource builder
471    pub fn new<S: Into<String>>(uri: S, name: S) -> Self {
472        Self {
473            uri: uri.into(),
474            name: name.into(),
475            description: None,
476            mime_type: None,
477        }
478    }
479
480    /// Set the resource description
481    pub fn description<S: Into<String>>(mut self, description: S) -> Self {
482        self.description = Some(description.into());
483        self
484    }
485
486    /// Set the MIME type
487    pub fn mime_type<S: Into<String>>(mut self, mime_type: S) -> Self {
488        self.mime_type = Some(mime_type.into());
489        self
490    }
491
492    /// Build the resource with the given handler
493    pub fn build<H>(self, handler: H) -> Resource
494    where
495        H: ResourceHandler + 'static,
496    {
497        let info = ResourceInfo {
498            uri: self.uri,
499            name: self.name,
500            description: self.description,
501            mime_type: self.mime_type,
502            annotations: None,
503            size: None,
504            title: None,
505            meta: None,
506        };
507
508        Resource::new(info, handler)
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[tokio::test]
517    async fn test_text_resource() {
518        let resource =
519            TextResource::new("Hello, World!".to_string(), Some("text/plain".to_string()));
520        let params = HashMap::new();
521
522        let content = resource.read("test://resource", &params).await.unwrap();
523        assert_eq!(content.len(), 1);
524        match &content[0] {
525            ResourceContents::Text {
526                text, mime_type, ..
527            } => {
528                assert_eq!(*text, "Hello, World!".to_string());
529                assert_eq!(*mime_type, Some("text/plain".to_string()));
530            }
531            _ => panic!("Expected text content"),
532        }
533    }
534
535    #[test]
536    fn test_resource_creation() {
537        let info = ResourceInfo {
538            uri: "test://resource".to_string(),
539            name: "Test Resource".to_string(),
540            description: Some("A test resource".to_string()),
541            mime_type: Some("text/plain".to_string()),
542            annotations: None,
543            size: None,
544            title: None,
545            meta: None,
546        };
547
548        let resource = Resource::new(info.clone(), TextResource::new("test".to_string(), None));
549        assert_eq!(resource.info, info);
550        assert!(resource.is_enabled());
551    }
552
553    #[test]
554    fn test_resource_template() {
555        let template = ResourceTemplate {
556            uri_template: "test://resource/{id}".to_string(),
557            name: "Test Template".to_string(),
558            description: Some("A test template".to_string()),
559            mime_type: Some("text/plain".to_string()),
560        };
561
562        let resource = Resource::with_template(
563            template.clone(),
564            TextResource::new("test".to_string(), None),
565        );
566        assert_eq!(resource.template, Some(template));
567    }
568
569    #[test]
570    fn test_resource_uri_matching() {
571        let template = ResourceTemplate {
572            uri_template: "test://resource/{id}".to_string(),
573            name: "Test Template".to_string(),
574            description: None,
575            mime_type: None,
576        };
577
578        let resource =
579            Resource::with_template(template, TextResource::new("test".to_string(), None));
580
581        // Simple test - real implementation would need proper URI template matching
582        assert!(resource.matches_uri("test://resource/123"));
583        assert!(!resource.matches_uri("other://resource/123"));
584    }
585
586    #[test]
587    fn test_resource_builder() {
588        let resource = ResourceBuilder::new("test://resource", "Test Resource")
589            .description("A test resource")
590            .mime_type("text/plain")
591            .build(TextResource::new("test".to_string(), None));
592
593        assert_eq!(resource.info.uri, "test://resource");
594        assert_eq!(resource.info.name, "Test Resource");
595        assert_eq!(
596            resource.info.description,
597            Some("A test resource".to_string())
598        );
599        assert_eq!(resource.info.mime_type, Some("text/plain".to_string()));
600    }
601}