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::note::{FrontmatterFormat, NoteMetadata, PropertyInput, PropertyKind};
12use kimun_core::{NoteVault, nfs::VaultPath};
13use rmcp::{
14    ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
15    handler::server::{
16        router::{prompt::PromptRouter, tool::ToolRouter},
17        wrapper::Parameters,
18    },
19    model::*,
20    prompt_handler, schemars,
21    service::RequestContext,
22    tool, tool_handler, tool_router,
23    transport::stdio,
24};
25use serde::{Deserialize, Serialize};
26
27use crate::cli::json_output::JsonProperties;
28
29// ---------------------------------------------------------------------------
30// Parameter structs
31// ---------------------------------------------------------------------------
32
33#[derive(Debug, Deserialize, schemars::JsonSchema)]
34pub struct CreateNoteParams {
35    pub path: String,
36    pub content: String,
37}
38
39#[derive(Debug, Deserialize, schemars::JsonSchema)]
40pub struct AppendNoteParams {
41    pub path: String,
42    pub content: String,
43}
44
45#[derive(Debug, Deserialize, schemars::JsonSchema)]
46pub struct ShowNoteParams {
47    pub path: String,
48}
49
50#[derive(Debug, Deserialize, schemars::JsonSchema)]
51pub struct SearchNotesParams {
52    pub query: String,
53}
54
55#[derive(Debug, Deserialize, schemars::JsonSchema)]
56pub struct ListNotesParams {
57    pub path: Option<String>,
58}
59
60#[derive(Debug, Deserialize, schemars::JsonSchema)]
61pub struct JournalParams {
62    pub text: String,
63    pub date: Option<String>,
64}
65
66#[derive(Debug, Deserialize, schemars::JsonSchema)]
67pub struct BacklinksParams {
68    pub path: String,
69}
70
71#[derive(Debug, Deserialize, schemars::JsonSchema)]
72pub struct ChunksParams {
73    pub path: String,
74}
75
76#[derive(Debug, Deserialize, schemars::JsonSchema)]
77pub struct OutlinksParams {
78    pub path: String,
79}
80
81#[derive(Debug, Deserialize, schemars::JsonSchema)]
82pub struct RenameNoteParams {
83    pub path: String,
84    /// New filename stem — no extension, no path separator
85    pub new_name: String,
86}
87
88#[derive(Debug, Deserialize, schemars::JsonSchema)]
89pub struct MoveNoteParams {
90    pub path: String,
91    pub new_path: String,
92}
93
94#[derive(Debug, Deserialize, schemars::JsonSchema)]
95pub struct QuickNoteParams {
96    /// Text content for the quick note
97    pub content: String,
98}
99
100#[derive(Debug, Deserialize, schemars::JsonSchema)]
101pub struct OverwriteNoteParams {
102    pub path: String,
103    pub content: String,
104}
105
106#[derive(Debug, Deserialize, schemars::JsonSchema)]
107pub struct ReplaceInNoteParams {
108    pub path: String,
109    /// Text to find
110    pub old: String,
111    /// Replacement text (may use $1/${name} capture references when regex is true)
112    pub new: String,
113    /// Replace every occurrence instead of requiring a unique match
114    pub replace_all: Option<bool>,
115    /// Treat `old` as a regular expression instead of a literal substring
116    pub regex: Option<bool>,
117    /// Preview the resulting content without writing the note (dry run)
118    pub preview: Option<bool>,
119}
120
121#[derive(Debug, Deserialize, schemars::JsonSchema)]
122pub struct DeleteNoteParams {
123    pub path: String,
124}
125
126#[derive(Debug, Deserialize, schemars::JsonSchema)]
127pub struct GetPropertiesParams {
128    pub path: String,
129}
130
131#[derive(Debug, Deserialize, schemars::JsonSchema)]
132pub struct SetPropertyParams {
133    pub path: String,
134    /// Property key (case-insensitive; a new key is written as spelled)
135    pub key: String,
136    /// The value: a string (typed like the key's values in other notes, or by its look for a new key), a number, true/false, or an array of strings for a list
137    pub value: serde_json::Value,
138    /// Force a type: text, number, bool, date, datetime or list. Needed to store a value that doesn't fit the key's type elsewhere in the vault; only this note changes.
139    #[serde(rename = "type")]
140    pub kind: Option<String>,
141    /// Frontmatter syntax for a note that has no frontmatter yet: "toml" (default) or "yaml". An existing block keeps its format.
142    pub format: Option<String>,
143}
144
145#[derive(Debug, Deserialize, schemars::JsonSchema)]
146pub struct RemovePropertyParams {
147    pub path: String,
148    /// Property key (case-insensitive)
149    pub key: String,
150}
151
152/// `get_properties` reply: properties in the note's order, plus its labels.
153#[derive(Serialize)]
154struct PropertiesReply {
155    properties: JsonProperties,
156    tags: Vec<String>,
157}
158
159/// A `set_property` JSON value as core's [`PropertyInput`]: a string is left
160/// to core's typing rules; a number, boolean or array *implies* its type (still
161/// checked against the key's type in the vault); `kind` forces one.
162fn property_input(value: &serde_json::Value, kind: Option<&str>) -> Result<PropertyInput, String> {
163    use serde_json::Value;
164    // One JSON scalar as the text core types, with the type it implies.
165    fn scalar(v: &Value) -> Option<(String, Option<PropertyKind>)> {
166        match v {
167            Value::String(s) => Some((s.clone(), None)),
168            Value::Number(n) => Some((n.to_string(), Some(PropertyKind::Number))),
169            Value::Bool(b) => Some((b.to_string(), Some(PropertyKind::Bool))),
170            _ => None,
171        }
172    }
173    let forced = kind.map(str::parse::<PropertyKind>).transpose()?;
174    let input = match value {
175        Value::Array(items) => items
176            .iter()
177            .map(|item| scalar(item).map(|(text, _)| text))
178            .collect::<Option<Vec<_>>>()
179            .map(|values| PropertyInput::new(values).implied(PropertyKind::List))
180            .ok_or("list items must be strings, numbers or true/false")?,
181        _ => match scalar(value) {
182            Some((text, Some(implied))) => PropertyInput::new(vec![text]).implied(implied),
183            Some((text, None)) => PropertyInput::new(vec![text]),
184            None => {
185                return Err("value must be a string, number, true/false or an array".to_string());
186            }
187        },
188    };
189    Ok(input.forced(forced))
190}
191
192// ---------------------------------------------------------------------------
193// Handler struct
194// ---------------------------------------------------------------------------
195
196#[derive(Clone)]
197pub struct KimunHandler {
198    vault: Arc<NoteVault>,
199    // Read at runtime by the `#[tool_handler]` / `#[prompt_handler]` generated
200    // impls (see below); clippy can't see through the macro expansion, so it
201    // flags these as unread despite being the live tool/prompt dispatch tables.
202    #[allow(dead_code)]
203    tool_router: ToolRouter<KimunHandler>,
204    #[allow(dead_code)]
205    prompt_router: PromptRouter<KimunHandler>,
206}
207
208// ---------------------------------------------------------------------------
209// Tool implementations
210// ---------------------------------------------------------------------------
211
212/// Map a vault error to the result a note-operation handler returns: the core
213/// user-facing message as a tool error the model can react to, or an internal
214/// protocol error. The message and the recoverable/internal split both come from
215/// core (`VaultError::user_message`), so the MCP server and the CLI render
216/// identical wording and a new error variant flows automatically.
217fn vault_err(e: kimun_core::error::VaultError) -> Result<CallToolResult, McpError> {
218    match e.user_message() {
219        Some(msg) => Ok(CallToolResult::error(vec![Content::text(msg)])),
220        None => Err(McpError::internal_error(e.to_string(), None)),
221    }
222}
223
224#[tool_router]
225impl KimunHandler {
226    pub fn new(vault: NoteVault) -> Self {
227        Self {
228            vault: Arc::new(vault),
229            tool_router: Self::tool_router(),
230            prompt_router: Self::prompt_router(),
231        }
232    }
233
234    fn resolve_path(path: &str) -> VaultPath {
235        VaultPath::note_path_from(path)
236    }
237
238    #[tool(
239        description = "Create a new note at the given vault path with the given markdown content. Fails if the note already exists."
240    )]
241    async fn create_note(
242        &self,
243        Parameters(p): Parameters<CreateNoteParams>,
244    ) -> Result<CallToolResult, McpError> {
245        let vault_path = Self::resolve_path(&p.path);
246        match self.vault.create_note(&vault_path, &p.content).await {
247            Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
248                "Note created: {}",
249                vault_path
250            ))])),
251            Err(e) => vault_err(e),
252        }
253    }
254
255    #[tool(description = "Append text to an existing note. Creates the note if it does not exist.")]
256    async fn append_note(
257        &self,
258        Parameters(p): Parameters<AppendNoteParams>,
259    ) -> Result<CallToolResult, McpError> {
260        let vault_path = Self::resolve_path(&p.path);
261        self.vault
262            .append_to_note(&vault_path, &p.content, None)
263            .await
264            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
265        Ok(CallToolResult::success(vec![Content::text(format!(
266            "Note saved: {}",
267            vault_path
268        ))]))
269    }
270
271    #[tool(
272        description = "Replace a note's entire content with new markdown. The previous content is backed up first. Destructive.",
273        annotations(destructive_hint = true)
274    )]
275    async fn overwrite_note(
276        &self,
277        Parameters(p): Parameters<OverwriteNoteParams>,
278    ) -> Result<CallToolResult, McpError> {
279        let vault_path = Self::resolve_path(&p.path);
280        if p.content.is_empty() {
281            return Ok(CallToolResult::error(vec![Content::text(
282                "Refusing to overwrite with empty content (this would wipe the note); pass content, or use delete_note to remove it",
283            )]));
284        }
285        match self.vault.save_note(&vault_path, &p.content).await {
286            Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
287                "Note saved: {}",
288                vault_path
289            ))])),
290            Err(e) => vault_err(e),
291        }
292    }
293
294    #[tool(
295        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.",
296        annotations(destructive_hint = true)
297    )]
298    async fn replace_in_note(
299        &self,
300        Parameters(p): Parameters<ReplaceInNoteParams>,
301    ) -> Result<CallToolResult, McpError> {
302        let vault_path = Self::resolve_path(&p.path);
303        let all = p.replace_all.unwrap_or(false);
304        let regex = p.regex.unwrap_or(false);
305
306        if p.preview.unwrap_or(false) {
307            return match self
308                .vault
309                .preview_replace(&vault_path, &p.old, &p.new, all, regex)
310                .await
311            {
312                Ok(pv) => Ok(CallToolResult::success(vec![Content::text(format!(
313                    "{} occurrence(s) would be replaced in {} (preview — not written). Resulting content:\n\n{}",
314                    pv.count, vault_path, pv.content
315                ))])),
316                Err(e) => vault_err(e),
317            };
318        }
319
320        match self
321            .vault
322            .replace_in_note(&vault_path, &p.old, &p.new, all, regex)
323            .await
324        {
325            Ok(n) => Ok(CallToolResult::success(vec![Content::text(format!(
326                "Replaced {} occurrence(s) in {}",
327                n, vault_path
328            ))])),
329            Err(e) => vault_err(e),
330        }
331    }
332
333    #[tool(
334        description = "Delete a note. The content is backed up first. Destructive.",
335        annotations(destructive_hint = true)
336    )]
337    async fn delete_note(
338        &self,
339        Parameters(p): Parameters<DeleteNoteParams>,
340    ) -> Result<CallToolResult, McpError> {
341        let vault_path = Self::resolve_path(&p.path);
342        match self.vault.delete_note(&vault_path).await {
343            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
344                "Note deleted: {}",
345                vault_path
346            ))])),
347            Err(e) => vault_err(e),
348        }
349    }
350
351    #[tool(
352        description = "Return a note's frontmatter properties as JSON, in the note's order: {\"properties\": {\"status\": \"done\", \"priority\": 2, \"due\": \"2024-03-01\", \"tags\": [\"a\"]}, \"tags\": [\"a\", \"inline\"]}. A key the note has with no value (YAML `due:`) or with one that can't be read (a TOML time of day, nan) is null. `tags` is every label of the note — its inline #hashtags plus its frontmatter tags."
353    )]
354    async fn get_properties(
355        &self,
356        Parameters(p): Parameters<GetPropertiesParams>,
357    ) -> Result<CallToolResult, McpError> {
358        let vault_path = Self::resolve_path(&p.path);
359        // Both fields from the one text read, so they can't disagree when the
360        // index lags behind an outside edit.
361        let meta = match self.vault.get_note_text(&vault_path).await {
362            Ok(text) => NoteMetadata::of(&text),
363            Err(e) => return vault_err(e),
364        };
365        let reply = PropertiesReply {
366            properties: JsonProperties(meta.properties),
367            tags: meta.tags,
368        };
369        let json = serde_json::to_string(&reply)
370            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
371        Ok(CallToolResult::success(vec![Content::text(json)]))
372    }
373
374    #[tool(
375        description = "Set a frontmatter property on a note, keeping the rest of its frontmatter (format, comments, order) as is. `value` is a string, number, true/false, or an array of strings (a list). `tags` is always a list (comma-separated text becomes separate items; [] clears it); `aliases` and `cssclasses` are always lists too, values kept as given. Any other value must fit the type the key has in other notes — a string is read as that type (a single string for a list key becomes a one-item list), and a number, true/false or array must match it too; a value that doesn't fit is refused — pass `type` to store it anyway (only this note changes). For a key no other note has, a number/true/false/array keeps its JSON type and a string is typed by its look: 5 → number, true → true/false, 2024-03-01 → date, 2024-03-01T14:30 → date-time (local, or with its offset as written), else text; text that only looks numeric (02134, 1.10) stays text. A note without frontmatter gets a TOML (+++) block unless format is \"yaml\"."
376    )]
377    async fn set_property(
378        &self,
379        Parameters(p): Parameters<SetPropertyParams>,
380    ) -> Result<CallToolResult, McpError> {
381        let vault_path = Self::resolve_path(&p.path);
382        let input = property_input(&p.value, p.kind.as_deref()).and_then(|input| {
383            let format = p
384                .format
385                .as_deref()
386                .map(str::parse::<FrontmatterFormat>)
387                .transpose()?;
388            Ok((input, format.unwrap_or_default()))
389        });
390        let (input, format) = match input {
391            Ok(input) => input,
392            Err(msg) => return Ok(CallToolResult::error(vec![Content::text(msg)])),
393        };
394        match self
395            .vault
396            .set_property_from_input(&vault_path, &p.key, &input, format)
397            .await
398        {
399            Ok(value) => Ok(CallToolResult::success(vec![Content::text(format!(
400                "Set {} = {} ({}) in {}",
401                p.key,
402                value,
403                value.kind(),
404                vault_path
405            ))])),
406            Err(e) => vault_err(e),
407        }
408    }
409
410    #[tool(
411        description = "Remove a frontmatter property from a note (with any comment lines directly above it). Not an error when the note doesn't have it.",
412        annotations(destructive_hint = true)
413    )]
414    async fn remove_property(
415        &self,
416        Parameters(p): Parameters<RemovePropertyParams>,
417    ) -> Result<CallToolResult, McpError> {
418        let vault_path = Self::resolve_path(&p.path);
419        match self.vault.remove_property(&vault_path, &p.key).await {
420            Ok(true) => Ok(CallToolResult::success(vec![Content::text(format!(
421                "Removed {} from {}",
422                p.key, vault_path
423            ))])),
424            Ok(false) => Ok(CallToolResult::success(vec![Content::text(format!(
425                "No property '{}' in {}",
426                p.key, vault_path
427            ))])),
428            Err(e) => vault_err(e),
429        }
430    }
431
432    #[tool(description = "Return the full markdown content of a note.")]
433    async fn show_note(
434        &self,
435        Parameters(p): Parameters<ShowNoteParams>,
436    ) -> Result<CallToolResult, McpError> {
437        let vault_path = Self::resolve_path(&p.path);
438        match self.vault.get_note_text(&vault_path).await {
439            Ok(text) => Ok(CallToolResult::success(vec![Content::text(text)])),
440            Err(e) => vault_err(e),
441        }
442    }
443
444    #[tool(
445        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), %key<op>value (or prop:key<op>value) for frontmatter properties with op one of = != < <= > >= (e.g. %status=done, %priority>=2, %due<2024-04-01; numbers compare numerically, dates chronologically, on a list = means contains; quote values or keys with spaces: %status=\"in progress\", %\"due date\"<2024-04-01; * is a wildcard with = and !=: %status=d*) or a bare %key for notes that have the property at all, ^%key (or or:prop:key; ^%\"due date\" for a key with spaces) to sort by a property (-^%key descending; notes without it last), and - prefix for exclusion (e.g. -term, -#label, -lb:label, -=name, -@heading, -/path, -<note, -lk:note, ->note, -fwd:note, -%key, -%key=value). 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*). Labels (#label) come from hashtags in note body text and from the frontmatter `tags` property (a list, or one string) — hashtags written inside frontmatter, fenced code blocks, inline code, HTML, markdown link bodies, and [[wikilinks]] are not indexed. Inline label names are ASCII [A-Za-z0-9_]+; all labels are matched case-insensitively. Long queries are truncated at 8 KB."
446    )]
447    async fn search_notes(
448        &self,
449        Parameters(p): Parameters<SearchNotesParams>,
450    ) -> Result<CallToolResult, McpError> {
451        let results = self
452            .vault
453            .search_notes(&p.query)
454            .await
455            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
456        if results.is_empty() {
457            return Ok(CallToolResult::success(vec![Content::text(
458                "No results found.",
459            )]));
460        }
461        let lines: Vec<String> = results
462            .iter()
463            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
464            .collect();
465        Ok(CallToolResult::success(vec![Content::text(
466            lines.join("\n"),
467        )]))
468    }
469
470    #[tool(description = "List all notes in the vault, optionally filtered by path prefix.")]
471    async fn list_notes(
472        &self,
473        Parameters(p): Parameters<ListNotesParams>,
474    ) -> Result<CallToolResult, McpError> {
475        let all = self
476            .vault
477            .get_all_notes()
478            .await
479            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
480        let filtered: Vec<_> = match &p.path {
481            None => all,
482            Some(prefix) => {
483                let norm = prefix.trim_matches('/');
484                all.into_iter()
485                    .filter(|(entry, _)| {
486                        let mut p = entry.path.clone();
487                        p.to_relative();
488                        p.to_string().starts_with(norm)
489                    })
490                    .collect()
491            }
492        };
493        if filtered.is_empty() {
494            return Ok(CallToolResult::success(vec![Content::text(
495                "No notes found.",
496            )]));
497        }
498        let lines: Vec<String> = filtered
499            .iter()
500            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
501            .collect();
502        Ok(CallToolResult::success(vec![Content::text(
503            lines.join("\n"),
504        )]))
505    }
506
507    #[tool(
508        description = "Append text to today's journal entry (or a specific date). Creates the entry if absent."
509    )]
510    async fn journal(
511        &self,
512        Parameters(p): Parameters<JournalParams>,
513    ) -> Result<CallToolResult, McpError> {
514        // Validate and resolve the date
515        let date_str = match p.date.as_deref() {
516            None => chrono::Utc::now().format("%Y-%m-%d").to_string(),
517            Some(d) => {
518                if chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").is_err() {
519                    return Ok(CallToolResult::error(vec![Content::text(format!(
520                        "Invalid date '{}' — expected YYYY-MM-DD",
521                        d
522                    ))]));
523                }
524                d.to_string()
525            }
526        };
527
528        // Both today and a specific date resolve to journal/<date>; append under
529        // the per-note lock so concurrent journal writes can't lose an entry.
530        let vault_path = self
531            .vault
532            .journal_path()
533            .append(&VaultPath::note_path_from(&date_str))
534            .absolute();
535        self.vault
536            .append_to_note(&vault_path, &p.text, Some(format!("# {}\n\n", date_str)))
537            .await
538            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
539
540        Ok(CallToolResult::success(vec![Content::text(format!(
541            "Note saved: {}",
542            vault_path
543        ))]))
544    }
545
546    #[tool(description = "Return the list of notes that link to the given note (backlinks).")]
547    async fn get_backlinks(
548        &self,
549        Parameters(p): Parameters<BacklinksParams>,
550    ) -> Result<CallToolResult, McpError> {
551        let vault_path = Self::resolve_path(&p.path);
552        let backlinks = self
553            .vault
554            .get_backlinks(&vault_path)
555            .await
556            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
557        if backlinks.is_empty() {
558            return Ok(CallToolResult::success(vec![Content::text(
559                "No backlinks found.",
560            )]));
561        }
562        let lines: Vec<String> = backlinks
563            .iter()
564            .map(|(entry, content)| format!("{} — {}", entry.path, content.title))
565            .collect();
566        Ok(CallToolResult::success(vec![Content::text(
567            lines.join("\n"),
568        )]))
569    }
570
571    #[tool(description = "Return the content chunks (sections) of a note as JSON.")]
572    async fn get_chunks(
573        &self,
574        Parameters(p): Parameters<ChunksParams>,
575    ) -> Result<CallToolResult, McpError> {
576        let vault_path = Self::resolve_path(&p.path);
577        let chunks_map = self
578            .vault
579            .get_note_chunks(&vault_path)
580            .await
581            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
582
583        let mut lines: Vec<String> = Vec::new();
584        for chunks in chunks_map.values() {
585            for chunk in chunks {
586                let breadcrumb = chunk
587                    .breadcrumb
588                    .replace(kimun_core::note::BREADCRUMB_SEP, " > ");
589                lines.push(format!("[{}] {}", breadcrumb, chunk.text.trim()));
590            }
591        }
592
593        if lines.is_empty() {
594            return Ok(CallToolResult::success(vec![Content::text(
595                "No chunks found.",
596            )]));
597        }
598        Ok(CallToolResult::success(vec![Content::text(
599            lines.join("\n\n"),
600        )]))
601    }
602
603    #[tool(description = "Return the list of notes that this note links to (outgoing wikilinks).")]
604    async fn get_outlinks(
605        &self,
606        Parameters(p): Parameters<OutlinksParams>,
607    ) -> Result<CallToolResult, McpError> {
608        use kimun_core::note::{LinkType, NoteDetails};
609
610        let vault_path = Self::resolve_path(&p.path);
611
612        let md_note = match self.vault.get_markdown_and_links(&vault_path).await {
613            Ok(n) => n,
614            Err(e) => return vault_err(e),
615        };
616
617        let note_links: Vec<_> = md_note
618            .links
619            .into_iter()
620            .filter_map(|link| {
621                if let LinkType::Note(path) = link.ltype {
622                    Some(path)
623                } else {
624                    None
625                }
626            })
627            .collect();
628
629        if note_links.is_empty() {
630            return Ok(CallToolResult::success(vec![Content::text(
631                "No outlinks found.",
632            )]));
633        }
634
635        let mut lines: Vec<String> = Vec::new();
636        for path in note_links {
637            let title = match self.vault.get_note_text(&path).await {
638                Ok(text) => {
639                    let t = NoteDetails::get_title_from_text(&text);
640                    if t.is_empty() {
641                        path.get_clean_name()
642                    } else {
643                        t
644                    }
645                }
646                Err(_) => path.get_clean_name(),
647            };
648            lines.push(format!("{} — {}", path, title));
649        }
650
651        Ok(CallToolResult::success(vec![Content::text(
652            lines.join("\n"),
653        )]))
654    }
655
656    #[tool(
657        description = "Rename a note within its current directory (filename only). Use move_note to change the directory."
658    )]
659    async fn rename_note(
660        &self,
661        Parameters(p): Parameters<RenameNoteParams>,
662    ) -> Result<CallToolResult, McpError> {
663        if p.new_name.contains('/') {
664            return Ok(CallToolResult::error(vec![Content::text(
665                "new_name must not contain '/'. Use move_note to change a note's directory.",
666            )]));
667        }
668
669        let from = Self::resolve_path(&p.path);
670        let (parent, _) = from.get_parent_path();
671        let to = parent
672            .append(&VaultPath::note_path_from(&p.new_name))
673            .absolute();
674
675        match self.vault.rename_note(&from, &to).await {
676            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
677                "Note renamed: {} → {}",
678                from, to
679            ))])),
680            Err(e) if e.is_user_error() => Ok(CallToolResult::error(vec![Content::text(format!(
681                "Note not found or destination already exists: {} → {}",
682                from, to
683            ))])),
684            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
685        }
686    }
687
688    #[tool(
689        description = "Move a note to a new vault path (different directory and/or name). Backlinks in other notes are updated automatically."
690    )]
691    async fn move_note(
692        &self,
693        Parameters(p): Parameters<MoveNoteParams>,
694    ) -> Result<CallToolResult, McpError> {
695        let from = Self::resolve_path(&p.path);
696        let to = Self::resolve_path(&p.new_path);
697
698        match self.vault.rename_note(&from, &to).await {
699            Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
700                "Note moved: {} → {}",
701                from, to
702            ))])),
703            Err(e) if e.is_user_error() => Ok(CallToolResult::error(vec![Content::text(format!(
704                "Note not found or destination already exists: {} → {}",
705                from, to
706            ))])),
707            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
708        }
709    }
710
711    #[tool(
712        description = "Quickly capture a thought into a timestamped note in the inbox directory. Returns the path of the created note."
713    )]
714    async fn quick_note(
715        &self,
716        Parameters(p): Parameters<QuickNoteParams>,
717    ) -> Result<CallToolResult, McpError> {
718        if p.content.trim().is_empty() {
719            return Ok(CallToolResult::error(vec![Content::text(
720                "Content cannot be empty.",
721            )]));
722        }
723        match self.vault.quick_note(&p.content).await {
724            Ok(details) => Ok(CallToolResult::success(vec![Content::text(format!(
725                "Note saved: {}",
726                details.path
727            ))])),
728            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
729        }
730    }
731}
732
733// ---------------------------------------------------------------------------
734// ServerHandler implementation
735// ---------------------------------------------------------------------------
736
737#[tool_handler]
738#[prompt_handler]
739impl ServerHandler for KimunHandler {
740    fn get_info(&self) -> ServerInfo {
741        ServerInfo::new(
742            ServerCapabilities::builder()
743                .enable_tools()
744                .enable_resources()
745                .enable_prompts()
746                .build(),
747        )
748        .with_instructions(
749            "Kimun notes MCP server — read and write vault notes via tools. \
750             Search, listing, backlinks, and labels are served from an index that \
751             these tools keep in sync automatically. If vault files are modified \
752             outside Kimün (e.g. edited directly with sed, another editor, or a sync \
753             tool), the index goes stale and results may be wrong until the workspace \
754             is reindexed — run `kimun workspace reindex` and reconnect to this server.",
755        )
756    }
757
758    async fn list_resources(
759        &self,
760        _request: Option<PaginatedRequestParams>,
761        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
762    ) -> Result<ListResourcesResult, McpError> {
763        let notes = self
764            .vault
765            .get_all_notes()
766            .await
767            .map_err(|e| McpError::internal_error(e.to_string(), None))?;
768
769        let resources: Vec<Resource> = notes
770            .into_iter()
771            .map(|(entry, content)| {
772                // Build URI: note://{relative_path_with_ext}
773                let mut rel_path = entry.path.clone();
774                rel_path.to_relative();
775                let uri = format!("note://{}", rel_path.to_string_with_ext());
776
777                // Name: title from NoteContentData, or stem of filename if title empty
778                let name = if content.title.is_empty() {
779                    entry.path.get_clean_name()
780                } else {
781                    content.title.clone()
782                };
783
784                RawResource::new(uri, name)
785                    .with_mime_type("text/markdown")
786                    .no_annotation()
787            })
788            .collect();
789
790        Ok(ListResourcesResult {
791            resources,
792            next_cursor: None,
793            meta: None,
794        })
795    }
796
797    async fn read_resource(
798        &self,
799        request: ReadResourceRequestParams,
800        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
801    ) -> Result<ReadResourceResult, McpError> {
802        let uri = &request.uri;
803
804        // Validate URI scheme
805        let path_with_ext = uri.strip_prefix("note://").ok_or_else(|| {
806            McpError::invalid_params(
807                format!("invalid URI scheme — expected note://, got: {}", uri),
808                None,
809            )
810        })?;
811
812        let vault_path = VaultPath::note_path_from(path_with_ext);
813
814        // Fetch note text
815        match self.vault.get_note_text(&vault_path).await {
816            Ok(text) => Ok(ReadResourceResult::new(vec![ResourceContents::text(
817                text,
818                uri.clone(),
819            )])),
820            Err(kimun_core::error::VaultError::FSError(
821                kimun_core::error::FSError::VaultPathNotFound { .. },
822            )) => Err(McpError::invalid_params(
823                format!("note not found: {}", uri),
824                None,
825            )),
826            Err(e) => Err(McpError::internal_error(e.to_string(), None)),
827        }
828    }
829
830    async fn list_resource_templates(
831        &self,
832        _request: Option<PaginatedRequestParams>,
833        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
834    ) -> Result<ListResourceTemplatesResult, McpError> {
835        Ok(ListResourceTemplatesResult {
836            resource_templates: vec![],
837            next_cursor: None,
838            meta: None,
839        })
840    }
841}
842
843// ---------------------------------------------------------------------------
844// Entry point
845// ---------------------------------------------------------------------------
846
847pub async fn run(config_path: Option<PathBuf>) -> Result<()> {
848    use crate::cli::helpers::create_and_init_vault;
849    let (vault, _) = create_and_init_vault(config_path).await?;
850    let handler = KimunHandler::new(vault);
851    let service = handler.serve(stdio()).await.map_err(|e| eyre!("{e}"))?;
852    service.waiting().await.map_err(|e| eyre!("{e}"))?;
853    Ok(())
854}
855
856// ---------------------------------------------------------------------------
857// Tests
858// ---------------------------------------------------------------------------
859
860#[cfg(test)]
861mod tests {
862    use super::*;
863    use kimun_core::{NoteVault, VaultConfig};
864    use tempfile::TempDir;
865
866    async fn make_handler() -> (KimunHandler, TempDir) {
867        let dir = TempDir::new().unwrap();
868        let vault = NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
869            .await
870            .unwrap();
871        vault.validate_and_init().await.unwrap();
872        let handler = KimunHandler::new(vault);
873        (handler, dir)
874    }
875
876    async fn set_prop(
877        handler: &KimunHandler,
878        path: &str,
879        key: &str,
880        value: serde_json::Value,
881        kind: Option<&str>,
882    ) -> CallToolResult {
883        handler
884            .set_property(Parameters(SetPropertyParams {
885                path: path.to_string(),
886                key: key.to_string(),
887                value,
888                kind: kind.map(str::to_string),
889                format: None,
890            }))
891            .await
892            .unwrap()
893    }
894
895    async fn properties_json(handler: &KimunHandler, path: &str) -> serde_json::Value {
896        let result = handler
897            .get_properties(Parameters(GetPropertiesParams {
898                path: path.to_string(),
899            }))
900            .await
901            .unwrap();
902        assert!(is_success(&result), "{}", result_text(&result));
903        let text = match &result.content[0].raw {
904            RawContent::Text(t) => t.text.clone(),
905            other => panic!("expected text content, got {other:?}"),
906        };
907        serde_json::from_str(&text).unwrap()
908    }
909
910    #[test]
911    fn json_values_imply_their_type_and_type_forces_it() {
912        use serde_json::json;
913        let text = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
914        assert_eq!(
915            property_input(&json!("5"), None),
916            Ok(PropertyInput::new(text(&["5"])))
917        );
918        assert_eq!(
919            property_input(&json!(5), None),
920            Ok(PropertyInput::new(text(&["5"])).implied(PropertyKind::Number))
921        );
922        assert_eq!(
923            property_input(&json!(true), None),
924            Ok(PropertyInput::new(text(&["true"])).implied(PropertyKind::Bool))
925        );
926        assert_eq!(
927            property_input(&json!(["a", 2]), None),
928            Ok(PropertyInput::new(text(&["a", "2"])).implied(PropertyKind::List))
929        );
930        assert_eq!(
931            property_input(&json!([]), None),
932            Ok(PropertyInput::new(vec![]).implied(PropertyKind::List))
933        );
934        assert_eq!(
935            property_input(&json!(2024), Some("text")),
936            Ok(PropertyInput::new(text(&["2024"]))
937                .implied(PropertyKind::Number)
938                .forced(Some(PropertyKind::Text)))
939        );
940        assert!(property_input(&json!({"a": 1}), None).is_err());
941        assert!(property_input(&json!([["nested"]]), None).is_err());
942        assert!(property_input(&json!("x"), Some("float")).is_err());
943    }
944
945    #[tokio::test]
946    async fn test_property_tools_round_trip() {
947        let (handler, _dir) = make_handler().await;
948        handler
949            .create_note(Parameters(CreateNoteParams {
950                path: "garden".to_string(),
951                content: "Garden #outdoor".to_string(),
952            }))
953            .await
954            .unwrap();
955        for (key, value) in [
956            ("status", serde_json::json!("active")),
957            ("priority", serde_json::json!(2)),
958            ("due", serde_json::json!("2026-05-01")),
959            ("tags", serde_json::json!(["garden", "spring"])),
960        ] {
961            let result = set_prop(&handler, "garden", key, value, None).await;
962            assert!(is_success(&result), "{}", result_text(&result));
963        }
964        assert_eq!(
965            properties_json(&handler, "garden").await,
966            serde_json::json!({
967                "properties": {
968                    "status": "active",
969                    "priority": 2,
970                    "due": "2026-05-01",
971                    "tags": ["garden", "spring"],
972                },
973                "tags": ["garden", "outdoor", "spring"],
974            })
975        );
976
977        let removed = handler
978            .remove_property(Parameters(RemovePropertyParams {
979                path: "garden".to_string(),
980                key: "Status".to_string(),
981            }))
982            .await
983            .unwrap();
984        assert!(result_text(&removed).contains("Removed"));
985        let again = handler
986            .remove_property(Parameters(RemovePropertyParams {
987                path: "garden".to_string(),
988                key: "status".to_string(),
989            }))
990            .await
991            .unwrap();
992        assert!(is_success(&again) && result_text(&again).contains("No property"));
993    }
994
995    #[tokio::test]
996    async fn test_set_property_refuses_vault_type_mismatch() {
997        let (handler, _dir) = make_handler().await;
998        for path in ["a", "b", "c"] {
999            handler
1000                .create_note(Parameters(CreateNoteParams {
1001                    path: path.to_string(),
1002                    content: "body".to_string(),
1003                }))
1004                .await
1005                .unwrap();
1006        }
1007        set_prop(&handler, "a", "priority", serde_json::json!(1), None).await;
1008        let refused = set_prop(&handler, "b", "priority", serde_json::json!("high"), None).await;
1009        assert_eq!(refused.is_error, Some(true));
1010        assert!(result_text(&refused).contains("holds number values"));
1011        let typed = set_prop(
1012            &handler,
1013            "b",
1014            "priority",
1015            serde_json::json!("high"),
1016            Some("text"),
1017        )
1018        .await;
1019        assert!(is_success(&typed), "{}", result_text(&typed));
1020        let bad_type = set_prop(&handler, "b", "x", serde_json::json!("1"), Some("float")).await;
1021        assert_eq!(bad_type.is_error, Some(true));
1022        // `priority` is tied number/text by now; `rating` is clearly a number.
1023        set_prop(&handler, "a", "rating", serde_json::json!(4), None).await;
1024        let json_bool = set_prop(&handler, "c", "rating", serde_json::json!(true), None).await;
1025        assert_eq!(
1026            json_bool.is_error,
1027            Some(true),
1028            "a JSON value's own type is still checked against the vault"
1029        );
1030        assert!(
1031            result_text(&json_bool).contains("holds number values"),
1032            "refused for its type, not for a missing note: {}",
1033            result_text(&json_bool)
1034        );
1035        let cleared = set_prop(&handler, "b", "tags", serde_json::json!([]), None).await;
1036        assert!(is_success(&cleared), "{}", result_text(&cleared));
1037    }
1038
1039    #[tokio::test]
1040    async fn test_get_properties_missing_note_is_tool_error() {
1041        let (handler, _dir) = make_handler().await;
1042        let result = handler
1043            .get_properties(Parameters(GetPropertiesParams {
1044                path: "nope".to_string(),
1045            }))
1046            .await
1047            .unwrap();
1048        assert_eq!(result.is_error, Some(true));
1049    }
1050
1051    fn is_success(result: &CallToolResult) -> bool {
1052        result.is_error != Some(true)
1053    }
1054
1055    fn result_text(result: &CallToolResult) -> String {
1056        serde_json::to_string(&result.content).unwrap_or_default()
1057    }
1058
1059    #[tokio::test]
1060    async fn test_create_note_succeeds() {
1061        let (handler, _dir) = make_handler().await;
1062        let result = handler
1063            .create_note(Parameters(CreateNoteParams {
1064                path: "test/hello".to_string(),
1065                content: "# Hello\n\nworld".to_string(),
1066            }))
1067            .await
1068            .unwrap();
1069        assert!(
1070            is_success(&result),
1071            "expected success, got: {:?}",
1072            result_text(&result)
1073        );
1074        assert!(result_text(&result).contains("test/hello"));
1075    }
1076
1077    #[tokio::test]
1078    async fn test_create_note_fails_if_exists() {
1079        let (handler, _dir) = make_handler().await;
1080        handler
1081            .create_note(Parameters(CreateNoteParams {
1082                path: "test/hello".to_string(),
1083                content: "first".to_string(),
1084            }))
1085            .await
1086            .unwrap();
1087        let result = handler
1088            .create_note(Parameters(CreateNoteParams {
1089                path: "test/hello".to_string(),
1090                content: "second".to_string(),
1091            }))
1092            .await
1093            .unwrap();
1094        assert_eq!(result.is_error, Some(true));
1095    }
1096
1097    #[tokio::test]
1098    async fn test_overwrite_note_replaces_whole_body() {
1099        let (handler, _dir) = make_handler().await;
1100        handler
1101            .create_note(Parameters(CreateNoteParams {
1102                path: "n".to_string(),
1103                content: "old body".to_string(),
1104            }))
1105            .await
1106            .unwrap();
1107
1108        let result = handler
1109            .overwrite_note(Parameters(OverwriteNoteParams {
1110                path: "n".to_string(),
1111                content: "new body".to_string(),
1112            }))
1113            .await
1114            .unwrap();
1115        assert!(is_success(&result), "got: {:?}", result_text(&result));
1116
1117        let shown = handler
1118            .show_note(Parameters(ShowNoteParams {
1119                path: "n".to_string(),
1120            }))
1121            .await
1122            .unwrap();
1123        assert!(result_text(&shown).contains("new body"));
1124        assert!(!result_text(&shown).contains("old body"));
1125    }
1126
1127    #[tokio::test]
1128    async fn test_replace_in_note_unique_match() {
1129        let (handler, _dir) = make_handler().await;
1130        handler
1131            .create_note(Parameters(CreateNoteParams {
1132                path: "n".to_string(),
1133                content: "hello world".to_string(),
1134            }))
1135            .await
1136            .unwrap();
1137
1138        let result = handler
1139            .replace_in_note(Parameters(ReplaceInNoteParams {
1140                path: "n".to_string(),
1141                old: "world".to_string(),
1142                new: "there".to_string(),
1143                replace_all: None,
1144                regex: None,
1145                preview: None,
1146            }))
1147            .await
1148            .unwrap();
1149        assert!(is_success(&result), "got: {:?}", result_text(&result));
1150
1151        let shown = handler
1152            .show_note(Parameters(ShowNoteParams {
1153                path: "n".to_string(),
1154            }))
1155            .await
1156            .unwrap();
1157        assert!(result_text(&shown).contains("hello there"));
1158    }
1159
1160    #[tokio::test]
1161    async fn test_replace_in_note_non_unique_is_error() {
1162        let (handler, _dir) = make_handler().await;
1163        handler
1164            .create_note(Parameters(CreateNoteParams {
1165                path: "n".to_string(),
1166                content: "a a".to_string(),
1167            }))
1168            .await
1169            .unwrap();
1170
1171        let result = handler
1172            .replace_in_note(Parameters(ReplaceInNoteParams {
1173                path: "n".to_string(),
1174                old: "a".to_string(),
1175                new: "b".to_string(),
1176                replace_all: None,
1177                regex: None,
1178                preview: None,
1179            }))
1180            .await
1181            .unwrap();
1182        assert_eq!(result.is_error, Some(true));
1183    }
1184
1185    #[tokio::test]
1186    async fn test_delete_note_removes_it() {
1187        let (handler, _dir) = make_handler().await;
1188        handler
1189            .create_note(Parameters(CreateNoteParams {
1190                path: "n".to_string(),
1191                content: "x".to_string(),
1192            }))
1193            .await
1194            .unwrap();
1195
1196        let result = handler
1197            .delete_note(Parameters(DeleteNoteParams {
1198                path: "n".to_string(),
1199            }))
1200            .await
1201            .unwrap();
1202        assert!(is_success(&result), "got: {:?}", result_text(&result));
1203
1204        let shown = handler
1205            .show_note(Parameters(ShowNoteParams {
1206                path: "n".to_string(),
1207            }))
1208            .await
1209            .unwrap();
1210        assert_eq!(shown.is_error, Some(true));
1211    }
1212
1213    #[tokio::test]
1214    async fn test_show_note_returns_content() {
1215        let (handler, _dir) = make_handler().await;
1216        handler
1217            .create_note(Parameters(CreateNoteParams {
1218                path: "show/me".to_string(),
1219                content: "# Show me\n\nsome content".to_string(),
1220            }))
1221            .await
1222            .unwrap();
1223        let result = handler
1224            .show_note(Parameters(ShowNoteParams {
1225                path: "show/me".to_string(),
1226            }))
1227            .await
1228            .unwrap();
1229        assert!(is_success(&result));
1230        assert!(result_text(&result).contains("some content"));
1231    }
1232
1233    #[tokio::test]
1234    async fn test_show_note_not_found_returns_error_result() {
1235        let (handler, _dir) = make_handler().await;
1236        let result = handler
1237            .show_note(Parameters(ShowNoteParams {
1238                path: "missing/note".to_string(),
1239            }))
1240            .await
1241            .unwrap();
1242        assert_eq!(result.is_error, Some(true));
1243    }
1244
1245    #[tokio::test]
1246    async fn test_append_note_creates_if_absent() {
1247        let (handler, _dir) = make_handler().await;
1248        let result = handler
1249            .append_note(Parameters(AppendNoteParams {
1250                path: "new/note".to_string(),
1251                content: "appended text".to_string(),
1252            }))
1253            .await
1254            .unwrap();
1255        assert!(is_success(&result));
1256        let show = handler
1257            .show_note(Parameters(ShowNoteParams {
1258                path: "new/note".to_string(),
1259            }))
1260            .await
1261            .unwrap();
1262        assert!(result_text(&show).contains("appended text"));
1263    }
1264
1265    #[tokio::test]
1266    async fn test_append_note_appends_to_existing() {
1267        let (handler, _dir) = make_handler().await;
1268        handler
1269            .create_note(Parameters(CreateNoteParams {
1270                path: "exist/note".to_string(),
1271                content: "original".to_string(),
1272            }))
1273            .await
1274            .unwrap();
1275        handler
1276            .append_note(Parameters(AppendNoteParams {
1277                path: "exist/note".to_string(),
1278                content: "added".to_string(),
1279            }))
1280            .await
1281            .unwrap();
1282        let show = handler
1283            .show_note(Parameters(ShowNoteParams {
1284                path: "exist/note".to_string(),
1285            }))
1286            .await
1287            .unwrap();
1288        let text = result_text(&show);
1289        assert!(text.contains("original"), "missing 'original' in: {}", text);
1290        assert!(text.contains("added"), "missing 'added' in: {}", text);
1291        let orig_pos = text.find("original").expect("original not found");
1292        let added_pos = text.find("added").expect("added not found");
1293        assert!(orig_pos < added_pos, "original should appear before added");
1294    }
1295
1296    #[tokio::test]
1297    async fn test_search_notes_finds_match() {
1298        let (handler, _dir) = make_handler().await;
1299        handler
1300            .create_note(Parameters(CreateNoteParams {
1301                path: "alpha/one".to_string(),
1302                content: "# Alpha\n\ncontains unique_keyword_xyz".to_string(),
1303            }))
1304            .await
1305            .unwrap();
1306        let result = handler
1307            .search_notes(Parameters(SearchNotesParams {
1308                query: "unique_keyword_xyz".to_string(),
1309            }))
1310            .await
1311            .unwrap();
1312        assert!(
1313            is_success(&result),
1314            "expected success: {}",
1315            result_text(&result)
1316        );
1317        assert!(
1318            result_text(&result).contains("alpha/one"),
1319            "search result did not include 'alpha/one': {}",
1320            result_text(&result)
1321        );
1322    }
1323
1324    #[tokio::test]
1325    async fn test_search_notes_returns_empty_for_no_match() {
1326        let (handler, _dir) = make_handler().await;
1327        let result = handler
1328            .search_notes(Parameters(SearchNotesParams {
1329                query: "nonexistent_zzz_123".to_string(),
1330            }))
1331            .await
1332            .unwrap();
1333        assert!(is_success(&result));
1334    }
1335
1336    #[tokio::test]
1337    async fn test_list_notes_returns_all() {
1338        let (handler, _dir) = make_handler().await;
1339        handler
1340            .create_note(Parameters(CreateNoteParams {
1341                path: "folder/a".to_string(),
1342                content: "note a".to_string(),
1343            }))
1344            .await
1345            .unwrap();
1346        handler
1347            .create_note(Parameters(CreateNoteParams {
1348                path: "folder/b".to_string(),
1349                content: "note b".to_string(),
1350            }))
1351            .await
1352            .unwrap();
1353        let result = handler
1354            .list_notes(Parameters(ListNotesParams { path: None }))
1355            .await
1356            .unwrap();
1357        assert!(is_success(&result));
1358        let text = result_text(&result);
1359        assert!(text.contains("folder/a"), "missing 'folder/a': {}", text);
1360        assert!(text.contains("folder/b"), "missing 'folder/b': {}", text);
1361    }
1362
1363    #[tokio::test]
1364    async fn test_journal_appends_to_today() {
1365        let (handler, _dir) = make_handler().await;
1366        let result = handler
1367            .journal(Parameters(JournalParams {
1368                text: "Today's thought".to_string(),
1369                date: None,
1370            }))
1371            .await
1372            .unwrap();
1373        assert!(
1374            is_success(&result),
1375            "expected success: {}",
1376            result_text(&result)
1377        );
1378        assert!(
1379            result_text(&result).contains("saved"),
1380            "expected 'saved' in result: {}",
1381            result_text(&result)
1382        );
1383    }
1384
1385    #[tokio::test]
1386    async fn test_journal_with_explicit_date() {
1387        let (handler, _dir) = make_handler().await;
1388        let result = handler
1389            .journal(Parameters(JournalParams {
1390                text: "Entry for specific date".to_string(),
1391                date: Some("2026-01-15".to_string()),
1392            }))
1393            .await
1394            .unwrap();
1395        assert!(
1396            is_success(&result),
1397            "expected success: {}",
1398            result_text(&result)
1399        );
1400    }
1401
1402    #[tokio::test]
1403    async fn test_journal_invalid_date_returns_error() {
1404        let (handler, _dir) = make_handler().await;
1405        let result = handler
1406            .journal(Parameters(JournalParams {
1407                text: "bad date".to_string(),
1408                date: Some("not-a-date".to_string()),
1409            }))
1410            .await
1411            .unwrap();
1412        assert_eq!(
1413            result.is_error,
1414            Some(true),
1415            "expected error for invalid date"
1416        );
1417    }
1418
1419    #[tokio::test]
1420    async fn test_get_backlinks_empty_for_no_links() {
1421        let (handler, _dir) = make_handler().await;
1422        handler
1423            .create_note(Parameters(CreateNoteParams {
1424                path: "standalone".to_string(),
1425                content: "# Standalone\n\nNo links here.".to_string(),
1426            }))
1427            .await
1428            .unwrap();
1429        let result = handler
1430            .get_backlinks(Parameters(BacklinksParams {
1431                path: "standalone".to_string(),
1432            }))
1433            .await
1434            .unwrap();
1435        assert!(is_success(&result));
1436    }
1437
1438    #[tokio::test]
1439    async fn test_get_backlinks_finds_linking_note() {
1440        let (handler, _dir) = make_handler().await;
1441        handler
1442            .create_note(Parameters(CreateNoteParams {
1443                path: "target".to_string(),
1444                content: "# Target".to_string(),
1445            }))
1446            .await
1447            .unwrap();
1448        handler
1449            .create_note(Parameters(CreateNoteParams {
1450                path: "source".to_string(),
1451                content: "links to [[target]]".to_string(),
1452            }))
1453            .await
1454            .unwrap();
1455        let result = handler
1456            .get_backlinks(Parameters(BacklinksParams {
1457                path: "target".to_string(),
1458            }))
1459            .await
1460            .unwrap();
1461        assert!(is_success(&result));
1462        assert!(
1463            result_text(&result).contains("source"),
1464            "expected 'source' in backlinks: {}",
1465            result_text(&result)
1466        );
1467    }
1468
1469    #[tokio::test]
1470    async fn test_get_chunks_returns_sections() {
1471        let (handler, _dir) = make_handler().await;
1472        handler
1473            .create_note(Parameters(CreateNoteParams {
1474                path: "chunked".to_string(),
1475                content: "# Title\n\n## Section One\n\nparagraph\n\n## Section Two\n\nmore"
1476                    .to_string(),
1477            }))
1478            .await
1479            .unwrap();
1480        let result = handler
1481            .get_chunks(Parameters(ChunksParams {
1482                path: "chunked".to_string(),
1483            }))
1484            .await
1485            .unwrap();
1486        assert!(is_success(&result));
1487        assert!(
1488            result_text(&result).contains("Section"),
1489            "expected section in chunks: {}",
1490            result_text(&result)
1491        );
1492    }
1493
1494    #[tokio::test]
1495    async fn test_get_chunks_missing_note_returns_gracefully() {
1496        let (handler, _dir) = make_handler().await;
1497        // get_note_chunks on a missing note may return empty map or an error —
1498        // either way it should not panic.
1499        let result = handler
1500            .get_chunks(Parameters(ChunksParams {
1501                path: "missing/note".to_string(),
1502            }))
1503            .await;
1504        // Just verify it returned something without panicking
1505        let _ = result;
1506    }
1507
1508    // ---- Resource tests ----
1509    //
1510    // `list_resources` and `read_resource` require a `RequestContext<RoleServer>`,
1511    // which in turn requires a `Peer<R>` constructed via `Peer::new` — a
1512    // `pub(crate)` function not accessible outside rmcp.  There is no public
1513    // test constructor or `Default` impl, so these tests are marked `#[ignore]`
1514    // until rmcp exposes a test helper.  The implementations themselves are
1515    // correct and covered by the integration smoke test.
1516
1517    #[tokio::test]
1518    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1519    async fn test_list_resources_returns_notes() {
1520        let (handler, _dir) = make_handler().await;
1521        handler
1522            .create_note(Parameters(CreateNoteParams {
1523                path: "res/alpha".to_string(),
1524                content: "# Alpha Note".to_string(),
1525            }))
1526            .await
1527            .unwrap();
1528        // Cannot call handler.list_resources(None, ctx) — ctx requires Peer which
1529        // is not constructable from outside rmcp.
1530        // The assertion below would be:
1531        //   assert!(result.resources.iter().any(|r| r.uri.contains("res/alpha")));
1532        unreachable!("test is ignored");
1533    }
1534
1535    #[tokio::test]
1536    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1537    async fn test_read_resource_returns_content() {
1538        let (handler, _dir) = make_handler().await;
1539        handler
1540            .create_note(Parameters(CreateNoteParams {
1541                path: "res/beta".to_string(),
1542                content: "# Beta\n\nbeta content".to_string(),
1543            }))
1544            .await
1545            .unwrap();
1546        // Would call: handler.read_resource(ReadResourceRequestParams::new("note://res/beta.md"), ctx)
1547        // and assert content_json.contains("beta content")
1548        unreachable!("test is ignored");
1549    }
1550
1551    #[tokio::test]
1552    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1553    async fn test_read_resource_not_found_returns_error() {
1554        let (handler, _dir) = make_handler().await;
1555        // Would call: handler.read_resource(ReadResourceRequestParams::new("note://missing/note.md"), ctx)
1556        // and assert result.is_err()
1557        let _ = &handler;
1558        unreachable!("test is ignored");
1559    }
1560
1561    #[tokio::test]
1562    #[ignore = "RequestContext<RoleServer> cannot be constructed outside rmcp (Peer::new is pub(crate))"]
1563    async fn test_read_resource_invalid_scheme_returns_error() {
1564        let (handler, _dir) = make_handler().await;
1565        // Would call: handler.read_resource(ReadResourceRequestParams::new("file:///etc/passwd"), ctx)
1566        // and assert result.is_err()
1567        let _ = &handler;
1568        unreachable!("test is ignored");
1569    }
1570
1571    #[tokio::test]
1572    async fn test_get_outlinks_returns_linked_notes() {
1573        let (handler, _dir) = make_handler().await;
1574        handler
1575            .create_note(Parameters(CreateNoteParams {
1576                path: "source".to_string(),
1577                content: "# Source\n\nSee [[target]] for more.".to_string(),
1578            }))
1579            .await
1580            .unwrap();
1581        handler
1582            .create_note(Parameters(CreateNoteParams {
1583                path: "target".to_string(),
1584                content: "# Target\n\nContent here.".to_string(),
1585            }))
1586            .await
1587            .unwrap();
1588        let result = handler
1589            .get_outlinks(Parameters(OutlinksParams {
1590                path: "source".to_string(),
1591            }))
1592            .await
1593            .unwrap();
1594        assert!(
1595            is_success(&result),
1596            "expected success: {}",
1597            result_text(&result)
1598        );
1599        assert!(
1600            result_text(&result).contains("target"),
1601            "expected 'target' in outlinks: {}",
1602            result_text(&result)
1603        );
1604    }
1605
1606    #[tokio::test]
1607    async fn test_get_outlinks_no_links_returns_empty_message() {
1608        let (handler, _dir) = make_handler().await;
1609        handler
1610            .create_note(Parameters(CreateNoteParams {
1611                path: "no-links".to_string(),
1612                content: "# No Links\n\nJust text, no wikilinks.".to_string(),
1613            }))
1614            .await
1615            .unwrap();
1616        let result = handler
1617            .get_outlinks(Parameters(OutlinksParams {
1618                path: "no-links".to_string(),
1619            }))
1620            .await
1621            .unwrap();
1622        assert!(is_success(&result));
1623        assert!(
1624            result_text(&result).contains("No outlinks found"),
1625            "expected empty message: {}",
1626            result_text(&result)
1627        );
1628    }
1629
1630    #[tokio::test]
1631    async fn test_get_outlinks_note_not_found_returns_error() {
1632        let (handler, _dir) = make_handler().await;
1633        let result = handler
1634            .get_outlinks(Parameters(OutlinksParams {
1635                path: "missing/note".to_string(),
1636            }))
1637            .await
1638            .unwrap();
1639        assert_eq!(result.is_error, Some(true));
1640    }
1641
1642    #[tokio::test]
1643    async fn test_rename_note_succeeds() {
1644        let (handler, _dir) = make_handler().await;
1645        handler
1646            .create_note(Parameters(CreateNoteParams {
1647                path: "old-name".to_string(),
1648                content: "# Old\n\nunique_rename_content_xyz".to_string(),
1649            }))
1650            .await
1651            .unwrap();
1652        let result = handler
1653            .rename_note(Parameters(RenameNoteParams {
1654                path: "old-name".to_string(),
1655                new_name: "new-name".to_string(),
1656            }))
1657            .await
1658            .unwrap();
1659        assert!(
1660            is_success(&result),
1661            "expected success: {}",
1662            result_text(&result)
1663        );
1664        let show = handler
1665            .show_note(Parameters(ShowNoteParams {
1666                path: "new-name".to_string(),
1667            }))
1668            .await
1669            .unwrap();
1670        assert!(is_success(&show), "new path should be readable");
1671        assert!(result_text(&show).contains("unique_rename_content_xyz"));
1672        let old = handler
1673            .show_note(Parameters(ShowNoteParams {
1674                path: "old-name".to_string(),
1675            }))
1676            .await
1677            .unwrap();
1678        assert_eq!(old.is_error, Some(true), "old path should be gone");
1679    }
1680
1681    #[tokio::test]
1682    async fn test_rename_note_rejects_slash_in_name() {
1683        let (handler, _dir) = make_handler().await;
1684        handler
1685            .create_note(Parameters(CreateNoteParams {
1686                path: "some/note".to_string(),
1687                content: "content".to_string(),
1688            }))
1689            .await
1690            .unwrap();
1691        let result = handler
1692            .rename_note(Parameters(RenameNoteParams {
1693                path: "some/note".to_string(),
1694                new_name: "other/dir".to_string(),
1695            }))
1696            .await
1697            .unwrap();
1698        assert_eq!(result.is_error, Some(true));
1699        assert!(
1700            result_text(&result).contains("move_note"),
1701            "hint should mention move_note: {}",
1702            result_text(&result)
1703        );
1704    }
1705
1706    #[tokio::test]
1707    async fn test_rename_note_updates_backlinks() {
1708        let (handler, _dir) = make_handler().await;
1709        handler
1710            .create_note(Parameters(CreateNoteParams {
1711                path: "target".to_string(),
1712                content: "# Target".to_string(),
1713            }))
1714            .await
1715            .unwrap();
1716        handler
1717            .create_note(Parameters(CreateNoteParams {
1718                path: "linker".to_string(),
1719                content: "see [[target]] for details".to_string(),
1720            }))
1721            .await
1722            .unwrap();
1723        handler
1724            .rename_note(Parameters(RenameNoteParams {
1725                path: "target".to_string(),
1726                new_name: "renamed-target".to_string(),
1727            }))
1728            .await
1729            .unwrap();
1730        let show = handler
1731            .show_note(Parameters(ShowNoteParams {
1732                path: "linker".to_string(),
1733            }))
1734            .await
1735            .unwrap();
1736        assert!(
1737            result_text(&show).contains("renamed-target"),
1738            "backlink should be updated: {}",
1739            result_text(&show)
1740        );
1741    }
1742
1743    #[tokio::test]
1744    async fn test_move_note_succeeds() {
1745        let (handler, _dir) = make_handler().await;
1746        handler
1747            .create_note(Parameters(CreateNoteParams {
1748                path: "original".to_string(),
1749                content: "# Original\n\nunique_move_content_xyz".to_string(),
1750            }))
1751            .await
1752            .unwrap();
1753        let result = handler
1754            .move_note(Parameters(MoveNoteParams {
1755                path: "original".to_string(),
1756                new_path: "folder/moved".to_string(),
1757            }))
1758            .await
1759            .unwrap();
1760        assert!(
1761            is_success(&result),
1762            "expected success: {}",
1763            result_text(&result)
1764        );
1765        let show = handler
1766            .show_note(Parameters(ShowNoteParams {
1767                path: "folder/moved".to_string(),
1768            }))
1769            .await
1770            .unwrap();
1771        assert!(is_success(&show));
1772        assert!(result_text(&show).contains("unique_move_content_xyz"));
1773        let old = handler
1774            .show_note(Parameters(ShowNoteParams {
1775                path: "original".to_string(),
1776            }))
1777            .await
1778            .unwrap();
1779        assert_eq!(old.is_error, Some(true), "old path should be gone");
1780    }
1781
1782    #[tokio::test]
1783    async fn test_move_note_fails_if_destination_exists() {
1784        let (handler, _dir) = make_handler().await;
1785        handler
1786            .create_note(Parameters(CreateNoteParams {
1787                path: "src".to_string(),
1788                content: "source".to_string(),
1789            }))
1790            .await
1791            .unwrap();
1792        handler
1793            .create_note(Parameters(CreateNoteParams {
1794                path: "dst".to_string(),
1795                content: "destination".to_string(),
1796            }))
1797            .await
1798            .unwrap();
1799        let result = handler
1800            .move_note(Parameters(MoveNoteParams {
1801                path: "src".to_string(),
1802                new_path: "dst".to_string(),
1803            }))
1804            .await
1805            .unwrap();
1806        assert_eq!(result.is_error, Some(true));
1807    }
1808
1809    #[tokio::test]
1810    async fn test_list_notes_filters_by_prefix() {
1811        let (handler, _dir) = make_handler().await;
1812        handler
1813            .create_note(Parameters(CreateNoteParams {
1814                path: "projects/foo".to_string(),
1815                content: "foo".to_string(),
1816            }))
1817            .await
1818            .unwrap();
1819        handler
1820            .create_note(Parameters(CreateNoteParams {
1821                path: "journal/2026-01-01".to_string(),
1822                content: "journal".to_string(),
1823            }))
1824            .await
1825            .unwrap();
1826        let result = handler
1827            .list_notes(Parameters(ListNotesParams {
1828                path: Some("projects".to_string()),
1829            }))
1830            .await
1831            .unwrap();
1832        assert!(is_success(&result));
1833        let text = result_text(&result);
1834        assert!(
1835            text.contains("projects/foo"),
1836            "missing projects/foo: {}",
1837            text
1838        );
1839        assert!(
1840            !text.contains("journal/2026"),
1841            "should not include journal: {}",
1842            text
1843        );
1844    }
1845}