Skip to main content

kimun_notes/cli/commands/mcp/
mod.rs

1// tui/src/cli/commands/mcp/mod.rs
2//
3// MCP server handler for kimun — exposes vault operations as MCP tools.
4
5pub mod prompts;
6
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use color_eyre::eyre::{Result, eyre};
11use kimun_core::{NoteVault, nfs::VaultPath};
12use rmcp::{
13    ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
14    handler::server::{
15        router::{prompt::PromptRouter, tool::ToolRouter},
16        wrapper::Parameters,
17    },
18    model::*,
19    prompt_handler, schemars,
20    service::RequestContext,
21    tool, tool_handler, tool_router,
22    transport::stdio,
23};
24use serde::Deserialize;
25
26// ---------------------------------------------------------------------------
27// Parameter structs
28// ---------------------------------------------------------------------------
29
30#[derive(Debug, Deserialize, schemars::JsonSchema)]
31pub struct CreateNoteParams {
32    pub path: String,
33    pub content: String,
34}
35
36#[derive(Debug, Deserialize, schemars::JsonSchema)]
37pub struct AppendNoteParams {
38    pub path: String,
39    pub content: String,
40}
41
42#[derive(Debug, Deserialize, schemars::JsonSchema)]
43pub struct ShowNoteParams {
44    pub path: String,
45}
46
47#[derive(Debug, Deserialize, schemars::JsonSchema)]
48pub struct SearchNotesParams {
49    pub query: String,
50}
51
52#[derive(Debug, Deserialize, schemars::JsonSchema)]
53pub struct ListNotesParams {
54    pub path: Option<String>,
55}
56
57#[derive(Debug, Deserialize, schemars::JsonSchema)]
58pub struct JournalParams {
59    pub text: String,
60    pub date: Option<String>,
61}
62
63#[derive(Debug, Deserialize, schemars::JsonSchema)]
64pub struct BacklinksParams {
65    pub path: String,
66}
67
68#[derive(Debug, Deserialize, schemars::JsonSchema)]
69pub struct ChunksParams {
70    pub path: String,
71}
72
73#[derive(Debug, Deserialize, schemars::JsonSchema)]
74pub struct OutlinksParams {
75    pub path: String,
76}
77
78#[derive(Debug, Deserialize, schemars::JsonSchema)]
79pub struct RenameNoteParams {
80    pub path: String,
81    /// New filename stem — no extension, no path separator
82    pub new_name: String,
83}
84
85#[derive(Debug, Deserialize, schemars::JsonSchema)]
86pub struct MoveNoteParams {
87    pub path: String,
88    pub new_path: String,
89}
90
91#[derive(Debug, Deserialize, schemars::JsonSchema)]
92pub struct QuickNoteParams {
93    /// Text content for the quick note
94    pub content: String,
95}
96
97// ---------------------------------------------------------------------------
98// Handler struct
99// ---------------------------------------------------------------------------
100
101#[derive(Clone)]
102pub struct KimunHandler {
103    vault: Arc<NoteVault>,
104    tool_router: ToolRouter<KimunHandler>,
105    prompt_router: PromptRouter<KimunHandler>,
106}
107
108// ---------------------------------------------------------------------------
109// Tool implementations
110// ---------------------------------------------------------------------------
111
112#[tool_router]
113impl KimunHandler {
114    pub fn new(vault: NoteVault) -> Self {
115        Self {
116            vault: Arc::new(vault),
117            tool_router: Self::tool_router(),
118            prompt_router: Self::prompt_router(),
119        }
120    }
121
122    fn resolve_path(path: &str) -> VaultPath {
123        VaultPath::note_path_from(path)
124    }
125
126    #[tool(
127        description = "Create a new note at the given vault path with the given markdown content. Fails if the note already exists."
128    )]
129    async fn create_note(
130        &self,
131        Parameters(p): Parameters<CreateNoteParams>,
132    ) -> Result<CallToolResult, McpError> {
133        let vault_path = Self::resolve_path(&p.path);
134        match self.vault.create_note(&vault_path, &p.content).await {
135            Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
136                "Note created: {}",
137                vault_path
138            ))])),
139            Err(kimun_core::error::VaultError::NoteExists { .. }) => {
140                Ok(CallToolResult::error(vec![Content::text(format!(
141                    "Note already exists: {}",
142                    vault_path
143                ))]))
144            }
145            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
146        }
147    }
148
149    #[tool(description = "Append text to an existing note. Creates the note if it does not exist.")]
150    async fn append_note(
151        &self,
152        Parameters(p): Parameters<AppendNoteParams>,
153    ) -> Result<CallToolResult, McpError> {
154        let vault_path = Self::resolve_path(&p.path);
155        let existing = self
156            .vault
157            .load_or_create_note(&vault_path, None)
158            .await
159            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
160        let combined = if existing.is_empty() {
161            p.content
162        } else {
163            format!("{}\n{}", existing, p.content)
164        };
165        self.vault
166            .save_note(&vault_path, &combined)
167            .await
168            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
169        Ok(CallToolResult::success(vec![Content::text(format!(
170            "Note saved: {}",
171            vault_path
172        ))]))
173    }
174
175    #[tool(description = "Return the full markdown content of a note.")]
176    async fn show_note(
177        &self,
178        Parameters(p): Parameters<ShowNoteParams>,
179    ) -> Result<CallToolResult, McpError> {
180        let vault_path = Self::resolve_path(&p.path);
181        match self.vault.get_note_text(&vault_path).await {
182            Ok(text) => Ok(CallToolResult::success(vec![Content::text(text)])),
183            Err(kimun_core::error::VaultError::FSError(
184                kimun_core::error::FSError::VaultPathNotFound { .. },
185            )) => Ok(CallToolResult::error(vec![Content::text(format!(
186                "Note not found: {}",
187                vault_path
188            ))])),
189            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
190        }
191    }
192
193    #[tool(
194        description = "Search notes by query. Supports @filename, >heading, /path prefix, and -exclusion operators."
195    )]
196    async fn search_notes(
197        &self,
198        Parameters(p): Parameters<SearchNotesParams>,
199    ) -> Result<CallToolResult, McpError> {
200        let results = self
201            .vault
202            .search_notes(&p.query)
203            .await
204            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
205        if results.is_empty() {
206            return Ok(CallToolResult::success(vec![Content::text(
207                "No results found.",
208            )]));
209        }
210        let lines: Vec<String> = results
211            .iter()
212            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
213            .collect();
214        Ok(CallToolResult::success(vec![Content::text(
215            lines.join("\n"),
216        )]))
217    }
218
219    #[tool(description = "List all notes in the vault, optionally filtered by path prefix.")]
220    async fn list_notes(
221        &self,
222        Parameters(p): Parameters<ListNotesParams>,
223    ) -> Result<CallToolResult, McpError> {
224        let all = self
225            .vault
226            .get_all_notes()
227            .await
228            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
229        let filtered: Vec<_> = match &p.path {
230            None => all,
231            Some(prefix) => {
232                let norm = prefix.trim_matches('/');
233                all.into_iter()
234                    .filter(|(entry, _)| {
235                        let mut p = entry.path.clone();
236                        p.to_relative();
237                        p.to_string().starts_with(norm)
238                    })
239                    .collect()
240            }
241        };
242        if filtered.is_empty() {
243            return Ok(CallToolResult::success(vec![Content::text(
244                "No notes found.",
245            )]));
246        }
247        let lines: Vec<String> = filtered
248            .iter()
249            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
250            .collect();
251        Ok(CallToolResult::success(vec![Content::text(
252            lines.join("\n"),
253        )]))
254    }
255
256    #[tool(
257        description = "Append text to today's journal entry (or a specific date). Creates the entry if absent."
258    )]
259    async fn journal(
260        &self,
261        Parameters(p): Parameters<JournalParams>,
262    ) -> Result<CallToolResult, McpError> {
263        // Validate and resolve the date
264        let date_str = match p.date.as_deref() {
265            None => chrono::Utc::now().format("%Y-%m-%d").to_string(),
266            Some(d) => {
267                if chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").is_err() {
268                    return Ok(CallToolResult::error(vec![Content::text(format!(
269                        "Invalid date '{}' — expected YYYY-MM-DD",
270                        d
271                    ))]));
272                }
273                d.to_string()
274            }
275        };
276
277        let (vault_path, existing) = if p.date.is_none() {
278            // Today — use journal_entry() which handles create-if-absent internally
279            let (details, existing) = self
280                .vault
281                .journal_entry()
282                .await
283                .map_err(|e| McpError::internal_error(e.to_string(), None))?;
284            (details.path, existing)
285        } else {
286            // Specific date — build path manually
287            let journal_path = self
288                .vault
289                .journal_path()
290                .append(&VaultPath::note_path_from(&date_str))
291                .absolute();
292            let existing = self
293                .vault
294                .load_or_create_note(&journal_path, Some(format!("# {}\n\n", date_str)))
295                .await
296                .map_err(|e| McpError::internal_error(e.to_string(), None))?;
297            (journal_path, existing)
298        };
299
300        let combined = format!("{}\n{}", existing, p.text);
301        self.vault
302            .save_note(&vault_path, &combined)
303            .await
304            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
305
306        Ok(CallToolResult::success(vec![Content::text(format!(
307            "Note saved: {}",
308            vault_path
309        ))]))
310    }
311
312    #[tool(description = "Return the list of notes that link to the given note (backlinks).")]
313    async fn get_backlinks(
314        &self,
315        Parameters(p): Parameters<BacklinksParams>,
316    ) -> Result<CallToolResult, McpError> {
317        let vault_path = Self::resolve_path(&p.path);
318        let backlinks = self
319            .vault
320            .get_backlinks(&vault_path)
321            .await
322            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
323        if backlinks.is_empty() {
324            return Ok(CallToolResult::success(vec![Content::text(
325                "No backlinks found.",
326            )]));
327        }
328        let lines: Vec<String> = backlinks
329            .iter()
330            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
331            .collect();
332        Ok(CallToolResult::success(vec![Content::text(
333            lines.join("\n"),
334        )]))
335    }
336
337    #[tool(description = "Return the content chunks (sections) of a note as JSON.")]
338    async fn get_chunks(
339        &self,
340        Parameters(p): Parameters<ChunksParams>,
341    ) -> Result<CallToolResult, McpError> {
342        let vault_path = Self::resolve_path(&p.path);
343        let chunks_map = self
344            .vault
345            .get_note_chunks(&vault_path)
346            .await
347            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
348
349        let mut lines: Vec<String> = Vec::new();
350        for chunks in chunks_map.values() {
351            for chunk in chunks {
352                let breadcrumb = chunk.breadcrumb.join(" > ");
353                lines.push(format!("[{}] {}", breadcrumb, chunk.text.trim()));
354            }
355        }
356
357        if lines.is_empty() {
358            return Ok(CallToolResult::success(vec![Content::text(
359                "No chunks found.",
360            )]));
361        }
362        Ok(CallToolResult::success(vec![Content::text(
363            lines.join("\n\n"),
364        )]))
365    }
366
367    #[tool(description = "Return the list of notes that this note links to (outgoing wikilinks).")]
368    async fn get_outlinks(
369        &self,
370        Parameters(p): Parameters<OutlinksParams>,
371    ) -> Result<CallToolResult, McpError> {
372        use kimun_core::error::{FSError, VaultError};
373        use kimun_core::note::{LinkType, NoteDetails};
374
375        let vault_path = Self::resolve_path(&p.path);
376
377        let md_note = match self.vault.get_markdown_and_links(&vault_path).await {
378            Ok(n) => n,
379            Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
380                return Ok(CallToolResult::error(vec![Content::text(format!(
381                    "Note not found: {}",
382                    vault_path
383                ))]));
384            }
385            Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
386        };
387
388        let note_links: Vec<_> = md_note
389            .links
390            .into_iter()
391            .filter_map(|link| {
392                if let LinkType::Note(path) = link.ltype {
393                    Some(path)
394                } else {
395                    None
396                }
397            })
398            .collect();
399
400        if note_links.is_empty() {
401            return Ok(CallToolResult::success(vec![Content::text(
402                "No outlinks found.",
403            )]));
404        }
405
406        let mut lines: Vec<String> = Vec::new();
407        for path in note_links {
408            let title = match self.vault.get_note_text(&path).await {
409                Ok(text) => {
410                    let t = NoteDetails::get_title_from_text(&text);
411                    if t.is_empty() {
412                        path.get_clean_name()
413                    } else {
414                        t
415                    }
416                }
417                Err(_) => path.get_clean_name(),
418            };
419            lines.push(format!("{} — {}", path, title));
420        }
421
422        Ok(CallToolResult::success(vec![Content::text(
423            lines.join("\n"),
424        )]))
425    }
426
427    #[tool(
428        description = "Rename a note within its current directory (filename only). Use move_note to change the directory."
429    )]
430    async fn rename_note(
431        &self,
432        Parameters(p): Parameters<RenameNoteParams>,
433    ) -> Result<CallToolResult, McpError> {
434        if p.new_name.contains('/') {
435            return Ok(CallToolResult::error(vec![Content::text(
436                "new_name must not contain '/'. Use move_note to change a note's directory.",
437            )]));
438        }
439
440        let from = Self::resolve_path(&p.path);
441        let (parent, _) = from.get_parent_path();
442        let to = parent
443            .append(&VaultPath::note_path_from(&p.new_name))
444            .absolute();
445
446        match self.vault.rename_note(&from, &to).await {
447            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
448                "Note renamed: {} → {}",
449                from, to
450            ))])),
451            Err(
452                kimun_core::error::VaultError::NoteExists { .. }
453                | kimun_core::error::VaultError::FSError(
454                    kimun_core::error::FSError::VaultPathNotFound { .. }
455                    | kimun_core::error::FSError::InvalidPath { .. },
456                ),
457            ) => Ok(CallToolResult::error(vec![Content::text(format!(
458                "Note not found or destination already exists: {} → {}",
459                from, to
460            ))])),
461            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
462        }
463    }
464
465    #[tool(
466        description = "Move a note to a new vault path (different directory and/or name). Backlinks in other notes are updated automatically."
467    )]
468    async fn move_note(
469        &self,
470        Parameters(p): Parameters<MoveNoteParams>,
471    ) -> Result<CallToolResult, McpError> {
472        let from = Self::resolve_path(&p.path);
473        let to = Self::resolve_path(&p.new_path);
474
475        match self.vault.rename_note(&from, &to).await {
476            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
477                "Note moved: {} → {}",
478                from, to
479            ))])),
480            Err(
481                kimun_core::error::VaultError::NoteExists { .. }
482                | kimun_core::error::VaultError::FSError(
483                    kimun_core::error::FSError::VaultPathNotFound { .. }
484                    | kimun_core::error::FSError::InvalidPath { .. },
485                ),
486            ) => Ok(CallToolResult::error(vec![Content::text(format!(
487                "Note not found or destination already exists: {} → {}",
488                from, to
489            ))])),
490            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
491        }
492    }
493
494    #[tool(
495        description = "Quickly capture a thought into a timestamped note in the inbox directory. Returns the path of the created note."
496    )]
497    async fn quick_note(
498        &self,
499        Parameters(p): Parameters<QuickNoteParams>,
500    ) -> Result<CallToolResult, McpError> {
501        if p.content.trim().is_empty() {
502            return Ok(CallToolResult::error(vec![Content::text(
503                "Content cannot be empty.",
504            )]));
505        }
506        match self.vault.quick_note(&p.content).await {
507            Ok(details) => Ok(CallToolResult::success(vec![Content::text(format!(
508                "Note saved: {}",
509                details.path
510            ))])),
511            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
512        }
513    }
514}
515
516// ---------------------------------------------------------------------------
517// ServerHandler implementation
518// ---------------------------------------------------------------------------
519
520#[tool_handler]
521#[prompt_handler]
522impl ServerHandler for KimunHandler {
523    fn get_info(&self) -> ServerInfo {
524        ServerInfo::new(
525            ServerCapabilities::builder()
526                .enable_tools()
527                .enable_resources()
528                .enable_prompts()
529                .build(),
530        )
531        .with_instructions("Kimun notes MCP server — read and write vault notes via tools.")
532    }
533
534    async fn list_resources(
535        &self,
536        _request: Option<PaginatedRequestParams>,
537        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
538    ) -> Result<ListResourcesResult, McpError> {
539        let notes = self
540            .vault
541            .get_all_notes()
542            .await
543            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
544
545        let resources: Vec<Resource> = notes
546            .into_iter()
547            .map(|(entry, content)| {
548                // Build URI: note://{relative_path_with_ext}
549                let mut rel_path = entry.path.clone();
550                rel_path.to_relative();
551                let uri = format!("note://{}", rel_path.to_string_with_ext());
552
553                // Name: title from NoteContentData, or stem of filename if title empty
554                let name = if content.title.is_empty() {
555                    entry.path.get_clean_name()
556                } else {
557                    content.title.clone()
558                };
559
560                RawResource::new(uri, name)
561                    .with_mime_type("text/markdown")
562                    .no_annotation()
563            })
564            .collect();
565
566        Ok(ListResourcesResult {
567            resources,
568            next_cursor: None,
569            meta: None,
570        })
571    }
572
573    async fn read_resource(
574        &self,
575        request: ReadResourceRequestParams,
576        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
577    ) -> Result<ReadResourceResult, McpError> {
578        let uri = &request.uri;
579
580        // Validate URI scheme
581        let path_with_ext = uri.strip_prefix("note://").ok_or_else(|| {
582            McpError::invalid_params(
583                format!("invalid URI scheme — expected note://, got: {}", uri),
584                None,
585            )
586        })?;
587
588        let vault_path = VaultPath::note_path_from(path_with_ext);
589
590        // Fetch note text
591        match self.vault.get_note_text(&vault_path).await {
592            Ok(text) => Ok(ReadResourceResult::new(vec![ResourceContents::text(
593                text,
594                uri.clone(),
595            )])),
596            Err(kimun_core::error::VaultError::FSError(
597                kimun_core::error::FSError::VaultPathNotFound { .. },
598            )) => Err(McpError::invalid_params(
599                format!("note not found: {}", uri),
600                None,
601            )),
602            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
603        }
604    }
605
606    async fn list_resource_templates(
607        &self,
608        _request: Option<PaginatedRequestParams>,
609        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
610    ) -> Result<ListResourceTemplatesResult, McpError> {
611        Ok(ListResourceTemplatesResult {
612            resource_templates: vec![],
613            next_cursor: None,
614            meta: None,
615        })
616    }
617}
618
619// ---------------------------------------------------------------------------
620// Entry point
621// ---------------------------------------------------------------------------
622
623pub async fn run(config_path: Option<PathBuf>) -> Result<()> {
624    use crate::cli::helpers::create_and_init_vault;
625    let (vault, _) = create_and_init_vault(config_path).await?;
626    let handler = KimunHandler::new(vault);
627    let service = handler.serve(stdio()).await.map_err(|e| eyre!("{e}"))?;
628    service.waiting().await.map_err(|e| eyre!("{e}"))?;
629    Ok(())
630}
631
632// ---------------------------------------------------------------------------
633// Tests
634// ---------------------------------------------------------------------------
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639    use kimun_core::NoteVault;
640    use tempfile::TempDir;
641
642    async fn make_handler() -> (KimunHandler, TempDir) {
643        let dir = TempDir::new().unwrap();
644        let vault = NoteVault::new(dir.path()).await.unwrap();
645        vault.validate_and_init().await.unwrap();
646        let handler = KimunHandler::new(vault);
647        (handler, dir)
648    }
649
650    fn is_success(result: &CallToolResult) -> bool {
651        result.is_error != Some(true)
652    }
653
654    fn result_text(result: &CallToolResult) -> String {
655        serde_json::to_string(&result.content).unwrap_or_default()
656    }
657
658    #[tokio::test]
659    async fn test_create_note_succeeds() {
660        let (handler, _dir) = make_handler().await;
661        let result = handler
662            .create_note(Parameters(CreateNoteParams {
663                path: "test/hello".to_string(),
664                content: "# Hello\n\nworld".to_string(),
665            }))
666            .await
667            .unwrap();
668        assert!(
669            is_success(&result),
670            "expected success, got: {:?}",
671            result_text(&result)
672        );
673        assert!(result_text(&result).contains("test/hello"));
674    }
675
676    #[tokio::test]
677    async fn test_create_note_fails_if_exists() {
678        let (handler, _dir) = make_handler().await;
679        handler
680            .create_note(Parameters(CreateNoteParams {
681                path: "test/hello".to_string(),
682                content: "first".to_string(),
683            }))
684            .await
685            .unwrap();
686        let result = handler
687            .create_note(Parameters(CreateNoteParams {
688                path: "test/hello".to_string(),
689                content: "second".to_string(),
690            }))
691            .await
692            .unwrap();
693        assert_eq!(result.is_error, Some(true));
694    }
695
696    #[tokio::test]
697    async fn test_show_note_returns_content() {
698        let (handler, _dir) = make_handler().await;
699        handler
700            .create_note(Parameters(CreateNoteParams {
701                path: "show/me".to_string(),
702                content: "# Show me\n\nsome content".to_string(),
703            }))
704            .await
705            .unwrap();
706        let result = handler
707            .show_note(Parameters(ShowNoteParams {
708                path: "show/me".to_string(),
709            }))
710            .await
711            .unwrap();
712        assert!(is_success(&result));
713        assert!(result_text(&result).contains("some content"));
714    }
715
716    #[tokio::test]
717    async fn test_show_note_not_found_returns_error_result() {
718        let (handler, _dir) = make_handler().await;
719        let result = handler
720            .show_note(Parameters(ShowNoteParams {
721                path: "missing/note".to_string(),
722            }))
723            .await
724            .unwrap();
725        assert_eq!(result.is_error, Some(true));
726    }
727
728    #[tokio::test]
729    async fn test_append_note_creates_if_absent() {
730        let (handler, _dir) = make_handler().await;
731        let result = handler
732            .append_note(Parameters(AppendNoteParams {
733                path: "new/note".to_string(),
734                content: "appended text".to_string(),
735            }))
736            .await
737            .unwrap();
738        assert!(is_success(&result));
739        let show = handler
740            .show_note(Parameters(ShowNoteParams {
741                path: "new/note".to_string(),
742            }))
743            .await
744            .unwrap();
745        assert!(result_text(&show).contains("appended text"));
746    }
747
748    #[tokio::test]
749    async fn test_append_note_appends_to_existing() {
750        let (handler, _dir) = make_handler().await;
751        handler
752            .create_note(Parameters(CreateNoteParams {
753                path: "exist/note".to_string(),
754                content: "original".to_string(),
755            }))
756            .await
757            .unwrap();
758        handler
759            .append_note(Parameters(AppendNoteParams {
760                path: "exist/note".to_string(),
761                content: "added".to_string(),
762            }))
763            .await
764            .unwrap();
765        let show = handler
766            .show_note(Parameters(ShowNoteParams {
767                path: "exist/note".to_string(),
768            }))
769            .await
770            .unwrap();
771        let text = result_text(&show);
772        assert!(text.contains("original"), "missing 'original' in: {}", text);
773        assert!(text.contains("added"), "missing 'added' in: {}", text);
774        let orig_pos = text.find("original").expect("original not found");
775        let added_pos = text.find("added").expect("added not found");
776        assert!(orig_pos < added_pos, "original should appear before added");
777    }
778
779    #[tokio::test]
780    async fn test_search_notes_finds_match() {
781        let (handler, _dir) = make_handler().await;
782        handler
783            .create_note(Parameters(CreateNoteParams {
784                path: "alpha/one".to_string(),
785                content: "# Alpha\n\ncontains unique_keyword_xyz".to_string(),
786            }))
787            .await
788            .unwrap();
789        let result = handler
790            .search_notes(Parameters(SearchNotesParams {
791                query: "unique_keyword_xyz".to_string(),
792            }))
793            .await
794            .unwrap();
795        assert!(
796            is_success(&result),
797            "expected success: {}",
798            result_text(&result)
799        );
800        assert!(
801            result_text(&result).contains("alpha/one"),
802            "search result did not include 'alpha/one': {}",
803            result_text(&result)
804        );
805    }
806
807    #[tokio::test]
808    async fn test_search_notes_returns_empty_for_no_match() {
809        let (handler, _dir) = make_handler().await;
810        let result = handler
811            .search_notes(Parameters(SearchNotesParams {
812                query: "nonexistent_zzz_123".to_string(),
813            }))
814            .await
815            .unwrap();
816        assert!(is_success(&result));
817    }
818
819    #[tokio::test]
820    async fn test_list_notes_returns_all() {
821        let (handler, _dir) = make_handler().await;
822        handler
823            .create_note(Parameters(CreateNoteParams {
824                path: "folder/a".to_string(),
825                content: "note a".to_string(),
826            }))
827            .await
828            .unwrap();
829        handler
830            .create_note(Parameters(CreateNoteParams {
831                path: "folder/b".to_string(),
832                content: "note b".to_string(),
833            }))
834            .await
835            .unwrap();
836        let result = handler
837            .list_notes(Parameters(ListNotesParams { path: None }))
838            .await
839            .unwrap();
840        assert!(is_success(&result));
841        let text = result_text(&result);
842        assert!(text.contains("folder/a"), "missing 'folder/a': {}", text);
843        assert!(text.contains("folder/b"), "missing 'folder/b': {}", text);
844    }
845
846    #[tokio::test]
847    async fn test_journal_appends_to_today() {
848        let (handler, _dir) = make_handler().await;
849        let result = handler
850            .journal(Parameters(JournalParams {
851                text: "Today's thought".to_string(),
852                date: None,
853            }))
854            .await
855            .unwrap();
856        assert!(
857            is_success(&result),
858            "expected success: {}",
859            result_text(&result)
860        );
861        assert!(
862            result_text(&result).contains("saved"),
863            "expected 'saved' in result: {}",
864            result_text(&result)
865        );
866    }
867
868    #[tokio::test]
869    async fn test_journal_with_explicit_date() {
870        let (handler, _dir) = make_handler().await;
871        let result = handler
872            .journal(Parameters(JournalParams {
873                text: "Entry for specific date".to_string(),
874                date: Some("2026-01-15".to_string()),
875            }))
876            .await
877            .unwrap();
878        assert!(
879            is_success(&result),
880            "expected success: {}",
881            result_text(&result)
882        );
883    }
884
885    #[tokio::test]
886    async fn test_journal_invalid_date_returns_error() {
887        let (handler, _dir) = make_handler().await;
888        let result = handler
889            .journal(Parameters(JournalParams {
890                text: "bad date".to_string(),
891                date: Some("not-a-date".to_string()),
892            }))
893            .await
894            .unwrap();
895        assert_eq!(
896            result.is_error,
897            Some(true),
898            "expected error for invalid date"
899        );
900    }
901
902    #[tokio::test]
903    async fn test_get_backlinks_empty_for_no_links() {
904        let (handler, _dir) = make_handler().await;
905        handler
906            .create_note(Parameters(CreateNoteParams {
907                path: "standalone".to_string(),
908                content: "# Standalone\n\nNo links here.".to_string(),
909            }))
910            .await
911            .unwrap();
912        let result = handler
913            .get_backlinks(Parameters(BacklinksParams {
914                path: "standalone".to_string(),
915            }))
916            .await
917            .unwrap();
918        assert!(is_success(&result));
919    }
920
921    #[tokio::test]
922    async fn test_get_backlinks_finds_linking_note() {
923        let (handler, _dir) = make_handler().await;
924        handler
925            .create_note(Parameters(CreateNoteParams {
926                path: "target".to_string(),
927                content: "# Target".to_string(),
928            }))
929            .await
930            .unwrap();
931        handler
932            .create_note(Parameters(CreateNoteParams {
933                path: "source".to_string(),
934                content: "links to [[target]]".to_string(),
935            }))
936            .await
937            .unwrap();
938        let result = handler
939            .get_backlinks(Parameters(BacklinksParams {
940                path: "target".to_string(),
941            }))
942            .await
943            .unwrap();
944        assert!(is_success(&result));
945        assert!(
946            result_text(&result).contains("source"),
947            "expected 'source' in backlinks: {}",
948            result_text(&result)
949        );
950    }
951
952    #[tokio::test]
953    async fn test_get_chunks_returns_sections() {
954        let (handler, _dir) = make_handler().await;
955        handler
956            .create_note(Parameters(CreateNoteParams {
957                path: "chunked".to_string(),
958                content: "# Title\n\n## Section One\n\nparagraph\n\n## Section Two\n\nmore"
959                    .to_string(),
960            }))
961            .await
962            .unwrap();
963        let result = handler
964            .get_chunks(Parameters(ChunksParams {
965                path: "chunked".to_string(),
966            }))
967            .await
968            .unwrap();
969        assert!(is_success(&result));
970        assert!(
971            result_text(&result).contains("Section"),
972            "expected section in chunks: {}",
973            result_text(&result)
974        );
975    }
976
977    #[tokio::test]
978    async fn test_get_chunks_missing_note_returns_gracefully() {
979        let (handler, _dir) = make_handler().await;
980        // get_note_chunks on a missing note may return empty map or an error —
981        // either way it should not panic.
982        let result = handler
983            .get_chunks(Parameters(ChunksParams {
984                path: "missing/note".to_string(),
985            }))
986            .await;
987        // Just verify it returned something without panicking
988        let _ = result;
989    }
990
991    // ---- Resource tests ----
992    //
993    // `list_resources` and `read_resource` require a `RequestContext<RoleServer>`,
994    // which in turn requires a `Peer<R>` constructed via `Peer::new` — a
995    // `pub(crate)` function not accessible outside rmcp.  There is no public
996    // test constructor or `Default` impl, so these tests are marked `#[ignore]`
997    // until rmcp exposes a test helper.  The implementations themselves are
998    // correct and covered by the integration smoke test.
999
1000    #[tokio::test]
1001    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1002    async fn test_list_resources_returns_notes() {
1003        let (handler, _dir) = make_handler().await;
1004        handler
1005            .create_note(Parameters(CreateNoteParams {
1006                path: "res/alpha".to_string(),
1007                content: "# Alpha Note".to_string(),
1008            }))
1009            .await
1010            .unwrap();
1011        // Cannot call handler.list_resources(None, ctx) — ctx requires Peer which
1012        // is not constructable from outside rmcp.
1013        // The assertion below would be:
1014        //   assert!(result.resources.iter().any(|r| r.uri.contains("res/alpha")));
1015        unreachable!("test is ignored");
1016    }
1017
1018    #[tokio::test]
1019    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1020    async fn test_read_resource_returns_content() {
1021        let (handler, _dir) = make_handler().await;
1022        handler
1023            .create_note(Parameters(CreateNoteParams {
1024                path: "res/beta".to_string(),
1025                content: "# Beta\n\nbeta content".to_string(),
1026            }))
1027            .await
1028            .unwrap();
1029        // Would call: handler.read_resource(ReadResourceRequestParams::new("note://res/beta.md"), ctx)
1030        // and assert content_json.contains("beta content")
1031        unreachable!("test is ignored");
1032    }
1033
1034    #[tokio::test]
1035    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1036    async fn test_read_resource_not_found_returns_error() {
1037        let (handler, _dir) = make_handler().await;
1038        // Would call: handler.read_resource(ReadResourceRequestParams::new("note://missing/note.md"), ctx)
1039        // and assert result.is_err()
1040        let _ = &handler;
1041        unreachable!("test is ignored");
1042    }
1043
1044    #[tokio::test]
1045    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1046    async fn test_read_resource_invalid_scheme_returns_error() {
1047        let (handler, _dir) = make_handler().await;
1048        // Would call: handler.read_resource(ReadResourceRequestParams::new("file:///etc/passwd"), ctx)
1049        // and assert result.is_err()
1050        let _ = &handler;
1051        unreachable!("test is ignored");
1052    }
1053
1054    #[tokio::test]
1055    async fn test_get_outlinks_returns_linked_notes() {
1056        let (handler, _dir) = make_handler().await;
1057        handler
1058            .create_note(Parameters(CreateNoteParams {
1059                path: "source".to_string(),
1060                content: "# Source\n\nSee [[target]] for more.".to_string(),
1061            }))
1062            .await
1063            .unwrap();
1064        handler
1065            .create_note(Parameters(CreateNoteParams {
1066                path: "target".to_string(),
1067                content: "# Target\n\nContent here.".to_string(),
1068            }))
1069            .await
1070            .unwrap();
1071        let result = handler
1072            .get_outlinks(Parameters(OutlinksParams {
1073                path: "source".to_string(),
1074            }))
1075            .await
1076            .unwrap();
1077        assert!(
1078            is_success(&result),
1079            "expected success: {}",
1080            result_text(&result)
1081        );
1082        assert!(
1083            result_text(&result).contains("target"),
1084            "expected 'target' in outlinks: {}",
1085            result_text(&result)
1086        );
1087    }
1088
1089    #[tokio::test]
1090    async fn test_get_outlinks_no_links_returns_empty_message() {
1091        let (handler, _dir) = make_handler().await;
1092        handler
1093            .create_note(Parameters(CreateNoteParams {
1094                path: "no-links".to_string(),
1095                content: "# No Links\n\nJust text, no wikilinks.".to_string(),
1096            }))
1097            .await
1098            .unwrap();
1099        let result = handler
1100            .get_outlinks(Parameters(OutlinksParams {
1101                path: "no-links".to_string(),
1102            }))
1103            .await
1104            .unwrap();
1105        assert!(is_success(&result));
1106        assert!(
1107            result_text(&result).contains("No outlinks found"),
1108            "expected empty message: {}",
1109            result_text(&result)
1110        );
1111    }
1112
1113    #[tokio::test]
1114    async fn test_get_outlinks_note_not_found_returns_error() {
1115        let (handler, _dir) = make_handler().await;
1116        let result = handler
1117            .get_outlinks(Parameters(OutlinksParams {
1118                path: "missing/note".to_string(),
1119            }))
1120            .await
1121            .unwrap();
1122        assert_eq!(result.is_error, Some(true));
1123    }
1124
1125    #[tokio::test]
1126    async fn test_rename_note_succeeds() {
1127        let (handler, _dir) = make_handler().await;
1128        handler
1129            .create_note(Parameters(CreateNoteParams {
1130                path: "old-name".to_string(),
1131                content: "# Old\n\nunique_rename_content_xyz".to_string(),
1132            }))
1133            .await
1134            .unwrap();
1135        let result = handler
1136            .rename_note(Parameters(RenameNoteParams {
1137                path: "old-name".to_string(),
1138                new_name: "new-name".to_string(),
1139            }))
1140            .await
1141            .unwrap();
1142        assert!(
1143            is_success(&result),
1144            "expected success: {}",
1145            result_text(&result)
1146        );
1147        let show = handler
1148            .show_note(Parameters(ShowNoteParams {
1149                path: "new-name".to_string(),
1150            }))
1151            .await
1152            .unwrap();
1153        assert!(is_success(&show), "new path should be readable");
1154        assert!(result_text(&show).contains("unique_rename_content_xyz"));
1155        let old = handler
1156            .show_note(Parameters(ShowNoteParams {
1157                path: "old-name".to_string(),
1158            }))
1159            .await
1160            .unwrap();
1161        assert_eq!(old.is_error, Some(true), "old path should be gone");
1162    }
1163
1164    #[tokio::test]
1165    async fn test_rename_note_rejects_slash_in_name() {
1166        let (handler, _dir) = make_handler().await;
1167        handler
1168            .create_note(Parameters(CreateNoteParams {
1169                path: "some/note".to_string(),
1170                content: "content".to_string(),
1171            }))
1172            .await
1173            .unwrap();
1174        let result = handler
1175            .rename_note(Parameters(RenameNoteParams {
1176                path: "some/note".to_string(),
1177                new_name: "other/dir".to_string(),
1178            }))
1179            .await
1180            .unwrap();
1181        assert_eq!(result.is_error, Some(true));
1182        assert!(
1183            result_text(&result).contains("move_note"),
1184            "hint should mention move_note: {}",
1185            result_text(&result)
1186        );
1187    }
1188
1189    #[tokio::test]
1190    async fn test_rename_note_updates_backlinks() {
1191        let (handler, _dir) = make_handler().await;
1192        handler
1193            .create_note(Parameters(CreateNoteParams {
1194                path: "target".to_string(),
1195                content: "# Target".to_string(),
1196            }))
1197            .await
1198            .unwrap();
1199        handler
1200            .create_note(Parameters(CreateNoteParams {
1201                path: "linker".to_string(),
1202                content: "see [[target]] for details".to_string(),
1203            }))
1204            .await
1205            .unwrap();
1206        handler
1207            .rename_note(Parameters(RenameNoteParams {
1208                path: "target".to_string(),
1209                new_name: "renamed-target".to_string(),
1210            }))
1211            .await
1212            .unwrap();
1213        let show = handler
1214            .show_note(Parameters(ShowNoteParams {
1215                path: "linker".to_string(),
1216            }))
1217            .await
1218            .unwrap();
1219        assert!(
1220            result_text(&show).contains("renamed-target"),
1221            "backlink should be updated: {}",
1222            result_text(&show)
1223        );
1224    }
1225
1226    #[tokio::test]
1227    async fn test_move_note_succeeds() {
1228        let (handler, _dir) = make_handler().await;
1229        handler
1230            .create_note(Parameters(CreateNoteParams {
1231                path: "original".to_string(),
1232                content: "# Original\n\nunique_move_content_xyz".to_string(),
1233            }))
1234            .await
1235            .unwrap();
1236        let result = handler
1237            .move_note(Parameters(MoveNoteParams {
1238                path: "original".to_string(),
1239                new_path: "folder/moved".to_string(),
1240            }))
1241            .await
1242            .unwrap();
1243        assert!(
1244            is_success(&result),
1245            "expected success: {}",
1246            result_text(&result)
1247        );
1248        let show = handler
1249            .show_note(Parameters(ShowNoteParams {
1250                path: "folder/moved".to_string(),
1251            }))
1252            .await
1253            .unwrap();
1254        assert!(is_success(&show));
1255        assert!(result_text(&show).contains("unique_move_content_xyz"));
1256        let old = handler
1257            .show_note(Parameters(ShowNoteParams {
1258                path: "original".to_string(),
1259            }))
1260            .await
1261            .unwrap();
1262        assert_eq!(old.is_error, Some(true), "old path should be gone");
1263    }
1264
1265    #[tokio::test]
1266    async fn test_move_note_fails_if_destination_exists() {
1267        let (handler, _dir) = make_handler().await;
1268        handler
1269            .create_note(Parameters(CreateNoteParams {
1270                path: "src".to_string(),
1271                content: "source".to_string(),
1272            }))
1273            .await
1274            .unwrap();
1275        handler
1276            .create_note(Parameters(CreateNoteParams {
1277                path: "dst".to_string(),
1278                content: "destination".to_string(),
1279            }))
1280            .await
1281            .unwrap();
1282        let result = handler
1283            .move_note(Parameters(MoveNoteParams {
1284                path: "src".to_string(),
1285                new_path: "dst".to_string(),
1286            }))
1287            .await
1288            .unwrap();
1289        assert_eq!(result.is_error, Some(true));
1290    }
1291
1292    #[tokio::test]
1293    async fn test_list_notes_filters_by_prefix() {
1294        let (handler, _dir) = make_handler().await;
1295        handler
1296            .create_note(Parameters(CreateNoteParams {
1297                path: "projects/foo".to_string(),
1298                content: "foo".to_string(),
1299            }))
1300            .await
1301            .unwrap();
1302        handler
1303            .create_note(Parameters(CreateNoteParams {
1304                path: "journal/2026-01-01".to_string(),
1305                content: "journal".to_string(),
1306            }))
1307            .await
1308            .unwrap();
1309        let result = handler
1310            .list_notes(Parameters(ListNotesParams {
1311                path: Some("projects".to_string()),
1312            }))
1313            .await
1314            .unwrap();
1315        assert!(is_success(&result));
1316        let text = result_text(&result);
1317        assert!(
1318            text.contains("projects/foo"),
1319            "missing projects/foo: {}",
1320            text
1321        );
1322        assert!(
1323            !text.contains("journal/2026"),
1324            "should not include journal: {}",
1325            text
1326        );
1327    }
1328}