@xevos117/mcp-zotero 1.0.8 → 1.0.9
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/build/server.js +2 -1
- package/build/tools/add-items-by-doi.js +21 -3
- package/build/tools/add-items.js +2 -2
- package/build/tools/find-and-attach-pdfs.js +21 -2
- package/build/tools/import-pdf-to-zotero.js +7 -0
- package/build/utils/concurrency.js +11 -1
- package/build/utils/pdf-uploader.js +97 -27
- package/build/utils/zotero-fulltext.js +6 -0
- package/package.json +8 -8
package/build/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import "dotenv
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
dotenv.config({ quiet: true });
|
|
3
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
6
|
import { createRequire } from "module";
|
|
@@ -6,7 +6,7 @@ import { cslToZoteroItem } from "../utils/csl-to-zotero.js";
|
|
|
6
6
|
import { logger } from "../utils/logger.js";
|
|
7
7
|
import { lookupOaPdf } from "../utils/unpaywall.js";
|
|
8
8
|
import { downloadAndUploadPdf } from "../utils/pdf-uploader.js";
|
|
9
|
-
import { mapWithConcurrency } from "../utils/concurrency.js";
|
|
9
|
+
import { mapWithConcurrency, createCancellationToken } from "../utils/concurrency.js";
|
|
10
10
|
export const toolConfig = {
|
|
11
11
|
name: "add_items_by_doi",
|
|
12
12
|
description: `Add items to your Zotero library by resolving DOIs. Works with ANY item type that has a DOI — journal articles, books, datasets, preprints, conference papers, reports, etc. For each DOI, resolves metadata via content negotiation and creates the item in Zotero with the correct type automatically. Returns a list of successfully added items (with item_key and title) and any failures.
|
|
@@ -54,6 +54,7 @@ async function attachPdfsToItems(items, zoteroApi, userId, apiKey) {
|
|
|
54
54
|
}];
|
|
55
55
|
}
|
|
56
56
|
// Email is valid — process all items in parallel (reuse probe for first)
|
|
57
|
+
const cancelToken = createCancellationToken();
|
|
57
58
|
const settled = await mapWithConcurrency(itemsWithDoi, async (item, i) => {
|
|
58
59
|
const oaResult = i === 0 ? probe : await lookupOaPdf(item.doi);
|
|
59
60
|
if (oaResult.found && oaResult.pdf_url) {
|
|
@@ -61,6 +62,9 @@ async function attachPdfsToItems(items, zoteroApi, userId, apiKey) {
|
|
|
61
62
|
url: oaResult.pdf_url,
|
|
62
63
|
parentItem: item.item_key,
|
|
63
64
|
});
|
|
65
|
+
if (!uploadResult.success && uploadResult.error.code === "storage_quota_exceeded") {
|
|
66
|
+
cancelToken.cancelled = true;
|
|
67
|
+
}
|
|
64
68
|
return {
|
|
65
69
|
item_key: item.item_key,
|
|
66
70
|
doi: item.doi,
|
|
@@ -80,7 +84,7 @@ async function attachPdfsToItems(items, zoteroApi, userId, apiKey) {
|
|
|
80
84
|
? "Open access copy exists at a repository but no direct PDF link is available. The user can download it manually from the landing page and use import_pdf_to_zotero to attach it."
|
|
81
85
|
: "No open access PDF found",
|
|
82
86
|
};
|
|
83
|
-
});
|
|
87
|
+
}, undefined, cancelToken);
|
|
84
88
|
return settled
|
|
85
89
|
.filter((r) => r.status === "fulfilled")
|
|
86
90
|
.map((r) => r.value);
|
|
@@ -131,12 +135,20 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
|
|
|
131
135
|
});
|
|
132
136
|
}
|
|
133
137
|
let pdf_results;
|
|
138
|
+
let pdf_attach_error;
|
|
134
139
|
if (auto_attach_pdf) {
|
|
135
140
|
const apiKey = process.env.ZOTERO_API_KEY;
|
|
136
141
|
if (apiKey) {
|
|
137
|
-
|
|
142
|
+
try {
|
|
143
|
+
pdf_results = await attachPdfsToItems(success, zoteroApi, userId, apiKey);
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
pdf_attach_error = `PDF attachment failed: ${err instanceof Error ? err.message : String(err)}. Items were created successfully.`;
|
|
147
|
+
logger.error("PDF attach phase failed", { error: pdf_attach_error });
|
|
148
|
+
}
|
|
138
149
|
}
|
|
139
150
|
}
|
|
151
|
+
const quotaHit = pdf_results?.some((r) => r.error?.includes("storage quota"));
|
|
140
152
|
return {
|
|
141
153
|
content: [
|
|
142
154
|
{
|
|
@@ -145,6 +157,12 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
|
|
|
145
157
|
success,
|
|
146
158
|
failed: resolved.failed,
|
|
147
159
|
...(pdf_results !== undefined ? { pdf_results } : {}),
|
|
160
|
+
...(pdf_attach_error !== undefined ? { pdf_attach_error } : {}),
|
|
161
|
+
...(quotaHit
|
|
162
|
+
? {
|
|
163
|
+
storage_quota_warning: "Zotero storage quota is full. Some PDF attachments were skipped. All items were created successfully (metadata only). Free up space at https://www.zotero.org/settings/storage or upgrade your plan.",
|
|
164
|
+
}
|
|
165
|
+
: {}),
|
|
148
166
|
}, null, 2),
|
|
149
167
|
},
|
|
150
168
|
],
|
package/build/tools/add-items.js
CHANGED
|
@@ -28,7 +28,7 @@ const ItemSchema = z
|
|
|
28
28
|
continue;
|
|
29
29
|
if (!validFields.has(key)) {
|
|
30
30
|
ctx.addIssue({
|
|
31
|
-
code:
|
|
31
|
+
code: "custom",
|
|
32
32
|
message: `Field "${key}" is not valid for itemType "${data.itemType}". Valid fields: ${[...validFields].join(", ")}`,
|
|
33
33
|
path: [key],
|
|
34
34
|
});
|
|
@@ -38,7 +38,7 @@ const ItemSchema = z
|
|
|
38
38
|
for (const [i, creator] of (data.creators ?? []).entries()) {
|
|
39
39
|
if (!validCreatorTypes.has(creator.creatorType)) {
|
|
40
40
|
ctx.addIssue({
|
|
41
|
-
code:
|
|
41
|
+
code: "custom",
|
|
42
42
|
message: `Invalid creatorType "${creator.creatorType}" for "${data.itemType}". Valid: ${[...validCreatorTypes].join(", ")}`,
|
|
43
43
|
path: ["creators", i, "creatorType"],
|
|
44
44
|
});
|
|
@@ -4,7 +4,7 @@ import { formatErrorResponse } from "../utils/error-formatter.js";
|
|
|
4
4
|
import { logger } from "../utils/logger.js";
|
|
5
5
|
import { lookupOaPdfWithFallbacks } from "../utils/unpaywall.js";
|
|
6
6
|
import { downloadAndUploadPdf } from "../utils/pdf-uploader.js";
|
|
7
|
-
import { mapWithConcurrency } from "../utils/concurrency.js";
|
|
7
|
+
import { mapWithConcurrency, createCancellationToken } from "../utils/concurrency.js";
|
|
8
8
|
import { fetchAllPages } from "../utils/pagination.js";
|
|
9
9
|
export const toolConfig = {
|
|
10
10
|
name: "find_and_attach_pdfs",
|
|
@@ -72,6 +72,7 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
// 3. Process each item in parallel
|
|
75
|
+
const cancelToken = createCancellationToken();
|
|
75
76
|
const settled = await mapWithConcurrency(keys, async (key) => {
|
|
76
77
|
const item = itemMap.get(key);
|
|
77
78
|
if (!item) {
|
|
@@ -145,6 +146,15 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
145
146
|
pdf_url: pdfUrl,
|
|
146
147
|
};
|
|
147
148
|
}
|
|
149
|
+
if (!uploadResult.success && uploadResult.error.code === "storage_quota_exceeded") {
|
|
150
|
+
cancelToken.cancelled = true;
|
|
151
|
+
return {
|
|
152
|
+
item_key: key,
|
|
153
|
+
doi,
|
|
154
|
+
status: "quota_exceeded",
|
|
155
|
+
reason: uploadResult.error.message,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
148
158
|
}
|
|
149
159
|
return {
|
|
150
160
|
item_key: key,
|
|
@@ -153,7 +163,7 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
153
163
|
reason: `Download failed for all ${urlsToTry.length} URL(s)`,
|
|
154
164
|
source: primary.source ?? undefined,
|
|
155
165
|
};
|
|
156
|
-
});
|
|
166
|
+
}, undefined, cancelToken);
|
|
157
167
|
const results = settled
|
|
158
168
|
.filter((r) => r.status === "fulfilled")
|
|
159
169
|
.map((r) => r.value);
|
|
@@ -161,6 +171,7 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
161
171
|
let notFound = 0;
|
|
162
172
|
let skipped = 0;
|
|
163
173
|
let errors = 0;
|
|
174
|
+
let quotaExceeded = 0;
|
|
164
175
|
for (const r of results) {
|
|
165
176
|
if (r.status === "attached")
|
|
166
177
|
attached++;
|
|
@@ -170,6 +181,8 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
170
181
|
skipped++;
|
|
171
182
|
else if (r.status === "error")
|
|
172
183
|
errors++;
|
|
184
|
+
else if (r.status === "quota_exceeded")
|
|
185
|
+
quotaExceeded++;
|
|
173
186
|
}
|
|
174
187
|
return {
|
|
175
188
|
content: [
|
|
@@ -181,7 +194,13 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
181
194
|
not_found: notFound,
|
|
182
195
|
skipped,
|
|
183
196
|
errors,
|
|
197
|
+
quota_exceeded: quotaExceeded,
|
|
184
198
|
dry_run,
|
|
199
|
+
...(quotaExceeded > 0
|
|
200
|
+
? {
|
|
201
|
+
storage_quota_warning: "Zotero storage quota is full. Remaining PDF uploads were skipped. Free up space at https://www.zotero.org/settings/storage or upgrade your plan.",
|
|
202
|
+
}
|
|
203
|
+
: {}),
|
|
185
204
|
results,
|
|
186
205
|
}, null, 2),
|
|
187
206
|
},
|
|
@@ -69,6 +69,13 @@ export async function handleImportPdfToZotero(zoteroApi, userId, args) {
|
|
|
69
69
|
return formatErrorResponse("File exceeds 100 MB limit", {
|
|
70
70
|
size_bytes: result.error.sizeBytes,
|
|
71
71
|
});
|
|
72
|
+
case "storage_quota_exceeded":
|
|
73
|
+
return formatErrorResponse("Zotero storage quota exceeded", {
|
|
74
|
+
status: result.error.status,
|
|
75
|
+
suggestion: "Your Zotero cloud storage is full. Free up space by deleting stored files in your Zotero library, or upgrade your storage plan at https://www.zotero.org/settings/storage. Note: item metadata and linked URL attachments do not consume storage — only imported file attachments do.",
|
|
76
|
+
item_key: result.itemKey,
|
|
77
|
+
note: "An attachment metadata record was created but the file could not be uploaded. You may want to delete it with delete_items or retry after freeing storage.",
|
|
78
|
+
});
|
|
72
79
|
case "auth_failed":
|
|
73
80
|
return formatErrorResponse("Upload authorization failed", {
|
|
74
81
|
status: result.error.status,
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
export const DEFAULT_CONCURRENCY = 5;
|
|
2
|
+
export function createCancellationToken() {
|
|
3
|
+
return { cancelled: false };
|
|
4
|
+
}
|
|
2
5
|
/**
|
|
3
6
|
* Runs `fn` over `items` with at most `concurrency` invocations in flight.
|
|
4
7
|
* Returns results in input order as `PromiseSettledResult<R>[]`.
|
|
8
|
+
*
|
|
9
|
+
* When `cancel` is provided and `cancel.cancelled` is set to `true`,
|
|
10
|
+
* workers stop picking up new items. Items that were never started
|
|
11
|
+
* have `undefined` slots in the returned array — callers that filter
|
|
12
|
+
* with `.filter(r => r?.status === "fulfilled")` handle this safely.
|
|
5
13
|
*/
|
|
6
|
-
export async function mapWithConcurrency(items, fn, concurrency = DEFAULT_CONCURRENCY) {
|
|
14
|
+
export async function mapWithConcurrency(items, fn, concurrency = DEFAULT_CONCURRENCY, cancel) {
|
|
7
15
|
const results = new Array(items.length);
|
|
8
16
|
let nextIndex = 0;
|
|
9
17
|
async function worker() {
|
|
10
18
|
while (nextIndex < items.length) {
|
|
19
|
+
if (cancel?.cancelled)
|
|
20
|
+
break;
|
|
11
21
|
const i = nextIndex++;
|
|
12
22
|
try {
|
|
13
23
|
const value = await fn(items[i], i);
|
|
@@ -110,10 +110,24 @@ export async function downloadAndUploadPdf(zoteroApi, userId, apiKey, options) {
|
|
|
110
110
|
else {
|
|
111
111
|
itemData.collections = options.collections ?? [];
|
|
112
112
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
let createResponse;
|
|
114
|
+
try {
|
|
115
|
+
createResponse = await zoteroApi
|
|
116
|
+
.library("user", userId)
|
|
117
|
+
.items()
|
|
118
|
+
.post([itemData]);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
122
|
+
return {
|
|
123
|
+
success: false,
|
|
124
|
+
error: {
|
|
125
|
+
code: "item_creation_failed",
|
|
126
|
+
message: `Failed to create attachment item: ${detail}`,
|
|
127
|
+
networkDetail: detail,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
117
131
|
if (!createResponse.isSuccess()) {
|
|
118
132
|
const errors = createResponse.getErrors();
|
|
119
133
|
const errorMsg = Object.values(errors).join("; ") || "Unknown error";
|
|
@@ -135,16 +149,42 @@ export async function downloadAndUploadPdf(zoteroApi, userId, apiKey, options) {
|
|
|
135
149
|
filesize: String(buffer.length),
|
|
136
150
|
mtime: String(Date.now()),
|
|
137
151
|
});
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
"
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
let authResponse;
|
|
153
|
+
try {
|
|
154
|
+
authResponse = await fetch(authUrl, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
"Zotero-API-Key": apiKey,
|
|
158
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
159
|
+
"If-None-Match": "*",
|
|
160
|
+
},
|
|
161
|
+
body: authBody.toString(),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
itemKey,
|
|
169
|
+
error: {
|
|
170
|
+
code: "auth_failed",
|
|
171
|
+
message: `Upload authorization failed: ${detail}`,
|
|
172
|
+
networkDetail: detail,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
147
176
|
if (!authResponse.ok) {
|
|
177
|
+
if (authResponse.status === 413) {
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
itemKey,
|
|
181
|
+
error: {
|
|
182
|
+
code: "storage_quota_exceeded",
|
|
183
|
+
message: "Zotero storage quota exceeded. The file cannot be uploaded because your Zotero cloud storage is full. Free up space by deleting stored files in Zotero, or upgrade your storage plan at https://www.zotero.org/settings/storage.",
|
|
184
|
+
status: 413,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
148
188
|
return {
|
|
149
189
|
success: false,
|
|
150
190
|
itemKey,
|
|
@@ -162,11 +202,26 @@ export async function downloadAndUploadPdf(zoteroApi, userId, apiKey, options) {
|
|
|
162
202
|
const prefix = Buffer.from(auth.prefix, "utf-8");
|
|
163
203
|
const suffix = Buffer.from(auth.suffix, "utf-8");
|
|
164
204
|
const uploadBody = Buffer.concat([prefix, buffer, suffix]);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
205
|
+
let uploadResponse;
|
|
206
|
+
try {
|
|
207
|
+
uploadResponse = await fetch(auth.url, {
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { "Content-Type": auth.contentType },
|
|
210
|
+
body: uploadBody,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
215
|
+
return {
|
|
216
|
+
success: false,
|
|
217
|
+
itemKey,
|
|
218
|
+
error: {
|
|
219
|
+
code: "upload_failed",
|
|
220
|
+
message: `File upload failed: ${detail}`,
|
|
221
|
+
networkDetail: detail,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
170
225
|
if (!uploadResponse.ok) {
|
|
171
226
|
return {
|
|
172
227
|
success: false,
|
|
@@ -178,15 +233,30 @@ export async function downloadAndUploadPdf(zoteroApi, userId, apiKey, options) {
|
|
|
178
233
|
},
|
|
179
234
|
};
|
|
180
235
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
236
|
+
let registerResponse;
|
|
237
|
+
try {
|
|
238
|
+
registerResponse = await fetch(authUrl, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: {
|
|
241
|
+
"Zotero-API-Key": apiKey,
|
|
242
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
243
|
+
"If-None-Match": "*",
|
|
244
|
+
},
|
|
245
|
+
body: `upload=${auth.uploadKey}`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
250
|
+
return {
|
|
251
|
+
success: false,
|
|
252
|
+
itemKey,
|
|
253
|
+
error: {
|
|
254
|
+
code: "registration_failed",
|
|
255
|
+
message: `Upload registration failed: ${detail}`,
|
|
256
|
+
networkDetail: detail,
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
190
260
|
if (!registerResponse.ok) {
|
|
191
261
|
return {
|
|
192
262
|
success: false,
|
|
@@ -15,6 +15,12 @@ export async function putFulltext(userId, itemKey, apiKey, content, totalPages)
|
|
|
15
15
|
if (response.status === 204) {
|
|
16
16
|
return { success: true };
|
|
17
17
|
}
|
|
18
|
+
if (response.status === 413) {
|
|
19
|
+
return {
|
|
20
|
+
success: false,
|
|
21
|
+
error: "Fulltext content too large for Zotero API (status 413). The PDF text may exceed the fulltext size limit.",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
18
24
|
return {
|
|
19
25
|
success: false,
|
|
20
26
|
error: `Fulltext PUT failed with status ${response.status}`,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xevos117/mcp-zotero",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.9",
|
|
5
5
|
"description": "Unofficial MCP server for Zotero integration with citation management",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -42,17 +42,17 @@
|
|
|
42
42
|
"author": "Xevos17",
|
|
43
43
|
"license": "MIT",
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
46
|
-
"dotenv": "^
|
|
47
|
-
"fast-xml-parser": "^5.
|
|
45
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
46
|
+
"dotenv": "^17.3.1",
|
|
47
|
+
"fast-xml-parser": "^5.5.8",
|
|
48
48
|
"jszip": "^3.10.1",
|
|
49
49
|
"unpdf": "^1.4.0",
|
|
50
|
-
"zod": "^3.
|
|
51
|
-
"zotero-api-client": "^0.
|
|
50
|
+
"zod": "^4.3.6",
|
|
51
|
+
"zotero-api-client": "^0.48.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@types/node": "
|
|
54
|
+
"@types/node": "^25.5.0",
|
|
55
55
|
"typescript": "latest",
|
|
56
|
-
"vitest": "^4.0
|
|
56
|
+
"vitest": "^4.1.0"
|
|
57
57
|
}
|
|
58
58
|
}
|