@xevos117/mcp-zotero 2.0.1 → 2.0.3

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.
Files changed (36) hide show
  1. package/README.md +133 -4
  2. package/build/citation-injector/citation-formatter.js +66 -7
  3. package/build/citation-injector/document-prefs.js +121 -0
  4. package/build/citation-injector/injector.js +54 -14
  5. package/build/server.js +10 -5
  6. package/build/tools/add-items-by-doi.js +15 -7
  7. package/build/tools/add-items.js +5 -2
  8. package/build/tools/add-linked-url-attachment.js +6 -2
  9. package/build/tools/create-collection.js +6 -2
  10. package/build/tools/delete-collection.js +7 -3
  11. package/build/tools/delete-items.js +7 -3
  12. package/build/tools/find-and-attach-pdfs.js +17 -9
  13. package/build/tools/get-collection-items.js +8 -7
  14. package/build/tools/get-collections.js +2 -2
  15. package/build/tools/get-item-fulltext.js +55 -17
  16. package/build/tools/get-items-details.js +7 -3
  17. package/build/tools/get-user-id.js +1 -1
  18. package/build/tools/import-pdf-to-zotero.js +45 -8
  19. package/build/tools/index.js +11 -3
  20. package/build/tools/inject-citations.js +33 -6
  21. package/build/tools/search-library.js +7 -5
  22. package/build/utils/attachments.js +13 -2
  23. package/build/utils/csl-to-zotero.js +39 -14
  24. package/build/utils/doi-resolver.js +20 -3
  25. package/build/utils/error-hints.js +179 -0
  26. package/build/utils/fetch-retry.js +2 -1
  27. package/build/utils/html-entities.js +55 -0
  28. package/build/utils/item-formatter.js +2 -1
  29. package/build/utils/json-response.js +29 -0
  30. package/build/utils/library-context.js +11 -11
  31. package/build/utils/pdf-uploader.js +41 -16
  32. package/build/utils/timeouts.js +97 -0
  33. package/build/utils/unpaywall.js +35 -6
  34. package/build/utils/zotero-fulltext.js +3 -2
  35. package/build/utils/zotero-keys.js +2 -1
  36. package/package.json +11 -2
