biblion/tools/
mod.rs

1//! MCP tool catalog and dispatch.
2//!
3//! # Tool organization
4//!
5//! Tools are split into categories matching the Python server:
6//!
7//! - **Read tools** (9): pure SQLite, sub-millisecond. The performance win.
8//! - **BBT tools** (3): BibTeX/bibliography export, still need BBT JSON-RPC.
9//! - **Write tools** (14): Zotero Web API, same latency as Python.
10//! - **PDF tools** (3): network-bound, tokio async.
11//!
12//! This module defines the tool catalog (for `tools/list`) and the dispatch
13//! function (for `tools/call`).
14
15pub mod bibliography;
16pub mod bibtex;
17pub mod format;
18pub mod paper;
19pub mod read;
20pub mod write;
21
22use serde_json::{Value, json};
23
24use crate::protocol::{ToolCallResult, ToolDefinition};
25use crate::server::ServerContext;
26
27// ---------------------------------------------------------------------------
28// Parameter extraction helpers — reduce boilerplate in tool handlers.
29// ---------------------------------------------------------------------------
30
31/// Extract a required string parameter, or return a ToolCallResult error.
32pub fn required_str<'a>(args: &'a Value, key: &str) -> Result<&'a str, ToolCallResult> {
33    args.get(key)
34        .and_then(|v| v.as_str())
35        .ok_or_else(|| ToolCallResult::error(format!("Missing required parameter: {key}")))
36}
37
38/// Extract an optional string parameter.
39pub fn optional_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
40    args.get(key).and_then(|v| v.as_str())
41}
42
43/// Extract an optional u64 parameter with a default value.
44pub fn optional_u64(args: &Value, key: &str, default: u64) -> u64 {
45    args.get(key).and_then(|v| v.as_u64()).unwrap_or(default)
46}
47
48/// Check if write tools are enabled. Returns error if not.
49pub fn check_writes_enabled(ctx: &ServerContext) -> Result<(), ToolCallResult> {
50    if !ctx.config.writes_enabled {
51        return Err(ToolCallResult::error(
52            "Write tools disabled. Set ZOTERO_MCP_ENABLE_WRITES=true to enable.".into(),
53        ));
54    }
55    Ok(())
56}
57
58/// Build the complete tool catalog for `tools/list`.
59///
60/// Each tool definition includes its name, description, and JSON Schema
61/// for input parameters. Claude uses this to know what tools are available.
62pub fn tool_catalog() -> Vec<ToolDefinition> {
63    let mut tools = Vec::new();
64
65    // --- Read tools (pure SQLite) ---
66    tools.push(tool(
67        "zotero_status",
68        "Check Zotero library statistics (item count, collection count).",
69        json!({
70            "type": "object",
71            "properties": {},
72        }),
73    ));
74
75    tools.push(tool("zotero_search", "Search Zotero library items by text query. Returns matching items with citekeys.", json!({
76        "type": "object",
77        "properties": {
78            "query": { "type": "string", "description": "Search query (matches title, DOI, abstract)" },
79            "limit": { "type": "integer", "default": 50, "description": "Maximum number of results" }
80        },
81        "required": ["query"]
82    })));
83
84    tools.push(tool("zotero_get_item", "Get full metadata for an item by its citation key.", json!({
85        "type": "object",
86        "properties": {
87            "citekey": { "type": "string", "description": "Citation key (e.g., 'demilloHintsTestData1978')" }
88        },
89        "required": ["citekey"]
90    })));
91
92    tools.push(tool(
93        "zotero_get_notes",
94        "Get all notes for an item by its citation key.",
95        json!({
96            "type": "object",
97            "properties": {
98                "citekey": { "type": "string", "description": "Citation key" }
99            },
100            "required": ["citekey"]
101        }),
102    ));
103
104    tools.push(tool(
105        "zotero_get_pdf_path",
106        "Get filesystem path(s) and MD5 content hash of PDF attachments for an item. Returns resolved paths + md5:{hash} for content-identity.",
107        json!({
108            "type": "object",
109            "properties": {
110                "citekey": { "type": "string", "description": "Citation key" }
111            },
112            "required": ["citekey"]
113        }),
114    ));
115
116    tools.push(tool(
117        "zotero_list_attachments",
118        "List all attachments for an item with content type, path, and MD5 hash.",
119        json!({
120            "type": "object",
121            "properties": {
122                "citekey": { "type": "string", "description": "Citation key" }
123            },
124            "required": ["citekey"]
125        }),
126    ));
127
128    tools.push(tool(
129        "zotero_get_collections",
130        "List all collections in the library with hierarchy.",
131        json!({
132            "type": "object",
133            "properties": {},
134        }),
135    ));
136
137    tools.push(tool("zotero_get_collection_items", "Get items in a specific collection by its key.", json!({
138        "type": "object",
139        "properties": {
140            "collection_key": { "type": "string", "description": "Collection key (8-char)" },
141            "limit": { "type": "integer", "default": 100, "description": "Maximum number of items" }
142        },
143        "required": ["collection_key"]
144    })));
145
146    tools.push(tool("zotero_get_recent", "Get recently modified items.", json!({
147        "type": "object",
148        "properties": {
149            "limit": { "type": "integer", "default": 20, "description": "Maximum number of items" }
150        },
151    })));
152
153    // --- BBT RPC tools (BibTeX/bibliography export) ---
154    tools.push(tool("zotero_get_bibtex", "Export items as BibTeX or BibLaTeX by citation keys.", json!({
155        "type": "object",
156        "properties": {
157            "citekeys": { "type": "array", "items": { "type": "string" }, "description": "Citation keys to export" },
158            "format": { "type": "string", "default": "Better BibTeX", "description": "Export format: 'Better BibTeX' or 'Better BibLaTeX'" }
159        },
160        "required": ["citekeys"]
161    })));
162
163    tools.push(tool("zotero_get_bibliography", "Generate formatted bibliography for citation keys.", json!({
164        "type": "object",
165        "properties": {
166            "citekeys": { "type": "array", "items": { "type": "string" }, "description": "Citation keys" },
167            "style": { "type": "string", "default": "http://www.zotero.org/styles/apa", "description": "CSL style URL" }
168        },
169        "required": ["citekeys"]
170    })));
171
172    tools.push(tool("zotero_export_bibtex", "Export a collection or item list as BibTeX/BibLaTeX.", json!({
173        "type": "object",
174        "properties": {
175            "collection_key": { "type": "string", "description": "Collection key to export" },
176            "item_keys": { "type": "array", "items": { "type": "string" }, "description": "Item keys to export" },
177            "format": { "type": "string", "default": "Better BibLaTeX" }
178        }
179    })));
180
181    // --- Write tools (Zotero Web API) ---
182    tools.push(tool("zotero_create_item", "Create a new Zotero item.", json!({
183        "type": "object",
184        "properties": {
185            "item_type": { "type": "string", "description": "Item type (journalArticle, book, etc.)" },
186            "title": { "type": "string" },
187            "creators": { "type": "array", "items": { "type": "object" } },
188            "fields": { "type": "object", "description": "Additional fields (date, DOI, etc.)" },
189            "collection_keys": { "type": "array", "items": { "type": "string" } },
190            "tags": { "type": "array", "items": { "type": "string" } }
191        },
192        "required": ["item_type", "title"]
193    })));
194
195    tools.push(tool(
196        "zotero_update_item",
197        "Update metadata fields of an existing item.",
198        json!({
199            "type": "object",
200            "properties": {
201                "citekey": { "type": "string" },
202                "fields": { "type": "object" },
203                "tags": { "type": "array", "items": { "type": "string" } }
204            },
205            "required": ["citekey"]
206        }),
207    ));
208
209    tools.push(tool(
210        "zotero_add_tags",
211        "Add tags to an item (preserves existing tags).",
212        json!({
213            "type": "object",
214            "properties": {
215                "citekey": { "type": "string" },
216                "tags": { "type": "array", "items": { "type": "string" } }
217            },
218            "required": ["citekey", "tags"]
219        }),
220    ));
221
222    tools.push(tool(
223        "zotero_add_note",
224        "Add a note to an item.",
225        json!({
226            "type": "object",
227            "properties": {
228                "citekey": { "type": "string" },
229                "content": { "type": "string", "description": "Note content (markdown or HTML)" },
230                "tags": { "type": "array", "items": { "type": "string" } }
231            },
232            "required": ["citekey", "content"]
233        }),
234    ));
235
236    tools.push(tool("zotero_create_collection", "Create a new collection.", json!({
237        "type": "object",
238        "properties": {
239            "name": { "type": "string" },
240            "parent_key": { "type": "string", "description": "Parent collection key (for sub-collections)" }
241        },
242        "required": ["name"]
243    })));
244
245    tools.push(tool(
246        "zotero_add_to_collection",
247        "Add an item to a collection.",
248        json!({
249            "type": "object",
250            "properties": {
251                "citekey": { "type": "string" },
252                "item_key": { "type": "string" },
253                "collection_key": { "type": "string" }
254            },
255            "required": ["collection_key"]
256        }),
257    ));
258
259    tools.push(tool(
260        "zotero_remove_from_collection",
261        "Remove an item from a collection.",
262        json!({
263            "type": "object",
264            "properties": {
265                "citekey": { "type": "string" },
266                "item_key": { "type": "string" },
267                "collection_key": { "type": "string" }
268            },
269            "required": ["collection_key"]
270        }),
271    ));
272
273    tools.push(tool(
274        "zotero_delete_item",
275        "Delete an item permanently.",
276        json!({
277            "type": "object",
278            "properties": {
279                "citekey": { "type": "string" },
280                "item_key": { "type": "string" }
281            }
282        }),
283    ));
284
285    tools.push(tool(
286        "zotero_merge_items",
287        "Merge two duplicate items (keeps one, deletes the other).",
288        json!({
289            "type": "object",
290            "properties": {
291                "keep_citekey": { "type": "string", "description": "Citekey of item to keep" },
292                "delete_citekey": { "type": "string", "description": "Citekey of item to delete" }
293            },
294            "required": ["keep_citekey", "delete_citekey"]
295        }),
296    ));
297
298    tools.push(tool(
299        "zotero_attach_pdf",
300        "Download a PDF and attach it to an item. Accepts either a Zotero item key or a BBT citekey.",
301        json!({
302            "type": "object",
303            "properties": {
304                "item_key": { "type": "string", "description": "Zotero item key or BBT citekey" },
305                "pdf_url": { "type": "string" },
306                "title": { "type": "string" }
307            },
308            "required": ["item_key", "pdf_url"]
309        }),
310    ));
311
312    tools.push(tool(
313        "zotero_fetch_missing_pdfs",
314        "Find and attach PDFs for items missing them (9-source resolver).",
315        json!({
316            "type": "object",
317            "properties": {
318                "collection_key": { "type": "string" },
319                "limit": { "type": "integer", "default": 50 },
320                "dry_run": { "type": "boolean", "default": false }
321            }
322        }),
323    ));
324
325    // --- Paper search tools (standalone, no Zotero item needed) ---
326    tools.push(tool(
327        "paper_resolve_pdf",
328        "Find an open-access PDF URL for a paper by DOI, title, or URL. Queries 9 academic sources concurrently.",
329        json!({
330            "type": "object",
331            "properties": {
332                "doi": { "type": "string", "description": "Digital Object Identifier (e.g., '10.1109/TSE.2010.62')" },
333                "title": { "type": "string", "description": "Paper title" },
334                "url": { "type": "string", "description": "Paper URL (e.g., arXiv link)" }
335            }
336        }),
337    ));
338
339    tools.push(tool(
340        "paper_source_status",
341        "Show configured paper resolver sources, their priority, and status.",
342        json!({
343            "type": "object",
344            "properties": {}
345        }),
346    ));
347
348    tools
349}
350
351/// Resolve a citekey to a Zotero item key.
352///
353/// Uses the DbPool's multi-source resolution:
354/// 1. `zotero.sqlite` citationKey field (99.9% coverage)
355/// 2. `better-bibtex.migrated` fallback
356pub fn resolve_citekey(ctx: &ServerContext, citekey: &str) -> Result<String, String> {
357    ctx.db
358        .item_key_for_citekey(citekey)
359        .map_err(|e| e.to_string())?
360        .ok_or_else(|| format!("Unknown citekey: {citekey}"))
361}
362
363fn tool(name: &str, description: &str, input_schema: Value) -> ToolDefinition {
364    ToolDefinition {
365        name: name.into(),
366        description: description.into(),
367        input_schema,
368    }
369}
370
371/// Dispatch a tool call to the appropriate handler.
372///
373/// Read tools are pure SQLite (sub-millisecond). BBT and write tools
374/// make network calls (same latency as Python server).
375pub fn handle_tool_call(name: &str, args: &Value, ctx: &ServerContext) -> ToolCallResult {
376    match name {
377        // Read tools (pure SQLite, <1ms)
378        "zotero_status" => read::zotero_status(ctx),
379        "zotero_search" => read::zotero_search(args, ctx),
380        "zotero_get_item" => read::zotero_get_item(args, ctx),
381        "zotero_get_notes" => read::zotero_get_notes(args, ctx),
382        "zotero_get_pdf_path" => read::zotero_get_pdf_path(args, ctx),
383        "zotero_list_attachments" => read::zotero_list_attachments(args, ctx),
384        "zotero_get_collections" => read::zotero_get_collections(ctx),
385        "zotero_get_collection_items" => read::zotero_get_collection_items(args, ctx),
386        "zotero_get_recent" => read::zotero_get_recent(args, ctx),
387
388        // BBT RPC tools (BibTeX/bibliography, requires Zotero running)
389        "zotero_get_bibtex" => write::zotero_get_bibtex(args, ctx),
390        "zotero_get_bibliography" => write::zotero_get_bibliography(args, ctx),
391        "zotero_export_bibtex" => write::zotero_export_bibtex(args, ctx),
392
393        // Write tools (Zotero Web API, requires API key)
394        "zotero_create_item" => write::zotero_create_item(args, ctx),
395        "zotero_update_item" => write::zotero_update_item(args, ctx),
396        "zotero_add_tags" => write::zotero_add_tags(args, ctx),
397        "zotero_add_note" => write::zotero_add_note(args, ctx),
398        "zotero_create_collection" => write::zotero_create_collection(args, ctx),
399        "zotero_add_to_collection" | "zotero_add_item_to_collection" => {
400            write::zotero_add_to_collection(args, ctx)
401        }
402        "zotero_remove_from_collection" => write::zotero_remove_from_collection(args, ctx),
403        "zotero_delete_item" => write::zotero_delete_item(args, ctx),
404        "zotero_merge_items" => write::zotero_merge_items(args, ctx),
405        "zotero_attach_pdf" => write::zotero_attach_pdf(args, ctx),
406        "zotero_fetch_missing_pdfs" => write::zotero_fetch_missing_pdfs(args, ctx),
407
408        // Paper search tools (standalone, no Zotero item needed)
409        "paper_resolve_pdf" => paper::paper_resolve_pdf(args, ctx),
410        // paper_search removed — paper_resolve_pdf handles title-only queries
411        "paper_source_status" => paper::paper_source_status(ctx),
412
413        _ => ToolCallResult::error(format!("Unknown tool: {name}")),
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn catalog_has_expected_tools() {
423        let catalog = tool_catalog();
424        let names: Vec<&str> = catalog.iter().map(|t| t.name.as_str()).collect();
425        assert!(names.contains(&"zotero_status"));
426        assert!(names.contains(&"zotero_search"));
427        assert!(names.contains(&"zotero_get_item"));
428        assert!(names.contains(&"zotero_get_collections"));
429        assert!(names.contains(&"zotero_get_recent"));
430    }
431
432    #[test]
433    fn catalog_tools_have_input_schema() {
434        let catalog = tool_catalog();
435        for tool in &catalog {
436            assert!(
437                tool.input_schema.is_object(),
438                "Tool {} missing input_schema",
439                tool.name
440            );
441        }
442    }
443
444    #[test]
445    fn unknown_tool_returns_error() {
446        let ctx = ServerContext {
447            db: crate::db::DbPool::empty(),
448            config: crate::config::Config {
449                zotero_sqlite_path: "/tmp/z.sqlite".into(),
450                zotero_storage_path: "/tmp/storage".into(),
451                bbt_migrated_path: "/tmp/bbt".into(),
452                zotero_api_key: None,
453                zotero_library_id: "1".into(),
454                zotero_library_type: "user".into(),
455                bbt_url: "http://localhost:23119".into(),
456                log_level: crate::config::LogLevel::Quiet,
457                writes_enabled: false,
458                resolver: paper_resolver::ResolverConfig::default(),
459                zotero_api_base_url: None,
460            },
461        };
462        let result = handle_tool_call("nonexistent", &json!({}), &ctx);
463        assert_eq!(result.is_error, Some(true));
464    }
465}