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