1pub 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
27pub 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
38pub fn optional_str<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
40 args.get(key).and_then(|v| v.as_str())
41}
42
43pub 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
48pub 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
58pub fn tool_catalog() -> Vec<ToolDefinition> {
63 let mut tools = Vec::new();
64
65 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 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 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 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
351pub 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
371pub fn handle_tool_call(name: &str, args: &Value, ctx: &ServerContext) -> ToolCallResult {
376 match name {
377 "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 "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 "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_resolve_pdf" => paper::paper_resolve_pdf(args, ctx),
410 "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}