@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.
- package/README.md +133 -4
- package/build/citation-injector/citation-formatter.js +66 -7
- package/build/citation-injector/document-prefs.js +121 -0
- package/build/citation-injector/injector.js +54 -14
- package/build/server.js +10 -5
- package/build/tools/add-items-by-doi.js +15 -7
- package/build/tools/add-items.js +5 -2
- package/build/tools/add-linked-url-attachment.js +6 -2
- package/build/tools/create-collection.js +6 -2
- package/build/tools/delete-collection.js +7 -3
- package/build/tools/delete-items.js +7 -3
- package/build/tools/find-and-attach-pdfs.js +17 -9
- package/build/tools/get-collection-items.js +8 -7
- package/build/tools/get-collections.js +2 -2
- package/build/tools/get-item-fulltext.js +55 -17
- package/build/tools/get-items-details.js +7 -3
- package/build/tools/get-user-id.js +1 -1
- package/build/tools/import-pdf-to-zotero.js +45 -8
- package/build/tools/index.js +11 -3
- package/build/tools/inject-citations.js +33 -6
- package/build/tools/search-library.js +7 -5
- package/build/utils/attachments.js +13 -2
- package/build/utils/csl-to-zotero.js +39 -14
- package/build/utils/doi-resolver.js +20 -3
- package/build/utils/error-hints.js +179 -0
- package/build/utils/fetch-retry.js +2 -1
- package/build/utils/html-entities.js +55 -0
- package/build/utils/item-formatter.js +2 -1
- package/build/utils/json-response.js +29 -0
- package/build/utils/library-context.js +11 -11
- package/build/utils/pdf-uploader.js +41 -16
- package/build/utils/timeouts.js +97 -0
- package/build/utils/unpaywall.js +35 -6
- package/build/utils/zotero-fulltext.js +3 -2
- package/build/utils/zotero-keys.js +2 -1
- 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 "&lt;" becomes the text "<",
|
|
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
|
-
|
|
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("
|
|
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
|
|
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("
|
|
69
|
-
"
|
|
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
|
|
95
|
-
"
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
+
}
|
package/build/utils/unpaywall.js
CHANGED
|
@@ -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:
|
|
16
|
+
warning: UNPAYWALL_EMAIL_MISSING,
|
|
11
17
|
};
|
|
12
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
}
|