zotero-bridge 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,630 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * ZoteroBridge - MCP Server for Zotero SQLite Database
5
+ *
6
+ * A Model Context Protocol server that provides direct access to Zotero's
7
+ * SQLite database for collection management, tagging, PDF reading, and more.
8
+ *
9
+ * @author Combjellyshen
10
+ * @version 1.0.0
11
+ */
12
+
13
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
14
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
+ import {
16
+ CallToolRequestSchema,
17
+ ListToolsRequestSchema,
18
+ } from '@modelcontextprotocol/sdk/types.js';
19
+ import { z } from 'zod';
20
+
21
+ import { ZoteroDatabase } from './database.js';
22
+ import { PDFProcessor } from './pdf.js';
23
+ import {
24
+ toolDefinitions,
25
+ listCollectionsSchema,
26
+ getCollectionSchema,
27
+ createCollectionSchema,
28
+ renameCollectionSchema,
29
+ moveCollectionSchema,
30
+ deleteCollectionSchema,
31
+ getSubcollectionsSchema,
32
+ listTagsSchema,
33
+ addTagSchema,
34
+ removeTagSchema,
35
+ getItemTagsSchema,
36
+ createTagSchema,
37
+ searchItemsSchema,
38
+ getItemDetailsSchema,
39
+ addItemToCollectionSchema,
40
+ removeItemFromCollectionSchema,
41
+ getCollectionItemsSchema,
42
+ getItemAbstractSchema,
43
+ setItemAbstractSchema,
44
+ getItemNotesSchema,
45
+ addItemNoteSchema,
46
+ extractPDFTextSchema,
47
+ getPDFSummarySchema,
48
+ getItemPDFsSchema,
49
+ searchPDFSchema,
50
+ generateAbstractFromPDFSchema,
51
+ getDatabaseInfoSchema,
52
+ rawQuerySchema,
53
+ // New schemas
54
+ findByDOISchema,
55
+ findByISBNSchema,
56
+ findByIdentifierSchema,
57
+ getItemAnnotationsSchema,
58
+ getAttachmentAnnotationsSchema,
59
+ getAnnotationsByTypeSchema,
60
+ getAnnotationsByColorSchema,
61
+ searchAnnotationsSchema,
62
+ searchFulltextSchema,
63
+ getFulltextContentSchema,
64
+ searchFulltextWithContextSchema,
65
+ getRelatedItemsSchema,
66
+ findSimilarByTagsSchema,
67
+ findSimilarByCreatorsSchema,
68
+ findSimilarByCollectionSchema
69
+ } from './tools.js';
70
+
71
+ // Server configuration
72
+ const SERVER_NAME = 'zotero-bridge';
73
+ const SERVER_VERSION = '1.0.0';
74
+
75
+ // Parse command line arguments
76
+ function parseArgs(): { dbPath?: string; readonly: boolean } {
77
+ const args = process.argv.slice(2);
78
+ let dbPath: string | undefined;
79
+ let readonly = false;
80
+
81
+ for (let i = 0; i < args.length; i++) {
82
+ if (args[i] === '--db' || args[i] === '-d') {
83
+ dbPath = args[i + 1];
84
+ i++;
85
+ } else if (args[i] === '--readonly' || args[i] === '-r') {
86
+ readonly = true;
87
+ } else if (args[i] === '--help' || args[i] === '-h') {
88
+ console.log(`
89
+ ZoteroBridge - MCP Server for Zotero SQLite Database
90
+
91
+ Usage: zotero-bridge [options]
92
+
93
+ Options:
94
+ -d, --db <path> Path to zotero.sqlite database
95
+ -r, --readonly Open database in read-only mode
96
+ -h, --help Show this help message
97
+
98
+ Example:
99
+ zotero-bridge --db ~/Zotero/zotero.sqlite
100
+ zotero-bridge --readonly
101
+ `);
102
+ process.exit(0);
103
+ }
104
+ }
105
+
106
+ return { dbPath, readonly };
107
+ }
108
+
109
+ // Initialize the server
110
+ async function main() {
111
+ const { dbPath, readonly } = parseArgs();
112
+
113
+ // Initialize database
114
+ const db = new ZoteroDatabase(dbPath, readonly);
115
+ await db.connect(); // Connect to database
116
+ const pdf = new PDFProcessor(db);
117
+
118
+ // Create MCP server
119
+ const server = new Server(
120
+ {
121
+ name: SERVER_NAME,
122
+ version: SERVER_VERSION,
123
+ },
124
+ {
125
+ capabilities: {
126
+ tools: {},
127
+ },
128
+ }
129
+ );
130
+
131
+ // Handle tool listing
132
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
133
+ return {
134
+ tools: toolDefinitions.map(tool => ({
135
+ name: tool.name,
136
+ description: tool.description,
137
+ inputSchema: {
138
+ type: 'object' as const,
139
+ properties: Object.fromEntries(
140
+ Object.entries(tool.inputSchema.shape || {}).map(([key, value]) => {
141
+ const zodType = value as z.ZodTypeAny;
142
+ const schema = zodToJsonSchema(zodType);
143
+ return [key, {
144
+ ...schema,
145
+ description: zodType.description || ''
146
+ }];
147
+ })
148
+ ),
149
+ required: Object.entries(tool.inputSchema.shape || {})
150
+ .filter(([_, value]) => !(value as z.ZodTypeAny).isOptional())
151
+ .map(([key]) => key)
152
+ }
153
+ }))
154
+ };
155
+ });
156
+
157
+ // Handle tool calls
158
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
159
+ const { name, arguments: args } = request.params;
160
+
161
+ try {
162
+ let result: any;
163
+
164
+ switch (name) {
165
+ // ============================================
166
+ // Collection Tools
167
+ // ============================================
168
+ case 'list_collections': {
169
+ const params = listCollectionsSchema.parse(args);
170
+ result = db.getCollections(params.libraryID);
171
+ break;
172
+ }
173
+
174
+ case 'get_collection': {
175
+ const params = getCollectionSchema.parse(args);
176
+ if (params.collectionID) {
177
+ result = db.getCollectionById(params.collectionID);
178
+ } else if (params.name) {
179
+ result = db.getCollectionByName(params.name, params.libraryID);
180
+ } else {
181
+ throw new Error('Either collectionID or name is required');
182
+ }
183
+ break;
184
+ }
185
+
186
+ case 'create_collection': {
187
+ const params = createCollectionSchema.parse(args);
188
+ const collectionID = db.createCollection(
189
+ params.name,
190
+ params.parentCollectionID || null,
191
+ params.libraryID
192
+ );
193
+ result = { success: true, collectionID };
194
+ break;
195
+ }
196
+
197
+ case 'rename_collection': {
198
+ const params = renameCollectionSchema.parse(args);
199
+ const success = db.renameCollection(params.collectionID, params.newName);
200
+ result = { success };
201
+ break;
202
+ }
203
+
204
+ case 'move_collection': {
205
+ const params = moveCollectionSchema.parse(args);
206
+ const success = db.moveCollection(params.collectionID, params.newParentID);
207
+ result = { success };
208
+ break;
209
+ }
210
+
211
+ case 'delete_collection': {
212
+ const params = deleteCollectionSchema.parse(args);
213
+ const success = db.deleteCollection(params.collectionID);
214
+ result = { success };
215
+ break;
216
+ }
217
+
218
+ case 'get_subcollections': {
219
+ const params = getSubcollectionsSchema.parse(args);
220
+ result = db.getSubcollections(params.parentCollectionID);
221
+ break;
222
+ }
223
+
224
+ // ============================================
225
+ // Tag Tools
226
+ // ============================================
227
+ case 'list_tags': {
228
+ listTagsSchema.parse(args);
229
+ result = db.getTags();
230
+ break;
231
+ }
232
+
233
+ case 'add_tag': {
234
+ const params = addTagSchema.parse(args);
235
+ const success = db.addTagToItem(params.itemID, params.tagName, params.type);
236
+ result = { success };
237
+ break;
238
+ }
239
+
240
+ case 'remove_tag': {
241
+ const params = removeTagSchema.parse(args);
242
+ const success = db.removeTagFromItem(params.itemID, params.tagName);
243
+ result = { success };
244
+ break;
245
+ }
246
+
247
+ case 'get_item_tags': {
248
+ const params = getItemTagsSchema.parse(args);
249
+ result = db.getItemTags(params.itemID);
250
+ break;
251
+ }
252
+
253
+ case 'create_tag': {
254
+ const params = createTagSchema.parse(args);
255
+ const tagID = db.createTag(params.name, params.type);
256
+ result = { success: true, tagID };
257
+ break;
258
+ }
259
+
260
+ // ============================================
261
+ // Item Tools
262
+ // ============================================
263
+ case 'search_items': {
264
+ const params = searchItemsSchema.parse(args);
265
+ result = db.searchItems(params.query, params.limit, params.libraryID);
266
+ break;
267
+ }
268
+
269
+ case 'get_item_details': {
270
+ const params = getItemDetailsSchema.parse(args);
271
+ if (params.itemID) {
272
+ result = db.getItemDetails(params.itemID);
273
+ } else if (params.itemKey) {
274
+ const item = db.getItemByKey(params.itemKey);
275
+ if (item) {
276
+ result = db.getItemDetails(item.itemID);
277
+ } else {
278
+ result = null;
279
+ }
280
+ } else {
281
+ throw new Error('Either itemID or itemKey is required');
282
+ }
283
+ break;
284
+ }
285
+
286
+ case 'add_item_to_collection': {
287
+ const params = addItemToCollectionSchema.parse(args);
288
+ const success = db.addItemToCollection(params.itemID, params.collectionID);
289
+ result = { success };
290
+ break;
291
+ }
292
+
293
+ case 'remove_item_from_collection': {
294
+ const params = removeItemFromCollectionSchema.parse(args);
295
+ const success = db.removeItemFromCollection(params.itemID, params.collectionID);
296
+ result = { success };
297
+ break;
298
+ }
299
+
300
+ case 'get_collection_items': {
301
+ const params = getCollectionItemsSchema.parse(args);
302
+ result = db.getCollectionItems(params.collectionID);
303
+ break;
304
+ }
305
+
306
+ // ============================================
307
+ // Abstract/Note Tools
308
+ // ============================================
309
+ case 'get_item_abstract': {
310
+ const params = getItemAbstractSchema.parse(args);
311
+ result = { abstract: db.getItemAbstract(params.itemID) };
312
+ break;
313
+ }
314
+
315
+ case 'set_item_abstract': {
316
+ const params = setItemAbstractSchema.parse(args);
317
+ const success = db.setItemAbstract(params.itemID, params.abstract);
318
+ result = { success };
319
+ break;
320
+ }
321
+
322
+ case 'get_item_notes': {
323
+ const params = getItemNotesSchema.parse(args);
324
+ result = db.getItemNotes(params.itemID);
325
+ break;
326
+ }
327
+
328
+ case 'add_item_note': {
329
+ const params = addItemNoteSchema.parse(args);
330
+ const noteID = db.addItemNote(params.itemID, params.content, params.title);
331
+ result = { success: true, noteID };
332
+ break;
333
+ }
334
+
335
+ // ============================================
336
+ // PDF Tools
337
+ // ============================================
338
+ case 'extract_pdf_text': {
339
+ const params = extractPDFTextSchema.parse(args);
340
+ result = await pdf.extractTextFromAttachment(params.attachmentItemID);
341
+ break;
342
+ }
343
+
344
+ case 'get_pdf_summary': {
345
+ const params = getPDFSummarySchema.parse(args);
346
+ result = await pdf.getPDFSummary(params.attachmentItemID);
347
+ break;
348
+ }
349
+
350
+ case 'get_item_pdfs': {
351
+ const params = getItemPDFsSchema.parse(args);
352
+ const attachments = db.getPDFAttachments(params.parentItemID);
353
+ result = attachments.map(att => ({
354
+ ...att,
355
+ fullPath: db.getAttachmentPath(att.itemID)
356
+ }));
357
+ break;
358
+ }
359
+
360
+ case 'search_pdf': {
361
+ const params = searchPDFSchema.parse(args);
362
+ const content = await pdf.extractTextFromAttachment(params.attachmentItemID);
363
+ if (content) {
364
+ result = pdf.searchInPDF(content, params.query, params.caseSensitive);
365
+ } else {
366
+ result = [];
367
+ }
368
+ break;
369
+ }
370
+
371
+ case 'generate_abstract_from_pdf': {
372
+ const params = generateAbstractFromPDFSchema.parse(args);
373
+ const content = await pdf.extractTextFromAttachment(params.attachmentItemID);
374
+
375
+ if (!content) {
376
+ throw new Error('Could not extract PDF content');
377
+ }
378
+
379
+ const abstract = pdf.generateSimpleSummary(content, params.maxLength);
380
+
381
+ if (params.saveToItem) {
382
+ // Get parent item ID from attachment
383
+ const attDetails = db.query(
384
+ 'SELECT parentItemID FROM itemAttachments WHERE itemID = ?',
385
+ [params.attachmentItemID]
386
+ )[0] as { parentItemID: number } | undefined;
387
+
388
+ if (attDetails?.parentItemID) {
389
+ db.setItemAbstract(attDetails.parentItemID, abstract);
390
+ }
391
+ }
392
+
393
+ result = { abstract, length: abstract.length };
394
+ break;
395
+ }
396
+
397
+ // ============================================
398
+ // Utility Tools
399
+ // ============================================
400
+ case 'get_database_info': {
401
+ getDatabaseInfoSchema.parse(args);
402
+ result = {
403
+ path: db.getPath(),
404
+ storagePath: db.getStoragePath(),
405
+ readonly,
406
+ collectionsCount: db.getCollections().length,
407
+ tagsCount: db.getTags().length
408
+ };
409
+ break;
410
+ }
411
+
412
+ case 'raw_query': {
413
+ const params = rawQuerySchema.parse(args);
414
+
415
+ // Security check - only allow SELECT queries
416
+ if (!params.sql.trim().toUpperCase().startsWith('SELECT')) {
417
+ throw new Error('Only SELECT queries are allowed');
418
+ }
419
+
420
+ result = db.query(params.sql, params.params);
421
+ break;
422
+ }
423
+
424
+ // ============================================
425
+ // Identifier Tools (DOI/ISBN)
426
+ // ============================================
427
+ case 'find_by_doi': {
428
+ const params = findByDOISchema.parse(args);
429
+ result = db.findItemByDOI(params.doi);
430
+ break;
431
+ }
432
+
433
+ case 'find_by_isbn': {
434
+ const params = findByISBNSchema.parse(args);
435
+ result = db.findItemByISBN(params.isbn);
436
+ break;
437
+ }
438
+
439
+ case 'find_by_identifier': {
440
+ const params = findByIdentifierSchema.parse(args);
441
+ result = db.findItemByIdentifier(params.identifier, params.type);
442
+ break;
443
+ }
444
+
445
+ // ============================================
446
+ // Annotation Tools
447
+ // ============================================
448
+ case 'get_item_annotations': {
449
+ const params = getItemAnnotationsSchema.parse(args);
450
+ result = db.getItemAnnotations(params.itemID);
451
+ break;
452
+ }
453
+
454
+ case 'get_attachment_annotations': {
455
+ const params = getAttachmentAnnotationsSchema.parse(args);
456
+ result = db.getAttachmentAnnotations(params.attachmentID);
457
+ break;
458
+ }
459
+
460
+ case 'get_annotations_by_type': {
461
+ const params = getAnnotationsByTypeSchema.parse(args);
462
+ result = db.getAnnotationsByType(params.itemID, params.types);
463
+ break;
464
+ }
465
+
466
+ case 'get_annotations_by_color': {
467
+ const params = getAnnotationsByColorSchema.parse(args);
468
+ result = db.getAnnotationsByColor(params.itemID, params.colors);
469
+ break;
470
+ }
471
+
472
+ case 'search_annotations': {
473
+ const params = searchAnnotationsSchema.parse(args);
474
+ result = db.searchAnnotations(params.query, params.itemID);
475
+ break;
476
+ }
477
+
478
+ // ============================================
479
+ // Fulltext Search Tools
480
+ // ============================================
481
+ case 'search_fulltext': {
482
+ const params = searchFulltextSchema.parse(args);
483
+ result = db.searchFulltext(params.query, params.libraryID);
484
+ break;
485
+ }
486
+
487
+ case 'get_fulltext_content': {
488
+ const params = getFulltextContentSchema.parse(args);
489
+ result = { content: db.getFulltextContent(params.attachmentID) };
490
+ break;
491
+ }
492
+
493
+ case 'search_fulltext_with_context': {
494
+ const params = searchFulltextWithContextSchema.parse(args);
495
+ result = db.searchFulltextWithContext(params.query, params.contextLength, params.libraryID);
496
+ break;
497
+ }
498
+
499
+ // ============================================
500
+ // Related/Similar Items Tools
501
+ // ============================================
502
+ case 'get_related_items': {
503
+ const params = getRelatedItemsSchema.parse(args);
504
+ result = db.getRelatedItems(params.itemID);
505
+ break;
506
+ }
507
+
508
+ case 'find_similar_by_tags': {
509
+ const params = findSimilarByTagsSchema.parse(args);
510
+ result = db.findSimilarByTags(params.itemID, params.minSharedTags);
511
+ break;
512
+ }
513
+
514
+ case 'find_similar_by_creators': {
515
+ const params = findSimilarByCreatorsSchema.parse(args);
516
+ result = db.findSimilarByCreators(params.itemID);
517
+ break;
518
+ }
519
+
520
+ case 'find_similar_by_collection': {
521
+ const params = findSimilarByCollectionSchema.parse(args);
522
+ result = db.findSimilarByCollection(params.itemID);
523
+ break;
524
+ }
525
+
526
+ default:
527
+ throw new Error(`Unknown tool: ${name}`);
528
+ }
529
+
530
+ return {
531
+ content: [
532
+ {
533
+ type: 'text',
534
+ text: JSON.stringify(result, null, 2)
535
+ }
536
+ ]
537
+ };
538
+
539
+ } catch (error) {
540
+ const errorMessage = error instanceof Error ? error.message : String(error);
541
+ return {
542
+ content: [
543
+ {
544
+ type: 'text',
545
+ text: JSON.stringify({ error: errorMessage })
546
+ }
547
+ ],
548
+ isError: true
549
+ };
550
+ }
551
+ });
552
+
553
+ // Start the server
554
+ const transport = new StdioServerTransport();
555
+ await server.connect(transport);
556
+
557
+ // Handle shutdown
558
+ process.on('SIGINT', () => {
559
+ db.disconnect();
560
+ process.exit(0);
561
+ });
562
+
563
+ process.on('SIGTERM', () => {
564
+ db.disconnect();
565
+ process.exit(0);
566
+ });
567
+
568
+ console.error(`ZoteroBridge MCP Server v${SERVER_VERSION} started`);
569
+ console.error(`Database: ${db.getPath()}`);
570
+ }
571
+
572
+ // Helper function to convert Zod type to JSON Schema
573
+ function zodToJsonSchema(zodType: z.ZodTypeAny): Record<string, any> {
574
+ // Unwrap optional, default, and nullable types
575
+ if (zodType instanceof z.ZodOptional) {
576
+ return zodToJsonSchema(zodType._def.innerType);
577
+ }
578
+ if (zodType instanceof z.ZodDefault) {
579
+ return zodToJsonSchema(zodType._def.innerType);
580
+ }
581
+ if (zodType instanceof z.ZodNullable) {
582
+ return zodToJsonSchema(zodType._def.innerType);
583
+ }
584
+
585
+ // Handle basic types
586
+ if (zodType instanceof z.ZodString) {
587
+ return { type: 'string' };
588
+ }
589
+ if (zodType instanceof z.ZodNumber) {
590
+ return { type: 'number' };
591
+ }
592
+ if (zodType instanceof z.ZodBoolean) {
593
+ return { type: 'boolean' };
594
+ }
595
+
596
+ // Handle array types - MUST include items
597
+ if (zodType instanceof z.ZodArray) {
598
+ const elementType = zodType._def.type;
599
+ const itemSchema = zodToJsonSchema(elementType);
600
+ // If items schema is empty (from ZodAny), use a more explicit schema
601
+ if (Object.keys(itemSchema).length === 0) {
602
+ return {
603
+ type: 'array',
604
+ items: { type: 'string' } // Default to string for any type to ensure valid JSON Schema
605
+ };
606
+ }
607
+ return {
608
+ type: 'array',
609
+ items: itemSchema
610
+ };
611
+ }
612
+
613
+ // Handle object types
614
+ if (zodType instanceof z.ZodObject) {
615
+ return { type: 'object' };
616
+ }
617
+
618
+ // Handle ZodAny - return empty object (accepts any value)
619
+ if (zodType instanceof z.ZodAny) {
620
+ return {};
621
+ }
622
+
623
+ // Default to string
624
+ return { type: 'string' };
625
+ }
626
+
627
+ main().catch((error) => {
628
+ console.error('Fatal error:', error);
629
+ process.exit(1);
630
+ });