biblion/tools/
read.rs

1//! Read-only MCP tools — pure SQLite, sub-millisecond.
2//!
3//! These are the tools that benefit from the Rust rewrite. Each one
4//! reads directly from `zotero.sqlite` and `better-bibtex.migrated`,
5//! bypassing the BBT JSON-RPC bottleneck entirely.
6//!
7//! # Citekey resolution pattern
8//!
9//! Most tools accept a `citekey` parameter. Resolution:
10//! 1. Look up citekey in `bbt.migrated` → get item_key
11//! 2. Look up item_key in `zotero.sqlite` → get full item
12//!
13//! Both are SQLite reads, total ~0.1ms.
14
15use serde_json::Value;
16use std::path::PathBuf;
17
18use crate::protocol::ToolCallResult;
19use crate::server::ServerContext;
20use crate::tools::format::{format_item_summary, html_to_text};
21
22use super::resolve_citekey;
23
24// ---------------------------------------------------------------------------
25// zotero_status
26// ---------------------------------------------------------------------------
27
28pub fn zotero_status(ctx: &ServerContext) -> ToolCallResult {
29    let zdb = match ctx.db.zotero() {
30        Ok(db) => db,
31        Err(e) => return ToolCallResult::error(e.to_string()),
32    };
33
34    let items = zdb.item_count().unwrap_or(0);
35    let collections = zdb.collection_count().unwrap_or(0);
36    let bbt_status = if ctx.db.bbt.is_some() {
37        "connected"
38    } else {
39        "unavailable"
40    };
41    let write_status = if ctx.config.has_write_access() {
42        "enabled"
43    } else {
44        "disabled (no API key)"
45    };
46
47    ToolCallResult::text(format!(
48        "Zotero MCP (Rust)\n\
49         Items: {items}\n\
50         Collections: {collections}\n\
51         BBT database: {bbt_status}\n\
52         Write access: {write_status}\n\
53         Version: {}",
54        env!("CARGO_PKG_VERSION")
55    ))
56}
57
58// ---------------------------------------------------------------------------
59// zotero_search
60// ---------------------------------------------------------------------------
61
62pub fn zotero_search(args: &Value, ctx: &ServerContext) -> ToolCallResult {
63    let query = match args.get("query").and_then(|v| v.as_str()) {
64        Some(q) => q,
65        None => return ToolCallResult::error("Missing required parameter: query".into()),
66    };
67    let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
68
69    let zdb = match ctx.db.zotero() {
70        Ok(db) => db,
71        Err(e) => return ToolCallResult::error(e.to_string()),
72    };
73
74    let results = match zdb.search_items(query, limit) {
75        Ok(r) => r,
76        Err(e) => return ToolCallResult::error(format!("Search failed: {e}")),
77    };
78
79    if results.is_empty() {
80        return ToolCallResult::text(format!("No items found for query: {query}"));
81    }
82
83    let mut output = format!("Found {} item(s) for '{query}':\n", results.len());
84
85    for (_item_id, item_key) in &results {
86        if let Ok(Some(item)) = zdb.item_by_key(item_key) {
87            let citekey = ctx.db.citekey_for_item_key(item_key);
88            output.push('\n');
89            output.push_str(&format_item_summary(&item, citekey.as_deref()));
90            output.push_str("\n\n---\n");
91        }
92    }
93
94    ToolCallResult::text(output)
95}
96
97// ---------------------------------------------------------------------------
98// zotero_get_item
99// ---------------------------------------------------------------------------
100
101pub fn zotero_get_item(args: &Value, ctx: &ServerContext) -> ToolCallResult {
102    let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
103        Some(ck) => ck,
104        None => return ToolCallResult::error("Missing required parameter: citekey".into()),
105    };
106
107    let item_key = match resolve_citekey(ctx, citekey) {
108        Ok(k) => k,
109        Err(e) => return ToolCallResult::error(e),
110    };
111
112    let zdb = match ctx.db.zotero() {
113        Ok(db) => db,
114        Err(e) => return ToolCallResult::error(e.to_string()),
115    };
116
117    match zdb.item_by_key(&item_key) {
118        Ok(Some(item)) => {
119            let mut output = format_item_summary(&item, Some(citekey));
120            if let Some(abs) = &item.abstract_note {
121                output.push_str(&format!("\n\nAbstract: {abs}"));
122            }
123            if !item.tags.is_empty() {
124                output.push_str(&format!("\nTags: {}", item.tags.join(", ")));
125            }
126            ToolCallResult::text(output)
127        }
128        Ok(None) => ToolCallResult::error(format!("Item not found: {item_key}")),
129        Err(e) => ToolCallResult::error(format!("Database error: {e}")),
130    }
131}
132
133// ---------------------------------------------------------------------------
134// zotero_get_notes
135// ---------------------------------------------------------------------------
136
137pub fn zotero_get_notes(args: &Value, ctx: &ServerContext) -> ToolCallResult {
138    let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
139        Some(ck) => ck,
140        None => return ToolCallResult::error("Missing required parameter: citekey".into()),
141    };
142
143    let item_key = match resolve_citekey(ctx, citekey) {
144        Ok(k) => k,
145        Err(e) => return ToolCallResult::error(e),
146    };
147
148    let zdb = match ctx.db.zotero() {
149        Ok(db) => db,
150        Err(e) => return ToolCallResult::error(e.to_string()),
151    };
152
153    // Get item_id from item_key
154    let item = match zdb.item_by_key(&item_key) {
155        Ok(Some(item)) => item,
156        Ok(None) => return ToolCallResult::error(format!("Item not found: {item_key}")),
157        Err(e) => return ToolCallResult::error(e.to_string()),
158    };
159
160    match zdb.item_notes(item.item_id) {
161        Ok(notes) if notes.is_empty() => ToolCallResult::text(format!("No notes for {citekey}")),
162        Ok(notes) => {
163            let mut output = format!("{} note(s) for {citekey}:\n\n", notes.len());
164            for (i, note) in notes.iter().enumerate() {
165                output.push_str(&format!(
166                    "--- Note {} ---\n{}\n\n",
167                    i + 1,
168                    html_to_text(note)
169                ));
170            }
171            ToolCallResult::text(output)
172        }
173        Err(e) => ToolCallResult::error(format!("Error reading notes: {e}")),
174    }
175}
176
177// ---------------------------------------------------------------------------
178// zotero_get_pdf_path
179// ---------------------------------------------------------------------------
180
181pub fn zotero_get_pdf_path(args: &Value, ctx: &ServerContext) -> ToolCallResult {
182    let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
183        Some(ck) => ck,
184        None => return ToolCallResult::error("Missing required parameter: citekey".into()),
185    };
186
187    let item_key = match resolve_citekey(ctx, citekey) {
188        Ok(k) => k,
189        Err(e) => return ToolCallResult::error(e),
190    };
191
192    let zdb = match ctx.db.zotero() {
193        Ok(db) => db,
194        Err(e) => return ToolCallResult::error(e.to_string()),
195    };
196
197    let item = match zdb.item_by_key(&item_key) {
198        Ok(Some(item)) => item,
199        Ok(None) => return ToolCallResult::error(format!("Item not found: {item_key}")),
200        Err(e) => return ToolCallResult::error(e.to_string()),
201    };
202
203    let attachments = match zdb.item_attachments(item.item_id) {
204        Ok(a) => a,
205        Err(e) => return ToolCallResult::error(e.to_string()),
206    };
207
208    let pdf_entries: Vec<String> = attachments
209        .iter()
210        .filter(|a| a.content_type == "application/pdf")
211        .filter_map(|a| {
212            a.path.as_ref().map(|p| {
213                let resolved = if let Some(filename) = p.strip_prefix("storage:") {
214                    let full_path: PathBuf = [
215                        ctx.config.zotero_storage_path.to_str().unwrap_or(""),
216                        &a.item_key,
217                        filename,
218                    ]
219                    .iter()
220                    .collect();
221                    full_path.to_string_lossy().to_string()
222                } else {
223                    p.clone()
224                };
225                match &a.storage_hash {
226                    Some(hash) => format!("{resolved}\n  md5:{hash}"),
227                    None => resolved,
228                }
229            })
230        })
231        .collect();
232
233    if pdf_entries.is_empty() {
234        ToolCallResult::text(format!("No PDF attachments for {citekey}"))
235    } else {
236        ToolCallResult::text(pdf_entries.join("\n"))
237    }
238}
239
240// ---------------------------------------------------------------------------
241// zotero_list_attachments
242// ---------------------------------------------------------------------------
243
244pub fn zotero_list_attachments(args: &Value, ctx: &ServerContext) -> ToolCallResult {
245    let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
246        Some(ck) => ck,
247        None => return ToolCallResult::error("Missing required parameter: citekey".into()),
248    };
249
250    let item_key = match resolve_citekey(ctx, citekey) {
251        Ok(k) => k,
252        Err(e) => return ToolCallResult::error(e),
253    };
254
255    let zdb = match ctx.db.zotero() {
256        Ok(db) => db,
257        Err(e) => return ToolCallResult::error(e.to_string()),
258    };
259
260    let item = match zdb.item_by_key(&item_key) {
261        Ok(Some(item)) => item,
262        Ok(None) => return ToolCallResult::error(format!("Item not found: {item_key}")),
263        Err(e) => return ToolCallResult::error(e.to_string()),
264    };
265
266    match zdb.item_attachments(item.item_id) {
267        Ok(attachments) if attachments.is_empty() => {
268            ToolCallResult::text(format!("No attachments for {citekey}"))
269        }
270        Ok(attachments) => {
271            let mut output = format!("{} attachment(s) for {citekey}:\n\n", attachments.len());
272            for att in &attachments {
273                let title = att.title.as_deref().unwrap_or("(untitled)");
274                let path = att.path.as_deref().unwrap_or("(no path)");
275                let hash = att
276                    .storage_hash
277                    .as_deref()
278                    .map(|h| format!("\n  md5:{h}"))
279                    .unwrap_or_default();
280                output.push_str(&format!(
281                    "- [{title}] {}\n  {path}{hash}\n",
282                    att.content_type
283                ));
284            }
285            ToolCallResult::text(output)
286        }
287        Err(e) => ToolCallResult::error(format!("Error listing attachments: {e}")),
288    }
289}
290
291// ---------------------------------------------------------------------------
292// zotero_get_collections
293// ---------------------------------------------------------------------------
294
295pub fn zotero_get_collections(ctx: &ServerContext) -> ToolCallResult {
296    let zdb = match ctx.db.zotero() {
297        Ok(db) => db,
298        Err(e) => return ToolCallResult::error(e.to_string()),
299    };
300
301    match zdb.collections() {
302        Ok(collections) => {
303            let mut output = format!("{} collection(s):\n\n", collections.len());
304            for coll in &collections {
305                let parent = coll.parent_key.as_deref().unwrap_or("-");
306                output.push_str(&format!(
307                    "- {} (key: {}, parent: {})\n",
308                    coll.name, coll.key, parent
309                ));
310            }
311            ToolCallResult::text(output)
312        }
313        Err(e) => ToolCallResult::error(format!("Error listing collections: {e}")),
314    }
315}
316
317// ---------------------------------------------------------------------------
318// zotero_get_collection_items
319// ---------------------------------------------------------------------------
320
321pub fn zotero_get_collection_items(args: &Value, ctx: &ServerContext) -> ToolCallResult {
322    let collection_key = match args.get("collection_key").and_then(|v| v.as_str()) {
323        Some(k) => k,
324        None => return ToolCallResult::error("Missing required parameter: collection_key".into()),
325    };
326    let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
327
328    let zdb = match ctx.db.zotero() {
329        Ok(db) => db,
330        Err(e) => return ToolCallResult::error(e.to_string()),
331    };
332
333    let items = match zdb.collection_items(collection_key, limit) {
334        Ok(i) => i,
335        Err(e) => return ToolCallResult::error(format!("Error: {e}")),
336    };
337
338    if items.is_empty() {
339        return ToolCallResult::text(format!("No items in collection {collection_key}"));
340    }
341
342    let mut output = format!("Collection {} ({} item(s)):\n", collection_key, items.len());
343    for (_item_id, item_key) in &items {
344        if let Ok(Some(item)) = zdb.item_by_key(item_key) {
345            let citekey = ctx.db.citekey_for_item_key(item_key);
346            output.push('\n');
347            output.push_str(&format_item_summary(&item, citekey.as_deref()));
348            output.push_str("\n\n---\n");
349        }
350    }
351
352    ToolCallResult::text(output)
353}
354
355// ---------------------------------------------------------------------------
356// zotero_get_recent
357// ---------------------------------------------------------------------------
358
359pub fn zotero_get_recent(args: &Value, ctx: &ServerContext) -> ToolCallResult {
360    let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
361
362    let zdb = match ctx.db.zotero() {
363        Ok(db) => db,
364        Err(e) => return ToolCallResult::error(e.to_string()),
365    };
366
367    let items = match zdb.recent_items(limit) {
368        Ok(i) => i,
369        Err(e) => return ToolCallResult::error(format!("Error: {e}")),
370    };
371
372    if items.is_empty() {
373        return ToolCallResult::text("No recent items".into());
374    }
375
376    let mut output = format!("{} recent item(s):\n", items.len());
377    for (_item_id, item_key) in &items {
378        if let Ok(Some(item)) = zdb.item_by_key(item_key) {
379            let citekey = ctx.db.citekey_for_item_key(item_key);
380            output.push('\n');
381            output.push_str(&format_item_summary(&item, citekey.as_deref()));
382            output.push_str("\n\n---\n");
383        }
384    }
385
386    ToolCallResult::text(output)
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::test_helpers::test_ctx;
393    use serde_json::json;
394
395    #[test]
396    fn status_returns_item_count() {
397        let ctx = test_ctx();
398        let result = zotero_status(&ctx);
399        assert!(result.is_error.is_none());
400        let text = &result.content[0].text;
401        assert!(text.contains("Items: 2"), "Got: {text}");
402        assert!(text.contains("Collections: 1"), "Got: {text}");
403    }
404
405    #[test]
406    fn search_finds_by_title() {
407        let ctx = test_ctx();
408        let result = zotero_search(&json!({"query": "Hints", "limit": 10}), &ctx);
409        let text = &result.content[0].text;
410        assert!(text.contains("demilloHintsTestData1978"), "Got: {text}");
411    }
412
413    #[test]
414    fn search_no_results() {
415        let ctx = test_ctx();
416        let result = zotero_search(&json!({"query": "quantum computing", "limit": 10}), &ctx);
417        let text = &result.content[0].text;
418        assert!(text.contains("No items found"), "Got: {text}");
419    }
420
421    #[test]
422    fn search_missing_query_returns_error() {
423        let ctx = test_ctx();
424        let result = zotero_search(&json!({}), &ctx);
425        assert_eq!(result.is_error, Some(true));
426    }
427
428    #[test]
429    fn get_item_by_citekey() {
430        let ctx = test_ctx();
431        let result = zotero_get_item(&json!({"citekey": "demilloHintsTestData1978"}), &ctx);
432        let text = &result.content[0].text;
433        assert!(text.contains("Hints on Test Data Selection"), "Got: {text}");
434        assert!(text.contains("DeMillo"), "Got: {text}");
435    }
436
437    #[test]
438    fn get_item_unknown_citekey() {
439        let ctx = test_ctx();
440        let result = zotero_get_item(&json!({"citekey": "nonexistent2099"}), &ctx);
441        assert_eq!(result.is_error, Some(true));
442    }
443
444    #[test]
445    fn get_notes_found() {
446        let ctx = test_ctx();
447        let result = zotero_get_notes(&json!({"citekey": "demilloHintsTestData1978"}), &ctx);
448        let text = &result.content[0].text;
449        assert!(text.contains("foundational paper"), "Got: {text}");
450    }
451
452    #[test]
453    fn get_collections_lists_all() {
454        let ctx = test_ctx();
455        let result = zotero_get_collections(&ctx);
456        let text = &result.content[0].text;
457        assert!(text.contains("Mutation Testing"), "Got: {text}");
458    }
459
460    #[test]
461    fn get_collection_items_found() {
462        let ctx = test_ctx();
463        let result =
464            zotero_get_collection_items(&json!({"collection_key": "COL00001", "limit": 10}), &ctx);
465        let text = &result.content[0].text;
466        assert!(text.contains("demilloHintsTestData1978"), "Got: {text}");
467    }
468
469    #[test]
470    fn get_recent_returns_items() {
471        let ctx = test_ctx();
472        let result = zotero_get_recent(&json!({"limit": 5}), &ctx);
473        let text = &result.content[0].text;
474        assert!(text.contains("demilloHintsTestData1978"), "Got: {text}");
475    }
476
477    #[test]
478    fn get_pdf_path_found() {
479        let ctx = test_ctx();
480        let result = zotero_get_pdf_path(&json!({"citekey": "demilloHintsTestData1978"}), &ctx);
481        let text = &result.content[0].text;
482        assert!(text.contains("DeMillo1978.pdf"), "Got: {text}");
483    }
484
485    #[test]
486    fn list_attachments_empty() {
487        let ctx = test_ctx();
488        let result = zotero_list_attachments(&json!({"citekey": "artTesting2020"}), &ctx);
489        let text = &result.content[0].text;
490        assert!(text.contains("No attachments"), "Got: {text}");
491    }
492}