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