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