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