@xevos117/mcp-zotero 1.0.7 → 1.0.8
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/README.md
CHANGED
|
@@ -132,7 +132,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
|
|
|
132
132
|
| `get_collections` | List all collections (folders) with keys, names, and parent relationships |
|
|
133
133
|
| `get_collection_items` | Get items in a specific collection with keys, titles, authors, dates |
|
|
134
134
|
| `search_library` | Search by query, or list items sorted by field (date, title, etc.) |
|
|
135
|
-
| `get_items_details` | Batch metadata retrieval for multiple items
|
|
135
|
+
| `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, etc.) |
|
|
136
136
|
| `get_item_fulltext` | Get full-text content of a PDF attachment via Zotero's fulltext index |
|
|
137
137
|
|
|
138
138
|
### Adding content
|
|
@@ -165,7 +165,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
|
|
|
165
165
|
```bash
|
|
166
166
|
npm install
|
|
167
167
|
npm run build # Compile TypeScript
|
|
168
|
-
npm test # Run tests (vitest,
|
|
168
|
+
npm test # Run tests (vitest, 404 tests)
|
|
169
169
|
npx tsx src/server.ts # Run directly without building
|
|
170
170
|
```
|
|
171
171
|
|
|
@@ -1,21 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
import { isZoteroApiError } from "../types/zotero-types.js";
|
|
2
3
|
import { formatErrorResponse } from "../utils/error-formatter.js";
|
|
3
4
|
import { logger } from "../utils/logger.js";
|
|
4
5
|
import { fetchAllPages } from "../utils/pagination.js";
|
|
5
6
|
export const toolConfig = {
|
|
6
7
|
name: "get_collections",
|
|
7
|
-
description: "List all collections (folders) in your Zotero library. Returns collection keys, names, and parent relationships. Use collection keys with get_collection_items or as parent_collection in create_collection.",
|
|
8
|
-
inputSchema: {
|
|
8
|
+
description: "List all collections (folders) in your Zotero library. Returns collection keys, names, and parent relationships. Use collection keys with get_collection_items or as parent_collection in create_collection. Trashed collections are excluded by default.",
|
|
9
|
+
inputSchema: {
|
|
10
|
+
include_trashed: z
|
|
11
|
+
.boolean()
|
|
12
|
+
.optional()
|
|
13
|
+
.default(false)
|
|
14
|
+
.describe("Include trashed (deleted) collections. Default false."),
|
|
15
|
+
},
|
|
9
16
|
};
|
|
10
|
-
|
|
17
|
+
const GetCollectionsSchema = z.object(toolConfig.inputSchema);
|
|
18
|
+
export async function handleGetCollections(zoteroApi, userId, args) {
|
|
19
|
+
const { include_trashed } = GetCollectionsSchema.parse(args);
|
|
11
20
|
try {
|
|
12
|
-
const { items:
|
|
13
|
-
if (!Array.isArray(
|
|
21
|
+
const { items: allCollections } = await fetchAllPages((params) => zoteroApi.library("user", userId).collections().get(params));
|
|
22
|
+
if (!Array.isArray(allCollections) || allCollections.length === 0) {
|
|
14
23
|
return formatErrorResponse("No collections found", {
|
|
15
24
|
suggestion: "Create a collection in your Zotero library first",
|
|
16
25
|
helpUrl: "https://www.zotero.org/support/collections",
|
|
17
26
|
});
|
|
18
27
|
}
|
|
28
|
+
// Zotero API returns deleted collections despite not requesting them —
|
|
29
|
+
// filter client-side as a workaround
|
|
30
|
+
const collections = include_trashed
|
|
31
|
+
? allCollections
|
|
32
|
+
: allCollections.filter((c) => !c.deleted);
|
|
33
|
+
if (collections.length === 0) {
|
|
34
|
+
return formatErrorResponse("No collections found", {
|
|
35
|
+
suggestion: "All collections are in the trash. Use include_trashed=true to see them.",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
19
38
|
return {
|
|
20
39
|
content: [
|
|
21
40
|
{ type: "text", text: JSON.stringify(collections, null, 2) },
|
|
@@ -5,7 +5,7 @@ import { formatCreators } from "../utils/item-formatter.js";
|
|
|
5
5
|
import { logger } from "../utils/logger.js";
|
|
6
6
|
export const toolConfig = {
|
|
7
7
|
name: "get_items_details",
|
|
8
|
-
description: "Get metadata for multiple Zotero items in a single call. Accepts an array of item keys and returns a map of key → metadata. Use this instead of calling get_item_details multiple times. Returns
|
|
8
|
+
description: "Get metadata for multiple Zotero items in a single call. Accepts an array of item keys and returns a map of key → metadata. Use this instead of calling get_item_details multiple times. Returns all type-specific fields (e.g. bookTitle for bookSection, proceedingsTitle for conferencePaper, university for thesis). Set include_abstract to include abstracts (excluded by default to keep responses lightweight).",
|
|
9
9
|
inputSchema: {
|
|
10
10
|
item_keys: z
|
|
11
11
|
.array(z.string())
|
|
@@ -18,6 +18,12 @@ export const toolConfig = {
|
|
|
18
18
|
},
|
|
19
19
|
};
|
|
20
20
|
const GetItemsDetailsSchema = z.object(toolConfig.inputSchema);
|
|
21
|
+
/** Fields excluded from the response (structural/internal, not bibliographic metadata). */
|
|
22
|
+
const SKIP_FIELDS = new Set([
|
|
23
|
+
"key", "version", "dateAdded", "dateModified",
|
|
24
|
+
"collections", "tags", "relations", "creators",
|
|
25
|
+
"abstractNote",
|
|
26
|
+
]);
|
|
21
27
|
export async function handleGetItemsDetails(zoteroApi, userId, args) {
|
|
22
28
|
const { item_keys, include_abstract } = GetItemsDetailsSchema.parse(args);
|
|
23
29
|
if (item_keys.length === 0) {
|
|
@@ -40,18 +46,26 @@ export async function handleGetItemsDetails(zoteroApi, userId, args) {
|
|
|
40
46
|
const key = item.key;
|
|
41
47
|
if (!key)
|
|
42
48
|
continue;
|
|
43
|
-
|
|
49
|
+
const entry = {
|
|
50
|
+
itemType: item.itemType || "document",
|
|
44
51
|
title: item.title || "Untitled",
|
|
45
52
|
authors: formatCreators(item.creators),
|
|
46
|
-
date: item.date || "No date",
|
|
47
|
-
doi: item.DOI || null,
|
|
48
|
-
itemType: item.itemType || "document",
|
|
49
|
-
publicationTitle: item.publicationTitle || null,
|
|
50
|
-
url: item.url || null,
|
|
51
53
|
};
|
|
54
|
+
// Include all non-empty bibliographic fields from the Zotero response
|
|
55
|
+
const raw = item;
|
|
56
|
+
for (const [field, value] of Object.entries(raw)) {
|
|
57
|
+
if (SKIP_FIELDS.has(field))
|
|
58
|
+
continue;
|
|
59
|
+
if (field in entry)
|
|
60
|
+
continue;
|
|
61
|
+
if (value === undefined || value === null || value === "" || value === false)
|
|
62
|
+
continue;
|
|
63
|
+
entry[field] = value;
|
|
64
|
+
}
|
|
52
65
|
if (include_abstract && item.abstractNote) {
|
|
53
|
-
|
|
66
|
+
entry.abstractNote = item.abstractNote;
|
|
54
67
|
}
|
|
68
|
+
result[key] = entry;
|
|
55
69
|
}
|
|
56
70
|
return {
|
|
57
71
|
content: [
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { ITEM_TYPE_FIELDS } from "./zotero-item-types.js";
|
|
1
2
|
const CSL_TO_ZOTERO_TYPE = {
|
|
3
|
+
// Standard CSL types
|
|
2
4
|
"article": "journalArticle",
|
|
3
5
|
"article-journal": "journalArticle",
|
|
4
6
|
"article-magazine": "magazineArticle",
|
|
@@ -34,6 +36,23 @@ const CSL_TO_ZOTERO_TYPE = {
|
|
|
34
36
|
"standard": "standard",
|
|
35
37
|
"thesis": "thesis",
|
|
36
38
|
"webpage": "webpage",
|
|
39
|
+
// Non-standard types returned by CrossRef/DataCite DOI content negotiation
|
|
40
|
+
"journal-article": "journalArticle",
|
|
41
|
+
"book-chapter": "bookSection",
|
|
42
|
+
"proceedings-article": "conferencePaper",
|
|
43
|
+
"posted-content": "preprint",
|
|
44
|
+
"dissertation": "thesis",
|
|
45
|
+
"monograph": "book",
|
|
46
|
+
"reference-entry": "encyclopediaArticle",
|
|
47
|
+
"component": "document",
|
|
48
|
+
"peer-review": "journalArticle",
|
|
49
|
+
"edited-book": "book",
|
|
50
|
+
"reference-book": "book",
|
|
51
|
+
"book-series": "book",
|
|
52
|
+
"book-part": "bookSection",
|
|
53
|
+
"proceedings": "book",
|
|
54
|
+
"journal": "journalArticle",
|
|
55
|
+
"report-series": "report",
|
|
37
56
|
};
|
|
38
57
|
const ZOTERO_TO_CSL_TYPE = {
|
|
39
58
|
journalArticle: "article-journal",
|
|
@@ -74,7 +93,27 @@ const ZOTERO_TO_CSL_TYPE = {
|
|
|
74
93
|
email: "personal_communication",
|
|
75
94
|
instantMessage: "personal_communication",
|
|
76
95
|
};
|
|
96
|
+
/**
|
|
97
|
+
* Maps CSL `container-title` to the correct Zotero field name per item type.
|
|
98
|
+
* Types not listed here have no container-title equivalent.
|
|
99
|
+
*/
|
|
100
|
+
const CONTAINER_TITLE_FIELD = {
|
|
101
|
+
journalArticle: "publicationTitle",
|
|
102
|
+
magazineArticle: "publicationTitle",
|
|
103
|
+
newspaperArticle: "publicationTitle",
|
|
104
|
+
bookSection: "bookTitle",
|
|
105
|
+
dictionaryEntry: "dictionaryTitle",
|
|
106
|
+
conferencePaper: "proceedingsTitle",
|
|
107
|
+
blogPost: "blogTitle",
|
|
108
|
+
encyclopediaArticle: "encyclopediaTitle",
|
|
109
|
+
forumPost: "forumTitle",
|
|
110
|
+
webpage: "websiteTitle",
|
|
111
|
+
radioBroadcast: "programTitle",
|
|
112
|
+
tvBroadcast: "programTitle",
|
|
113
|
+
podcast: "programTitle",
|
|
114
|
+
};
|
|
77
115
|
export function cslToZoteroItem(csl, options) {
|
|
116
|
+
const itemType = (CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle");
|
|
78
117
|
const creators = (csl.author ?? []).map((a) => ({
|
|
79
118
|
firstName: a.given ?? "",
|
|
80
119
|
lastName: a.family ?? a.literal ?? "",
|
|
@@ -87,13 +126,11 @@ export function cslToZoteroItem(csl, options) {
|
|
|
87
126
|
collections.push(options.collectionKey);
|
|
88
127
|
}
|
|
89
128
|
const tags = (options?.tags ?? []).map((t) => ({ tag: t }));
|
|
90
|
-
|
|
91
|
-
|
|
129
|
+
// Build candidate fields (CSL → Zotero field names)
|
|
130
|
+
const candidates = {
|
|
92
131
|
title: csl.title ?? "",
|
|
93
|
-
creators,
|
|
94
132
|
date,
|
|
95
133
|
DOI: csl.DOI ?? "",
|
|
96
|
-
publicationTitle: csl["container-title"] ?? "",
|
|
97
134
|
volume: csl.volume ?? "",
|
|
98
135
|
issue: csl.issue ?? "",
|
|
99
136
|
pages: csl.page ?? "",
|
|
@@ -107,9 +144,29 @@ export function cslToZoteroItem(csl, options) {
|
|
|
107
144
|
numPages: csl["number-of-pages"] ?? "",
|
|
108
145
|
series: csl["collection-title"] ?? "",
|
|
109
146
|
language: csl.language ?? "",
|
|
110
|
-
collections,
|
|
111
|
-
tags,
|
|
112
147
|
};
|
|
148
|
+
// Map container-title to the correct field for this item type
|
|
149
|
+
const containerTitleField = CONTAINER_TITLE_FIELD[itemType];
|
|
150
|
+
if (containerTitleField) {
|
|
151
|
+
candidates[containerTitleField] = csl["container-title"] ?? "";
|
|
152
|
+
}
|
|
153
|
+
// Filter: only include fields valid for this item type
|
|
154
|
+
const validFields = ITEM_TYPE_FIELDS[itemType];
|
|
155
|
+
const result = { itemType, creators, collections, tags };
|
|
156
|
+
if (validFields) {
|
|
157
|
+
for (const [field, value] of Object.entries(candidates)) {
|
|
158
|
+
if (validFields.has(field)) {
|
|
159
|
+
result[field] = value;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// Unknown item type — include all fields as fallback
|
|
165
|
+
for (const [field, value] of Object.entries(candidates)) {
|
|
166
|
+
result[field] = value;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
113
170
|
}
|
|
114
171
|
export function zoteroItemToCsl(item) {
|
|
115
172
|
const authors = (item.creators ?? [])
|