@@ -0,0 +1,55 @@
1
+ /**
2
+ * HTML entities in registry metadata. doi.org / Crossref return some strings HTML-escaped
3
+ * ("Computers & Operations Research"); Zotero stores what it is given, so the entity ends up in the library
4
+ * and in every bibliography. Decoding only turns entities into the characters they stand for: the text stays
5
+ * plain text (no HTML is parsed or built), and every output that needs escaping (the .docx XML, JSON field codes)
6
+ * escapes it again where it is written.
7
+ */
8
+ const NAMED = {
9
+ amp: "&",
10
+ lt: "<",
11
+ gt: ">",
12
+ quot: '"',
13
+ apos: "'",
14
+ nbsp: " ",
15
+ ndash: "–",
16
+ mdash: "—",
17
+ lsquo: "‘",
18
+ rsquo: "’",
19
+ ldquo: "“",
20
+ rdquo: "”",
21
+ hellip: "…",
22
+ };
23
+ const ENTITY = /&(?:#(\d{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z]+));/g;
24
+ function fromCodePoint(cp) {
25
+ // NUL, surrogates and out-of-range values are not characters: keep the entity as it is
26
+ if (cp === 0 || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff))
27
+ return undefined;
28
+ return String.fromCodePoint(cp);
29
+ }
30
+ /**
31
+ * Decodes HTML entities in ONE pass: the output is never scanned again, so "&amp;lt;" becomes the text "&lt;",
32
+ * not "<". Unknown or invalid entities are left unchanged.
33
+ */
34
+ export function decodeHtmlEntities(text) {
35
+ if (!text.includes("&"))
36
+ return text;
37
+ return text.replace(ENTITY, (whole, dec, hex, name) => {
38
+ if (dec !== undefined)
39
+ return fromCodePoint(Number.parseInt(dec, 10)) ?? whole;
40
+ if (hex !== undefined)
41
+ return fromCodePoint(Number.parseInt(hex, 16)) ?? whole;
42
+ return NAMED[name.toLowerCase()] ?? whole;
43
+ });
44
+ }
45
+ /** decodeHtmlEntities on every string inside a JSON value (keys untouched); other values are returned as they are. */
46
+ export function decodeEntitiesDeep(value) {
47
+ if (typeof value === "string")
48
+ return decodeHtmlEntities(value);
49
+ if (Array.isArray(value))
50
+ return value.map((v) => decodeEntitiesDeep(v));
51
+ if (value !== null && typeof value === "object") {
52
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, decodeEntitiesDeep(v)]));
53
+ }
54
+ return value;
55
+ }
@@ -1,6 +1,7 @@
1
1
  export function formatCreators(creators) {
2
2
  return (creators
3
- ?.map((c) => `${c.firstName || ""} ${c.lastName || ""}`.trim())
3
+ // single-field names (institutions, consortia) are stored in `name`
4
+ ?.map((c) => (c.name?.trim() || `${c.firstName || ""} ${c.lastName || ""}`.trim()))
4
5
  .filter(Boolean)
5
6
  .join(", ") || "No authors listed");
6
7
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A response body that is not the JSON object the API documents: an HTML error or landing page (doi.org answers
3
+ * an unresolvable DOI with HTML), an empty body, or a bare value. The message names the status and content type,
4
+ * never the raw JSON.parse error.
5
+ */
6
+ export class InvalidJsonResponseError extends Error {
7
+ status;
8
+ constructor(response) {
9
+ const type = response.headers?.get?.("content-type")?.split(";")[0].trim();
10
+ super(`response is not a JSON object (HTTP ${response.status}${type ? `, ${type}` : ""})`);
11
+ this.name = "InvalidJsonResponseError";
12
+ this.status = response.status;
13
+ }
14
+ }
15
+ /** Body of `response` as a JSON object; InvalidJsonResponseError otherwise. Timeouts while reading propagate. */
16
+ export async function readJsonObject(response) {
17
+ let data;
18
+ try {
19
+ data = await response.json();
20
+ }
21
+ catch (err) {
22
+ if (err instanceof SyntaxError)
23
+ throw new InvalidJsonResponseError(response);
24
+ throw err;
25
+ }
26
+ if (data === null || typeof data !== "object" || Array.isArray(data))
27
+ throw new InvalidJsonResponseError(response);
28
+ return data;
29
+ }
@@ -18,6 +18,7 @@
18
18
  * the personal library (ZOTERO_USER_ID); a group needs an explicit id.
19
19
  */
20
20
  import { z } from "zod";
21
+ import { SETTINGS, whereToSet } from "./error-hints.js";
21
22
  /** Zotero user and group IDs are numeric; also keeps ids safe to interpolate into API URLs. */
22
23
  const LIBRARY_ID_PATTERN = /^\d+$/;
23
24
  export function getLibraryType() {
@@ -48,7 +49,7 @@ export function getDefaultLibrary() {
48
49
  }
49
50
  const id = getLibraryId();
50
51
  if (!id) {
51
- throw new Error("Missing ZOTERO_USER_ID or ZOTERO_LIBRARY_ID environment variable");
52
+ throw new Error("Missing ZOTERO_USER_ID or ZOTERO_LIBRARY_ID environment variable (the userID is shown at https://www.zotero.org/settings/keys)");
52
53
  }
53
54
  assertLibraryId(id, process.env.ZOTERO_LIBRARY_ID ? "ZOTERO_LIBRARY_ID" : "ZOTERO_USER_ID");
54
55
  return { type, id };
@@ -58,16 +59,13 @@ export const libraryArgsSchema = {
58
59
  library_type: z
59
60
  .enum(["user", "group"])
60
61
  .optional()
61
- .describe("Override the configured Zotero library type for this call. " +
62
- "Defaults to ZOTERO_LIBRARY_TYPE env (or 'user'). " +
63
- "Use to target a different library per call without restarting the server."),
62
+ .describe("Library for this call: 'user' (personal) or 'group'. Default: the library configured on the server."),
64
63
  library_id: z
65
64
  .string()
66
- .regex(LIBRARY_ID_PATTERN, "library_id must be a numeric Zotero library ID")
65
+ .regex(LIBRARY_ID_PATTERN, "library_id must be a numeric Zotero user or group ID (a group's ID is the number in its zotero.org/groups/<id> URL)")
67
66
  .optional()
68
- .describe("Override the configured library ID for this call. " +
69
- "Defaults to ZOTERO_LIBRARY_ID env (or ZOTERO_USER_ID). " +
70
- "Required when library_type is 'group' and the server is configured for a user library."),
67
+ .describe("Numeric user or group ID for this call (a group's ID is the number in its zotero.org/groups/<id> URL). " +
68
+ "Default: the configured library. Required with library_type 'group' when the server is configured for a user library."),
71
69
  };
72
70
  /**
73
71
  * Resolve the library context for a single tool call. Per-call args win,
@@ -87,12 +85,14 @@ export function resolveLibrary(args, defaultLibraryId) {
87
85
  }
88
86
  else {
89
87
  // The default id belongs to a user library: never reuse it as a group id
90
- throw new Error("library_id is required when library_type is 'group' and the server is configured for a user library.");
88
+ throw new Error("library_id is required when library_type is 'group' and the server is configured for a user library: " +
89
+ "pass the numeric group ID (the number in the group's zotero.org/groups/<id> URL).");
91
90
  }
92
91
  }
93
92
  if (!id) {
94
- throw new Error("Zotero library ID is not set. Provide library_id arg, or set " +
95
- "ZOTERO_LIBRARY_ID / ZOTERO_USER_ID in the server environment.");
93
+ throw new Error("Zotero library ID is not set: the server has no ZOTERO_USER_ID for the personal library. Pass library_id " +
94
+ "(the numeric user ID), or tell the user to set their userID (shown at https://www.zotero.org/settings/keys) in " +
95
+ `${whereToSet(SETTINGS.userId)}.`);
96
96
  }
97
97
  assertLibraryId(id, "library_id");
98
98
  return { type, id };
@@ -1,9 +1,12 @@
1
+ import { InvalidJsonResponseError, readJsonObject } from "./json-response.js";
1
2
  import { createHash } from "node:crypto";
3
+ import { isZoteroApiError } from "../types/zotero-types.js";
2
4
  import { logger } from "./logger.js";
3
5
  import { extractPdfText } from "./pdf-text-extractor.js";
4
6
  import { putFulltext } from "./zotero-fulltext.js";
5
7
  import { formatWriteErrors } from "./write-results.js";
6
8
  import { errorMessage } from "./error-formatter.js";
9
+ import { fetchWithTimeout, isTimeoutAbort, timeoutError, RequestTimeoutError, TIMEOUTS } from "./timeouts.js";
7
10
  const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
8
11
  function safeBasename(name) {
9
12
  return (name.split(/[\\/]/).pop() ?? "").replace(/[\x00-\x1f"]/g, "").trim();
@@ -53,11 +56,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
53
56
  // 1. Download the file
54
57
  let downloadResponse;
55
58
  try {
56
- downloadResponse = await fetch(options.url, {
57
- headers: {
58
- "User-Agent": "mcp-zotero/1.0 (Open Access PDF retrieval)",
59
- },
60
- });
59
+ downloadResponse = await fetchWithTimeout(options.url, { headers: { "User-Agent": "mcp-zotero/1.0 (Open Access PDF retrieval)" } }, TIMEOUTS.pdfTransferMs, "PDF download");
61
60
  }
62
61
  catch (err) {
63
62
  const detail = errorMessage(err);
@@ -87,8 +86,18 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
87
86
  contentType: responseContentType,
88
87
  });
89
88
  }
90
- const arrayBuffer = await downloadResponse.arrayBuffer();
91
- const buffer = Buffer.from(arrayBuffer);
89
+ let buffer;
90
+ try {
91
+ buffer = Buffer.from(await downloadResponse.arrayBuffer());
92
+ }
93
+ catch (err) {
94
+ // the download bound also covers the body: a server that stalls mid-file ends here
95
+ const detail = isTimeoutAbort(err) ? timeoutError("PDF download", options.url, TIMEOUTS.pdfTransferMs).message : errorMessage(err);
96
+ return {
97
+ success: false,
98
+ error: { code: "network_error", message: `Network error downloading file: ${detail}.`, networkDetail: detail },
99
+ };
100
+ }
92
101
  const filename = options.filename ??
93
102
  resolvePdfFilename(options.url, downloadResponse.headers.get("content-disposition"), options.doi);
94
103
  const title = options.title ?? filename;
@@ -153,6 +162,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
153
162
  error: {
154
163
  code: "item_creation_failed",
155
164
  message: `Failed to create attachment item: ${detail}`,
165
+ ...(isZoteroApiError(err) ? { status: err.response.status } : {}),
156
166
  networkDetail: detail,
157
167
  },
158
168
  };
@@ -179,7 +189,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
179
189
  });
180
190
  let authResponse;
181
191
  try {
182
- authResponse = await fetch(authUrl, {
192
+ authResponse = await fetchWithTimeout(authUrl, {
183
193
  method: "POST",
184
194
  headers: {
185
195
  "Zotero-API-Key": apiKey,
@@ -187,7 +197,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
187
197
  "If-None-Match": "*",
188
198
  },
189
199
  body: authBody.toString(),
190
- });
200
+ }, TIMEOUTS.zoteroApiMs, "Upload authorization");
191
201
  }
192
202
  catch (err) {
193
203
  const detail = errorMessage(err);
@@ -223,7 +233,19 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
223
233
  },
224
234
  };
225
235
  }
226
- const authData = (await authResponse.json());
236
+ let authData;
237
+ try {
238
+ authData = await readJsonObject(authResponse);
239
+ }
240
+ catch (err) {
241
+ if (!(err instanceof InvalidJsonResponseError))
242
+ throw err;
243
+ return {
244
+ success: false,
245
+ itemKey,
246
+ error: { code: "auth_failed", message: `Upload authorization failed: ${err.message}`, status: authResponse.status, networkDetail: err.message },
247
+ };
248
+ }
227
249
  // 6-7. Upload binary + register (skip if already exists)
228
250
  if (!("exists" in authData)) {
229
251
  const auth = authData;
@@ -232,11 +254,11 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
232
254
  const uploadBody = Buffer.concat([prefix, buffer, suffix]);
233
255
  let uploadResponse;
234
256
  try {
235
- uploadResponse = await fetch(auth.url, {
257
+ uploadResponse = await fetchWithTimeout(auth.url, {
236
258
  method: "POST",
237
259
  headers: { "Content-Type": auth.contentType },
238
260
  body: uploadBody,
239
- });
261
+ }, TIMEOUTS.pdfTransferMs, "File upload");
240
262
  }
241
263
  catch (err) {
242
264
  const detail = errorMessage(err);
@@ -263,7 +285,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
263
285
  }
264
286
  let registerResponse;
265
287
  try {
266
- registerResponse = await fetch(authUrl, {
288
+ registerResponse = await fetchWithTimeout(authUrl, {
267
289
  method: "POST",
268
290
  headers: {
269
291
  "Zotero-API-Key": apiKey,
@@ -271,7 +293,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
271
293
  "If-None-Match": "*",
272
294
  },
273
295
  body: `upload=${auth.uploadKey}`,
274
- });
296
+ }, TIMEOUTS.zoteroApiMs, "Upload registration");
275
297
  }
276
298
  catch (err) {
277
299
  const detail = errorMessage(err);
@@ -309,8 +331,11 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
309
331
  ? "Fulltext indexed successfully. Use get_item_fulltext to retrieve content."
310
332
  : `Fulltext PUT failed: ${putResult.error}. Sync with Zotero Desktop to index the PDF.`;
311
333
  }
312
- catch {
313
- fulltextStatus = "PDF text extraction failed. Sync with Zotero Desktop to index the PDF.";
334
+ catch (err) {
335
+ fulltextStatus =
336
+ err instanceof RequestTimeoutError
337
+ ? `Fulltext indexing failed: ${err.message}. Sync with Zotero Desktop to index the PDF.`
338
+ : "PDF text extraction failed. Sync with Zotero Desktop to index the PDF.";
314
339
  }
315
340
  }
316
341
  else {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Bounds for every network call the server makes. A remote that accepts the connection and then
3
+ * never answers would otherwise block a tool until the MCP client gives up, with no useful error.
4
+ * Values are read at call time, so tests can lower them.
5
+ */
6
+ export const TIMEOUTS = {
7
+ /** Zotero Web API: metadata reads/writes, fulltext, upload authorization and registration. */
8
+ zoteroApiMs: 30_000,
9
+ /** DOI resolution (doi.org / Crossref) and Unpaywall lookups, per attempt. */
10
+ metadataMs: 30_000,
11
+ /**
12
+ * PDF download and upload to Zotero storage, body included. Files are capped at 100 MB, so this
13
+ * allows ~0.6 MB/s for the largest file; typical papers (1-10 MB) finish in seconds.
14
+ */
15
+ pdfTransferMs: 180_000,
16
+ };
17
+ /** A network call that hit its bound. The message names the operation, host and bound. */
18
+ export class RequestTimeoutError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "RequestTimeoutError";
22
+ }
23
+ }
24
+ /** True for the abort raised by AbortSignal.timeout (DOMException "TimeoutError"). */
25
+ export function isTimeoutAbort(err) {
26
+ return typeof err === "object" && err !== null && err.name === "TimeoutError";
27
+ }
28
+ function host(url) {
29
+ try {
30
+ return new URL(url).host;
31
+ }
32
+ catch {
33
+ return url;
34
+ }
35
+ }
36
+ function seconds(ms) {
37
+ return `${ms / 1000} s`;
38
+ }
39
+ export function timeoutError(label, url, timeoutMs) {
40
+ return new RequestTimeoutError(`${label} timed out after ${seconds(timeoutMs)} (${host(url)})`);
41
+ }
42
+ /** Signal that aborts after `timeoutMs`, combined with the caller's signal when there is one. */
43
+ export function timeoutSignal(timeoutMs, signal) {
44
+ const bound = AbortSignal.timeout(timeoutMs);
45
+ return signal ? AbortSignal.any([signal, bound]) : bound;
46
+ }
47
+ /**
48
+ * fetch() with a bound. The signal stays attached to the response, so reading the body is bounded
49
+ * too: callers that read it should map isTimeoutAbort() errors with timeoutError().
50
+ */
51
+ export async function fetchWithTimeout(url, init, timeoutMs, label) {
52
+ try {
53
+ return await fetch(url, { ...init, signal: timeoutSignal(timeoutMs, init.signal) });
54
+ }
55
+ catch (err) {
56
+ if (isTimeoutAbort(err))
57
+ throw timeoutError(label, url, timeoutMs);
58
+ throw err;
59
+ }
60
+ }
61
+ const VERB_OPTS_INDEX = { get: 0, post: 1, put: 1, patch: 1, delete: 1 };
62
+ /**
63
+ * Wraps a zotero-api-client instance so every request carries a fresh AbortSignal.timeout
64
+ * (the client forwards `signal` to fetch). Chain methods return wrapped chains; terminal verbs get
65
+ * the signal merged into their options, and a timeout becomes a RequestTimeoutError.
66
+ */
67
+ export function withRequestTimeout(api, timeoutMs) {
68
+ return new Proxy(api, {
69
+ get(target, prop, receiver) {
70
+ const value = Reflect.get(target, prop, receiver);
71
+ if (typeof value !== "function" || typeof prop !== "string")
72
+ return value;
73
+ const optsIndex = VERB_OPTS_INDEX[prop];
74
+ if (optsIndex === undefined) {
75
+ return (...args) => {
76
+ const result = value.apply(target, args);
77
+ return result && typeof result === "object" ? withRequestTimeout(result, timeoutMs) : result;
78
+ };
79
+ }
80
+ return async (...args) => {
81
+ const bound = timeoutMs ?? TIMEOUTS.zoteroApiMs;
82
+ const opts = (args[optsIndex] ?? {});
83
+ const callArgs = [...args];
84
+ callArgs[optsIndex] = { ...opts, signal: timeoutSignal(bound, opts.signal) };
85
+ try {
86
+ return await value.apply(target, callArgs);
87
+ }
88
+ catch (err) {
89
+ if (isTimeoutAbort(err)) {
90
+ throw new RequestTimeoutError(`Zotero API request (${prop.toUpperCase()}) timed out after ${seconds(bound)} (api.zotero.org)`);
91
+ }
92
+ throw err;
93
+ }
94
+ };
95
+ },
96
+ });
97
+ }
@@ -1,15 +1,31 @@
1
+ import { InvalidJsonResponseError, readJsonObject } from "./json-response.js";
1
2
  import { fetchWithRetry } from "./fetch-retry.js";
