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}