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(
634            "Kimun notes MCP server — read and write vault notes via tools. \
635             Search, listing, backlinks, and labels are served from an index that \
636             these tools keep in sync automatically. If vault files are modified \
637             outside Kimün (e.g. edited directly with sed, another editor, or a sync \
638             tool), the index goes stale and results may be wrong until the workspace \
639             is reindexed — run `kimun workspace reindex` and reconnect to this server.",
640        )
641    }
642
643    async fn list_resources(
644        &self,
645        _request: Option<PaginatedRequestParams>,
646        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
647    ) -> Result<ListResourcesResult, McpError> {
648        let notes = self
649            .vault
650            .get_all_notes()
651            .await
652            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
653
654        let resources: Vec<Resource> = notes
655            .into_iter()
656            .map(|(entry, content)| {
657                // Build URI: note://{relative_path_with_ext}
658                let mut rel_path = entry.path.clone();
659                rel_path.to_relative();
660                let uri = format!("note://{}", rel_path.to_string_with_ext());
661
662                // Name: title from NoteContentData, or stem of filename if title empty
663                let name = if content.title.is_empty() {
664                    entry.path.get_clean_name()
665                } else {
666                    content.title.clone()
667                };
668
669                RawResource::new(uri, name)
670                    .with_mime_type("text/markdown")
671                    .no_annotation()
672            })
673            .collect();
674
675        Ok(ListResourcesResult {
676            resources,
677            next_cursor: None,
678            meta: None,
679        })
680    }
681
682    async fn read_resource(
683        &self,
684        request: ReadResourceRequestParams,
685        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
686    ) -> Result<ReadResourceResult, McpError> {
687        let uri = &request.uri;
688
689        // Validate URI scheme
690        let path_with_ext = uri.strip_prefix("note://").ok_or_else(|| {
691            McpError::invalid_params(
692                format!("invalid URI scheme — expected note://, got: {}", uri),
693                None,
694            )
695        })?;
696
697        let vault_path = VaultPath::note_path_from(path_with_ext);
698
699        // Fetch note text
700        match self.vault.get_note_text(&vault_path).await {
701            Ok(text) => Ok(ReadResourceResult::new(vec![ResourceContents::text(
702                text,
703                uri.clone(),
704            )])),
705            Err(kimun_core::error::VaultError::FSError(
706                kimun_core::error::FSError::VaultPathNotFound { .. },
707            )) => Err(McpError::invalid_params(
708                format!("note not found: {}", uri),
709                None,
710            )),
711            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
712        }
713    }
714
715    async fn list_resource_templates(
716        &self,
717        _request: Option<PaginatedRequestParams>,
718        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
719    ) -> Result<ListResourceTemplatesResult, McpError> {
720        Ok(ListResourceTemplatesResult {
721            resource_templates: vec![],
722            next_cursor: None,
723            meta: None,
724        })
725    }
726}
727
728// ---------------------------------------------------------------------------
729// Entry point
730// ---------------------------------------------------------------------------
731
732pub async fn run(config_path: Option<PathBuf>) -> Result<()> {
733    use crate::cli::helpers::create_and_init_vault;
734    let (vault, _) = create_and_init_vault(config_path).await?;
735    let handler = KimunHandler::new(vault);
736    let service = handler.serve(stdio()).await.map_err(|e| eyre!("{e}"))?;
737    service.waiting().await.map_err(|e| eyre!("{e}"))?;
738    Ok(())
739}
740
741// ---------------------------------------------------------------------------
742// Tests
743// ---------------------------------------------------------------------------
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use kimun_core::{NoteVault, VaultConfig};
749    use tempfile::TempDir;
750
751    async fn make_handler() -> (KimunHandler, TempDir) {
752        let dir = TempDir::new().unwrap();
753        let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
754        vault.validate_and_init().await.unwrap();
755        let handler = KimunHandler::new(vault);
756        (handler, dir)
757    }
758
759    fn is_success(result: &CallToolResult) -> bool {
760        result.is_error != Some(true)
761    }
762
763    fn result_text(result: &CallToolResult) -> String {
764        serde_json::to_string(&result.content).unwrap_or_default()
765    }
766
767    #[tokio::test]
768    async fn test_create_note_succeeds() {
769        let (handler, _dir) = make_handler().await;
770        let result = handler
771            .create_note(Parameters(CreateNoteParams {
772                path: "test/hello".to_string(),
773                content: "# Hello\n\nworld".to_string(),
774            }))
775            .await
776            .unwrap();
777        assert!(
778            is_success(&result),
779            "expected success, got: {:?}",
780            result_text(&result)
781        );
782        assert!(result_text(&result).contains("test/hello"));
783    }
784
785    #[tokio::test]
786    async fn test_create_note_fails_if_exists() {
787        let (handler, _dir) = make_handler().await;
788        handler
789            .create_note(Parameters(CreateNoteParams {
790                path: "test/hello".to_string(),
791                content: "first".to_string(),
792            }))
793            .await
794            .unwrap();
795        let result = handler
796            .create_note(Parameters(CreateNoteParams {
797                path: "test/hello".to_string(),
798                content: "second".to_string(),
799            }))
800            .await
801            .unwrap();
802        assert_eq!(result.is_error, Some(true));
803    }
804
805    #[tokio::test]
806    async fn test_overwrite_note_replaces_whole_body() {
807        let (handler, _dir) = make_handler().await;
808        handler
809            .create_note(Parameters(CreateNoteParams {
810                path: "n".to_string(),
811                content: "old body".to_string(),
812            }))
813            .await
814            .unwrap();
815
816        let result = handler
817            .overwrite_note(Parameters(OverwriteNoteParams {
818                path: "n".to_string(),
819                content: "new body".to_string(),
820            }))
821            .await
822            .unwrap();
823        assert!(is_success(&result), "got: {:?}", result_text(&result));
824
825        let shown = handler
826            .show_note(Parameters(ShowNoteParams {
827                path: "n".to_string(),
828            }))
829            .await
830            .unwrap();
831        assert!(result_text(&shown).contains("new body"));
832        assert!(!result_text(&shown).contains("old body"));
833    }
834
835    #[tokio::test]
836    async fn test_replace_in_note_unique_match() {
837        let (handler, _dir) = make_handler().await;
838        handler
839            .create_note(Parameters(CreateNoteParams {
840                path: "n".to_string(),
841                content: "hello world".to_string(),
842            }))
843            .await
844            .unwrap();
845
846        let result = handler
847            .replace_in_note(Parameters(ReplaceInNoteParams {
848                path: "n".to_string(),
849                old: "world".to_string(),
850                new: "there".to_string(),
851                replace_all: None,
852                regex: None,
853                preview: None,
854            }))
855            .await
856            .unwrap();
857        assert!(is_success(&result), "got: {:?}", result_text(&result));
858
859        let shown = handler
860            .show_note(Parameters(ShowNoteParams {
861                path: "n".to_string(),
862            }))
863            .await
864            .unwrap();
865        assert!(result_text(&shown).contains("hello there"));
866    }
867
868    #[tokio::test]
869    async fn test_replace_in_note_non_unique_is_error() {
870        let (handler, _dir) = make_handler().await;
871        handler
872            .create_note(Parameters(CreateNoteParams {
873                path: "n".to_string(),
874                content: "a a".to_string(),
875            }))
876            .await
877            .unwrap();
878
879        let result = handler
880            .replace_in_note(Parameters(ReplaceInNoteParams {
881                path: "n".to_string(),
882                old: "a".to_string(),
883                new: "b".to_string(),
884                replace_all: None,
885                regex: None,
886                preview: None,
887            }))
888            .await
889            .unwrap();
890        assert_eq!(result.is_error, Some(true));
891    }
892
893    #[tokio::test]
894    async fn test_delete_note_removes_it() {
895        let (handler, _dir) = make_handler().await;
896        handler
897            .create_note(Parameters(CreateNoteParams {
898                path: "n".to_string(),
899                content: "x".to_string(),
900            }))
901            .await
902            .unwrap();
903
904        let result = handler
905            .delete_note(Parameters(DeleteNoteParams {
906                path: "n".to_string(),
907            }))
908            .await
909            .unwrap();
910        assert!(is_success(&result), "got: {:?}", result_text(&result));
911
912        let shown = handler
913            .show_note(Parameters(ShowNoteParams {
914                path: "n".to_string(),
915            }))
916            .await
917            .unwrap();
918        assert_eq!(shown.is_error, Some(true));
919    }
920
921    #[tokio::test]
922    async fn test_show_note_returns_content() {
923        let (handler, _dir) = make_handler().await;
924        handler
925            .create_note(Parameters(CreateNoteParams {
926                path: "show/me".to_string(),
927                content: "# Show me\n\nsome content".to_string(),
928            }))
929            .await
930            .unwrap();
931        let result = handler
932            .show_note(Parameters(ShowNoteParams {
933                path: "show/me".to_string(),
934            }))
935            .await
936            .unwrap();
937        assert!(is_success(&result));
938        assert!(result_text(&result).contains("some content"));
939    }
940
941    #[tokio::test]
942    async fn test_show_note_not_found_returns_error_result() {
943        let (handler, _dir) = make_handler().await;
944        let result = handler
945            .show_note(Parameters(ShowNoteParams {
946                path: "missing/note".to_string(),
947            }))
948            .await
949            .unwrap();
950        assert_eq!(result.is_error, Some(true));
951    }
952
953    #[tokio::test]
954    async fn test_append_note_creates_if_absent() {
955        let (handler, _dir) = make_handler().await;
956        let result = handler
957            .append_note(Parameters(AppendNoteParams {
958                path: "new/note".to_string(),
959                content: "appended text".to_string(),
960            }))
961            .await
962            .unwrap();
963        assert!(is_success(&result));
964        let show = handler
965            .show_note(Parameters(ShowNoteParams {
966                path: "new/note".to_string(),
967            }))
968            .await
969            .unwrap();
970        assert!(result_text(&show).contains("appended text"));
971    }
972
973    #[tokio::test]
974    async fn test_append_note_appends_to_existing() {
975        let (handler, _dir) = make_handler().await;
976        handler
977            .create_note(Parameters(CreateNoteParams {
978                path: "exist/note".to_string(),
979                content: "original".to_string(),
980            }))
981            .await
982            .unwrap();
983        handler
984            .append_note(Parameters(AppendNoteParams {
985                path: "exist/note".to_string(),
986                content: "added".to_string(),
987            }))
988            .await
989            .unwrap();
990        let show = handler
991            .show_note(Parameters(ShowNoteParams {
992                path: "exist/note".to_string(),
993            }))
994            .await
995            .unwrap();
996        let text = result_text(&show);
997        assert!(text.contains("original"), "missing 'original' in: {}", text);
998        assert!(text.contains("added"), "missing 'added' in: {}", text);
999        let orig_pos = text.find("original").expect("original not found");
1000        let added_pos = text.find("added").expect("added not found");
1001        assert!(orig_pos < added_pos, "original should appear before added");
1002    }
1003
1004    #[tokio::test]
1005    async fn test_search_notes_finds_match() {
1006        let (handler, _dir) = make_handler().await;
1007        handler
1008            .create_note(Parameters(CreateNoteParams {
1009                path: "alpha/one".to_string(),
1010                content: "# Alpha\n\ncontains unique_keyword_xyz".to_string(),
1011            }))
1012            .await
1013            .unwrap();
1014        let result = handler
1015            .search_notes(Parameters(SearchNotesParams {
1016                query: "unique_keyword_xyz".to_string(),
1017            }))
1018            .await
1019            .unwrap();
1020        assert!(
1021            is_success(&result),
1022            "expected success: {}",
1023            result_text(&result)
1024        );
1025        assert!(
1026            result_text(&result).contains("alpha/one"),
1027            "search result did not include 'alpha/one': {}",
1028            result_text(&result)
1029        );
1030    }
1031
1032    #[tokio::test]
1033    async fn test_search_notes_returns_empty_for_no_match() {
1034        let (handler, _dir) = make_handler().await;
1035        let result = handler
1036            .search_notes(Parameters(SearchNotesParams {
1037                query: "nonexistent_zzz_123".to_string(),
1038            }))
1039            .await
1040            .unwrap();
1041        assert!(is_success(&result));
1042    }
1043
1044    #[tokio::test]
1045    async fn test_list_notes_returns_all() {
1046        let (handler, _dir) = make_handler().await;
1047        handler
1048            .create_note(Parameters(CreateNoteParams {
1049                path: "folder/a".to_string(),
1050                content: "note a".to_string(),
1051            }))
1052            .await
1053            .unwrap();
1054        handler
1055            .create_note(Parameters(CreateNoteParams {
1056                path: "folder/b".to_string(),
1057                content: "note b".to_string(),
1058            }))
1059            .await
1060            .unwrap();
1061        let result = handler
1062            .list_notes(Parameters(ListNotesParams { path: None }))
1063            .await
1064            .unwrap();
1065        assert!(is_success(&result));
1066        let text = result_text(&result);
1067        assert!(text.contains("folder/a"), "missing 'folder/a': {}", text);
1068        assert!(text.contains("folder/b"), "missing 'folder/b': {}", text);
1069    }
1070
1071    #[tokio::test]
1072    async fn test_journal_appends_to_today() {
1073        let (handler, _dir) = make_handler().await;
1074        let result = handler
1075            .journal(Parameters(JournalParams {
1076                text: "Today's thought".to_string(),
1077                date: None,
1078            }))
1079            .await
1080            .unwrap();
1081        assert!(
1082            is_success(&result),
1083            "expected success: {}",
1084            result_text(&result)
1085        );
1086        assert!(
1087            result_text(&result).contains("saved"),
1088            "expected 'saved' in result: {}",
1089            result_text(&result)
1090        );
1091    }
1092
1093    #[tokio::test]
1094    async fn test_journal_with_explicit_date() {
1095        let (handler, _dir) = make_handler().await;
1096        let result = handler
1097            .journal(Parameters(JournalParams {
1098                text: "Entry for specific date".to_string(),
1099                date: Some("2026-01-15".to_string()),
1100            }))
1101            .await
1102            .unwrap();
1103        assert!(
1104            is_success(&result),
1105            "expected success: {}",
1106            result_text(&result)
1107        );
1108    }
1109
1110    #[tokio::test]
1111    async fn test_journal_invalid_date_returns_error() {
1112        let (handler, _dir) = make_handler().await;
1113        let result = handler
1114            .journal(Parameters(JournalParams {
1115                text: "bad date".to_string(),
1116                date: Some("not-a-date".to_string()),
1117            }))
1118            .await
1119            .unwrap();
1120        assert_eq!(
1121            result.is_error,
1122            Some(true),
1123            "expected error for invalid date"
1124        );
1125    }
1126
1127    #[tokio::test]
1128    async fn test_get_backlinks_empty_for_no_links() {
1129        let (handler, _dir) = make_handler().await;
1130        handler
1131            .create_note(Parameters(CreateNoteParams {
1132                path: "standalone".to_string(),
1133                content: "# Standalone\n\nNo links here.".to_string(),
1134            }))
1135            .await
1136            .unwrap();
1137        let result = handler
1138            .get_backlinks(Parameters(BacklinksParams {
1139                path: "standalone".to_string(),
1140            }))
1141            .await
1142            .unwrap();
1143        assert!(is_success(&result));
1144    }
1145
1146    #[tokio::test]
1147    async fn test_get_backlinks_finds_linking_note() {
1148        let (handler, _dir) = make_handler().await;
1149        handler
1150            .create_note(Parameters(CreateNoteParams {
1151                path: "target".to_string(),
1152                content: "# Target".to_string(),
1153            }))
1154            .await
1155            .unwrap();
1156        handler
1157            .create_note(Parameters(CreateNoteParams {
1158                path: "source".to_string(),
1159                content: "links to [[target]]".to_string(),
1160            }))
1161            .await
1162            .unwrap();
1163        let result = handler
1164            .get_backlinks(Parameters(BacklinksParams {
1165                path: "target".to_string(),
1166            }))
1167            .await
1168            .unwrap();
1169        assert!(is_success(&result));
1170        assert!(
1171            result_text(&result).contains("source"),
1172            "expected 'source' in backlinks: {}",
1173            result_text(&result)
1174        );
1175    }
1176
1177    #[tokio::test]
1178    async fn test_get_chunks_returns_sections() {
1179        let (handler, _dir) = make_handler().await;
1180        handler
1181            .create_note(Parameters(CreateNoteParams {
1182                path: "chunked".to_string(),
1183                content: "# Title\n\n## Section One\n\nparagraph\n\n## Section Two\n\nmore"
1184                    .to_string(),
1185            }))
1186            .await
1187            .unwrap();
1188        let result = handler
1189            .get_chunks(Parameters(ChunksParams {
1190                path: "chunked".to_string(),
1191            }))
1192            .await
1193            .unwrap();
1194        assert!(is_success(&result));
1195        assert!(
1196            result_text(&result).contains("Section"),
1197            "expected section in chunks: {}",
1198            result_text(&result)
1199        );
1200    }
1201
1202    #[tokio::test]
1203    async fn test_get_chunks_missing_note_returns_gracefully() {
1204        let (handler, _dir) = make_handler().await;
1205        // get_note_chunks on a missing note may return empty map or an error —
1206        // either way it should not panic.
1207        let result = handler
1208            .get_chunks(Parameters(ChunksParams {
1209                path: "missing/note".to_string(),
1210            }))
1211            .await;
1212        // Just verify it returned something without panicking
1213        let _ = result;
1214    }
1215
1216    // ---- Resource tests ----
1217    //
1218    // `list_resources` and `read_resource` require a `RequestContext<RoleServer>`,
1219    // which in turn requires a `Peer<R>` constructed via `Peer::new` — a
1220    // `pub(crate)` function not accessible outside rmcp.  There is no public
1221    // test constructor or `Default` impl, so these tests are marked `#[ignore]`
1222    // until rmcp exposes a test helper.  The implementations themselves are
1223    // correct and covered by the integration smoke test.
1224
1225    #[tokio::test]
1226    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1227    async fn test_list_resources_returns_notes() {
1228        let (handler, _dir) = make_handler().await;
1229        handler
1230            .create_note(Parameters(CreateNoteParams {
1231                path: "res/alpha".to_string(),
1232                content: "# Alpha Note".to_string(),
1233            }))
1234            .await
1235            .unwrap();
1236        // Cannot call handler.list_resources(None, ctx) — ctx requires Peer which
1237        // is not constructable from outside rmcp.
1238        // The assertion below would be:
1239        //   assert!(result.resources.iter().any(|r| r.uri.contains("res/alpha")));
1240        unreachable!("test is ignored");
1241    }
1242
1243    #[tokio::test]
1244    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1245    async fn test_read_resource_returns_content() {
1246        let (handler, _dir) = make_handler().await;
1247        handler
1248            .create_note(Parameters(CreateNoteParams {
1249                path: "res/beta".to_string(),
1250                content: "# Beta\n\nbeta content".to_string(),
1251            }))
1252            .await
1253            .unwrap();
1254        // Would call: handler.read_resource(ReadResourceRequestParams::new("note://res/beta.md"), ctx)
1255        // and assert content_json.contains("beta content")
1256        unreachable!("test is ignored");
1257    }
1258
1259    #[tokio::test]
1260    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1261    async fn test_read_resource_not_found_returns_error() {
1262        let (handler, _dir) = make_handler().await;
1263        // Would call: handler.read_resource(ReadResourceRequestParams::new("note://missing/note.md"), ctx)
1264        // and assert result.is_err()
1265        let _ = &handler;
1266        unreachable!("test is ignored");
1267    }
1268
1269    #[tokio::test]
1270    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1271    async fn test_read_resource_invalid_scheme_returns_error() {
1272        let (handler, _dir) = make_handler().await;
1273        // Would call: handler.read_resource(ReadResourceRequestParams::new("file:///etc/passwd"), ctx)
1274        // and assert result.is_err()
1275        let _ = &handler;
1276        unreachable!("test is ignored");
1277    }
1278
1279    #[tokio::test]
1280    async fn test_get_outlinks_returns_linked_notes() {
1281        let (handler, _dir) = make_handler().await;
1282        handler
1283            .create_note(Parameters(CreateNoteParams {
1284                path: "source".to_string(),
1285                content: "# Source\n\nSee [[target]] for more.".to_string(),
1286            }))
1287            .await
1288            .unwrap();
1289        handler
1290            .create_note(Parameters(CreateNoteParams {
1291                path: "target".to_string(),
1292                content: "# Target\n\nContent here.".to_string(),
1293            }))
1294            .await
1295            .unwrap();
1296        let result = handler
1297            .get_outlinks(Parameters(OutlinksParams {
1298                path: "source".to_string(),
1299            }))
1300            .await
1301            .unwrap();
1302        assert!(
1303            is_success(&result),
1304            "expected success: {}",
1305            result_text(&result)
1306        );
1307        assert!(
1308            result_text(&result).contains("target"),
1309            "expected 'target' in outlinks: {}",
1310            result_text(&result)
1311        );
1312    }
1313
1314    #[tokio::test]
1315    async fn test_get_outlinks_no_links_returns_empty_message() {
1316        let (handler, _dir) = make_handler().await;
1317        handler
1318            .create_note(Parameters(CreateNoteParams {
1319                path: "no-links".to_string(),
1320                content: "# No Links\n\nJust text, no wikilinks.".to_string(),
1321            }))
1322            .await
1323            .unwrap();
1324        let result = handler
1325            .get_outlinks(Parameters(OutlinksParams {
1326                path: "no-links".to_string(),
1327            }))
1328            .await
1329            .unwrap();
1330        assert!(is_success(&result));
1331        assert!(
1332            result_text(&result).contains("No outlinks found"),
1333            "expected empty message: {}",
1334            result_text(&result)
1335        );
1336    }
1337
1338    #[tokio::test]
1339    async fn test_get_outlinks_note_not_found_returns_error() {
1340        let (handler, _dir) = make_handler().await;
1341        let result = handler
1342            .get_outlinks(Parameters(OutlinksParams {
1343                path: "missing/note".to_string(),
1344            }))
1345            .await
1346            .unwrap();
1347        assert_eq!(result.is_error, Some(true));
1348    }
1349
1350    #[tokio::test]
1351    async fn test_rename_note_succeeds() {
1352        let (handler, _dir) = make_handler().await;
1353        handler
1354            .create_note(Parameters(CreateNoteParams {
1355                path: "old-name".to_string(),
1356                content: "# Old\n\nunique_rename_content_xyz".to_string(),
1357            }))
1358            .await
1359            .unwrap();
1360        let result = handler
1361            .rename_note(Parameters(RenameNoteParams {
1362                path: "old-name".to_string(),
1363                new_name: "new-name".to_string(),
1364            }))
1365            .await
1366            .unwrap();
1367        assert!(
1368            is_success(&result),
1369            "expected success: {}",
1370            result_text(&result)
1371        );
1372        let show = handler
1373            .show_note(Parameters(ShowNoteParams {
1374                path: "new-name".to_string(),
1375            }))
1376            .await
1377            .unwrap();
1378        assert!(is_success(&show), "new path should be readable");
1379        assert!(result_text(&show).contains("unique_rename_content_xyz"));
1380        let old = handler
1381            .show_note(Parameters(ShowNoteParams {
1382                path: "old-name".to_string(),
1383            }))
1384            .await
1385            .unwrap();
1386        assert_eq!(old.is_error, Some(true), "old path should be gone");
1387    }
1388
1389    #[tokio::test]
1390    async fn test_rename_note_rejects_slash_in_name() {
1391        let (handler, _dir) = make_handler().await;
1392        handler
1393            .create_note(Parameters(CreateNoteParams {
1394                path: "some/note".to_string(),
1395                content: "content".to_string(),
1396            }))
1397            .await
1398            .unwrap();
1399        let result = handler
1400            .rename_note(Parameters(RenameNoteParams {
1401                path: "some/note".to_string(),
1402                new_name: "other/dir".to_string(),
1403            }))
1404            .await
1405            .unwrap();
1406        assert_eq!(result.is_error, Some(true));
1407        assert!(
1408            result_text(&result).contains("move_note"),
1409            "hint should mention move_note: {}",
1410            result_text(&result)
1411        );
1412    }
1413
1414    #[tokio::test]
1415    async fn test_rename_note_updates_backlinks() {
1416        let (handler, _dir) = make_handler().await;
1417        handler
1418            .create_note(Parameters(CreateNoteParams {
1419                path: "target".to_string(),
1420                content: "# Target".to_string(),
1421            }))
1422            .await
1423            .unwrap();
1424        handler
1425            .create_note(Parameters(CreateNoteParams {
1426                path: "linker".to_string(),
1427                content: "see [[target]] for details".to_string(),
1428            }))
1429            .await
1430            .unwrap();
1431        handler
1432            .rename_note(Parameters(RenameNoteParams {
1433                path: "target".to_string(),
1434                new_name: "renamed-target".to_string(),
1435            }))
1436            .await
1437            .unwrap();
1438        let show = handler
1439            .show_note(Parameters(ShowNoteParams {
1440                path: "linker".to_string(),
1441            }))
1442            .await
1443            .unwrap();
1444        assert!(
1445            result_text(&show).contains("renamed-target"),
1446            "backlink should be updated: {}",
1447            result_text(&show)
1448        );
1449    }
1450
1451    #[tokio::test]
1452    async fn test_move_note_succeeds() {
1453        let (handler, _dir) = make_handler().await;
1454        handler
1455            .create_note(Parameters(CreateNoteParams {
1456                path: "original".to_string(),
1457                content: "# Original\n\nunique_move_content_xyz".to_string(),
1458            }))
1459            .await
1460            .unwrap();
1461        let result = handler
1462            .move_note(Parameters(MoveNoteParams {
1463                path: "original".to_string(),
1464                new_path: "folder/moved".to_string(),
1465            }))
1466            .await
1467            .unwrap();
1468        assert!(
1469            is_success(&result),
1470            "expected success: {}",
1471            result_text(&result)
1472        );
1473        let show = handler
1474            .show_note(Parameters(ShowNoteParams {
1475                path: "folder/moved".to_string(),
1476            }))
1477            .await
1478            .unwrap();
1479        assert!(is_success(&show));
1480        assert!(result_text(&show).contains("unique_move_content_xyz"));
1481        let old = handler
1482            .show_note(Parameters(ShowNoteParams {
1483                path: "original".to_string(),
1484            }))
1485            .await
1486            .unwrap();
1487        assert_eq!(old.is_error, Some(true), "old path should be gone");
1488    }
1489
1490    #[tokio::test]
1491    async fn test_move_note_fails_if_destination_exists() {
1492        let (handler, _dir) = make_handler().await;
1493        handler
1494            .create_note(Parameters(CreateNoteParams {
1495                path: "src".to_string(),
1496                content: "source".to_string(),
1497            }))
1498            .await
1499            .unwrap();
1500        handler
1501            .create_note(Parameters(CreateNoteParams {
1502                path: "dst".to_string(),
1503                content: "destination".to_string(),
1504            }))
1505            .await
1506            .unwrap();
1507        let result = handler
1508            .move_note(Parameters(MoveNoteParams {
1509                path: "src".to_string(),
1510                new_path: "dst".to_string(),
1511            }))
1512            .await
1513            .unwrap();
1514        assert_eq!(result.is_error, Some(true));
1515    }
1516
1517    #[tokio::test]
1518    async fn test_list_notes_filters_by_prefix() {
1519        let (handler, _dir) = make_handler().await;
1520        handler
1521            .create_note(Parameters(CreateNoteParams {
1522                path: "projects/foo".to_string(),
1523                content: "foo".to_string(),
1524            }))
1525            .await
1526            .unwrap();
1527        handler
1528            .create_note(Parameters(CreateNoteParams {
1529                path: "journal/2026-01-01".to_string(),
1530                content: "journal".to_string(),
1531            }))
1532            .await
1533            .unwrap();
1534        let result = handler
1535            .list_notes(Parameters(ListNotesParams {
1536                path: Some("projects".to_string()),
1537            }))
1538            .await
1539            .unwrap();
1540        assert!(is_success(&result));
1541        let text = result_text(&result);
1542        assert!(
1543            text.contains("projects/foo"),
1544            "missing projects/foo: {}",
1545            text
1546        );
1547        assert!(
1548            !text.contains("journal/2026"),
1549            "should not include journal: {}",
1550            text
1551        );
1552    }
1553}