Skip to main content

prism_mcp_rs/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::{Icon, 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            icons: None,
185            title: None,
186            meta: None,
187        };
188
189        Self {
190            info,
191            handler: Box::new(handler),
192            template: Some(template),
193            enabled: true,
194        }
195    }
196
197    /// Enable the resource
198    pub fn enable(&mut self) {
199        self.enabled = true;
200    }
201
202    /// Disable the resource
203    pub fn disable(&mut self) {
204        self.enabled = false;
205    }
206
207    /// Check if the resource is enabled
208    pub fn is_enabled(&self) -> bool {
209        self.enabled
210    }
211
212    /// Read the resource if it's enabled
213    ///
214    /// # Arguments
215    /// * `uri` - URI of the resource to read
216    /// * `params` - Additional parameters for the resource
217    ///
218    /// # Returns
219    /// Result containing the resource content or an error
220    pub async fn read(
221        &self,
222        uri: &str,
223        params: &HashMap<String, String>,
224    ) -> McpResult<Vec<ResourceContents>> {
225        if !self.enabled {
226            let name = self.info.name.as_str();
227            return Err(McpError::validation(format!(
228                "Resource '{name}' is disabled"
229            )));
230        }
231
232        self.handler.read(uri, params).await
233    }
234
235    /// List resources from this handler
236    pub async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
237        if !self.enabled {
238            return Ok(vec![]);
239        }
240
241        self.handler.list().await
242    }
243
244    /// Subscribe to resource changes
245    pub async fn subscribe(&self, uri: &str) -> McpResult<()> {
246        if !self.enabled {
247            let name = self.info.name.as_str();
248            return Err(McpError::validation(format!(
249                "Resource '{name}' is disabled"
250            )));
251        }
252
253        self.handler.subscribe(uri).await
254    }
255
256    /// Unsubscribe from resource changes
257    pub async fn unsubscribe(&self, uri: &str) -> McpResult<()> {
258        if !self.enabled {
259            let name = self.info.name.as_str();
260            return Err(McpError::validation(format!(
261                "Resource '{name}' is disabled"
262            )));
263        }
264
265        self.handler.unsubscribe(uri).await
266    }
267
268    /// Check if this resource matches the given URI
269    pub fn matches_uri(&self, uri: &str) -> bool {
270        if let Some(template) = &self.template {
271            // Simple template matching - in a real implementation,
272            // you'd want more complete URI template matching
273            uri.starts_with(&template.uri_template.replace("{id}", "").replace("{*}", ""))
274        } else {
275            self.info.uri == uri
276        }
277    }
278}
279
280impl std::fmt::Debug for Resource {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        f.debug_struct("Resource")
283            .field("info", &self.info)
284            .field("template", &self.template)
285            .field("enabled", &self.enabled)
286            .finish()
287    }
288}
289
290// Common resource implementations
291
292/// Simple text resource
293pub struct TextResource {
294    content: String,
295    mime_type: String,
296}
297
298impl TextResource {
299    /// Create a new text resource
300    pub fn new(content: String, mime_type: Option<String>) -> Self {
301        Self {
302            content,
303            mime_type: mime_type.unwrap_or_else(|| "text/plain".to_string()),
304        }
305    }
306}
307
308#[async_trait]
309impl ResourceHandler for TextResource {
310    async fn read(
311        &self,
312        uri: &str,
313        _params: &HashMap<String, String>,
314    ) -> McpResult<Vec<ResourceContents>> {
315        Ok(vec![ResourceContents::Text {
316            uri: uri.to_string(),
317            mime_type: Some(self.mime_type.clone()),
318            text: self.content.clone(),
319            meta: None,
320        }])
321    }
322
323    async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
324        // Static resources don't provide dynamic listing
325        Ok(vec![])
326    }
327}
328
329/// File system resource handler
330pub struct FileSystemResource {
331    base_path: std::path::PathBuf,
332    allowed_extensions: Option<Vec<String>>,
333}
334
335impl FileSystemResource {
336    /// Create a new file system resource handler
337    pub fn new<P: AsRef<std::path::Path>>(base_path: P) -> Self {
338        Self {
339            base_path: base_path.as_ref().to_path_buf(),
340            allowed_extensions: None,
341        }
342    }
343
344    /// Set allowed file extensions
345    pub fn with_extensions(mut self, extensions: Vec<String>) -> Self {
346        self.allowed_extensions = Some(extensions);
347        self
348    }
349
350    fn is_allowed_file(&self, path: &std::path::Path) -> bool {
351        if let Some(ref allowed) = self.allowed_extensions {
352            if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
353                return allowed.contains(&ext.to_lowercase());
354            }
355            return false;
356        }
357        true
358    }
359
360    fn get_mime_type(&self, path: &std::path::Path) -> String {
361        match path.extension().and_then(|e| e.to_str()) {
362            Some("txt") => "text/plain".to_string(),
363            Some("json") => "application/json".to_string(),
364            Some("html") => "text/html".to_string(),
365            Some("css") => "text/css".to_string(),
366            Some("js") => "application/javascript".to_string(),
367            Some("md") => "text/markdown".to_string(),
368            Some("xml") => "application/xml".to_string(),
369            Some("yaml") | Some("yml") => "application/yaml".to_string(),
370            _ => "application/octet-stream".to_string(),
371        }
372    }
373}
374
375#[async_trait]
376impl ResourceHandler for FileSystemResource {
377    async fn read(
378        &self,
379        uri: &str,
380        _params: &HashMap<String, String>,
381    ) -> McpResult<Vec<ResourceContents>> {
382        // Extract file path from URI (assuming file://scheme or relative path)
383        let file_path = if uri.starts_with("file://") {
384            uri.strip_prefix("file://").unwrap_or(uri)
385        } else {
386            uri
387        };
388
389        let full_path = self.base_path.join(file_path);
390
391        // Security check - ensure path is within base directory
392        let canonical_base = self.base_path.canonicalize().map_err(McpError::io)?;
393        let canonical_target = full_path
394            .canonicalize()
395            .map_err(|_| McpError::ResourceNotFound(uri.to_string()))?;
396
397        if !canonical_target.starts_with(&canonical_base) {
398            return Err(McpError::validation("Path outside of allowed directory"));
399        }
400
401        if !self.is_allowed_file(&canonical_target) {
402            return Err(McpError::validation("File type not allowed"));
403        }
404
405        let content = tokio::fs::read_to_string(&canonical_target)
406            .await
407            .map_err(|_| McpError::ResourceNotFound(uri.to_string()))?;
408
409        let mime_type = self.get_mime_type(&canonical_target);
410
411        Ok(vec![ResourceContents::Text {
412            uri: uri.to_string(),
413            mime_type: Some(mime_type),
414            text: content,
415            meta: None,
416        }])
417    }
418
419    async fn list(&self) -> McpResult<Vec<ResourceInfo>> {
420        let mut resources = Vec::new();
421        let mut stack = vec![self.base_path.clone()];
422
423        while let Some(dir_path) = stack.pop() {
424            let mut dir = tokio::fs::read_dir(&dir_path).await.map_err(McpError::io)?;
425
426            while let Some(entry) = dir.next_entry().await.map_err(McpError::io)? {
427                let path = entry.path();
428
429                if path.is_dir() {
430                    stack.push(path);
431                } else if self.is_allowed_file(&path) {
432                    let relative_path = path
433                        .strip_prefix(&self.base_path)
434                        .map_err(|_| McpError::internal("Path computation error"))?;
435
436                    let path_display = relative_path.display();
437                    let uri = format!("file://{path_display}");
438                    let name = path
439                        .file_name()
440                        .and_then(|n| n.to_str())
441                        .unwrap_or("unnamed")
442                        .to_string();
443
444                    resources.push(ResourceInfo {
445                        uri,
446                        name,
447                        description: None,
448                        mime_type: Some(self.get_mime_type(&path)),
449                        annotations: None,
450                        size: None,
451                        icons: None,
452                        title: None,
453                        meta: None,
454                    });
455                }
456            }
457        }
458
459        Ok(resources)
460    }
461}
462
463/// Builder for creating resources with fluent API
464pub struct ResourceBuilder {
465    uri: String,
466    name: String,
467    description: Option<String>,
468    mime_type: Option<String>,
469    title: Option<String>,
470    icons: Option<Vec<Icon>>,
471}
472
473impl ResourceBuilder {
474    /// Create a new resource builder
475    pub fn new<S: Into<String>>(uri: S, name: S) -> Self {
476        Self {
477            uri: uri.into(),
478            name: name.into(),
479            description: None,
480            mime_type: None,
481            title: None,
482            icons: None,
483        }
484    }
485
486    /// Set the resource description
487    pub fn description<S: Into<String>>(mut self, description: S) -> Self {
488        self.description = Some(description.into());
489        self
490    }
491
492    /// Set the MIME type
493    pub fn mime_type<S: Into<String>>(mut self, mime_type: S) -> Self {
494        self.mime_type = Some(mime_type.into());
495        self
496    }
497
498    /// Set the resource title (for UI display)
499    pub fn title<S: Into<String>>(mut self, title: S) -> Self {
500        self.title = Some(title.into());
501        self
502    }
503
504    /// Set resource icons (for UI display)
505    pub fn icons(mut self, icons: Vec<Icon>) -> Self {
506        self.icons = Some(icons);
507        self
508    }
509
510    /// Add a single resource icon (for UI display)
511    pub fn icon(mut self, icon: Icon) -> Self {
512        self.icons.get_or_insert_with(Vec::new).push(icon);
513        self
514    }
515
516    /// Build the resource with the given handler
517    pub fn build<H>(self, handler: H) -> Resource
518    where
519        H: ResourceHandler + 'static,
520    {
521        let info = ResourceInfo {
522            uri: self.uri,
523            name: self.name,
524            description: self.description,
525            mime_type: self.mime_type,
526            annotations: None,
527            size: None,
528            icons: self.icons,
529            title: self.title,
530            meta: None,
531        };
532
533        Resource::new(info, handler)
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[tokio::test]
542    async fn test_text_resource() {
543        let resource =
544            TextResource::new("Hello, World!".to_string(), Some("text/plain".to_string()));
545        let params = HashMap::new();
546
547        let content = resource.read("test://resource", &params).await.unwrap();
548        assert_eq!(content.len(), 1);
549        match &content[0] {
550            ResourceContents::Text {
551                text, mime_type, ..
552            } => {
553                assert_eq!(*text, "Hello, World!".to_string());
554                assert_eq!(*mime_type, Some("text/plain".to_string()));
555            }
556            _ => panic!("Expected text content"),
557        }
558    }
559
560    #[test]
561    fn test_resource_creation() {
562        let info = ResourceInfo {
563            uri: "test://resource".to_string(),
564            name: "Test Resource".to_string(),
565            description: Some("A test resource".to_string()),
566            mime_type: Some("text/plain".to_string()),
567            annotations: None,
568            size: None,
569            icons: None,
570            title: None,
571            meta: None,
572        };
573
574        let resource = Resource::new(info.clone(), TextResource::new("test".to_string(), None));
575        assert_eq!(resource.info, info);
576        assert!(resource.is_enabled());
577    }
578
579    #[test]
580    fn test_resource_template() {
581        let template = ResourceTemplate {
582            uri_template: "test://resource/{id}".to_string(),
583            name: "Test Template".to_string(),
584            description: Some("A test template".to_string()),
585            mime_type: Some("text/plain".to_string()),
586        };
587
588        let resource = Resource::with_template(
589            template.clone(),
590            TextResource::new("test".to_string(), None),
591        );
592        assert_eq!(resource.template, Some(template));
593    }
594
595    #[test]
596    fn test_resource_uri_matching() {
597        let template = ResourceTemplate {
598            uri_template: "test://resource/{id}".to_string(),
599            name: "Test Template".to_string(),
600            description: None,
601            mime_type: None,
602        };
603
604        let resource =
605            Resource::with_template(template, TextResource::new("test".to_string(), None));
606
607        // Simple test - real implementation would need proper URI template matching
608        assert!(resource.matches_uri("test://resource/123"));
609        assert!(!resource.matches_uri("other://resource/123"));
610    }
611
612    #[test]
613    fn test_resource_builder() {
614        let resource = ResourceBuilder::new("test://resource", "Test Resource")
615            .description("A test resource")
616            .mime_type("text/plain")
617            .title("Test Resource Title")
618            .icon(Icon {
619                src: "https://example.com/resource-icon.svg".to_string(),
620                mime_type: Some("image/svg+xml".to_string()),
621                sizes: None,
622                theme: None,
623            })
624            .build(TextResource::new("test".to_string(), None));
625
626        assert_eq!(resource.info.uri, "test://resource");
627        assert_eq!(resource.info.name, "Test Resource");
628        assert_eq!(
629            resource.info.description,
630            Some("A test resource".to_string())
631        );
632        assert_eq!(resource.info.mime_type, Some("text/plain".to_string()));
633        assert_eq!(resource.info.title, Some("Test Resource Title".to_string()));
634        assert_eq!(resource.info.icons.as_ref().map(Vec::len), Some(1));
635    }
636}