1pub 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#[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 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 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 pub old: String,
111 pub new: String,
113 pub replace_all: Option<bool>,
115 pub regex: Option<bool>,
117 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 pub key: String,
136 pub value: serde_json::Value,
138 #[serde(rename = "type")]
140 pub kind: Option<String>,
141 pub format: Option<String>,
143}
144
145#[derive(Debug, Deserialize, schemars::JsonSchema)]
146pub struct RemovePropertyParams {
147 pub path: String,
148 pub key: String,
150}
151
152#[derive(Serialize)]
154struct PropertiesReply {
155 properties: JsonProperties,
156 tags: Vec<String>,
157}
158
159fn property_input(value: &serde_json::Value, kind: Option<&str>) -> Result<PropertyInput, String> {
163 use serde_json::Value;
164 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#[derive(Clone)]
197pub struct KimunHandler {
198 vault: Arc<NoteVault>,
199 #[allow(dead_code)]
203 tool_router: ToolRouter<KimunHandler>,
204 #[allow(dead_code)]
205 prompt_router: PromptRouter<KimunHandler>,
206}
207
208fn 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 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 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 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#[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 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 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 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 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
843pub 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#[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 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 let result = handler
1500 .get_chunks(Parameters(ChunksParams {
1501 path: "missing/note".to_string(),
1502 }))
1503 .await;
1504 let _ = result;
1506 }
1507
1508 #[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 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 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 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 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}