org_mcp_server/resources/
mod.rs

1mod org_file;
2mod org_file_list;
3mod org_heading;
4mod org_id;
5mod org_outline;
6mod utils;
7
8#[cfg(test)]
9mod resource_tests;
10
11use rmcp::model::{
12    AnnotateAble, Implementation, InitializeRequestParam, InitializeResult,
13    ListResourceTemplatesResult, ListResourcesResult, PaginatedRequestParam, RawResource,
14    RawResourceTemplate, ReadResourceRequestParam, ReadResourceResult,
15};
16use rmcp::service::RequestContext;
17use rmcp::{
18    ErrorData as McpError,
19    model::{ServerCapabilities, ServerInfo},
20    tool_handler,
21};
22
23use rmcp::{RoleServer, ServerHandler};
24use serde_json::json;
25
26use crate::core::OrgModeRouter;
27
28pub enum OrgResource {
29    OrgFiles,
30    Org { path: String },
31    OrgOutline { path: String },
32    OrgHeading { path: String, heading: String },
33    OrgId { id: String },
34}
35
36#[tool_handler]
37impl ServerHandler for OrgModeRouter {
38    fn get_info(&self) -> ServerInfo {
39        const INSTRUCTIONS: &str = concat!(
40            "This server provides org-mode tools and resources.\n\n",
41            "Tools:\n",
42            "- org-file-list\n",
43            "Resources:\n",
44            "- org:// (List all org-mode files in the configured directory tree)\n",
45            "- org://{file} (Access the raw content of an allowed Org file)\n",
46            "- org-outline://{file} (Get the hierarchical structure of an Org file)\n",
47            "- org-heading://{file}#{heading} (Access the content of a specific headline by its path)\n",
48            "- org-id://{uuid} (Access Org node content by its unique ID property)\n",
49        );
50
51        ServerInfo {
52            instructions: Some(INSTRUCTIONS.into()),
53            capabilities: ServerCapabilities::builder()
54                .enable_tools()
55                .enable_resources()
56                .enable_completions()
57                .build(),
58            server_info: Implementation::from_build_env(),
59            ..Default::default()
60        }
61    }
62
63    async fn list_resources(
64        &self,
65        _request: Option<PaginatedRequestParam>,
66        _: RequestContext<RoleServer>,
67    ) -> Result<ListResourcesResult, McpError> {
68        Ok(ListResourcesResult {
69            resources: vec![
70                RawResource {
71                    uri: "org://".to_string(),
72                    name: "org".to_string(),
73                    title: None,
74                    icons: None,
75                    description: Some(
76                        "List all org-mode files in the configured directory tree".to_string(),
77                    ),
78                    size: None,
79                    mime_type: Some("application/json".to_string()),
80                }
81                .no_annotation(),
82            ],
83            next_cursor: None,
84        })
85    }
86
87    async fn list_resource_templates(
88        &self,
89        _: Option<PaginatedRequestParam>,
90        _: RequestContext<RoleServer>,
91    ) -> Result<ListResourceTemplatesResult, McpError> {
92        Ok(ListResourceTemplatesResult {
93            next_cursor: None,
94            resource_templates: vec![
95                RawResourceTemplate {
96                    uri_template: "org://{file}".to_string(),
97                    name: "org-file".to_string(),
98                    title: None,
99                    description: Some(
100                        "Access the raw content of an org-mode file by its path".to_string(),
101                    ),
102                    mime_type: Some("text/org".to_string()),
103                }
104                .no_annotation(),
105                RawResourceTemplate {
106                    uri_template: "org-outline://{file}".to_string(),
107                    name: "org-outline-file".to_string(),
108                    title: None,
109                    description: Some(
110                        "Get the hierarchical outline structure of an org-mode file as JSON"
111                            .to_string(),
112                    ),
113                    mime_type: Some("application/json".to_string()),
114                }
115                .no_annotation(),
116                RawResourceTemplate {
117                    uri_template: "org-heading://{file}#{heading}".to_string(),
118                    name: "org-heading-file".to_string(),
119                    title: None,
120                    description: Some(
121                        "Access the content of a specific heading within an org-mode file"
122                            .to_string(),
123                    ),
124                    mime_type: Some("text/org".to_string()),
125                }
126                .no_annotation(),
127                RawResourceTemplate {
128                    uri_template: "org-id://{id}".to_string(),
129                    name: "org-element-by-id".to_string(),
130                    title: None,
131                    description: Some(
132                        "Access the content of any org-mode element by its unique ID property"
133                            .to_string(),
134                    ),
135                    mime_type: Some("text/org".to_string()),
136                }
137                .no_annotation(),
138            ],
139        })
140    }
141
142    async fn read_resource(
143        &self,
144        ReadResourceRequestParam { uri }: ReadResourceRequestParam,
145        _context: RequestContext<RoleServer>,
146    ) -> Result<ReadResourceResult, McpError> {
147        match OrgModeRouter::parse_resource(uri.clone()) {
148            Some(OrgResource::OrgFiles) => self.list_files(uri).await,
149            Some(OrgResource::Org { path }) => self.read_file(uri, path).await,
150            Some(OrgResource::OrgOutline { path }) => self.outline(uri, path).await,
151            Some(OrgResource::OrgHeading { path, heading }) => {
152                self.heading(uri, path, heading).await
153            }
154            Some(OrgResource::OrgId { id }) => self.id(uri, id).await,
155
156            None => Err(McpError::resource_not_found(
157                format!("Invalid resource URI format: {}", uri),
158                Some(json!({"uri": uri})),
159            )),
160        }
161    }
162
163    async fn initialize(
164        &self,
165        _request: InitializeRequestParam,
166        _context: RequestContext<RoleServer>,
167    ) -> Result<InitializeResult, McpError> {
168        Ok(self.get_info())
169    }
170}
171
172impl OrgModeRouter {
173    fn parse_resource(uri: String) -> Option<OrgResource> {
174        let uri = Self::decode_uri_path(&uri);
175
176        if uri == "org://" {
177            Some(OrgResource::OrgFiles)
178        } else if let Some(path) = uri.strip_prefix("org://")
179            && !path.is_empty()
180        {
181            Some(OrgResource::Org {
182                path: path.to_string(),
183            })
184        } else if let Some(id) = uri.strip_prefix("org-id://")
185            && !id.is_empty()
186        {
187            Some(OrgResource::OrgId { id: id.to_string() })
188        } else if let Some(path) = uri.strip_prefix("org-outline://")
189            && !path.is_empty()
190        {
191            Some(OrgResource::OrgOutline {
192                path: path.to_string(),
193            })
194        } else if let Some(remainder) = uri.strip_prefix("org-heading://")
195            && !remainder.is_empty()
196            && let Some((path, heading)) = remainder.split_once('#')
197            && !path.is_empty()
198            && !heading.is_empty()
199        {
200            Some(OrgResource::OrgHeading {
201                path: path.to_string(),
202                heading: heading.to_string(),
203            })
204        } else {
205            None
206        }
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use crate::{core::OrgModeRouter, resources::OrgResource};
213
214    #[test]
215    fn test_org_files_list_resource() {
216        let result = OrgModeRouter::parse_resource("org://".to_string());
217        assert!(matches!(result, Some(OrgResource::OrgFiles)));
218    }
219
220    #[test]
221    fn test_org_resource_parsing() {
222        let cases = vec![
223            ("org://simple.org", "simple.org"),
224            ("org://path/to/file.org", "path/to/file.org"),
225            (
226                "org://deep/nested/path/document.org",
227                "deep/nested/path/document.org",
228            ),
229            (
230                "org://file_with_underscores.org",
231                "file_with_underscores.org",
232            ),
233            ("org://file-with-dashes.org", "file-with-dashes.org"),
234        ];
235
236        for (uri, expected_path) in cases {
237            let result = OrgModeRouter::parse_resource(uri.to_string());
238            match result {
239                Some(OrgResource::Org { path }) => {
240                    assert_eq!(path, expected_path, "Failed for URI: {}", uri);
241                }
242                _ => {
243                    unreachable!("Expected Org resource for URI: {}", uri);
244                }
245            }
246        }
247    }
248
249    #[test]
250    fn test_org_outline_resource_parsing() {
251        let cases = vec![
252            ("org-outline://simple.org", "simple.org"),
253            ("org-outline://path/to/file.org", "path/to/file.org"),
254            (
255                "org-outline://deep/nested/path/document.org",
256                "deep/nested/path/document.org",
257            ),
258        ];
259
260        for (uri, expected_path) in cases {
261            let result = OrgModeRouter::parse_resource(uri.to_string());
262            match result {
263                Some(OrgResource::OrgOutline { path }) => {
264                    assert_eq!(path, expected_path, "Failed for URI: {}", uri);
265                }
266                _ => {
267                    unreachable!("Expected OrgOutline resource for URI: {}", uri);
268                }
269            }
270        }
271    }
272
273    #[test]
274    fn test_org_heading_resource_parsing() {
275        let cases = vec![
276            (
277                "org-heading://file.org#Introduction",
278                "file.org",
279                "Introduction",
280            ),
281            (
282                "org-heading://path/to/file.org#My Heading",
283                "path/to/file.org",
284                "My Heading",
285            ),
286            (
287                "org-heading://notes/tasks.org#Project Planning",
288                "notes/tasks.org",
289                "Project Planning",
290            ),
291            (
292                "org-heading://complex/path#Heading with Multiple Words",
293                "complex/path",
294                "Heading with Multiple Words",
295            ),
296            (
297                "org-heading://file.org#Section 1.2.3",
298                "file.org",
299                "Section 1.2.3",
300            ),
301        ];
302
303        for (uri, expected_path, expected_heading) in cases {
304            let result = OrgModeRouter::parse_resource(uri.to_string());
305            match result {
306                Some(OrgResource::OrgHeading { path, heading }) => {
307                    assert_eq!(path, expected_path, "Path failed for URI: {}", uri);
308                    assert_eq!(heading, expected_heading, "Heading failed for URI: {}", uri);
309                }
310                _ => {
311                    unreachable!("Expected OrgHeading resource for URI: {}", uri);
312                }
313            }
314        }
315    }
316
317    #[test]
318    fn test_uri_decoding() {
319        let cases = vec![
320            ("path%2Fto%2Ffile.org", "path/to/file.org"),
321            ("file%20with%20spaces.org", "file with spaces.org"),
322            ("deep%2Fnested%2Fpath.org", "deep/nested/path.org"),
323            (
324                "path%2Fto%2Ffile.org%23Special%20Heading",
325                "path/to/file.org#Special Heading",
326            ),
327        ];
328
329        for (encoded_path, expected_decoded) in cases {
330            let decoded = OrgModeRouter::decode_uri_path(encoded_path);
331            assert_eq!(
332                decoded, expected_decoded,
333                "URI decoding failed for: {}",
334                encoded_path
335            );
336        }
337    }
338
339    #[test]
340    fn test_uri_decoding_in_parsing() {
341        let result = OrgModeRouter::parse_resource("org://path%2Fto%2Ffile.org".to_string());
342        match result {
343            Some(OrgResource::Org { path }) => {
344                assert_eq!(path, "path/to/file.org");
345            }
346            _ => {
347                unreachable!("Failed to parse URL-encoded org URI");
348            }
349        }
350
351        let result = OrgModeRouter::parse_resource(
352            "org-heading://notes%2Ftasks.org%23Project%20Planning".to_string(),
353        );
354        match result {
355            Some(OrgResource::OrgHeading { path, heading }) => {
356                assert_eq!(path, "notes/tasks.org");
357                assert_eq!(heading, "Project Planning");
358            }
359            _ => {
360                unreachable!("Failed to parse URL-encoded org-heading URI");
361            }
362        }
363    }
364
365    #[test]
366    fn test_invalid_uris() {
367        let invalid_cases = vec![
368            ("", "empty string"),
369            ("invalid://path", "invalid scheme"),
370            ("org-outline://", "empty outline path"),
371            ("org-heading://", "empty heading URI"),
372            ("org-heading://path", "missing heading separator"),
373            ("org-heading://path#", "empty heading"),
374            ("org-heading://#heading", "empty path"),
375            ("random-string", "no scheme"),
376            ("org", "incomplete scheme"),
377            ("org:/", "incomplete scheme"),
378        ];
379
380        for (uri, description) in invalid_cases {
381            let result = OrgModeRouter::parse_resource(uri.to_string());
382            assert!(
383                result.is_none(),
384                "Expected None for {} (URI: '{}')",
385                description,
386                uri
387            );
388        }
389    }
390
391    #[test]
392    fn test_boundary_cases() {
393        assert!(matches!(
394            OrgModeRouter::parse_resource("org://a".to_string()),
395            Some(OrgResource::Org { path }) if path == "a"
396        ));
397
398        assert!(matches!(
399            OrgModeRouter::parse_resource("org-heading://a#b".to_string()),
400            Some(OrgResource::OrgHeading { path, heading }) if path == "a" && heading == "b"
401        ));
402
403        let result =
404            OrgModeRouter::parse_resource("org-heading://file.org#Heading: With Colon".to_string());
405        match result {
406            Some(OrgResource::OrgHeading { path, heading }) => {
407                assert_eq!(path, "file.org");
408                assert_eq!(heading, "Heading: With Colon");
409            }
410            _ => {
411                unreachable!("Failed to parse heading with special characters");
412            }
413        }
414
415        let result =
416            OrgModeRouter::parse_resource("org-heading://file.org#Section#Subsection".to_string());
417        match result {
418            Some(OrgResource::OrgHeading { path, heading }) => {
419                assert_eq!(path, "file.org");
420                assert_eq!(heading, "Section#Subsection");
421            }
422            _ => {
423                unreachable!("Failed to parse heading with multiple # characters");
424            }
425        }
426    }
427
428    #[test]
429    fn test_case_sensitivity() {
430        let invalid_cases = vec![
431            "ORG://path/to/file",
432            "Org://path/to/file",
433            "org-OUTLINE://path/to/file",
434            "ORG-HEADING://path#heading",
435        ];
436
437        for uri in invalid_cases {
438            let result = OrgModeRouter::parse_resource(uri.to_string());
439            assert!(
440                result.is_none(),
441                "Expected case-sensitive scheme rejection for: {}",
442                uri
443            );
444        }
445    }
446}