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#[derive(Debug, Deserialize, schemars::JsonSchema)]
98pub struct OverwriteNoteParams {
99    pub path: String,
100    pub content: String,
101}
102
103#[derive(Debug, Deserialize, schemars::JsonSchema)]
104pub struct ReplaceInNoteParams {
105    pub path: String,
106    /// Text to find
107    pub old: String,
108    /// Replacement text (may use $1/${name} capture references when regex is true)
109    pub new: String,
110    /// Replace every occurrence instead of requiring a unique match
111    pub replace_all: Option<bool>,
112    /// Treat `old` as a regular expression instead of a literal substring
113    pub regex: Option<bool>,
114    /// Preview the resulting content without writing the note (dry run)
115    pub preview: Option<bool>,
116}
117
118#[derive(Debug, Deserialize, schemars::JsonSchema)]
119pub struct DeleteNoteParams {
120    pub path: String,
121}
122
123// ---------------------------------------------------------------------------
124// Handler struct
125// ---------------------------------------------------------------------------
126
127#[derive(Clone)]
128pub struct KimunHandler {
129    vault: Arc<NoteVault>,
130    // Read at runtime by the `#[tool_handler]` / `#[prompt_handler]` generated
131    // impls (see below); clippy can't see through the macro expansion, so it
132    // flags these as unread despite being the live tool/prompt dispatch tables.
133    #[allow(dead_code)]
134    tool_router: ToolRouter<KimunHandler>,
135    #[allow(dead_code)]
136    prompt_router: PromptRouter<KimunHandler>,
137}
138
139// ---------------------------------------------------------------------------
140// Tool implementations
141// ---------------------------------------------------------------------------
142
143#[tool_router]
144impl KimunHandler {
145    pub fn new(vault: NoteVault) -> Self {
146        Self {
147            vault: Arc::new(vault),
148            tool_router: Self::tool_router(),
149            prompt_router: Self::prompt_router(),
150        }
151    }
152
153    fn resolve_path(path: &str) -> VaultPath {
154        VaultPath::note_path_from(path)
155    }
156
157    #[tool(
158        description = "Create a new note at the given vault path with the given markdown content. Fails if the note already exists."
159    )]
160    async fn create_note(
161        &self,
162        Parameters(p): Parameters<CreateNoteParams>,
163    ) -> Result<CallToolResult, McpError> {
164        let vault_path = Self::resolve_path(&p.path);
165        match self.vault.create_note(&vault_path, &p.content).await {
166            Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
167                "Note created: {}",
168                vault_path
169            ))])),
170            Err(kimun_core::error::VaultError::NoteExists { .. }) => {
171                Ok(CallToolResult::error(vec![Content::text(format!(
172                    "Note already exists: {}",
173                    vault_path
174                ))]))
175            }
176            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
177        }
178    }
179
180    #[tool(description = "Append text to an existing note. Creates the note if it does not exist.")]
181    async fn append_note(
182        &self,
183        Parameters(p): Parameters<AppendNoteParams>,
184    ) -> Result<CallToolResult, McpError> {
185        let vault_path = Self::resolve_path(&p.path);
186        self.vault
187            .append_to_note(&vault_path, &p.content, None)
188            .await
189            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
190        Ok(CallToolResult::success(vec![Content::text(format!(
191            "Note saved: {}",
192            vault_path
193        ))]))
194    }
195
196    #[tool(
197        description = "Replace a note's entire content with new markdown. The previous content is backed up first. Destructive.",
198        annotations(destructive_hint = true)
199    )]
200    async fn overwrite_note(
201        &self,
202        Parameters(p): Parameters<OverwriteNoteParams>,
203    ) -> Result<CallToolResult, McpError> {
204        let vault_path = Self::resolve_path(&p.path);
205        if p.content.is_empty() {
206            return Ok(CallToolResult::error(vec![Content::text(
207                "Refusing to overwrite with empty content (this would wipe the note); pass content, or use delete_note to remove it",
208            )]));
209        }
210        match self.vault.save_note(&vault_path, &p.content).await {
211            Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
212                "Note saved: {}",
213                vault_path
214            ))])),
215            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
216        }
217    }
218
219    #[tool(
220        description = "Replace text in a note. `old` is a literal substring by default; set regex=true to treat it as a regular expression, in which case `new` may reference capture groups ($1, ${name}; $$ for a literal $). The match must be unique unless replace_all is true. Set preview=true to get the resulting content back without writing (dry run). The previous content is backed up first. Destructive.",
221        annotations(destructive_hint = true)
222    )]
223    async fn replace_in_note(
224        &self,
225        Parameters(p): Parameters<ReplaceInNoteParams>,
226    ) -> Result<CallToolResult, McpError> {
227        let vault_path = Self::resolve_path(&p.path);
228        let all = p.replace_all.unwrap_or(false);
229        let regex = p.regex.unwrap_or(false);
230
231        if p.preview.unwrap_or(false) {
232            return match self
233                .vault
234                .preview_replace(&vault_path, &p.old, &p.new, all, regex)
235                .await
236            {
237                Ok(pv) => Ok(CallToolResult::success(vec![Content::text(format!(
238                    "{} occurrence(s) would be replaced in {} (preview — not written). Resulting content:\n\n{}",
239                    pv.count, vault_path, pv.content
240                ))])),
241                Err(
242                    e @ (kimun_core::error::VaultError::ReplaceTextNotFound { .. }
243                    | kimun_core::error::VaultError::ReplaceTextNotUnique { .. }
244                    | kimun_core::error::VaultError::InvalidRegex { .. }),
245                ) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
246                Err(e) => Err(McpError::internal_error(e.to_string(), None)),
247            };
248        }
249
250        match self
251            .vault
252            .replace_in_note(&vault_path, &p.old, &p.new, all, regex)
253            .await
254        {
255            Ok(n) => Ok(CallToolResult::success(vec![Content::text(format!(
256                "Replaced {} occurrence(s) in {}",
257                n, vault_path
258            ))])),
259            Err(
260                e @ (kimun_core::error::VaultError::ReplaceTextNotFound { .. }
261                | kimun_core::error::VaultError::ReplaceTextNotUnique { .. }
262                | kimun_core::error::VaultError::InvalidRegex { .. }),
263            ) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
264            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
265        }
266    }
267
268    #[tool(
269        description = "Delete a note. The content is backed up first. Destructive.",
270        annotations(destructive_hint = true)
271    )]
272    async fn delete_note(
273        &self,
274        Parameters(p): Parameters<DeleteNoteParams>,
275    ) -> Result<CallToolResult, McpError> {
276        let vault_path = Self::resolve_path(&p.path);
277        match self.vault.delete_note(&vault_path).await {
278            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
279                "Note deleted: {}",
280                vault_path
281            ))])),
282            Err(kimun_core::error::VaultError::FSError(
283                kimun_core::error::FSError::VaultPathNotFound { .. },
284            )) => Ok(CallToolResult::error(vec![Content::text(format!(
285                "Note not found: {}",
286                vault_path
287            ))])),
288            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
289        }
290    }
291
292    #[tool(description = "Return the full markdown content of a note.")]
293    async fn show_note(
294        &self,
295        Parameters(p): Parameters<ShowNoteParams>,
296    ) -> Result<CallToolResult, McpError> {
297        let vault_path = Self::resolve_path(&p.path);
298        match self.vault.get_note_text(&vault_path).await {
299            Ok(text) => Ok(CallToolResult::success(vec![Content::text(text)])),
300            Err(kimun_core::error::VaultError::FSError(
301                kimun_core::error::FSError::VaultPathNotFound { .. },
302            )) => Ok(CallToolResult::error(vec![Content::text(format!(
303                "Note not found: {}",
304                vault_path
305            ))])),
306            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
307        }
308    }
309
310    #[tool(
311        description = "Search notes by query. Supports =name (or name:name) to match by note name, @heading (or in:heading), /path prefix, #label (or lb:label) for hashtag-derived labels, <note (or lk:note) for notes that link to the given note (its backlinks), >note (or fwd:note) for the notes the given note links to (its forward links), and - prefix for exclusion (e.g. -term, -#label, -lb:label, -=name, -@heading, -/path, -<note, -lk:note, ->note, -fwd:note). The link filters match by note name (the .md extension is optional, case-insensitive); a bare name matches a linked note in any folder, a path like <dir/note disambiguates, and * wildcards are allowed (<proj*). Hashtag labels (#label) are extracted from note body text only — hashtags inside YAML/TOML frontmatter, fenced code blocks, inline code, HTML, markdown link bodies, and [[wikilinks]] are not indexed. Label names are ASCII [A-Za-z0-9_]+ and matched case-insensitively. Long queries are truncated at 8 KB."
312    )]
313    async fn search_notes(
314        &self,
315        Parameters(p): Parameters<SearchNotesParams>,
316    ) -> Result<CallToolResult, McpError> {
317        let results = self
318            .vault
319            .search_notes(&p.query)
320            .await
321            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
322        if results.is_empty() {
323            return Ok(CallToolResult::success(vec![Content::text(
324                "No results found.",
325            )]));
326        }
327        let lines: Vec<String> = results
328            .iter()
329            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
330            .collect();
331        Ok(CallToolResult::success(vec![Content::text(
332            lines.join("\n"),
333        )]))
334    }
335
336    #[tool(description = "List all notes in the vault, optionally filtered by path prefix.")]
337    async fn list_notes(
338        &self,
339        Parameters(p): Parameters<ListNotesParams>,
340    ) -> Result<CallToolResult, McpError> {
341        let all = self
342            .vault
343            .get_all_notes()
344            .await
345            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
346        let filtered: Vec<_> = match &p.path {
347            None => all,
348            Some(prefix) => {
349                let norm = prefix.trim_matches('/');
350                all.into_iter()
351                    .filter(|(entry, _)| {
352                        let mut p = entry.path.clone();
353                        p.to_relative();
354                        p.to_string().starts_with(norm)
355                    })
356                    .collect()
357            }
358        };
359        if filtered.is_empty() {
360            return Ok(CallToolResult::success(vec![Content::text(
361                "No notes found.",
362            )]));
363        }
364        let lines: Vec<String> = filtered
365            .iter()
366            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
367            .collect();
368        Ok(CallToolResult::success(vec![Content::text(
369            lines.join("\n"),
370        )]))
371    }
372
373    #[tool(
374        description = "Append text to today's journal entry (or a specific date). Creates the entry if absent."
375    )]
376    async fn journal(
377        &self,
378        Parameters(p): Parameters<JournalParams>,
379    ) -> Result<CallToolResult, McpError> {
380        // Validate and resolve the date
381        let date_str = match p.date.as_deref() {
382            None => chrono::Utc::now().format("%Y-%m-%d").to_string(),
383            Some(d) => {
384                if chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").is_err() {
385                    return Ok(CallToolResult::error(vec![Content::text(format!(
386                        "Invalid date '{}' — expected YYYY-MM-DD",
387                        d
388                    ))]));
389                }
390                d.to_string()
391            }
392        };
393
394        // Both today and a specific date resolve to journal/<date>; append under
395        // the per-note lock so concurrent journal writes can't lose an entry.
396        let vault_path = self
397            .vault
398            .journal_path()
399            .append(&VaultPath::note_path_from(&date_str))
400            .absolute();
401        self.vault
402            .append_to_note(&vault_path, &p.text, Some(format!("# {}\n\n", date_str)))
403            .await
404            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
405
406        Ok(CallToolResult::success(vec![Content::text(format!(
407            "Note saved: {}",
408            vault_path
409        ))]))
410    }
411
412    #[tool(description = "Return the list of notes that link to the given note (backlinks).")]
413    async fn get_backlinks(
414        &self,
415        Parameters(p): Parameters<BacklinksParams>,
416    ) -> Result<CallToolResult, McpError> {
417        let vault_path = Self::resolve_path(&p.path);
418        let backlinks = self
419            .vault
420            .get_backlinks(&vault_path)
421            .await
422            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
423        if backlinks.is_empty() {
424            return Ok(CallToolResult::success(vec![Content::text(
425                "No backlinks found.",
426            )]));
427        }
428        let lines: Vec<String> = backlinks
429            .iter()
430            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
431            .collect();
432        Ok(CallToolResult::success(vec![Content::text(
433            lines.join("\n"),
434        )]))
435    }
436
437    #[tool(description = "Return the content chunks (sections) of a note as JSON.")]
438    async fn get_chunks(
439        &self,
440        Parameters(p): Parameters<ChunksParams>,
441    ) -> Result<CallToolResult, McpError> {
442        let vault_path = Self::resolve_path(&p.path);
443        let chunks_map = self
444            .vault
445            .get_note_chunks(&vault_path)
446            .await
447            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
448
449        let mut lines: Vec<String> = Vec::new();
450        for chunks in chunks_map.values() {
451            for chunk in chunks {
452                let breadcrumb = chunk
453                    .breadcrumb
454                    .replace(kimun_core::note::BREADCRUMB_SEP, " > ");
455                lines.push(format!("[{}] {}", breadcrumb, chunk.text.trim()));
456            }
457        }
458
459        if lines.is_empty() {
460            return Ok(CallToolResult::success(vec![Content::text(
461                "No chunks found.",
462            )]));
463        }
464        Ok(CallToolResult::success(vec![Content::text(
465            lines.join("\n\n"),
466        )]))
467    }
468
469    #[tool(description = "Return the list of notes that this note links to (outgoing wikilinks).")]
470    async fn get_outlinks(
471        &self,
472        Parameters(p): Parameters<OutlinksParams>,
473    ) -> Result<CallToolResult, McpError> {
474        use kimun_core::error::{FSError, VaultError};
475        use kimun_core::note::{LinkType, NoteDetails};
476
477        let vault_path = Self::resolve_path(&p.path);
478
479        let md_note = match self.vault.get_markdown_and_links(&vault_path).await {
480            Ok(n) => n,
481            Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
482                return Ok(CallToolResult::error(vec![Content::text(format!(
483                    "Note not found: {}",
484                    vault_path
485                ))]));
486            }
487            Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
488        };
489
490        let note_links: Vec<_> = md_note
491            .links
492            .into_iter()
493            .filter_map(|link| {
494                if let LinkType::Note(path) = link.ltype {
495                    Some(path)
496                } else {
497                    None
498                }
499            })
500            .collect();
501
502        if note_links.is_empty() {
503            return Ok(CallToolResult::success(vec![Content::text(
504                "No outlinks found.",
505            )]));
506        }
507
508        let mut lines: Vec<String> = Vec::new();
509        for path in note_links {
510            let title = match self.vault.get_note_text(&path).await {
511                Ok(text) => {
512                    let t = NoteDetails::get_title_from_text(&text);
513                    if t.is_empty() {
514                        path.get_clean_name()
515                    } else {
516                        t
517                    }
518                }
519                Err(_) => path.get_clean_name(),
520            };
521            lines.push(format!("{} — {}", path, title));
522        }
523
524        Ok(CallToolResult::success(vec![Content::text(
525            lines.join("\n"),
526        )]))
527    }
528
529    #[tool(
530        description = "Rename a note within its current directory (filename only). Use move_note to change the directory."
531    )]
532    async fn rename_note(
533        &self,
534        Parameters(p): Parameters<RenameNoteParams>,
535    ) -> Result<CallToolResult, McpError> {
536        if p.new_name.contains('/') {
537            return Ok(CallToolResult::error(vec![Content::text(
538                "new_name must not contain '/'. Use move_note to change a note's directory.",
539            )]));
540        }
541
542        let from = Self::resolve_path(&p.path);
543        let (parent, _) = from.get_parent_path();
544        let to = parent
545            .append(&VaultPath::note_path_from(&p.new_name))
546            .absolute();
547
548        match self.vault.rename_note(&from, &to).await {
549            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
550                "Note renamed: {} → {}",
551                from, to
552            ))])),
553            Err(
554                kimun_core::error::VaultError::NoteExists { .. }
555                | kimun_core::error::VaultError::FSError(
556                    kimun_core::error::FSError::VaultPathNotFound { .. }
557                    | kimun_core::error::FSError::InvalidPath { .. },
558                ),
559            ) => Ok(CallToolResult::error(vec![Content::text(format!(
560                "Note not found or destination already exists: {} → {}",
561                from, to
562            ))])),
563            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
564        }
565    }
566
567    #[tool(
568        description = "Move a note to a new vault path (different directory and/or name). Backlinks in other notes are updated automatically."
569    )]
570    async fn move_note(
571        &self,
572        Parameters(p): Parameters<MoveNoteParams>,
573    ) -> Result<CallToolResult, McpError> {
574        let from = Self::resolve_path(&p.path);
575        let to = Self::resolve_path(&p.new_path);
576
577        match self.vault.rename_note(&from, &to).await {
578            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
579                "Note moved: {} → {}",
580                from, to
581            ))])),
582            Err(
583                kimun_core::error::VaultError::NoteExists { .. }
584                | kimun_core::error::VaultError::FSError(
585                    kimun_core::error::FSError::VaultPathNotFound { .. }
586                    | kimun_core::error::FSError::InvalidPath { .. },
587                ),
588            ) => Ok(CallToolResult::error(vec![Content::text(format!(
589                "Note not found or destination already exists: {} → {}",
590                from, to
591            ))])),
592            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
593        }
594    }
595
596    #[tool(
597        description = "Quickly capture a thought into a timestamped note in the inbox directory. Returns the path of the created note."
598    )]
599    async fn quick_note(
600        &self,
601        Parameters(p): Parameters<QuickNoteParams>,
602    ) -> Result<CallToolResult, McpError> {
603        if p.content.trim().is_empty() {
604            return Ok(CallToolResult::error(vec![Content::text(
605                "Content cannot be empty.",
606            )]));
607        }
608        match self.vault.quick_note(&p.content).await {
609            Ok(details) => Ok(CallToolResult::success(vec![Content::text(format!(
610                "Note saved: {}",
611                details.path
612            ))])),
613            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
614        }
615    }
616}
617
618// ---------------------------------------------------------------------------
619// ServerHandler implementation
620// ---------------------------------------------------------------------------
621
622#[tool_handler]
623#[prompt_handler]
624impl ServerHandler for KimunHandler {
625    fn get_info(&self) -> ServerInfo {
626        ServerInfo::new(
627            ServerCapabilities::builder()
628                .enable_tools()
629                .enable_resources()
630                .enable_prompts()
631                .build(),
632        )
633        .with_instructions("Kimun notes MCP server — read and write vault notes via tools.")
634    }
635
636    async fn list_resources(
637        &self,
638        _request: Option<PaginatedRequestParams>,
639        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
640    ) -> Result<ListResourcesResult, McpError> {
641        let notes = self
642            .vault
643            .get_all_notes()
644            .await
645            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
646
647        let resources: Vec<Resource> = notes
648            .into_iter()
649            .map(|(entry, content)| {
650                // Build URI: note://{relative_path_with_ext}
651                let mut rel_path = entry.path.clone();
652                rel_path.to_relative();
653                let uri = format!("note://{}", rel_path.to_string_with_ext());
654
655                // Name: title from NoteContentData, or stem of filename if title empty
656                let name = if content.title.is_empty() {
657                    entry.path.get_clean_name()
658                } else {
659                    content.title.clone()
660                };
661
662                RawResource::new(uri, name)
663                    .with_mime_type("text/markdown")
664                    .no_annotation()
665            })
666            .collect();
667
668        Ok(ListResourcesResult {
669            resources,
670            next_cursor: None,
671            meta: None,
672        })
673    }
674
675    async fn read_resource(
676        &self,
677        request: ReadResourceRequestParams,
678        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
679    ) -> Result<ReadResourceResult, McpError> {
680        let uri = &request.uri;
681
682        // Validate URI scheme
683        let path_with_ext = uri.strip_prefix("note://").ok_or_else(|| {
684            McpError::invalid_params(
685                format!("invalid URI scheme — expected note://, got: {}", uri),
686                None,
687            )
688        })?;
689
690        let vault_path = VaultPath::note_path_from(path_with_ext);
691
692        // Fetch note text
693        match self.vault.get_note_text(&vault_path).await {
694            Ok(text) => Ok(ReadResourceResult::new(vec![ResourceContents::text(
695                text,
696                uri.clone(),
697            )])),
698            Err(kimun_core::error::VaultError::FSError(
699                kimun_core::error::FSError::VaultPathNotFound { .. },
700            )) => Err(McpError::invalid_params(
701                format!("note not found: {}", uri),
702                None,
703            )),
704            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
705        }
706    }
707
708    async fn list_resource_templates(
709        &self,
710        _request: Option<PaginatedRequestParams>,
711        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
712    ) -> Result<ListResourceTemplatesResult, McpError> {
713        Ok(ListResourceTemplatesResult {
714            resource_templates: vec![],
715            next_cursor: None,
716            meta: None,
717        })
718    }
719}
720
721// ---------------------------------------------------------------------------
722// Entry point
723// ---------------------------------------------------------------------------
724
725pub async fn run(config_path: Option<PathBuf>) -> Result<()> {
726    use crate::cli::helpers::create_and_init_vault;
727    let (vault, _) = create_and_init_vault(config_path).await?;
728    let handler = KimunHandler::new(vault);
729    let service = handler.serve(stdio()).await.map_err(|e| eyre!("{e}"))?;
730    service.waiting().await.map_err(|e| eyre!("{e}"))?;
731    Ok(())
732}
733
734// ---------------------------------------------------------------------------
735// Tests
736// ---------------------------------------------------------------------------
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use kimun_core::{NoteVault, VaultConfig};
742    use tempfile::TempDir;
743
744    async fn make_handler() -> (KimunHandler, TempDir) {
745        let dir = TempDir::new().unwrap();
746        let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
747        vault.validate_and_init().await.unwrap();
748        let handler = KimunHandler::new(vault);
749        (handler, dir)
750    }
751
752    fn is_success(result: &CallToolResult) -> bool {
753        result.is_error != Some(true)
754    }
755
756    fn result_text(result: &CallToolResult) -> String {
757        serde_json::to_string(&result.content).unwrap_or_default()
758    }
759
760    #[tokio::test]
761    async fn test_create_note_succeeds() {
762        let (handler, _dir) = make_handler().await;
763        let result = handler
764            .create_note(Parameters(CreateNoteParams {
765                path: "test/hello".to_string(),
766                content: "# Hello\n\nworld".to_string(),
767            }))
768            .await
769            .unwrap();
770        assert!(
771            is_success(&result),
772            "expected success, got: {:?}",
773            result_text(&result)
774        );
775        assert!(result_text(&result).contains("test/hello"));
776    }
777
778    #[tokio::test]
779    async fn test_create_note_fails_if_exists() {
780        let (handler, _dir) = make_handler().await;
781        handler
782            .create_note(Parameters(CreateNoteParams {
783                path: "test/hello".to_string(),
784                content: "first".to_string(),
785            }))
786            .await
787            .unwrap();
788        let result = handler
789            .create_note(Parameters(CreateNoteParams {
790                path: "test/hello".to_string(),
791                content: "second".to_string(),
792            }))
793            .await
794            .unwrap();
795        assert_eq!(result.is_error, Some(true));
796    }
797
798    #[tokio::test]
799    async fn test_overwrite_note_replaces_whole_body() {
800        let (handler, _dir) = make_handler().await;
801        handler
802            .create_note(Parameters(CreateNoteParams {
803                path: "n".to_string(),
804                content: "old body".to_string(),
805            }))
806            .await
807            .unwrap();
808
809        let result = handler
810            .overwrite_note(Parameters(OverwriteNoteParams {
811                path: "n".to_string(),
812                content: "new body".to_string(),
813            }))
814            .await
815            .unwrap();
816        assert!(is_success(&result), "got: {:?}", result_text(&result));
817
818        let shown = handler
819            .show_note(Parameters(ShowNoteParams {
820                path: "n".to_string(),
821            }))
822            .await
823            .unwrap();
824        assert!(result_text(&shown).contains("new body"));
825        assert!(!result_text(&shown).contains("old body"));
826    }
827
828    #[tokio::test]
829    async fn test_replace_in_note_unique_match() {
830        let (handler, _dir) = make_handler().await;
831        handler
832            .create_note(Parameters(CreateNoteParams {
833                path: "n".to_string(),
834                content: "hello world".to_string(),
835            }))
836            .await
837            .unwrap();
838
839        let result = handler
840            .replace_in_note(Parameters(ReplaceInNoteParams {
841                path: "n".to_string(),
842                old: "world".to_string(),
843                new: "there".to_string(),
844                replace_all: None,
845                regex: None,
846                preview: None,
847            }))
848            .await
849            .unwrap();
850        assert!(is_success(&result), "got: {:?}", result_text(&result));
851
852        let shown = handler
853            .show_note(Parameters(ShowNoteParams {
854                path: "n".to_string(),
855            }))
856            .await
857            .unwrap();
858        assert!(result_text(&shown).contains("hello there"));
859    }
860
861    #[tokio::test]
862    async fn test_replace_in_note_non_unique_is_error() {
863        let (handler, _dir) = make_handler().await;
864        handler
865            .create_note(Parameters(CreateNoteParams {
866                path: "n".to_string(),
867                content: "a a".to_string(),
868            }))
869            .await
870            .unwrap();
871
872        let result = handler
873            .replace_in_note(Parameters(ReplaceInNoteParams {
874                path: "n".to_string(),
875                old: "a".to_string(),
876                new: "b".to_string(),
877                replace_all: None,
878                regex: None,
879                preview: None,
880            }))
881            .await
882            .unwrap();
883        assert_eq!(result.is_error, Some(true));
884    }
885
886    #[tokio::test]
887    async fn test_delete_note_removes_it() {
888        let (handler, _dir) = make_handler().await;
889        handler
890            .create_note(Parameters(CreateNoteParams {
891                path: "n".to_string(),
892                content: "x".to_string(),
893            }))
894            .await
895            .unwrap();
896
897        let result = handler
898            .delete_note(Parameters(DeleteNoteParams {
899                path: "n".to_string(),
900            }))
901            .await
902            .unwrap();
903        assert!(is_success(&result), "got: {:?}", result_text(&result));
904
905        let shown = handler
906            .show_note(Parameters(ShowNoteParams {
907                path: "n".to_string(),
908            }))
909            .await
910            .unwrap();
911        assert_eq!(shown.is_error, Some(true));
912    }
913
914    #[tokio::test]
915    async fn test_show_note_returns_content() {
916        let (handler, _dir) = make_handler().await;
917        handler
918            .create_note(Parameters(CreateNoteParams {
919                path: "show/me".to_string(),
920                content: "# Show me\n\nsome content".to_string(),
921            }))
922            .await
923            .unwrap();
924        let result = handler
925            .show_note(Parameters(ShowNoteParams {
926                path: "show/me".to_string(),
927            }))
928            .await
929            .unwrap();
930        assert!(is_success(&result));
931        assert!(result_text(&result).contains("some content"));
932    }
933
934    #[tokio::test]
935    async fn test_show_note_not_found_returns_error_result() {
936        let (handler, _dir) = make_handler().await;
937        let result = handler
938            .show_note(Parameters(ShowNoteParams {
939                path: "missing/note".to_string(),
940            }))
941            .await
942            .unwrap();
943        assert_eq!(result.is_error, Some(true));
944    }
945
946    #[tokio::test]
947    async fn test_append_note_creates_if_absent() {
948        let (handler, _dir) = make_handler().await;
949        let result = handler
950            .append_note(Parameters(AppendNoteParams {
951                path: "new/note".to_string(),
952                content: "appended text".to_string(),
953            }))
954            .await
955            .unwrap();
956        assert!(is_success(&result));
957        let show = handler
958            .show_note(Parameters(ShowNoteParams {
959                path: "new/note".to_string(),
960            }))
961            .await
962            .unwrap();
963        assert!(result_text(&show).contains("appended text"));
964    }
965
966    #[tokio::test]
967    async fn test_append_note_appends_to_existing() {
968        let (handler, _dir) = make_handler().await;
969        handler
970            .create_note(Parameters(CreateNoteParams {
971                path: "exist/note".to_string(),
972                content: "original".to_string(),
973            }))
974            .await
975            .unwrap();
976        handler
977            .append_note(Parameters(AppendNoteParams {
978                path: "exist/note".to_string(),
979                content: "added".to_string(),
980            }))
981            .await
982            .unwrap();
983        let show = handler
984            .show_note(Parameters(ShowNoteParams {
985                path: "exist/note".to_string(),
986            }))
987            .await
988            .unwrap();
989        let text = result_text(&show);
990        assert!(text.contains("original"), "missing 'original' in: {}", text);
991        assert!(text.contains("added"), "missing 'added' in: {}", text);
992        let orig_pos = text.find("original").expect("original not found");
993        let added_pos = text.find("added").expect("added not found");
994        assert!(orig_pos < added_pos, "original should appear before added");
995    }
996
997    #[tokio::test]
998    async fn test_search_notes_finds_match() {
999        let (handler, _dir) = make_handler().await;
1000        handler
1001            .create_note(Parameters(CreateNoteParams {
1002                path: "alpha/one".to_string(),
1003                content: "# Alpha\n\ncontains unique_keyword_xyz".to_string(),
1004            }))
1005            .await
1006            .unwrap();
1007        let result = handler
1008            .search_notes(Parameters(SearchNotesParams {
1009                query: "unique_keyword_xyz".to_string(),
1010            }))
1011            .await
1012            .unwrap();
1013        assert!(
1014            is_success(&result),
1015            "expected success: {}",
1016            result_text(&result)
1017        );
1018        assert!(
1019            result_text(&result).contains("alpha/one"),
1020            "search result did not include 'alpha/one': {}",
1021            result_text(&result)
1022        );
1023    }
1024
1025    #[tokio::test]
1026    async fn test_search_notes_returns_empty_for_no_match() {
1027        let (handler, _dir) = make_handler().await;
1028        let result = handler
1029            .search_notes(Parameters(SearchNotesParams {
1030                query: "nonexistent_zzz_123".to_string(),
1031            }))
1032            .await
1033            .unwrap();
1034        assert!(is_success(&result));
1035    }
1036
1037    #[tokio::test]
1038    async fn test_list_notes_returns_all() {
1039        let (handler, _dir) = make_handler().await;
1040        handler
1041            .create_note(Parameters(CreateNoteParams {
1042                path: "folder/a".to_string(),
1043                content: "note a".to_string(),
1044            }))
1045            .await
1046            .unwrap();
1047        handler
1048            .create_note(Parameters(CreateNoteParams {
1049                path: "folder/b".to_string(),
1050                content: "note b".to_string(),
1051            }))
1052            .await
1053            .unwrap();
1054        let result = handler
1055            .list_notes(Parameters(ListNotesParams { path: None }))
1056            .await
1057            .unwrap();
1058        assert!(is_success(&result));
1059        let text = result_text(&result);
1060        assert!(text.contains("folder/a"), "missing 'folder/a': {}", text);
1061        assert!(text.contains("folder/b"), "missing 'folder/b': {}", text);
1062    }
1063
1064    #[tokio::test]
1065    async fn test_journal_appends_to_today() {
1066        let (handler, _dir) = make_handler().await;
1067        let result = handler
1068            .journal(Parameters(JournalParams {
1069                text: "Today's thought".to_string(),
1070                date: None,
1071            }))
1072            .await
1073            .unwrap();
1074        assert!(
1075            is_success(&result),
1076            "expected success: {}",
1077            result_text(&result)
1078        );
1079        assert!(
1080            result_text(&result).contains("saved"),
1081            "expected 'saved' in result: {}",
1082            result_text(&result)
1083        );
1084    }
1085
1086    #[tokio::test]
1087    async fn test_journal_with_explicit_date() {
1088        let (handler, _dir) = make_handler().await;
1089        let result = handler
1090            .journal(Parameters(JournalParams {
1091                text: "Entry for specific date".to_string(),
1092                date: Some("2026-01-15".to_string()),
1093            }))
1094            .await
1095            .unwrap();
1096        assert!(
1097            is_success(&result),
1098            "expected success: {}",
1099            result_text(&result)
1100        );
1101    }
1102
1103    #[tokio::test]
1104    async fn test_journal_invalid_date_returns_error() {
1105        let (handler, _dir) = make_handler().await;
1106        let result = handler
1107            .journal(Parameters(JournalParams {
1108                text: "bad date".to_string(),
1109                date: Some("not-a-date".to_string()),
1110            }))
1111            .await
1112            .unwrap();
1113        assert_eq!(
1114            result.is_error,
1115            Some(true),
1116            "expected error for invalid date"
1117        );
1118    }
1119
1120    #[tokio::test]
1121    async fn test_get_backlinks_empty_for_no_links() {
1122        let (handler, _dir) = make_handler().await;
1123        handler
1124            .create_note(Parameters(CreateNoteParams {
1125                path: "standalone".to_string(),
1126                content: "# Standalone\n\nNo links here.".to_string(),
1127            }))
1128            .await
1129            .unwrap();
1130        let result = handler
1131            .get_backlinks(Parameters(BacklinksParams {
1132                path: "standalone".to_string(),
1133            }))
1134            .await
1135            .unwrap();
1136        assert!(is_success(&result));
1137    }
1138
1139    #[tokio::test]
1140    async fn test_get_backlinks_finds_linking_note() {
1141        let (handler, _dir) = make_handler().await;
1142        handler
1143            .create_note(Parameters(CreateNoteParams {
1144                path: "target".to_string(),
1145                content: "# Target".to_string(),
1146            }))
1147            .await
1148            .unwrap();
1149        handler
1150            .create_note(Parameters(CreateNoteParams {
1151                path: "source".to_string(),
1152                content: "links to [[target]]".to_string(),
1153            }))
1154            .await
1155            .unwrap();
1156        let result = handler
1157            .get_backlinks(Parameters(BacklinksParams {
1158                path: "target".to_string(),
1159            }))
1160            .await
1161            .unwrap();
1162        assert!(is_success(&result));
1163        assert!(
1164            result_text(&result).contains("source"),
1165            "expected 'source' in backlinks: {}",
1166            result_text(&result)
1167        );
1168    }
1169
1170    #[tokio::test]
1171    async fn test_get_chunks_returns_sections() {
1172        let (handler, _dir) = make_handler().await;
1173        handler
1174            .create_note(Parameters(CreateNoteParams {
1175                path: "chunked".to_string(),
1176                content: "# Title\n\n## Section One\n\nparagraph\n\n## Section Two\n\nmore"
1177                    .to_string(),
1178            }))
1179            .await
1180            .unwrap();
1181        let result = handler
1182            .get_chunks(Parameters(ChunksParams {
1183                path: "chunked".to_string(),
1184            }))
1185            .await
1186            .unwrap();
1187        assert!(is_success(&result));
1188        assert!(
1189            result_text(&result).contains("Section"),
1190            "expected section in chunks: {}",
1191            result_text(&result)
1192        );
1193    }
1194
1195    #[tokio::test]
1196    async fn test_get_chunks_missing_note_returns_gracefully() {
1197        let (handler, _dir) = make_handler().await;
1198        // get_note_chunks on a missing note may return empty map or an error —
1199        // either way it should not panic.
1200        let result = handler
1201            .get_chunks(Parameters(ChunksParams {
1202                path: "missing/note".to_string(),
1203            }))
1204            .await;
1205        // Just verify it returned something without panicking
1206        let _ = result;
1207    }
1208
1209    // ---- Resource tests ----
1210    //
1211    // `list_resources` and `read_resource` require a `RequestContext<RoleServer>`,
1212    // which in turn requires a `Peer<R>` constructed via `Peer::new` — a
1213    // `pub(crate)` function not accessible outside rmcp.  There is no public
1214    // test constructor or `Default` impl, so these tests are marked `#[ignore]`
1215    // until rmcp exposes a test helper.  The implementations themselves are
1216    // correct and covered by the integration smoke test.
1217
1218    #[tokio::test]
1219    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1220    async fn test_list_resources_returns_notes() {
1221        let (handler, _dir) = make_handler().await;
1222        handler
1223            .create_note(Parameters(CreateNoteParams {
1224                path: "res/alpha".to_string(),
1225                content: "# Alpha Note".to_string(),
1226            }))
1227            .await
1228            .unwrap();
1229        // Cannot call handler.list_resources(None, ctx) — ctx requires Peer which
1230        // is not constructable from outside rmcp.
1231        // The assertion below would be:
1232        //   assert!(result.resources.iter().any(|r| r.uri.contains("res/alpha")));
1233        unreachable!("test is ignored");
1234    }
1235
1236    #[tokio::test]
1237    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1238    async fn test_read_resource_returns_content() {
1239        let (handler, _dir) = make_handler().await;
1240        handler
1241            .create_note(Parameters(CreateNoteParams {
1242                path: "res/beta".to_string(),
1243                content: "# Beta\n\nbeta content".to_string(),
1244            }))
1245            .await
1246            .unwrap();
1247        // Would call: handler.read_resource(ReadResourceRequestParams::new("note://res/beta.md"), ctx)
1248        // and assert content_json.contains("beta content")
1249        unreachable!("test is ignored");
1250    }
1251
1252    #[tokio::test]
1253    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1254    async fn test_read_resource_not_found_returns_error() {
1255        let (handler, _dir) = make_handler().await;
1256        // Would call: handler.read_resource(ReadResourceRequestParams::new("note://missing/note.md"), ctx)
1257        // and assert result.is_err()
1258        let _ = &handler;
1259        unreachable!("test is ignored");
1260    }
1261
1262    #[tokio::test]
1263    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1264    async fn test_read_resource_invalid_scheme_returns_error() {
1265        let (handler, _dir) = make_handler().await;
1266        // Would call: handler.read_resource(ReadResourceRequestParams::new("file:///etc/passwd"), ctx)
1267        // and assert result.is_err()
1268        let _ = &handler;
1269        unreachable!("test is ignored");
1270    }
1271
1272    #[tokio::test]
1273    async fn test_get_outlinks_returns_linked_notes() {
1274        let (handler, _dir) = make_handler().await;
1275        handler
1276            .create_note(Parameters(CreateNoteParams {
1277                path: "source".to_string(),
1278                content: "# Source\n\nSee [[target]] for more.".to_string(),
1279            }))
1280            .await
1281            .unwrap();
1282        handler
1283            .create_note(Parameters(CreateNoteParams {
1284                path: "target".to_string(),
1285                content: "# Target\n\nContent here.".to_string(),
1286            }))
1287            .await
1288            .unwrap();
1289        let result = handler
1290            .get_outlinks(Parameters(OutlinksParams {
1291                path: "source".to_string(),
1292            }))
1293            .await
1294            .unwrap();
1295        assert!(
1296            is_success(&result),
1297            "expected success: {}",
1298            result_text(&result)
1299        );
1300        assert!(
1301            result_text(&result).contains("target"),
1302            "expected 'target' in outlinks: {}",
1303            result_text(&result)
1304        );
1305    }
1306
1307    #[tokio::test]
1308    async fn test_get_outlinks_no_links_returns_empty_message() {
1309        let (handler, _dir) = make_handler().await;
1310        handler
1311            .create_note(Parameters(CreateNoteParams {
1312                path: "no-links".to_string(),
1313                content: "# No Links\n\nJust text, no wikilinks.".to_string(),
1314            }))
1315            .await
1316            .unwrap();
1317        let result = handler
1318            .get_outlinks(Parameters(OutlinksParams {
1319                path: "no-links".to_string(),
1320            }))
1321            .await
1322            .unwrap();
1323        assert!(is_success(&result));
1324        assert!(
1325            result_text(&result).contains("No outlinks found"),
1326            "expected empty message: {}",
1327            result_text(&result)
1328        );
1329    }
1330
1331    #[tokio::test]
1332    async fn test_get_outlinks_note_not_found_returns_error() {
1333        let (handler, _dir) = make_handler().await;
1334        let result = handler
1335            .get_outlinks(Parameters(OutlinksParams {
1336                path: "missing/note".to_string(),
1337            }))
1338            .await
1339            .unwrap();
1340        assert_eq!(result.is_error, Some(true));
1341    }
1342
1343    #[tokio::test]
1344    async fn test_rename_note_succeeds() {
1345        let (handler, _dir) = make_handler().await;
1346        handler
1347            .create_note(Parameters(CreateNoteParams {
1348                path: "old-name".to_string(),
1349                content: "# Old\n\nunique_rename_content_xyz".to_string(),
1350            }))
1351            .await
1352            .unwrap();
1353        let result = handler
1354            .rename_note(Parameters(RenameNoteParams {
1355                path: "old-name".to_string(),
1356                new_name: "new-name".to_string(),
1357            }))
1358            .await
1359            .unwrap();
1360        assert!(
1361            is_success(&result),
1362            "expected success: {}",
1363            result_text(&result)
1364        );
1365        let show = handler
1366            .show_note(Parameters(ShowNoteParams {
1367                path: "new-name".to_string(),
1368            }))
1369            .await
1370            .unwrap();
1371        assert!(is_success(&show), "new path should be readable");
1372        assert!(result_text(&show).contains("unique_rename_content_xyz"));
1373        let old = handler
1374            .show_note(Parameters(ShowNoteParams {
1375                path: "old-name".to_string(),
1376            }))
1377            .await
1378            .unwrap();
1379        assert_eq!(old.is_error, Some(true), "old path should be gone");
1380    }
1381
1382    #[tokio::test]
1383    async fn test_rename_note_rejects_slash_in_name() {
1384        let (handler, _dir) = make_handler().await;
1385        handler
1386            .create_note(Parameters(CreateNoteParams {
1387                path: "some/note".to_string(),
1388                content: "content".to_string(),
1389            }))
1390            .await
1391            .unwrap();
1392        let result = handler
1393            .rename_note(Parameters(RenameNoteParams {
1394                path: "some/note".to_string(),
1395                new_name: "other/dir".to_string(),
1396            }))
1397            .await
1398            .unwrap();
1399        assert_eq!(result.is_error, Some(true));
1400        assert!(
1401            result_text(&result).contains("move_note"),
1402            "hint should mention move_note: {}",
1403            result_text(&result)
1404        );
1405    }
1406
1407    #[tokio::test]
1408    async fn test_rename_note_updates_backlinks() {
1409        let (handler, _dir) = make_handler().await;
1410        handler
1411            .create_note(Parameters(CreateNoteParams {
1412                path: "target".to_string(),
1413                content: "# Target".to_string(),
1414            }))
1415            .await
1416            .unwrap();
1417        handler
1418            .create_note(Parameters(CreateNoteParams {
1419                path: "linker".to_string(),
1420                content: "see [[target]] for details".to_string(),
1421            }))
1422            .await
1423            .unwrap();
1424        handler
1425            .rename_note(Parameters(RenameNoteParams {
1426                path: "target".to_string(),
1427                new_name: "renamed-target".to_string(),
1428            }))
1429            .await
1430            .unwrap();
1431        let show = handler
1432            .show_note(Parameters(ShowNoteParams {
1433                path: "linker".to_string(),
1434            }))
1435            .await
1436            .unwrap();
1437        assert!(
1438            result_text(&show).contains("renamed-target"),
1439            "backlink should be updated: {}",
1440            result_text(&show)
1441        );
1442    }
1443
1444    #[tokio::test]
1445    async fn test_move_note_succeeds() {
1446        let (handler, _dir) = make_handler().await;
1447        handler
1448            .create_note(Parameters(CreateNoteParams {
1449                path: "original".to_string(),
1450                content: "# Original\n\nunique_move_content_xyz".to_string(),
1451            }))
1452            .await
1453            .unwrap();
1454        let result = handler
1455            .move_note(Parameters(MoveNoteParams {
1456                path: "original".to_string(),
1457                new_path: "folder/moved".to_string(),
1458            }))
1459            .await
1460            .unwrap();
1461        assert!(
1462            is_success(&result),
1463            "expected success: {}",
1464            result_text(&result)
1465        );
1466        let show = handler
1467            .show_note(Parameters(ShowNoteParams {
1468                path: "folder/moved".to_string(),
1469            }))
1470            .await
1471            .unwrap();
1472        assert!(is_success(&show));
1473        assert!(result_text(&show).contains("unique_move_content_xyz"));
1474        let old = handler
1475            .show_note(Parameters(ShowNoteParams {
1476                path: "original".to_string(),
1477            }))
1478            .await
1479            .unwrap();
1480        assert_eq!(old.is_error, Some(true), "old path should be gone");
1481    }
1482
1483    #[tokio::test]
1484    async fn test_move_note_fails_if_destination_exists() {
1485        let (handler, _dir) = make_handler().await;
1486        handler
1487            .create_note(Parameters(CreateNoteParams {
1488                path: "src".to_string(),
1489                content: "source".to_string(),
1490            }))
1491            .await
1492            .unwrap();
1493        handler
1494            .create_note(Parameters(CreateNoteParams {
1495                path: "dst".to_string(),
1496                content: "destination".to_string(),
1497            }))
1498            .await
1499            .unwrap();
1500        let result = handler
1501            .move_note(Parameters(MoveNoteParams {
1502                path: "src".to_string(),
1503                new_path: "dst".to_string(),
1504            }))
1505            .await
1506            .unwrap();
1507        assert_eq!(result.is_error, Some(true));
1508    }
1509
1510    #[tokio::test]
1511    async fn test_list_notes_filters_by_prefix() {
1512        let (handler, _dir) = make_handler().await;
1513        handler
1514            .create_note(Parameters(CreateNoteParams {
1515                path: "projects/foo".to_string(),
1516                content: "foo".to_string(),
1517            }))
1518            .await
1519            .unwrap();
1520        handler
1521            .create_note(Parameters(CreateNoteParams {
1522                path: "journal/2026-01-01".to_string(),
1523                content: "journal".to_string(),
1524            }))
1525            .await
1526            .unwrap();
1527        let result = handler
1528            .list_notes(Parameters(ListNotesParams {
1529                path: Some("projects".to_string()),
1530            }))
1531            .await
1532            .unwrap();
1533        assert!(is_success(&result));
1534        let text = result_text(&result);
1535        assert!(
1536            text.contains("projects/foo"),
1537            "missing projects/foo: {}",
1538            text
1539        );
1540        assert!(
1541            !text.contains("journal/2026"),
1542            "should not include journal: {}",
1543            text
1544        );
1545    }
1546}