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