Skip to main content

mcp_plugin_api/
resource.rs

1//! Type-safe MCP resource definitions
2//!
3//! This module provides a high-level API for defining MCP resources with
4//! compile-time type checking and automatic JSON generation for the
5//! resources/list, resources/templates/list, and resources/read protocol messages.
6
7use regex::Regex;
8use serde_json::{json, Value};
9use std::collections::HashMap;
10
11/// Handler for a URI template match: receives the request URI and extracted `{name}` values.
12pub type TemplateResourceHandler =
13    fn(uri: &str, vars: &HashMap<String, String>) -> Result<ResourceContents, String>;
14
15/// Fallback handler when no static resource or template matches (user parses URI as needed).
16pub type GenericResourceReadHandler = fn(&str) -> Result<ResourceContents, String>;
17
18/// Content of a resource, either text or binary (base64-encoded).
19///
20/// Matches the MCP resources/read response format:
21/// - Text content: `{ "uri", "mimeType?", "text" }`
22/// - Binary content: `{ "uri", "mimeType?", "blob" }` (base64)
23#[derive(Debug, Clone)]
24pub struct ResourceContent {
25    /// Resource URI
26    pub uri: String,
27    /// Optional MIME type
28    pub mime_type: Option<String>,
29    /// Text content (use for UTF-8 text)
30    pub text: Option<String>,
31    /// Base64-encoded binary content (use for binary data)
32    pub blob: Option<String>,
33}
34
35impl ResourceContent {
36    /// Create text content
37    pub fn text(uri: impl Into<String>, content: impl Into<String>, mime_type: Option<String>) -> Self {
38        Self {
39            uri: uri.into(),
40            mime_type,
41            text: Some(content.into()),
42            blob: None,
43        }
44    }
45
46    /// Create binary content (base64-encoded)
47    pub fn blob(uri: impl Into<String>, base64_data: impl Into<String>, mime_type: Option<String>) -> Self {
48        Self {
49            uri: uri.into(),
50            mime_type,
51            text: None,
52            blob: Some(base64_data.into()),
53        }
54    }
55
56    /// Convert to MCP content block JSON
57    pub fn to_json(&self) -> Value {
58        let mut obj = serde_json::Map::new();
59        obj.insert("uri".to_string(), json!(self.uri));
60        if let Some(ref mt) = self.mime_type {
61            obj.insert("mimeType".to_string(), json!(mt));
62        }
63        if let Some(ref t) = self.text {
64            obj.insert("text".to_string(), json!(t));
65        }
66        if let Some(ref b) = self.blob {
67            obj.insert("blob".to_string(), json!(b));
68        }
69        Value::Object(obj)
70    }
71}
72
73/// Collection of resource contents (e.g. multi-part resource)
74pub type ResourceContents = Vec<ResourceContent>;
75
76/// Resource read handler function type
77///
78/// Given a URI, returns the resource contents or an error.
79pub type ResourceHandler = fn(&str) -> Result<ResourceContents, String>;
80
81/// A resource definition for the resources/list response
82///
83/// Represents a single resource with its metadata. The handler is called
84/// when the client requests the resource via resources/read.
85#[derive(Debug, Clone)]
86pub struct Resource {
87    /// Unique resource URI
88    pub uri: String,
89    /// Resource name (e.g. filename)
90    pub name: Option<String>,
91    /// Human-readable title for display
92    pub title: Option<String>,
93    /// Description of the resource
94    pub description: Option<String>,
95    /// Optional MIME type
96    pub mime_type: Option<String>,
97    /// Handler to read resource content when requested
98    pub handler: ResourceHandler,
99}
100
101impl Resource {
102    /// Create a new resource with a builder
103    ///
104    /// # Example
105    ///
106    /// ```ignore
107    /// Resource::builder("file:///docs/readme", read_readme)
108    ///     .name("readme.md")
109    ///     .description("Project documentation")
110    ///     .mime_type("text/markdown")
111    /// ```
112    pub fn builder(uri: impl Into<String>, handler: ResourceHandler) -> ResourceBuilder {
113        ResourceBuilder {
114            uri: uri.into(),
115            name: None,
116            title: None,
117            description: None,
118            mime_type: None,
119            handler,
120        }
121    }
122
123    /// Convert to MCP resources/list item format
124    ///
125    /// Returns a JSON object compatible with MCP protocol:
126    /// ```json
127    /// {
128    ///   "uri": "file:///...",
129    ///   "name": "main.rs",
130    ///   "title": "Optional title",
131    ///   "description": "Optional description",
132    ///   "mimeType": "text/x-rust"
133    /// }
134    /// ```
135    pub fn to_list_item(&self) -> Value {
136        let mut obj = serde_json::Map::new();
137        obj.insert("uri".to_string(), json!(self.uri));
138        if let Some(ref n) = self.name {
139            obj.insert("name".to_string(), json!(n));
140        }
141        if let Some(ref t) = self.title {
142            obj.insert("title".to_string(), json!(t));
143        }
144        if let Some(ref d) = self.description {
145            obj.insert("description".to_string(), json!(d));
146        }
147        if let Some(ref mt) = self.mime_type {
148            obj.insert("mimeType".to_string(), json!(mt));
149        }
150        Value::Object(obj)
151    }
152}
153
154/// Builder for creating resources with a fluent API
155pub struct ResourceBuilder {
156    uri: String,
157    name: Option<String>,
158    title: Option<String>,
159    description: Option<String>,
160    mime_type: Option<String>,
161    handler: ResourceHandler,
162}
163
164impl ResourceBuilder {
165    /// Set the resource name
166    pub fn name(mut self, name: impl Into<String>) -> Self {
167        self.name = Some(name.into());
168        self
169    }
170
171    /// Set the human-readable title
172    pub fn title(mut self, title: impl Into<String>) -> Self {
173        self.title = Some(title.into());
174        self
175    }
176
177    /// Set the description
178    pub fn description(mut self, description: impl Into<String>) -> Self {
179        self.description = Some(description.into());
180        self
181    }
182
183    /// Set the MIME type
184    pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
185        self.mime_type = Some(mime_type.into());
186        self
187    }
188
189    /// Finalize and build the Resource
190    pub fn build(self) -> Resource {
191        Resource {
192            uri: self.uri,
193            name: self.name,
194            title: self.title,
195            description: self.description,
196            mime_type: self.mime_type,
197            handler: self.handler,
198        }
199    }
200}
201
202// --- URI template matching ({var} placeholders) -------------------------------------------
203
204/// Compiles a uriTemplate into a regex and ordered variable names.
205///
206/// Each `{name}` becomes a capture group `([^/]+)` (path segment). Literal parts are
207/// [`regex::escape`]-d so dots and other regex metacharacters match literally—aligned
208/// with typical client-side template matching.
209fn compile_uri_template(template: &str) -> Result<(Regex, Vec<String>), ()> {
210    if !template.contains('{') {
211        let re = Regex::new(&format!("^{}$", regex::escape(template))).map_err(|_| ())?;
212        return Ok((re, Vec::new()));
213    }
214
215    let mut var_names = Vec::new();
216    let mut pattern = String::from("^");
217    let mut rest = template;
218
219    while let Some(open) = rest.find('{') {
220        let literal = &rest[..open];
221        pattern.push_str(&regex::escape(literal));
222
223        let after = &rest[open + 1..];
224        let close = after.find('}').ok_or(())?;
225        let name = after[..close].trim();
226        if name.is_empty() {
227            return Err(());
228        }
229        var_names.push(name.to_string());
230        pattern.push_str("([^/]+)");
231        rest = &after[close + 1..];
232    }
233
234    pattern.push_str(&regex::escape(rest));
235    pattern.push('$');
236
237    let re = Regex::new(&pattern).map_err(|_| ())?;
238    Ok((re, var_names))
239}
240
241/// Match `uri` against a template string with `{var}` placeholders.
242///
243/// Uses the same idea as common MCP clients: compile a regex with `([^/]+)` per
244/// placeholder and [`regex::escape`] for literals. Returns captured names → values, or
245/// `None` if the URI does not match or the template is invalid.
246///
247/// **Note:** This compiles a fresh regex on every call. For repeated matching (e.g.
248/// inside `declare_resources!`), the framework uses [`CompiledTemplateMatcher`] which
249/// pre-compiles once at initialization. Use this function for ad-hoc / one-off matching.
250///
251/// Not full RFC 6570; use [`GenericResourceReadHandler`] as `read_fallback` for exotic cases.
252pub fn match_uri_against_template(uri: &str, template: &str) -> Option<HashMap<String, String>> {
253    let (re, var_names) = compile_uri_template(template).ok()?;
254    let caps = re.captures(uri)?;
255
256    if var_names.is_empty() {
257        return Some(HashMap::new());
258    }
259
260    let mut vars = HashMap::new();
261    for (i, name) in var_names.iter().enumerate() {
262        let group = caps.get(i + 1)?.as_str();
263        vars.insert(name.clone(), group.to_string());
264    }
265    Some(vars)
266}
267
268/// Pre-compiled URI template matcher. Built once at initialization time so that
269/// `read_resource` dispatch only runs the regex (no compilation overhead per request).
270///
271/// Created via [`CompiledTemplateMatcher::new`] and stored in a `OnceLock` by the
272/// [`declare_resources!`](crate::declare_resources) macro.
273#[derive(Debug)]
274pub struct CompiledTemplateMatcher {
275    matcher: Regex,
276    var_names: Vec<String>,
277    /// The original template definition (metadata + handler).
278    pub template: ResourceTemplate,
279}
280
281impl CompiledTemplateMatcher {
282    /// Compile a [`ResourceTemplate`] into a matcher.
283    ///
284    /// Returns `Err` with a message if the URI template is syntactically invalid.
285    pub fn new(template: ResourceTemplate) -> Result<Self, String> {
286        let (matcher, var_names) = compile_uri_template(&template.uri_template)
287            .map_err(|_| format!("invalid URI template: {}", template.uri_template))?;
288        Ok(Self {
289            matcher,
290            var_names,
291            template,
292        })
293    }
294
295    /// Try to match a URI. Returns extracted `{name}` → value pairs, or `None`.
296    pub fn match_uri(&self, uri: &str) -> Option<HashMap<String, String>> {
297        let caps = self.matcher.captures(uri)?;
298        let mut vars = HashMap::with_capacity(self.var_names.len());
299        for (i, name) in self.var_names.iter().enumerate() {
300            let group = caps.get(i + 1)?.as_str();
301            vars.insert(name.clone(), group.to_string());
302        }
303        Some(vars)
304    }
305}
306
307// --- Resource templates (resources/templates/list) ----------------------------------------
308
309/// Parameterized resource definition for `resources/templates/list` and template-based reads.
310#[derive(Debug, Clone)]
311pub struct ResourceTemplate {
312    /// URI template (e.g. `file:///project/{path}`)
313    pub uri_template: String,
314    pub name: Option<String>,
315    pub title: Option<String>,
316    pub description: Option<String>,
317    pub mime_type: Option<String>,
318    pub handler: TemplateResourceHandler,
319}
320
321impl ResourceTemplate {
322    pub fn builder(
323        uri_template: impl Into<String>,
324        handler: TemplateResourceHandler,
325    ) -> ResourceTemplateBuilder {
326        ResourceTemplateBuilder {
327            uri_template: uri_template.into(),
328            name: None,
329            title: None,
330            description: None,
331            mime_type: None,
332            handler,
333        }
334    }
335
336    /// MCP `resourceTemplates` list item: `uriTemplate`, `name`, optional fields.
337    pub fn to_template_list_item(&self) -> Value {
338        let mut obj = serde_json::Map::new();
339        obj.insert("uriTemplate".to_string(), json!(self.uri_template));
340        if let Some(ref n) = self.name {
341            obj.insert("name".to_string(), json!(n));
342        }
343        if let Some(ref t) = self.title {
344            obj.insert("title".to_string(), json!(t));
345        }
346        if let Some(ref d) = self.description {
347            obj.insert("description".to_string(), json!(d));
348        }
349        if let Some(ref mt) = self.mime_type {
350            obj.insert("mimeType".to_string(), json!(mt));
351        }
352        Value::Object(obj)
353    }
354}
355
356pub struct ResourceTemplateBuilder {
357    uri_template: String,
358    name: Option<String>,
359    title: Option<String>,
360    description: Option<String>,
361    mime_type: Option<String>,
362    handler: TemplateResourceHandler,
363}
364
365impl ResourceTemplateBuilder {
366    pub fn name(mut self, name: impl Into<String>) -> Self {
367        self.name = Some(name.into());
368        self
369    }
370
371    pub fn title(mut self, title: impl Into<String>) -> Self {
372        self.title = Some(title.into());
373        self
374    }
375
376    pub fn description(mut self, description: impl Into<String>) -> Self {
377        self.description = Some(description.into());
378        self
379    }
380
381    pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
382        self.mime_type = Some(mime_type.into());
383        self
384    }
385
386    pub fn build(self) -> ResourceTemplate {
387        ResourceTemplate {
388            uri_template: self.uri_template,
389            name: self.name,
390            title: self.title,
391            description: self.description,
392            mime_type: self.mime_type,
393            handler: self.handler,
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::{match_uri_against_template, CompiledTemplateMatcher, ResourceTemplate};
401    use std::collections::HashMap;
402
403    fn dummy_handler(_uri: &str, _vars: &HashMap<String, String>) -> Result<super::ResourceContents, String> {
404        Ok(vec![])
405    }
406
407    #[test]
408    fn match_uri_template_trailing_placeholder() {
409        let m = match_uri_against_template("file:///docs/readme.md", "file:///docs/{path}").unwrap();
410        assert_eq!(m.get("path").map(|s| s.as_str()), Some("readme.md"));
411    }
412
413    #[test]
414    fn match_uri_template_two_segments() {
415        let m = match_uri_against_template(
416            "https://example.com/foo",
417            "https://{host}/{path}",
418        )
419        .unwrap();
420        assert_eq!(m.get("host").map(|s| s.as_str()), Some("example.com"));
421        assert_eq!(m.get("path").map(|s| s.as_str()), Some("foo"));
422    }
423
424    #[test]
425    fn match_uri_template_multi_segment_path_needs_more_placeholders() {
426        assert!(
427            match_uri_against_template("https://example.com/foo/bar", "https://{host}/{path}")
428                .is_none(),
429            "two placeholders => two segments; leftover /bar does not match"
430        );
431        let m = match_uri_against_template(
432            "https://example.com/foo/bar",
433            "https://{host}/{a}/{b}",
434        )
435        .unwrap();
436        assert_eq!(m.get("a").map(|s| s.as_str()), Some("foo"));
437        assert_eq!(m.get("b").map(|s| s.as_str()), Some("bar"));
438    }
439
440    #[test]
441    fn compiled_matcher_reuses_regex() {
442        let tmpl = ResourceTemplate::builder("gmc:///reading/{token}", dummy_handler)
443            .name("readings")
444            .build();
445        let matcher = CompiledTemplateMatcher::new(tmpl).unwrap();
446
447        let m1 = matcher.match_uri("gmc:///reading/abc123").unwrap();
448        assert_eq!(m1.get("token").map(|s| s.as_str()), Some("abc123"));
449
450        let m2 = matcher.match_uri("gmc:///reading/xyz789").unwrap();
451        assert_eq!(m2.get("token").map(|s| s.as_str()), Some("xyz789"));
452
453        assert!(matcher.match_uri("gmc:///other/abc").is_none());
454    }
455
456    #[test]
457    fn compiled_matcher_dots_in_literal_are_exact() {
458        let tmpl = ResourceTemplate::builder("https://api.example.com/{id}", dummy_handler)
459            .build();
460        let matcher = CompiledTemplateMatcher::new(tmpl).unwrap();
461
462        assert!(matcher.match_uri("https://api.example.com/42").is_some());
463        assert!(
464            matcher.match_uri("https://apiXexampleYcom/42").is_none(),
465            "dots in the literal must match literally, not as regex wildcards"
466        );
467    }
468
469    #[test]
470    fn compiled_matcher_invalid_template() {
471        let tmpl = ResourceTemplate::builder("bad://{unclosed", dummy_handler).build();
472        assert!(CompiledTemplateMatcher::new(tmpl).is_err());
473    }
474}