2
3
  import { logger } from "./logger.js";
3
4
  import { errorMessage } from "./error-formatter.js";
5
+ import { SETTINGS, whereToSet } from "./error-hints.js";
6
+ /** Per-item reason when the lookup is disabled; tools add UNPAYWALL_EMAIL_HINT once at top level. */
7
+ export const UNPAYWALL_EMAIL_MISSING = "UNPAYWALL_EMAIL is not set or invalid in the server configuration: open-access PDF lookup skipped.";
8
+ export const UNPAYWALL_EMAIL_HINT = "No PDF lookup was done (the items themselves are fine). Tell the user that open-access PDF lookup needs UNPAYWALL_EMAIL " +
9
+ `(a valid email address of theirs, which Unpaywall requires with each request) in ${whereToSet(SETTINGS.unpaywallEmail)}, and then run find_and_attach_pdfs.`;
4
10
  const SKIPPED_RESULT = {
5
11
  found: false,
6
12
  pdf_url: null,
7
13
  source: null,
8
14
  license: null,
9
15
  oa_status: null,
10
- warning: "UNPAYWALL_EMAIL environment variable is not set or invalid. Set it to a valid email address to enable OA PDF lookup.",
16
+ warning: UNPAYWALL_EMAIL_MISSING,
11
17
  };
