git.lucas.co / cce-notes
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 }