notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git
src/mcp.rs (9.8K)
1 //! MCP tools, so agents can read and write the vault through the running
2 //! app (cce-ui's tools-only server, as cce-designer uses it):
3 //!
4 //! ```sh
5 //! claude mcp add --transport http cce-notes http://127.0.0.1:3002/mcp
6 //! ```
7 //!
8 //! Only the single instance serves it (a forwarding launch exits first).
9 //! `CCE_NOTES_MCP_PORT` moves the port, for a second instance in a shadow.
10 //! Calls run on the app's event loop, so they see the same index as the
11 //! window, and writes go through the same paths (a note open and dirty in
12 //! the window raises the usual conflict rather than being overwritten).
13
14 use cce_ui::mcp::{McpTool, McpToolCall};
15 use cce_vault::index::stem;
16 use cce_vault::FileKind;
17 use serde_json::{json, Value};
18
19 use crate::{Message, NotesApp};
20
21 const DEFAULT_PORT: u16 = 3002;
22
23 pub fn start(sender: calloop::channel::Sender<Message>) {
24 let port = std::env::var("CCE_NOTES_MCP_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(DEFAULT_PORT);
25 cce_ui::mcp::start_mcp_server("cce-notes", port, tools(), sender, Message::Mcp);
26 }
27
28 fn tools() -> Vec<McpTool> {
29 let tool = |name: &str, description: &str, input_schema: Value| McpTool {
30 name: name.into(),
31 description: description.into(),
32 input_schema,
33 };
34 let note_arg = json!({ "type": "string", "description": "A note name, vault path, or path without .md" });
35 vec![
36 tool(
37 "search",
38 "Full-text search over the vault's notes (every word must match), or `#tag` / `tag:name` for the notes carrying a tag. Returns matching notes with up to 5 matching lines each (1-based line numbers).",
39 json!({ "type": "object", "properties": {
40 "query": { "type": "string" },
41 "limit": { "type": "integer", "description": "Most notes to return (default 20)" }
42 }, "required": ["query"] }),
43 ),
44 tool(
45 "find_notes",
46 "Fuzzy-match note names, aliases and paths, best first.",
47 json!({ "type": "object", "properties": {
48 "query": { "type": "string" },
49 "limit": { "type": "integer", "description": "Default 10" }
50 }, "required": ["query"] }),
51 ),
52 tool(
53 "read_note",
54 "A note's full Markdown text, with its vault path.",
55 json!({ "type": "object", "properties": { "note": note_arg }, "required": ["note"] }),
56 ),
57 tool(
58 "backlinks",
59 "Notes linking to a note (with the linking lines, 1-based), and unlinked mentions of its name.",
60 json!({ "type": "object", "properties": { "note": note_arg }, "required": ["note"] }),
61 ),
62 tool(
63 "open_note",
64 "Show a note in the cce-notes window, optionally at a 1-based line.",
65 json!({ "type": "object", "properties": {
66 "note": note_arg,
67 "line": { "type": "integer" }
68 }, "required": ["note"] }),
69 ),
70 tool(
71 "append_daily",
72 "Append a paragraph to a day's daily note (today by default), creating the note from the vault's daily template if needed. Returns the note's path.",
73 json!({ "type": "object", "properties": {
74 "text": { "type": "string" },
75 "date": { "type": "string", "description": "YYYY-MM-DD; default today" }
76 }, "required": ["text"] }),
77 ),
78 tool(
79 "current_note",
80 "What the window shows: the open note's path, the mode (reading or source) and whether it has unsaved edits.",
81 json!({ "type": "object", "properties": {} }),
82 ),
83 ]
84 }
85
86 fn str_arg<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
87 args.get(key).and_then(Value::as_str).ok_or_else(|| format!("missing string argument `{key}`"))
88 }
89
90 fn limit_arg(args: &Value, default: usize) -> usize {
91 args.get("limit").and_then(Value::as_u64).map(|n| n.clamp(1, 200) as usize).unwrap_or(default)
92 }
93
94 impl NotesApp {
95 pub(crate) fn mcp_call(&mut self, call: McpToolCall) {
96 let result = self.mcp_dispatch(&call.name, &call.arguments);
97 let _ = call.reply.send(result);
98 }
99
100 fn resolve_note(&self, note: &str) -> Result<String, String> {
101 let ix = self.index.as_ref().ok_or("no vault is configured")?;
102 let path = ix.lookup(note).ok_or_else(|| format!("no note named `{note}`"))?;
103 match ix.entry(&path).map(|e| e.kind) {
104 Some(FileKind::Note) => Ok(path),
105 _ => Err(format!("`{path}` is not a note")),
106 }
107 }
108
109 fn mcp_dispatch(&mut self, name: &str, args: &Value) -> Result<Value, String> {
110 match name {
111 "current_note" => Ok(json!({
112 "path": self.current,
113 "mode": if self.mode == crate::Mode::Source { "source" } else { "reading" },
114 "unsaved": self.dirty(),
115 "conflict": self.conflict,
116 })),
117 "open_note" => {
118 let path = self.resolve_note(str_arg(args, "note")?)?;
119 let line = args.get("line").and_then(Value::as_u64).map(|l| (l as usize).saturating_sub(1));
120 self.open_path(&path, line, true);
121 if self.current.as_deref() != Some(path.as_str()) {
122 return Err(self.status.as_ref().map(|s| s.0.clone()).unwrap_or_else(|| "could not open".into()));
123 }
124 self.needs_rebuild = true;
125 Ok(json!({ "opened": path }))
126 }
127 "append_daily" => {
128 let text = str_arg(args, "text")?.to_string();
129 let date = match args.get("date").and_then(Value::as_str) {
130 Some(d) => Some(chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").map_err(|_| format!("not a date: {d}"))?),
131 None => None,
132 };
133 let ix = self.index.as_mut().ok_or("no vault is configured")?;
134 let date = date.unwrap_or_else(|| chrono::Local::now().date_naive());
135 let (path, _) = ix.daily(date, true).map_err(|e| e.to_string())?;
136 ix.append(&path, &text).map_err(|e| e.to_string())?;
137 // The window, if it shows that note, catches up as it does
138 // for any outside edit.
139 self.vault_changed(vec![ix_abs(self, &path)]);
140 Ok(json!({ "appended_to": path }))
141 }
142 _ => {
143 let ix = self.index.as_ref().ok_or("no vault is configured")?;
144 match name {
145 "search" => {
146 let q = str_arg(args, "query")?;
147 let limit = limit_arg(args, 20);
148 if let Some(tag) = crate::side::tag_query(q) {
149 let mut notes = ix.tagged(tag);
150 notes.truncate(limit);
151 return Ok(json!({ "tag": tag, "notes": notes }));
152 }
153 let hits: Vec<Value> = ix
154 .search(q, limit)
155 .into_iter()
156 .map(|h| {
157 json!({
158 "path": h.path,
159 "matching_lines": h.total,
160 "lines": h.lines.iter().map(|l| json!({ "line": l.line + 1, "text": l.text })).collect::<Vec<_>>(),
161 })
162 })
163 .collect();
164 Ok(json!(hits))
165 }
166 "find_notes" => {
167 let q = str_arg(args, "query")?;
168 let found: Vec<Value> = ix
169 .find(q, limit_arg(args, 10))
170 .into_iter()
171 .map(|m| json!({ "path": m.path, "matched": m.matched }))
172 .collect();
173 Ok(json!(found))
174 }
175 "read_note" => {
176 let path = self.resolve_note(str_arg(args, "note")?)?;
177 let text = ix.read_text(&path).map_err(|e| e.to_string())?;
178 Ok(json!({ "path": path, "text": text }))
179 }
180 "backlinks" => {
181 let path = self.resolve_note(str_arg(args, "note")?)?;
182 let mut linked = Vec::new();
183 let mut texts: std::collections::HashMap<&str, String> = Default::default();
184 for b in ix.backlinks(&path) {
185 let text = texts.entry(b.source).or_insert_with(|| ix.read_text(b.source).unwrap_or_default());
186 let line_text = text.lines().nth(b.link.line).unwrap_or("").trim().to_string();
187 linked.push(json!({ "source": b.source, "line": b.link.line + 1, "text": line_text }));
188 }
189 let unlinked: Vec<Value> = ix
190 .unlinked_mentions(&path)
191 .into_iter()
192 .map(|h| {
193 json!({
194 "source": h.path,
195 "lines": h.lines.iter().map(|l| json!({ "line": l.line + 1, "text": l.text })).collect::<Vec<_>>(),
196 })
197 })
198 .collect();
199 Ok(json!({ "note": path, "name": stem(&path), "linked": linked, "unlinked": unlinked }))
200 }
201 other => Err(format!("unknown tool `{other}`")),
202 }
203 }
204 }
205 }
206 }
207
208 fn ix_abs(app: &NotesApp, path: &str) -> std::path::PathBuf {
209 app.index.as_ref().map(|ix| ix.abs(path)).unwrap_or_default()
210 }