12
- const UNAVAILABLE_RESULT = { found: false, pdf_url: null, source: null, license: null, oa_status: null };
18
+ /** Unpaywall unreachable or failing: not the same as "no open access copy", so say so. */
19
+ function unavailableResult(what) {
20
+ return {
21
+ found: false,
22
+ pdf_url: null,
23
+ source: null,
24
+ license: null,
25
+ oa_status: null,
26
+ warning: `Unpaywall lookup failed (${what}): this does not mean there is no open access PDF. Retry later with find_and_attach_pdfs.`,
27
+ };
28
+ }
13
29
  function isValidEmail(email) {
14
30
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
15
31
  }
@@ -28,18 +44,31 @@ export async function lookupOaPdfWithFallbacks(doi) {
28
44
  const url = `https://api.unpaywall.org/v2/${encodeURIComponent(doi)}?email=${encodeURIComponent(email)}`;
29
45
  let response;
30
46
  try {
31
- response = await fetchWithRetry(url);
47
+ response = await fetchWithRetry(url, undefined, { label: "Unpaywall lookup" });
32
48
  }
33
49
  catch (err) {
34
50
  const detail = errorMessage(err);
35
51
  logger.error("Unpaywall API network error", { doi, error: detail });
36
- return { primary: { ...UNAVAILABLE_RESULT }, fallback_urls: [] };
52
+ return { primary: unavailableResult(detail), fallback_urls: [] };
37
53
  }
38
54
  if (!response.ok) {
39
55
  logger.error("Unpaywall API error", { doi, status: response.status });
40
- return { primary: { ...UNAVAILABLE_RESULT }, fallback_urls: [] };
56
+ if (response.status === 404) {
57
+ // Unpaywall does not know the DOI (not a Crossref DOI, e.g. DataCite): nothing to look up
58
+ return { primary: { found: false, pdf_url: null, source: null, license: null, oa_status: null }, fallback_urls: [] };
59
+ }
60
+ return { primary: unavailableResult(`HTTP ${response.status}`), fallback_urls: [] };
61
+ }
62
+ let data;
63
+ try {
64
+ data = await readJsonObject(response);
65
+ }
66
+ catch (err) {
67
+ if (!(err instanceof InvalidJsonResponseError))
68
+ throw err;
69
+ logger.error("Unpaywall API returned an invalid response", { doi, error: err.message });
70
+ return { primary: unavailableResult(err.message), fallback_urls: [] };
41
71
  }
42
- const data = (await response.json());
43
72
  const locations = data.is_oa
44
73
  ? [data.best_oa_location, ...(data.oa_locations ?? [])].filter((l) => l !== null && l !== undefined)
45
74
  : [];
@@ -1,6 +1,7 @@
1
+ import { fetchWithTimeout, TIMEOUTS } from "./timeouts.js";
1
2
  export async function putFulltext(libraryType, userId, itemKey, apiKey, content, totalPages) {
2
3
  const url = `https://api.zotero.org/${libraryType}s/${userId}/items/${itemKey}/fulltext`;
3
- const response = await fetch(url, {
4
+ const response = await fetchWithTimeout(url, {
4
5
  method: "PUT",
5
6
  headers: {
6
7
  "Content-Type": "application/json",
@@ -11,7 +12,7 @@ export async function putFulltext(libraryType, userId, itemKey, apiKey, content,
11
12
  indexedPages: totalPages,
12
13
  totalPages,
13
14
  }),
14
- });
15
+ }, TIMEOUTS.zoteroApiMs, "Zotero fulltext upload");
15
16
  if (response.status === 204) {
16
17
  return { success: true };
17
18
  }
@@ -1,7 +1,8 @@
1
1
  import { z } from "zod";
2
2
  /** Zotero object keys (items, collections): 8 characters from this alphabet, never 0, 1, O or lowercase. */
3
3
  export const ZOTERO_KEY = /^[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}$/;
4
- export const INVALID_KEY_MESSAGE = "Invalid Zotero key: expected 8 characters from 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ";
4
+ export const INVALID_KEY_MESSAGE = "Invalid Zotero key: expected 8 characters from 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ (e.g. ABCD2345). " +
5
+ "Use a key returned by search_library, get_collection_items, get_collections or the add_* tools; never guess keys";
5
6
  /**
6
7
  * Schema for every key argument of every tool. Keys end up in API URL paths (zotero-api-client does not
7
8
  * encode path segments, and URL parsing resolves "..") and in itemKey filters (ignored by the API when
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@xevos117/mcp-zotero",
3
3
  "type": "module",
4
- "version": "2.0.1",
4
+ "version": "2.0.3",
5
+ "mcpName": "io.github.Xevos117/mcp-zotero",
5
6
  "description": "Unofficial MCP server for Zotero integration with citation management",
6
7
  "repository": {
7
8
  "type": "git",
@@ -26,9 +27,12 @@
26
27
  "test:watch": "vitest",
27
28
  "build": "tsc",
28
29
  "smoke": "node scripts/smoke-stdio.mjs",
30
+ "bundle": "node scripts/build-mcpb.mjs",
29
31
  "start": "node build/server.js",
30
32
  "prepublishOnly": "npm test && npm run build",
31
- "test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts"
33
+ "test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts",
34
+ "test:live": "npm run build && vitest run --config vitest.live.config.ts",
35
+ "test:live:cleanup": "node test/live/cleanup.mjs"
32
36
  },
33
37
  "keywords": [
34
38
  "mcp",
@@ -53,7 +57,12 @@
53
57
  "zotero-api-client": "^0.51.0"
54
58
  },
55
59
  "devDependencies": {
60
+ "@anthropic-ai/claude-agent-sdk": "^0.3.296",
61
+ "@anthropic-ai/mcpb": "2.1.2",
56
62
  "@types/node": "^26.6.4",
63
+ "ajv": "^8.20.0",
64
+ "ajv-formats": "^3.0.1",
65
+ "citeproc": "^2.4.63",
57
66
  "typescript": "^7.0.2",
58
67
  "vitest": "^5.0.3"
59
68
  }