@xevos117/mcp-zotero 2.0.2 → 2.0.4
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 +50 -8
- package/build/citation-injector/citation-formatter.js +66 -7
- package/build/citation-injector/document-prefs.js +121 -0
- package/build/citation-injector/injector.js +35 -11
- package/build/server.js +7 -4
- package/build/tools/add-items-by-doi.js +14 -6
- 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 +38 -21
- package/build/tools/delete-items.js +59 -39
- 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 +27 -12
- 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 +41 -7
- 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/csl-to-zotero.js +107 -27
- package/build/utils/doi-resolver.js +10 -2
- package/build/utils/error-hints.js +179 -0
- package/build/utils/html-entities.js +55 -0
- package/build/utils/library-context.js +11 -11
- package/build/utils/pdf-uploader.js +2 -0
- package/build/utils/unpaywall.js +24 -5
- package/build/utils/zotero-keys.js +2 -1
- package/package.json +5 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { isZoteroApiError } from "../types/zotero-types.js";
|
|
2
|
+
import { errorMessage } from "./error-formatter.js";
|
|
3
|
+
import { RequestTimeoutError } from "./timeouts.js";
|
|
4
|
+
/**
|
|
5
|
+
* Wording shared by the tool errors: each one says what happened, what the model can do next and,
|
|
6
|
+
* when the fix is on the user's side, what to tell the user. No secrets: status codes and hosts only.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Server settings as the user sees them: the variable name, and the title of the same setting in the Claude Desktop
|
|
10
|
+
* extension (user_config of manifest.json), which has no env block.
|
|
11
|
+
*/
|
|
12
|
+
export const SETTINGS = {
|
|
13
|
+
apiKey: { env: "ZOTERO_API_KEY", title: "Zotero API key" },
|
|
14
|
+
userId: { env: "ZOTERO_USER_ID", title: "Zotero user ID" },
|
|
15
|
+
libraryId: { env: "ZOTERO_LIBRARY_ID", title: "Group library ID" },
|
|
16
|
+
unpaywallEmail: { env: "UNPAYWALL_EMAIL", title: "Email for Unpaywall" },
|
|
17
|
+
unsafeOperations: { env: "UNSAFE_OPERATIONS", title: "Deletions allowed" },
|
|
18
|
+
};
|
|
19
|
+
/** Where the user changes these settings, for every "tell the user" hint: extension or MCP config, then a restart. */
|
|
20
|
+
export function whereToSet(...settings) {
|
|
21
|
+
const titles = settings.map((s) => `"${s.title}"`).join(" / ");
|
|
22
|
+
const envs = settings.map((s) => s.env).join(" / ");
|
|
23
|
+
return (`the server's settings: with the Claude Desktop extension, ${titles} in Settings → Extensions → MCP Zotero; ` +
|
|
24
|
+
`in other clients, ${envs} in the env block of this server in the MCP config (e.g. claude_desktop_config.json or .mcp.json). ` +
|
|
25
|
+
"Then restart the client");
|
|
26
|
+
}
|
|
27
|
+
export const API_KEYS_PAGE = "https://www.zotero.org/settings/keys";
|
|
28
|
+
/** Hint for an argument that must be an existing item key. */
|
|
29
|
+
export const ITEM_KEY_HINT = "Get valid item keys from search_library or get_collection_items (for a group library pass the same library_type and library_id); never guess keys.";
|
|
30
|
+
/** Hint for an argument that must be an existing collection key. */
|
|
31
|
+
export const COLLECTION_KEY_HINT = "Get valid collection keys from get_collections (for a group library pass the same library_type and library_id); never guess keys.";
|
|
32
|
+
export const MISSING_API_KEY = "ZOTERO_API_KEY is not set in the server's environment";
|
|
33
|
+
export const MISSING_API_KEY_HINT = `Tell the user to create a Zotero API key at ${API_KEYS_PAGE} and set it in ${whereToSet(SETTINGS.apiKey)}.`;
|
|
34
|
+
const ACCESS_HINT = "Retrying or using other tools will not help. Tell the user to check the API key at " +
|
|
35
|
+
`${API_KEYS_PAGE}: it needs "Allow library access", "Allow write access" for tools that add or delete, ` +
|
|
36
|
+
"and access to the group for a group library; ZOTERO_USER_ID must be the userID shown on that page. " +
|
|
37
|
+
`They fix it by updating the key / user ID in ${whereToSet(SETTINGS.apiKey, SETTINGS.userId)}.`;
|
|
38
|
+
const STATUS_NAMES = {
|
|
39
|
+
400: "Bad Request",
|
|
40
|
+
401: "Unauthorized",
|
|
41
|
+
403: "Forbidden",
|
|
42
|
+
404: "Not Found",
|
|
43
|
+
409: "Conflict",
|
|
44
|
+
412: "Precondition Failed",
|
|
45
|
+
413: "Request Entity Too Large",
|
|
46
|
+
429: "Too Many Requests",
|
|
47
|
+
500: "Internal Server Error",
|
|
48
|
+
502: "Bad Gateway",
|
|
49
|
+
503: "Service Unavailable",
|
|
50
|
+
504: "Gateway Timeout",
|
|
51
|
+
};
|
|
52
|
+
function statusLabel(status) {
|
|
53
|
+
return STATUS_NAMES[status] ? `HTTP ${status} ${STATUS_NAMES[status]}` : `HTTP ${status}`;
|
|
54
|
+
}
|
|
55
|
+
function retryWait(retryAfterS) {
|
|
56
|
+
return retryAfterS !== undefined ? `${retryAfterS} s` : "about a minute";
|
|
57
|
+
}
|
|
58
|
+
/** What a Zotero Web API status means for the model: the error text and the next step. */
|
|
59
|
+
export function zoteroStatusHint(status, retryAfterS) {
|
|
60
|
+
const label = statusLabel(status);
|
|
61
|
+
if (status === 401 || status === 403) {
|
|
62
|
+
return {
|
|
63
|
+
error: `Zotero refused access (${label}): the API key is invalid or revoked, cannot access this library, or lacks write access for this operation.`,
|
|
64
|
+
hint: ACCESS_HINT,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (status === 404) {
|
|
68
|
+
return {
|
|
69
|
+
error: `Zotero returned ${label}: the library, item or collection does not exist or this API key cannot see it.`,
|
|
70
|
+
hint: `Do not retry with the same item or collection key. ${ITEM_KEY_HINT} Collection keys come from get_collections. If every call fails like this, the configured library id is wrong: tell the user to check the user ID (the userID at ${API_KEYS_PAGE}) or group library ID in ${whereToSet(SETTINGS.userId, SETTINGS.libraryId)}.`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (status === 409 || status === 412) {
|
|
74
|
+
return {
|
|
75
|
+
error: `The Zotero library changed or was locked during the request (${label}).`,
|
|
76
|
+
hint: "Retry the same call once.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (status === 429) {
|
|
80
|
+
return {
|
|
81
|
+
error: `Zotero rate limit reached (${label}).`,
|
|
82
|
+
hint: `Wait ${retryWait(retryAfterS)} and retry once; avoid parallel calls and send fewer items per call.`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (status >= 500) {
|
|
86
|
+
return {
|
|
87
|
+
error: `The Zotero API is temporarily unavailable (${label}).`,
|
|
88
|
+
hint: `Wait ${retryWait(retryAfterS)} and retry once. If it keeps failing, tell the user that Zotero's servers seem to have problems and to try again later.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (status === 400 || status === 413) {
|
|
92
|
+
return {
|
|
93
|
+
error: `Zotero rejected the request (${label}).`,
|
|
94
|
+
hint: "Retrying unchanged will fail again: check the arguments (and details, if present), fix them and retry; for large batches send fewer items per call.",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return { error: `Zotero API error (${label}).`, hint: "Retry once; if it fails again, tell the user what failed." };
|
|
98
|
+
}
|
|
99
|
+
const NETWORK_ERROR = /fetch failed|socket hang up|ENOTFOUND|EAI_AGAIN|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH/;
|
|
100
|
+
/**
|
|
101
|
+
* A failed connection (DNS, refused, reset), as thrown by fetch: a TypeError, the cause carrying the code. Only these
|
|
102
|
+
* markers count: a bug's TypeError that merely mentions a network is not reported as a retryable network error.
|
|
103
|
+
*/
|
|
104
|
+
function networkDetail(err) {
|
|
105
|
+
if (!(err instanceof TypeError))
|
|
106
|
+
return undefined;
|
|
107
|
+
const cause = err.cause;
|
|
108
|
+
const causeMessage = cause instanceof Error ? cause.message : undefined;
|
|
109
|
+
const text = causeMessage ? `${err.message} (${causeMessage})` : err.message;
|
|
110
|
+
return NETWORK_ERROR.test(text) ? text : undefined;
|
|
111
|
+
}
|
|
112
|
+
function retryAfterSeconds(response) {
|
|
113
|
+
const raw = response.headers?.get?.("Retry-After");
|
|
114
|
+
const seconds = raw ? Number(raw) : NaN;
|
|
115
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Short plain-text reason from a Zotero error body ("Write access denied"), never an HTML page. Library paths and
|
|
119
|
+
* long digit runs (user and group ids) are masked: the account behind the server must not be identifiable.
|
|
120
|
+
*/
|
|
121
|
+
function apiReason(err) {
|
|
122
|
+
const reason = err.reason;
|
|
123
|
+
if (typeof reason !== "string")
|
|
124
|
+
return undefined;
|
|
125
|
+
const text = reason
|
|
126
|
+
.trim()
|
|
127
|
+
.replace(/\b(users|groups)\/\d+/g, "$1/<id>")
|
|
128
|
+
.replace(/\b\d{5,}\b/g, "<id>");
|
|
129
|
+
if (!text || /^</.test(text))
|
|
130
|
+
return undefined;
|
|
131
|
+
return text.length > 300 ? `${text.slice(0, 300)}…` : text;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Error text and next step for a failure a tool did not handle itself: Zotero API errors, timeouts and
|
|
135
|
+
* network errors. Undefined for anything else (a bug), which keeps its own message.
|
|
136
|
+
*/
|
|
137
|
+
export function describeError(err) {
|
|
138
|
+
if (isZoteroApiError(err)) {
|
|
139
|
+
const status = err.response.status;
|
|
140
|
+
const retry_after_s = retryAfterSeconds(err.response);
|
|
141
|
+
const { error, hint } = zoteroStatusHint(status, retry_after_s);
|
|
142
|
+
const details = apiReason(err);
|
|
143
|
+
return {
|
|
144
|
+
error,
|
|
145
|
+
hint,
|
|
146
|
+
http_status: status,
|
|
147
|
+
...(retry_after_s !== undefined ? { retry_after_s } : {}),
|
|
148
|
+
...(details ? { details } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (err instanceof RequestTimeoutError) {
|
|
152
|
+
return {
|
|
153
|
+
error: err.message,
|
|
154
|
+
hint: "Retry once. A write that timed out may still have been saved: check with search_library before repeating it, to avoid duplicates. " +
|
|
155
|
+
"If it keeps timing out, tell the user to check the network connection of the machine running this server.",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const network = networkDetail(err);
|
|
159
|
+
if (network) {
|
|
160
|
+
return {
|
|
161
|
+
error: `Network error: ${network}`,
|
|
162
|
+
hint: "Retry once. If it keeps failing, tell the user that the machine running this server cannot reach the service (internet connection, DNS, proxy or firewall).",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
/** describeError, falling back to the raw message (with no hint) for unexpected errors. */
|
|
168
|
+
export function describeAnyError(err) {
|
|
169
|
+
return describeError(err) ?? { error: errorMessage(err) };
|
|
170
|
+
}
|
|
171
|
+
/** Hint for a deletion blocked by UNSAFE_OPERATIONS: tell the user how to enable it, never work around it. */
|
|
172
|
+
export function unsafeOperationsHint(target) {
|
|
173
|
+
const values = target === "items"
|
|
174
|
+
? "'items' (allows delete_items) or 'all' (also delete_collection)"
|
|
175
|
+
: "'all' (allows delete_collection and delete_items; 'items' allows only delete_items)";
|
|
176
|
+
return ("Do not try to get around this with other tools (moving to the trash, editing, removing from collections). " +
|
|
177
|
+
`Tell the user that deleting is disabled in the server configuration and that, if they want to allow it, they can set ${values} ` +
|
|
178
|
+
`in ${whereToSet(SETTINGS.unsafeOperations)}, and then ask again.`);
|
|
179
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
import { InvalidJsonResponseError, readJsonObject } from "./json-response.js";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { isZoteroApiError } from "../types/zotero-types.js";
|
|
3
4
|
import { logger } from "./logger.js";
|
|
4
5
|
import { extractPdfText } from "./pdf-text-extractor.js";
|
|
5
6
|
import { putFulltext } from "./zotero-fulltext.js";
|
|
@@ -161,6 +162,7 @@ export async function downloadAndUploadPdf(zoteroApi, libraryType, userId, apiKe
|
|
|
161
162
|
error: {
|
|
162
163
|
code: "item_creation_failed",
|
|
163
164
|
message: `Failed to create attachment item: ${detail}`,
|
|
165
|
+
...(isZoteroApiError(err) ? { status: err.response.status } : {}),
|
|
164
166
|
networkDetail: detail,
|
|
165
167
|
},
|
|
166
168
|
};
|
package/build/utils/unpaywall.js
CHANGED
|
@@ -2,15 +2,30 @@ import { InvalidJsonResponseError, readJsonObject } from "./json-response.js";
|
|
|
2
2
|
import { fetchWithRetry } from "./fetch-retry.js";
|
|
3
3
|
import { logger } from "./logger.js";
|
|
4
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.`;
|
|
5
10
|
const SKIPPED_RESULT = {
|
|
6
11
|
found: false,
|
|
7
12
|
pdf_url: null,
|
|
8
13
|
source: null,
|
|
9
14
|
license: null,
|
|
10
15
|
oa_status: null,
|
|
11
|
-
warning:
|
|
16
|
+
warning: UNPAYWALL_EMAIL_MISSING,
|
|
12
17
|
};
|
|
13
|
-
|
|
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
|
+
}
|
|
14
29
|
function isValidEmail(email) {
|
|
15
30
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
16
31
|
}
|
|
@@ -34,11 +49,15 @@ export async function lookupOaPdfWithFallbacks(doi) {
|
|
|
34
49
|
catch (err) {
|
|
35
50
|
const detail = errorMessage(err);
|
|
36
51
|
logger.error("Unpaywall API network error", { doi, error: detail });
|
|
37
|
-
return { primary:
|
|
52
|
+
return { primary: unavailableResult(detail), fallback_urls: [] };
|
|
38
53
|
}
|
|
39
54
|
if (!response.ok) {
|
|
40
55
|
logger.error("Unpaywall API error", { doi, status: response.status });
|
|
41
|
-
|
|
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: [] };
|
|
42
61
|
}
|
|
43
62
|
let data;
|
|
44
63
|
try {
|
|
@@ -48,7 +67,7 @@ export async function lookupOaPdfWithFallbacks(doi) {
|
|
|
48
67
|
if (!(err instanceof InvalidJsonResponseError))
|
|
49
68
|
throw err;
|
|
50
69
|
logger.error("Unpaywall API returned an invalid response", { doi, error: err.message });
|
|
51
|
-
return { primary:
|
|
70
|
+
return { primary: unavailableResult(err.message), fallback_urls: [] };
|
|
52
71
|
}
|
|
53
72
|
const locations = data.is_oa
|
|
54
73
|
? [data.best_oa_location, ...(data.oa_locations ?? [])].filter((l) => l !== null && l !== undefined)
|
|
@@ -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.4",
|
|
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,6 +27,7 @@
|
|
|
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
33
|
"test:e2e": "npm run build && vitest run --config vitest.e2e.config.ts",
|
|
@@ -56,8 +58,10 @@
|
|
|
56
58
|
},
|
|
57
59
|
"devDependencies": {
|
|
58
60
|
"@anthropic-ai/claude-agent-sdk": "^0.3.296",
|
|
61
|
+
"@anthropic-ai/mcpb": "2.1.2",
|
|
59
62
|
"@types/node": "^26.6.4",
|
|
60
63
|
"ajv": "^8.20.0",
|
|
64
|
+
"ajv-formats": "^3.0.1",
|
|
61
65
|
"citeproc": "^2.4.63",
|
|
62
66
|
"typescript": "^7.0.2",
|
|
63
67
|
"vitest": "^5.0.3"
|