mcp_host/registry/
resources.rs

1//! Resource registry for MCP servers
2//!
3//! Provides registration and reading of MCP resources with URI templates and retry logic
4
5use std::collections::HashMap;
6use std::sync::{Arc, RwLock};
7
8use async_trait::async_trait;
9use chrono_machines::{BackoffStrategy, ExponentialBackoff};
10use dashmap::DashMap;
11use rand::SeedableRng;
12use rand::rngs::SmallRng;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15use tokio::sync::mpsc;
16
17use crate::content::resource::ResourceContent;
18use crate::server::session::Session;
19use crate::server::visibility::{ExecutionContext, VisibilityContext};
20use crate::transport::traits::JsonRpcNotification;
21
22/// Resource execution errors
23#[derive(Debug, Error)]
24pub enum ResourceError {
25    /// Resource not found
26    #[error("Resource not found: {0}")]
27    NotFound(String),
28
29    /// Invalid URI
30    #[error("Invalid URI: {0}")]
31    InvalidUri(String),
32
33    /// Read error (retryable)
34    #[error("Read error: {0}")]
35    Read(String),
36
37    /// Internal error
38    #[error("Internal error: {0}")]
39    Internal(String),
40
41    /// Retry exhausted
42    #[error("Retry exhausted after {attempts} attempts: {message}")]
43    RetryExhausted { attempts: u8, message: String },
44}
45
46/// Type alias for resource lookup results to avoid type complexity warnings
47pub type ResourceLookupResult = Result<(Arc<dyn Resource>, HashMap<String, String>), ResourceError>;
48
49impl ResourceError {
50    /// Check if this error is retryable
51    pub fn is_retryable(&self) -> bool {
52        matches!(self, Self::Read(_) | Self::Internal(_))
53    }
54}
55
56/// Retry configuration for resources
57#[derive(Debug, Clone)]
58pub struct ResourceRetryConfig {
59    /// Maximum number of retry attempts
60    pub max_attempts: u8,
61    /// Base delay in milliseconds
62    pub base_delay_ms: u64,
63    /// Exponential multiplier
64    pub multiplier: f64,
65    /// Maximum delay cap in milliseconds
66    pub max_delay_ms: u64,
67    /// Jitter factor (0.0 = no jitter, 1.0 = full jitter)
68    pub jitter_factor: f64,
69}
70
71impl Default for ResourceRetryConfig {
72    fn default() -> Self {
73        Self {
74            max_attempts: 3,
75            base_delay_ms: 100,
76            multiplier: 2.0,
77            max_delay_ms: 10_000,
78            jitter_factor: 1.0, // Full jitter by default
79        }
80    }
81}
82
83/// Resource metadata for listing
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ResourceInfo {
86    /// Resource URI or URI template
87    pub uri: String,
88
89    /// Resource name
90    pub name: String,
91
92    /// Resource description
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub description: Option<String>,
95
96    /// MIME type
97    #[serde(skip_serializing_if = "Option::is_none", rename = "mimeType")]
98    pub mime_type: Option<String>,
99}
100
101/// Resource template metadata for listing
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct ResourceTemplateInfo {
105    /// URI template with placeholders (e.g., "file:///{path}")
106    pub uri_template: String,
107
108    /// Template name
109    pub name: String,
110
111    /// Optional display title
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub title: Option<String>,
114
115    /// Template description
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub description: Option<String>,
118
119    /// MIME type for resources matching this template
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub mime_type: Option<String>,
122}
123
124/// Resource trait for implementing MCP resources
125#[async_trait]
126pub trait Resource: Send + Sync {
127    /// Get resource URI or URI template
128    fn uri(&self) -> &str;
129
130    /// Get resource name
131    fn name(&self) -> &str;
132
133    /// Get resource description
134    fn description(&self) -> Option<&str> {
135        None
136    }
137
138    /// Get MIME type
139    fn mime_type(&self) -> Option<&str> {
140        None
141    }
142
143    /// Check if this resource should be visible in the given context
144    ///
145    /// Override this to implement contextual visibility. The default implementation
146    /// always returns true (always visible).
147    fn is_visible(&self, _ctx: &VisibilityContext) -> bool {
148        true
149    }
150
151    /// Read the resource with execution context
152    ///
153    /// The execution context provides access to:
154    /// - `ctx.uri_params`: Parameters extracted from URI template
155    /// - `ctx.session`: Current session (roles, state, client info)
156    /// - `ctx.environment`: Optional environment state
157    ///
158    /// Returns ResourceContent which automatically includes uri and mimeType.
159    ///
160    /// # Example
161    ///
162    /// ```rust,ignore
163    /// async fn read(&self, ctx: ExecutionContext<'_>) -> Result<Vec<ResourceContent>, ResourceError> {
164    ///     let id = ctx.get_uri_param("id").unwrap_or("unknown");
165    ///     let content = format!("Resource {}", id);
166    ///
167    ///     Ok(vec![self.text_content(content)])
168    /// }
169    /// ```
170    async fn read(&self, ctx: ExecutionContext<'_>) -> Result<Vec<ResourceContent>, ResourceError>;
171
172    /// Helper: Create a text ResourceContent with this resource's URI and mime_type
173    ///
174    /// This is a convenience method that automatically includes the resource's
175    /// URI and MIME type in the response.
176    ///
177    /// # Example
178    ///
179    /// ```rust,ignore
180    /// async fn read(&self, ctx: ExecutionContext<'_>) -> Result<Vec<ResourceContent>, ResourceError> {
181    ///     Ok(vec![self.text_content("Resource content here")])
182    /// }
183    /// ```
184    fn text_content(&self, text: &str) -> ResourceContent {
185        ResourceContent {
186            uri: self.uri().to_string(),
187            mime_type: self.mime_type().map(|s| s.to_string()),
188            text: Some(text.to_string()),
189            blob: None,
190        }
191    }
192
193    /// Helper: Create a blob ResourceContent with this resource's URI and mime_type
194    ///
195    /// # Example
196    ///
197    /// ```rust,ignore
198    /// async fn read(&self, ctx: ExecutionContext<'_>) -> Result<Vec<ResourceContent>, ResourceError> {
199    ///     Ok(vec![self.blob_content(base64_data)])
200    /// }
201    /// ```
202    fn blob_content(&self, data: &str) -> ResourceContent {
203        ResourceContent {
204            uri: self.uri().to_string(),
205            mime_type: self.mime_type().map(|s| s.to_string()),
206            text: None,
207            blob: Some(data.to_string()),
208        }
209    }
210}
211
212/// Resource template trait for implementing URI templates
213///
214/// Resource templates allow dynamic resources with URI patterns like `file:///{path}`.
215/// The template defines the pattern, and resources matching it provide the actual data.
216#[async_trait]
217pub trait ResourceTemplate: Send + Sync {
218    /// Get URI template pattern (e.g., "file:///{path}")
219    fn uri_template(&self) -> &str;
220
221    /// Get template name
222    fn name(&self) -> &str;
223
224    /// Get optional display title
225    fn title(&self) -> Option<&str> {
226        None
227    }
228
229    /// Get template description
230    fn description(&self) -> Option<&str> {
231        None
232    }
233
234    /// Get MIME type for resources matching this template
235    fn mime_type(&self) -> Option<&str> {
236        None
237    }
238
239    /// Check if this template should be visible in the given context
240    fn is_visible(&self, _ctx: &VisibilityContext) -> bool {
241        true
242    }
243
244    /// Read a resource matching this template
245    ///
246    /// The URI parameters are extracted from the concrete URI and passed via `ctx.uri_params`.
247    ///
248    /// # Example
249    ///
250    /// ```rust,ignore
251    /// async fn read(&self, ctx: ExecutionContext<'_>) -> Result<Vec<ResourceContent>, ResourceError> {
252    ///     let path = ctx.get_uri_param("path").ok_or_else(||
253    ///         ResourceError::InvalidUri("Missing path parameter".into())
254    ///     )?;
255    ///
256    ///     let content = std::fs::read_to_string(path)
257    ///         .map_err(|e| ResourceError::Read(e.to_string()))?;
258    ///
259    ///     Ok(vec![ResourceContent::text(
260    ///         format!("file:///{}", path),
261    ///         content
262    ///     )])
263    /// }
264    /// ```
265    async fn read(&self, ctx: ExecutionContext<'_>) -> Result<Vec<ResourceContent>, ResourceError>;
266}
267
268/// Dynamic resource that wraps a template for on-demand resource creation
269struct DynamicTemplateResource {
270    template: Arc<dyn ResourceTemplate>,
271    uri: String,
272}
273
274#[async_trait]
275impl Resource for DynamicTemplateResource {
276    fn uri(&self) -> &str {
277        &self.uri
278    }
279
280    fn name(&self) -> &str {
281        self.template.name()
282    }
283
284    fn description(&self) -> Option<&str> {
285        self.template.description()
286    }
287
288    fn mime_type(&self) -> Option<&str> {
289        self.template.mime_type()
290    }
291
292    fn is_visible(&self, ctx: &VisibilityContext) -> bool {
293        self.template.is_visible(ctx)
294    }
295
296    async fn read(&self, ctx: ExecutionContext<'_>) -> Result<Vec<ResourceContent>, ResourceError> {
297        self.template.read(ctx).await
298    }
299}
300
301/// Resource manager for managing available resources with retry logic
302#[derive(Clone)]
303pub struct ResourceManager {
304    resources: Arc<DashMap<String, Arc<dyn Resource>>>,
305    templates: Arc<DashMap<String, Arc<dyn ResourceTemplate>>>,
306    retry_config: Arc<RwLock<ResourceRetryConfig>>,
307    notification_tx: Option<mpsc::UnboundedSender<JsonRpcNotification>>,
308}
309
310impl ResourceManager {
311    /// Create new resource manager
312    pub fn new() -> Self {
313        Self {
314            resources: Arc::new(DashMap::new()),
315            templates: Arc::new(DashMap::new()),
316            retry_config: Arc::new(RwLock::new(ResourceRetryConfig::default())),
317            notification_tx: None,
318        }
319    }
320
321    /// Create resource manager with notification channel
322    pub fn with_notifications(notification_tx: mpsc::UnboundedSender<JsonRpcNotification>) -> Self {
323        Self {
324            resources: Arc::new(DashMap::new()),
325            templates: Arc::new(DashMap::new()),
326            retry_config: Arc::new(RwLock::new(ResourceRetryConfig::default())),
327            notification_tx: Some(notification_tx),
328        }
329    }
330
331    /// Set notification channel
332    pub fn set_notification_tx(&mut self, tx: mpsc::UnboundedSender<JsonRpcNotification>) {
333        self.notification_tx = Some(tx);
334    }
335
336    /// Configure retry behavior
337    pub fn set_retry_config(&self, config: ResourceRetryConfig) {
338        if let Ok(mut cfg) = self.retry_config.write() {
339            *cfg = config;
340        }
341    }
342
343    /// Send notification
344    fn send_notification(&self, method: &str, params: Option<serde_json::Value>) {
345        if let Some(tx) = &self.notification_tx {
346            let notification = JsonRpcNotification::new(method, params);
347            let _ = tx.send(notification);
348        }
349    }
350
351    /// Notify resources list changed
352    fn notify_resources_changed(&self) {
353        self.send_notification("notifications/resources/list_changed", None);
354    }
355
356    /// Send logging message notification (visible to LLM)
357    fn notify_message(&self, level: &str, logger: &str, message: &str) {
358        self.send_notification(
359            "notifications/message",
360            Some(serde_json::json!({
361                "level": level,
362                "logger": logger,
363                "data": message
364            })),
365        );
366    }
367
368    /// Register a resource
369    pub fn register<R: Resource + 'static>(&self, resource: R) {
370        let uri = resource.uri().to_string();
371        self.resources.insert(uri, Arc::new(resource));
372    }
373
374    /// Register a boxed resource
375    pub fn register_boxed(&self, resource: Arc<dyn Resource>) {
376        let uri = resource.uri().to_string();
377        self.resources.insert(uri, resource);
378    }
379
380    /// Register a resource template
381    pub fn register_template<T: ResourceTemplate + 'static>(&self, template: T) {
382        let name = template.name().to_string();
383        self.templates.insert(name, Arc::new(template));
384    }
385
386    /// Register a boxed resource template
387    pub fn register_template_boxed(&self, template: Arc<dyn ResourceTemplate>) {
388        let name = template.name().to_string();
389        self.templates.insert(name, template);
390    }
391
392    /// Get a template by name
393    pub fn get_template(&self, name: &str) -> Option<Arc<dyn ResourceTemplate>> {
394        self.templates.get(name).map(|t| Arc::clone(&t))
395    }
396
397    /// Get a resource by URI
398    pub fn get(&self, uri: &str) -> Option<Arc<dyn Resource>> {
399        self.resources.get(uri).map(|r| Arc::clone(&r))
400    }
401
402    /// List all registered resources
403    pub fn list(&self) -> Vec<ResourceInfo> {
404        self.resources
405            .iter()
406            .map(|entry| {
407                let resource = entry.value();
408                ResourceInfo {
409                    uri: resource.uri().to_string(),
410                    name: resource.name().to_string(),
411                    description: resource.description().map(|s| s.to_string()),
412                    mime_type: resource.mime_type().map(|s| s.to_string()),
413                }
414            })
415            .collect()
416    }
417
418    /// List all resource templates
419    pub fn list_templates(&self) -> Vec<ResourceTemplateInfo> {
420        self.templates
421            .iter()
422            .map(|entry| {
423                let template = entry.value();
424                ResourceTemplateInfo {
425                    uri_template: template.uri_template().to_string(),
426                    name: template.name().to_string(),
427                    title: template.title().map(|s| s.to_string()),
428                    description: template.description().map(|s| s.to_string()),
429                    mime_type: template.mime_type().map(|s| s.to_string()),
430                }
431            })
432            .collect()
433    }
434
435    /// List templates visible in the given session/context
436    pub fn list_templates_for_session(
437        &self,
438        _session: &Session,
439        ctx: &VisibilityContext<'_>,
440    ) -> Vec<ResourceTemplateInfo> {
441        self.templates
442            .iter()
443            .filter(|entry| entry.value().is_visible(ctx))
444            .map(|entry| {
445                let template = entry.value();
446                ResourceTemplateInfo {
447                    uri_template: template.uri_template().to_string(),
448                    name: template.name().to_string(),
449                    title: template.title().map(|s| s.to_string()),
450                    description: template.description().map(|s| s.to_string()),
451                    mime_type: template.mime_type().map(|s| s.to_string()),
452                }
453            })
454            .collect()
455    }
456
457    /// Read a resource by URI with parameters and retry logic
458    pub async fn read(
459        &self,
460        uri: &str,
461        params: HashMap<String, String>,
462        session: &Session,
463        logger: &crate::logging::McpLogger,
464    ) -> Result<Vec<ResourceContent>, ResourceError> {
465        // Find the resource first
466        let (resource, combined_params) = self.find_resource(uri, params)?;
467
468        // Get retry config
469        let retry_config = self
470            .retry_config
471            .read()
472            .map(|c| c.clone())
473            .unwrap_or_default();
474
475        // Build backoff strategy using chrono-machines
476        let backoff = ExponentialBackoff::default()
477            .max_attempts(retry_config.max_attempts)
478            .base_delay_ms(retry_config.base_delay_ms)
479            .multiplier(retry_config.multiplier)
480            .max_delay_ms(retry_config.max_delay_ms)
481            .jitter_factor(retry_config.jitter_factor);
482
483        let mut rng = SmallRng::from_os_rng();
484        let mut attempt: u8 = 1;
485
486        loop {
487            // Create execution context for resource
488            let ctx = ExecutionContext::for_resource(combined_params.clone(), session, logger);
489
490            match resource.read(ctx).await {
491                Ok(content) => {
492                    // If we recovered after failures, notify
493                    if attempt > 1 {
494                        self.notify_message(
495                            "info",
496                            "chrono-machines",
497                            &format!("Resource '{}' succeeded after {} attempts", uri, attempt),
498                        );
499                    }
500                    return Ok(content);
501                }
502                Err(e) => {
503                    // Check if error is retryable
504                    if !e.is_retryable() {
505                        return Err(e);
506                    }
507
508                    // Check if we should retry
509                    if !backoff.should_retry(attempt) {
510                        // Exhausted retries, send notifications
511                        self.notify_resources_changed();
512                        self.notify_message(
513                            "warning",
514                            "chrono-machines",
515                            &format!(
516                                "Resource '{}' failed after {} attempts: {}",
517                                uri, attempt, e
518                            ),
519                        );
520
521                        return Err(ResourceError::RetryExhausted {
522                            attempts: attempt,
523                            message: e.to_string(),
524                        });
525                    }
526
527                    // Calculate delay and sleep
528                    if let Some(delay_ms) = backoff.delay(attempt, &mut rng) {
529                        tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
530                    }
531
532                    attempt = attempt.saturating_add(1);
533                }
534            }
535        }
536    }
537
538    /// Find a resource by URI (exact match or template match)
539    fn find_resource(&self, uri: &str, params: HashMap<String, String>) -> ResourceLookupResult {
540        // Try exact match first
541        if let Some(resource) = self.get(uri) {
542            return Ok((resource, params));
543        }
544
545        // Try template matching in static resources
546        for entry in self.resources.iter() {
547            let template_uri = entry.key();
548            if let Some(extracted_params) = self.match_template(template_uri, uri) {
549                let resource = Arc::clone(entry.value());
550                let mut combined_params = params.clone();
551                combined_params.extend(extracted_params);
552                return Ok((resource, combined_params));
553            }
554        }
555
556        // Try matching against resource templates
557        for entry in self.templates.iter() {
558            let template = entry.value();
559            let template_uri = template.uri_template();
560            if let Some(extracted_params) = self.match_template(template_uri, uri) {
561                // Create a dynamic resource from the template
562                let dynamic_resource = DynamicTemplateResource {
563                    template: Arc::clone(template),
564                    uri: uri.to_string(),
565                };
566                let mut combined_params = params.clone();
567                combined_params.extend(extracted_params);
568                return Ok((Arc::new(dynamic_resource), combined_params));
569            }
570        }
571
572        Err(ResourceError::NotFound(uri.to_string()))
573    }
574
575    /// Match URI against template and extract parameters
576    /// Template format: "scheme://path/{param}/more/{param2}"
577    /// If the last part is a parameter, it greedily matches the rest of the URI
578    fn match_template(&self, template: &str, uri: &str) -> Option<HashMap<String, String>> {
579        let template_parts: Vec<&str> = template.split('/').collect();
580        let uri_parts: Vec<&str> = uri.split('/').collect();
581
582        // Check if template has fewer parts (only valid if last part is a parameter)
583        if template_parts.len() > uri_parts.len() {
584            return None;
585        }
586
587        let mut params = HashMap::new();
588        let last_idx = template_parts.len() - 1;
589
590        for (i, template_part) in template_parts.iter().enumerate() {
591            if template_part.starts_with('{') && template_part.ends_with('}') {
592                let param_name = &template_part[1..template_part.len() - 1];
593
594                if i == last_idx {
595                    // Last parameter: greedily match remaining URI parts
596                    let remaining = uri_parts[i..].join("/");
597                    params.insert(param_name.to_string(), remaining);
598                    break;
599                } else {
600                    // Middle parameter: match single part
601                    if i >= uri_parts.len() {
602                        return None;
603                    }
604                    params.insert(param_name.to_string(), uri_parts[i].to_string());
605                }
606            } else if i >= uri_parts.len() || template_part != &uri_parts[i] {
607                // Static part doesn't match or URI too short
608                return None;
609            }
610        }
611
612        // If last template part wasn't a greedy parameter, ensure lengths match exactly
613        let last_was_greedy_param = template_parts
614            .last()
615            .is_some_and(|p| p.starts_with('{') && p.ends_with('}'));
616
617        if !last_was_greedy_param && template_parts.len() != uri_parts.len() {
618            return None;
619        }
620
621        Some(params)
622    }
623
624    // ==================== Session-Aware Methods ====================
625
626    /// List resources visible to a specific session
627    ///
628    /// Resolution order:
629    /// 1. Session overrides (replace global resources with session-specific implementations)
630    /// 2. Session extras (additional resources added to session)
631    /// 3. Global resources (filtered by hidden list and visibility predicate)
632    pub fn list_for_session(
633        &self,
634        session: &Session,
635        ctx: &VisibilityContext<'_>,
636    ) -> Vec<ResourceInfo> {
637        let mut resources = std::collections::HashMap::new();
638
639        // 1. Add global resources (filtered by hidden and visibility)
640        for entry in self.resources.iter() {
641            let uri = entry.key().clone();
642            if !session.is_resource_hidden(&uri) {
643                let resource = entry.value();
644                if resource.is_visible(ctx) {
645                    resources.insert(
646                        uri,
647                        ResourceInfo {
648                            uri: resource.uri().to_string(),
649                            name: resource.name().to_string(),
650                            description: resource.description().map(|s| s.to_string()),
651                            mime_type: resource.mime_type().map(|s| s.to_string()),
652                        },
653                    );
654                }
655            }
656        }
657
658        // 2. Add session extras
659        for entry in session.resource_extras().iter() {
660            let uri = entry.key().clone();
661            let resource = entry.value();
662            if resource.is_visible(ctx) {
663                resources.insert(
664                    uri,
665                    ResourceInfo {
666                        uri: resource.uri().to_string(),
667                        name: resource.name().to_string(),
668                        description: resource.description().map(|s| s.to_string()),
669                        mime_type: resource.mime_type().map(|s| s.to_string()),
670                    },
671                );
672            }
673        }
674
675        // 3. Apply session overrides
676        for entry in session.resource_overrides().iter() {
677            let uri = entry.key().clone();
678            let resource = entry.value();
679            if resource.is_visible(ctx) {
680                resources.insert(
681                    uri,
682                    ResourceInfo {
683                        uri: resource.uri().to_string(),
684                        name: resource.name().to_string(),
685                        description: resource.description().map(|s| s.to_string()),
686                        mime_type: resource.mime_type().map(|s| s.to_string()),
687                    },
688                );
689            }
690        }
691
692        resources.into_values().collect()
693    }
694
695    /// Read a resource with session context
696    ///
697    /// Resolution order:
698    /// 1. Check session overrides
699    /// 2. Check session extras
700    /// 3. Check session hidden
701    /// 4. Check visibility predicate
702    /// 5. Fall back to global registry with retry logic
703    pub async fn read_for_session(
704        &self,
705        uri: &str,
706        params: HashMap<String, String>,
707        session: &Session,
708        logger: &crate::logging::McpLogger,
709        visibility_ctx: &VisibilityContext<'_>,
710    ) -> Result<Vec<ResourceContent>, ResourceError> {
711        // Create execution context (reuse environment from visibility context)
712        let exec_ctx = match visibility_ctx.environment {
713            Some(env) => ExecutionContext::for_resource_with_environment(
714                params.clone(),
715                session,
716                logger,
717                env,
718            ),
719            None => ExecutionContext::for_resource(params.clone(), session, logger),
720        };
721
722        // 1. Check session override first
723        if let Some(resource) = session.get_resource_override(uri) {
724            if !resource.is_visible(visibility_ctx) {
725                return Err(ResourceError::NotFound(uri.to_string()));
726            }
727            return resource.read(exec_ctx).await;
728        }
729
730        // 2. Check session extras
731        if let Some(resource) = session.get_resource_extra(uri) {
732            if !resource.is_visible(visibility_ctx) {
733                return Err(ResourceError::NotFound(uri.to_string()));
734            }
735            return resource.read(exec_ctx).await;
736        }
737
738        // 3. Check if hidden in session
739        if session.is_resource_hidden(uri) {
740            return Err(ResourceError::NotFound(uri.to_string()));
741        }
742
743        // 4. Check global registry with visibility check
744        if let Some(resource) = self.get(uri)
745            && !resource.is_visible(visibility_ctx)
746        {
747            return Err(ResourceError::NotFound(uri.to_string()));
748        }
749
750        // 5. Fall back to global registry with retry logic
751        self.read(uri, params, session, logger).await
752    }
753
754    /// Get number of registered resources
755    pub fn len(&self) -> usize {
756        self.resources.len()
757    }
758
759    /// Check if manager is empty
760    pub fn is_empty(&self) -> bool {
761        self.resources.is_empty()
762    }
763}
764
765impl Default for ResourceManager {
766    fn default() -> Self {
767        Self::new()
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774
775    // Example static resource for testing
776    struct HelloResource;
777
778    #[async_trait]
779    impl Resource for HelloResource {
780        fn uri(&self) -> &str {
781            "test://hello"
782        }
783
784        fn name(&self) -> &str {
785            "hello"
786        }
787
788        fn description(&self) -> Option<&str> {
789            Some("Returns a greeting")
790        }
791
792        fn mime_type(&self) -> Option<&str> {
793            Some("text/plain")
794        }
795
796        async fn read(
797            &self,
798            _ctx: ExecutionContext<'_>,
799        ) -> Result<Vec<ResourceContent>, ResourceError> {
800            Ok(vec![self.text_content("Hello, World!")])
801        }
802    }
803
804    // Example template resource for testing
805    struct UserResource;
806
807    #[async_trait]
808    impl Resource for UserResource {
809        fn uri(&self) -> &str {
810            "test://users/{id}"
811        }
812
813        fn name(&self) -> &str {
814            "user"
815        }
816
817        fn description(&self) -> Option<&str> {
818            Some("Returns user information")
819        }
820
821        async fn read(
822            &self,
823            ctx: ExecutionContext<'_>,
824        ) -> Result<Vec<ResourceContent>, ResourceError> {
825            let id = ctx
826                .get_uri_param("id")
827                .ok_or_else(|| ResourceError::InvalidUri("Missing 'id' parameter".to_string()))?;
828
829            Ok(vec![self.text_content(&format!("User ID: {}", id))])
830        }
831    }
832
833    #[test]
834    fn test_manager_creation() {
835        let manager = ResourceManager::new();
836        assert!(manager.is_empty());
837    }
838
839    #[test]
840    fn test_resource_registration() {
841        let manager = ResourceManager::new();
842        manager.register(HelloResource);
843
844        assert_eq!(manager.len(), 1);
845        assert!(!manager.is_empty());
846    }
847
848    #[test]
849    fn test_get_resource() {
850        let manager = ResourceManager::new();
851        manager.register(HelloResource);
852
853        let resource = manager.get("test://hello");
854        assert!(resource.is_some());
855        assert_eq!(resource.unwrap().name(), "hello");
856
857        let missing = manager.get("test://nonexistent");
858        assert!(missing.is_none());
859    }
860
861    #[test]
862    fn test_list_resources() {
863        let manager = ResourceManager::new();
864        manager.register(HelloResource);
865
866        let resources = manager.list();
867        assert_eq!(resources.len(), 1);
868        assert_eq!(resources[0].uri, "test://hello");
869        assert_eq!(resources[0].name, "hello");
870        assert_eq!(
871            resources[0].description,
872            Some("Returns a greeting".to_string())
873        );
874        assert_eq!(resources[0].mime_type, Some("text/plain".to_string()));
875    }
876
877    #[tokio::test]
878    async fn test_read_static_resource() {
879        let (_tx, _rx) = tokio::sync::mpsc::unbounded_channel();
880        let logger = crate::logging::McpLogger::new(_tx, "test");
881        let manager = ResourceManager::new();
882        manager.register(HelloResource);
883        let session = Session::new();
884
885        let result = manager
886            .read("test://hello", HashMap::new(), &session, &logger)
887            .await
888            .unwrap();
889        assert_eq!(result.len(), 1);
890    }
891
892    #[tokio::test]
893    async fn test_read_missing_resource() {
894        let (_tx, _rx) = tokio::sync::mpsc::unbounded_channel();
895        let logger = crate::logging::McpLogger::new(_tx, "test");
896        let manager = ResourceManager::new();
897        let session = Session::new();
898
899        let result = manager
900            .read("test://nonexistent", HashMap::new(), &session, &logger)
901            .await;
902        assert!(matches!(result, Err(ResourceError::NotFound(_))));
903    }
904
905    #[tokio::test]
906    async fn test_template_matching() {
907        let (_tx, _rx) = tokio::sync::mpsc::unbounded_channel();
908        let logger = crate::logging::McpLogger::new(_tx, "test");
909        let manager = ResourceManager::new();
910        manager.register(UserResource);
911        let session = Session::new();
912
913        // Test template parameter extraction
914        let result = manager
915            .read("test://users/123", HashMap::new(), &session, &logger)
916            .await
917            .unwrap();
918        assert_eq!(result.len(), 1);
919    }
920
921    #[test]
922    fn test_template_matching_internal() {
923        let manager = ResourceManager::new();
924
925        // Test exact template matching
926        let params = manager.match_template("test://users/{id}", "test://users/123");
927        assert!(params.is_some());
928        let params = params.unwrap();
929        assert_eq!(params.get("id"), Some(&"123".to_string()));
930
931        // Test multiple parameters
932        let params = manager.match_template(
933            "test://org/{org}/repo/{repo}",
934            "test://org/myorg/repo/myrepo",
935        );
936        assert!(params.is_some());
937        let params = params.unwrap();
938        assert_eq!(params.get("org"), Some(&"myorg".to_string()));
939        assert_eq!(params.get("repo"), Some(&"myrepo".to_string()));
940
941        // Test non-matching
942        let params = manager.match_template("test://users/{id}", "test://posts/123");
943        assert!(params.is_none());
944
945        // Test greedy matching (last parameter matches remaining segments)
946        let params = manager.match_template("test://users/{id}", "test://users/123/extra");
947        assert!(params.is_some());
948        let params = params.unwrap();
949        assert_eq!(params.get("id"), Some(&"123/extra".to_string()));
950    }
951
952    #[tokio::test]
953    async fn test_resource_non_matching_template() {
954        let (_tx, _rx) = tokio::sync::mpsc::unbounded_channel();
955        let logger = crate::logging::McpLogger::new(_tx, "test");
956        let manager = ResourceManager::new();
957        manager.register(UserResource);
958        let session = Session::new();
959
960        // This won't match because "posts" != "users"
961        let result = manager
962            .read("test://posts/123", HashMap::new(), &session, &logger)
963            .await;
964        assert!(matches!(result, Err(ResourceError::NotFound(_))));
965    }
966
967    #[tokio::test]
968    async fn test_resource_greedy_matching() {
969        let (_tx, _rx) = tokio::sync::mpsc::unbounded_channel();
970        let logger = crate::logging::McpLogger::new(_tx, "test");
971        let manager = ResourceManager::new();
972        manager.register(UserResource);
973        let session = Session::new();
974
975        // The {id} parameter greedily matches the remaining segments
976        let result = manager
977            .read("test://users/123/extra", HashMap::new(), &session, &logger)
978            .await;
979        assert!(result.is_ok());
980        let content = result.unwrap();
981        assert_eq!(content.len(), 1);
982        // Verify the id parameter captured the greedy match
983        assert!(content[0].text.as_ref().unwrap().contains("123/extra"));
984    }
985
986    // ==================== Resource Template Tests ====================
987
988    // Example template for testing
989    struct FileTemplate;
990
991    #[async_trait]
992    impl ResourceTemplate for FileTemplate {
993        fn uri_template(&self) -> &str {
994            "file:///{path}"
995        }
996
997        fn name(&self) -> &str {
998            "project_files"
999        }
1000
1001        fn title(&self) -> Option<&str> {
1002            Some("Project Files")
1003        }
1004
1005        fn description(&self) -> Option<&str> {
1006            Some("Access files in the project directory")
1007        }
1008
1009        fn mime_type(&self) -> Option<&str> {
1010            Some("application/octet-stream")
1011        }
1012
1013        async fn read(
1014            &self,
1015            ctx: ExecutionContext<'_>,
1016        ) -> Result<Vec<ResourceContent>, ResourceError> {
1017            let path = ctx
1018                .get_uri_param("path")
1019                .ok_or_else(|| ResourceError::InvalidUri("Missing 'path' parameter".to_string()))?;
1020
1021            Ok(vec![ResourceContent::text(
1022                format!("file:///{}", path),
1023                format!("Mock content for file: {}", path),
1024            )])
1025        }
1026    }
1027
1028    // Template with visibility predicate
1029    struct AdminOnlyTemplate;
1030
1031    #[async_trait]
1032    impl ResourceTemplate for AdminOnlyTemplate {
1033        fn uri_template(&self) -> &str {
1034            "admin:///{resource}"
1035        }
1036
1037        fn name(&self) -> &str {
1038            "admin_resources"
1039        }
1040
1041        fn is_visible(&self, ctx: &VisibilityContext) -> bool {
1042            ctx.has_role("admin")
1043        }
1044
1045        async fn read(
1046            &self,
1047            _ctx: ExecutionContext<'_>,
1048        ) -> Result<Vec<ResourceContent>, ResourceError> {
1049            Ok(vec![ResourceContent::text(
1050                "admin:///test",
1051                "Admin content",
1052            )])
1053        }
1054    }
1055
1056    #[test]
1057    fn test_template_registration() {
1058        let manager = ResourceManager::new();
1059        manager.register_template(FileTemplate);
1060
1061        let template = manager.get_template("project_files");
1062        assert!(template.is_some());
1063        assert_eq!(template.unwrap().name(), "project_files");
1064    }
1065
1066    #[test]
1067    fn test_get_template() {
1068        let manager = ResourceManager::new();
1069        manager.register_template(FileTemplate);
1070
1071        let template = manager.get_template("project_files");
1072        assert!(template.is_some());
1073        assert_eq!(template.unwrap().uri_template(), "file:///{path}");
1074
1075        let missing = manager.get_template("nonexistent");
1076        assert!(missing.is_none());
1077    }
1078
1079    #[test]
1080    fn test_list_templates() {
1081        let manager = ResourceManager::new();
1082        manager.register_template(FileTemplate);
1083
1084        let templates = manager.list_templates();
1085        assert_eq!(templates.len(), 1);
1086        assert_eq!(templates[0].uri_template, "file:///{path}");
1087        assert_eq!(templates[0].name, "project_files");
1088        assert_eq!(templates[0].title, Some("Project Files".to_string()));
1089        assert_eq!(
1090            templates[0].description,
1091            Some("Access files in the project directory".to_string())
1092        );
1093        assert_eq!(
1094            templates[0].mime_type,
1095            Some("application/octet-stream".to_string())
1096        );
1097    }
1098
1099    #[test]
1100    fn test_list_templates_for_session_visibility() {
1101        let manager = ResourceManager::new();
1102        manager.register_template(FileTemplate);
1103        manager.register_template(AdminOnlyTemplate);
1104
1105        // Regular session without admin role
1106        let session = Session::new();
1107        let ctx = VisibilityContext::new(&session);
1108        let templates = manager.list_templates_for_session(&session, &ctx);
1109
1110        // Should only see FileTemplate, not AdminOnlyTemplate
1111        assert_eq!(templates.len(), 1);
1112        assert_eq!(templates[0].name, "project_files");
1113
1114        // Admin session
1115        let admin_session = Session::new();
1116        admin_session.set_state("roles", serde_json::json!(["admin"]));
1117        let admin_ctx = VisibilityContext::new(&admin_session);
1118        let admin_templates = manager.list_templates_for_session(&admin_session, &admin_ctx);
1119
1120        // Should see both templates
1121        assert_eq!(admin_templates.len(), 2);
1122        let names: Vec<_> = admin_templates.iter().map(|t| t.name.as_str()).collect();
1123        assert!(names.contains(&"project_files"));
1124        assert!(names.contains(&"admin_resources"));
1125    }
1126
1127    #[test]
1128    fn test_template_info_serialization() {
1129        let manager = ResourceManager::new();
1130        manager.register_template(FileTemplate);
1131
1132        let templates = manager.list_templates();
1133        let serialized = serde_json::to_value(&templates[0]).unwrap();
1134
1135        // Verify MCP-compliant field names (camelCase)
1136        assert!(serialized.get("uriTemplate").is_some());
1137        assert_eq!(serialized["uriTemplate"], "file:///{path}");
1138        assert_eq!(serialized["name"], "project_files");
1139        assert_eq!(serialized["mimeType"], "application/octet-stream");
1140
1141        // Verify snake_case fields are NOT present
1142        assert!(serialized.get("uri_template").is_none());
1143        assert!(serialized.get("mime_type").is_none());
1144    }
1145
1146    #[tokio::test]
1147    async fn test_template_read_with_uri_params() {
1148        let (_tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1149        let logger = crate::logging::McpLogger::new(_tx, "test");
1150        let session = Session::new();
1151        let ctx = ExecutionContext::for_resource(
1152            vec![("path".to_string(), "src/main.rs".to_string())]
1153                .into_iter()
1154                .collect(),
1155            &session,
1156            &logger,
1157        );
1158
1159        let template = FileTemplate;
1160        let result = template.read(ctx).await.unwrap();
1161
1162        assert_eq!(result.len(), 1);
1163        assert_eq!(result[0].uri, "file:///src/main.rs");
1164        assert!(result[0].text.as_ref().unwrap().contains("src/main.rs"));
1165    }
1166
1167    #[tokio::test]
1168    async fn test_template_read_missing_param() {
1169        let (_tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1170        let logger = crate::logging::McpLogger::new(_tx, "test");
1171        let session = Session::new();
1172        let ctx = ExecutionContext::for_resource(HashMap::new(), &session, &logger);
1173
1174        let template = FileTemplate;
1175        let result = template.read(ctx).await;
1176
1177        // Should fail with InvalidUri when path parameter is missing
1178        assert!(matches!(result, Err(ResourceError::InvalidUri(_))));
1179    }
1180}