@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 CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import "dotenv/config";
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
- pdf_results = await attachPdfsToItems(success, zoteroApi, userId, apiKey);
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
  ],
@@ -28,7 +28,7 @@ const ItemSchema = z
28
28
  continue;
29
29
  if (!validFields.has(key)) {
30
30
  ctx.addIssue({
31
- code: z.ZodIssueCode.custom,
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: z.ZodIssueCode.custom,
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
- const createResponse = await zoteroApi
114
- .library("user", userId)
115
- .items()
116
- .post([itemData]);
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
- const authResponse = await fetch(authUrl, {
139
- method: "POST",
140
- headers: {
141
- "Zotero-API-Key": apiKey,
142
- "Content-Type": "application/x-www-form-urlencoded",
143
- "If-None-Match": "*",
144
- },
145
- body: authBody.toString(),
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
- const uploadResponse = await fetch(auth.url, {
166
- method: "POST",
167
- headers: { "Content-Type": auth.contentType },
168
- body: uploadBody,
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
- const registerResponse = await fetch(authUrl, {
182
- method: "POST",
183
- headers: {
184
- "Zotero-API-Key": apiKey,
185
- "Content-Type": "application/x-www-form-urlencoded",
186
- "If-None-Match": "*",
187
- },
188
- body: `upload=${auth.uploadKey}`,
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.8",
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.0.4",
46
- "dotenv": "^16.4.7",
47
- "fast-xml-parser": "^5.3.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.24.1",
51
- "zotero-api-client": "^0.46.0"
50
+ "zod": "^4.3.6",
51
+ "zotero-api-client": "^0.48.0"
52
52
  },
53
53
  "devDependencies": {
54
- "@types/node": "latest",
54
+ "@types/node": "^25.5.0",
55
55
  "typescript": "latest",
56
- "vitest": "^4.0.18"
56
+ "vitest": "^4.1.0"
57
57
  }
58
58
  }