@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
|
@@ -3,9 +3,10 @@ import { zoteroKey } from "../utils/zotero-keys.js";
|
|
|
3
3
|
import { formatJsonResult, formatErrorResponse, errorMessage, logToolError } from "../utils/error-formatter.js";
|
|
4
4
|
import { downloadAndUploadPdf } from "../utils/pdf-uploader.js";
|
|
5
5
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
6
|
+
import { COLLECTION_KEY_HINT, ITEM_KEY_HINT, MISSING_API_KEY, MISSING_API_KEY_HINT, describeAnyError, zoteroStatusHint } from "../utils/error-hints.js";
|
|
6
7
|
export const toolConfig = {
|
|
7
8
|
name: "import_pdf_to_zotero",
|
|
8
|
-
description: "Download a PDF from a URL and upload it to Zotero storage as an imported_url attachment. Unlike linked URL attachments, imported files are stored in Zotero's storage and become fulltext-indexed (searchable via get_item_fulltext). Use this when you need the PDF content to be indexed by Zotero.",
|
|
9
|
+
description: "Download a PDF from a URL and upload it to Zotero storage as an imported_url attachment. Unlike linked URL attachments, imported files are stored in Zotero's storage and become fulltext-indexed (searchable via get_item_fulltext). Use this when you need the PDF content to be indexed by Zotero. The URL must lead straight to the PDF file (no login, paywall or HTML landing page); files up to 100 MB, counted against the user's Zotero file storage. For items with a DOI, find_and_attach_pdfs finds open-access PDFs by itself.",
|
|
9
10
|
inputSchema: {
|
|
10
11
|
url: z.string().url().describe("URL of the PDF to download and import"),
|
|
11
12
|
filename: z
|
|
@@ -20,7 +21,7 @@ export const toolConfig = {
|
|
|
20
21
|
.string()
|
|
21
22
|
.optional()
|
|
22
23
|
.default("application/pdf")
|
|
23
|
-
.describe('MIME type
|
|
24
|
+
.describe('MIME type recorded on the attachment (default: "application/pdf"). This tool imports PDFs only: the downloaded file must start with %PDF- or the import is rejected, whatever this value says. Full text is extracted only for "application/pdf".'),
|
|
24
25
|
parent_item: zoteroKey()
|
|
25
26
|
.optional()
|
|
26
27
|
.describe("Item key of the parent item. If provided, the attachment becomes a child of that item."),
|
|
@@ -36,12 +37,33 @@ export const toolConfig = {
|
|
|
36
37
|
},
|
|
37
38
|
};
|
|
38
39
|
const ImportPdfSchema = z.object(toolConfig.inputSchema);
|
|
40
|
+
/** Next step for an HTTP error from the site serving the PDF. */
|
|
41
|
+
function downloadFailureHint(status) {
|
|
42
|
+
if (status === 401 || status === 402 || status === 403) {
|
|
43
|
+
return "The site refuses the download (login, paywall or bot protection): try an open-access copy (find_and_attach_pdfs for an item with a DOI, or a repository link), or the user downloads it in a browser and adds it in Zotero desktop.";
|
|
44
|
+
}
|
|
45
|
+
if (status === 404 || status === 410)
|
|
46
|
+
return "The URL does not exist: check it for typos or find the current PDF link.";
|
|
47
|
+
if (status === 429 || (status !== undefined && status >= 500))
|
|
48
|
+
return "The site is busy or failing: wait a minute and retry once.";
|
|
49
|
+
return "Check that the URL leads straight to the PDF file and retry.";
|
|
50
|
+
}
|
|
51
|
+
/** Zotero storage step failed after the attachment record was created: say so, and what to do next. */
|
|
52
|
+
function uploadFailureHint(status, itemKey) {
|
|
53
|
+
const next = status === 401 || status === 403
|
|
54
|
+
? zoteroStatusHint(status).hint
|
|
55
|
+
: "Retry once after a short wait (timeouts and 5xx errors are usually temporary).";
|
|
56
|
+
return {
|
|
57
|
+
...(itemKey ? { item_key: itemKey } : {}),
|
|
58
|
+
hint: `${itemKey ? "An attachment record (item_key) was created without the file; a retry creates a new one, so mention it to the user (they can delete it in Zotero). " : ""}${next}`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
39
61
|
export async function handleImportPdfToZotero(zoteroApi, userId, args) {
|
|
40
62
|
const { url, filename, title, content_type, parent_item, collections, tags, library_type, library_id } = ImportPdfSchema.parse(args);
|
|
41
63
|
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
42
64
|
const apiKey = process.env.ZOTERO_API_KEY;
|
|
43
65
|
if (!apiKey) {
|
|
44
|
-
return formatErrorResponse(
|
|
66
|
+
return formatErrorResponse(MISSING_API_KEY, { hint: MISSING_API_KEY_HINT });
|
|
45
67
|
}
|
|
46
68
|
try {
|
|
47
69
|
const result = await downloadAndUploadPdf(zoteroApi, libraryType, libraryId, apiKey, {
|
|
@@ -59,45 +81,59 @@ export async function handleImportPdfToZotero(zoteroApi, userId, args) {
|
|
|
59
81
|
return formatErrorResponse("Network error downloading file", {
|
|
60
82
|
url,
|
|
61
83
|
details: result.error.networkDetail,
|
|
62
|
-
suggestion: "The
|
|
84
|
+
suggestion: "The site may be unreachable, block automated downloads or require a login. Retry once if it looks temporary (timeout, connection reset); otherwise try a direct PDF link from an open source (PubMed Central, arXiv, an institutional repository, the publisher's PDF link) or, for an item with a DOI, find_and_attach_pdfs. If the user has the PDF, they can add it in Zotero desktop.",
|
|
63
85
|
});
|
|
64
86
|
case "download_failed":
|
|
65
|
-
return formatErrorResponse(
|
|
87
|
+
return formatErrorResponse(`Failed to download file from URL (HTTP ${result.error.status})`, {
|
|
66
88
|
url,
|
|
67
89
|
status: result.error.status,
|
|
90
|
+
suggestion: downloadFailureHint(result.error.status),
|
|
68
91
|
});
|
|
69
92
|
case "file_too_large":
|
|
70
93
|
return formatErrorResponse("File exceeds 100 MB limit", {
|
|
71
94
|
size_bytes: result.error.sizeBytes,
|
|
95
|
+
suggestion: "This tool imports files up to 100 MB; nothing was created. The user can add larger files in Zotero desktop.",
|
|
72
96
|
});
|
|
73
97
|
case "storage_quota_exceeded":
|
|
74
98
|
return formatErrorResponse("Zotero storage quota exceeded", {
|
|
75
99
|
status: result.error.status,
|
|
76
100
|
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.",
|
|
77
101
|
item_key: result.itemKey,
|
|
78
|
-
note: "An attachment
|
|
102
|
+
note: "An attachment record (item_key) was created without the file. Tell the user: they can delete it in Zotero (or with delete_items, if deletion is enabled) and retry after freeing storage.",
|
|
79
103
|
});
|
|
80
104
|
case "auth_failed":
|
|
81
105
|
return formatErrorResponse("Upload authorization failed", {
|
|
82
106
|
status: result.error.status,
|
|
107
|
+
details: result.error.networkDetail, // e.g. the timeout or connection error
|
|
108
|
+
...uploadFailureHint(result.error.status, result.itemKey),
|
|
83
109
|
});
|
|
84
110
|
case "upload_failed":
|
|
85
111
|
return formatErrorResponse("File upload failed", {
|
|
86
112
|
status: result.error.status,
|
|
113
|
+
details: result.error.networkDetail, // e.g. the timeout or connection error
|
|
114
|
+
...uploadFailureHint(result.error.status, result.itemKey),
|
|
87
115
|
});
|
|
88
116
|
case "not_pdf":
|
|
89
117
|
return formatErrorResponse("Downloaded file is not a valid PDF", {
|
|
90
118
|
url,
|
|
91
119
|
details: result.error.message,
|
|
92
|
-
suggestion: "The URL may redirect to an HTML page or require browser access
|
|
120
|
+
suggestion: "Nothing was created. The URL may redirect to an HTML page or require browser access: use the direct PDF link (on arXiv /pdf/ instead of /abs/), or for an item with a DOI try find_and_attach_pdfs.",
|
|
93
121
|
});
|
|
94
122
|
case "registration_failed":
|
|
95
123
|
return formatErrorResponse("Upload registration failed", {
|
|
96
124
|
status: result.error.status,
|
|
125
|
+
details: result.error.networkDetail, // e.g. the timeout or connection error
|
|
126
|
+
...uploadFailureHint(result.error.status, result.itemKey),
|
|
97
127
|
});
|
|
98
128
|
case "item_creation_failed":
|
|
99
129
|
return formatErrorResponse("import_pdf_to_zotero failed", {
|
|
100
130
|
details: result.error.message,
|
|
131
|
+
...(result.error.status !== undefined ? { status: result.error.status } : {}),
|
|
132
|
+
hint: result.error.status !== undefined && result.error.status !== 400
|
|
133
|
+
? `Nothing was created. ${zoteroStatusHint(result.error.status).hint}`
|
|
134
|
+
: parent_item
|
|
135
|
+
? `Zotero refused to create the attachment (details). parent_item must be an existing regular item, not an attachment or note: ${ITEM_KEY_HINT}`
|
|
136
|
+
: `Zotero refused to create the attachment (details). Check the collections keys: ${COLLECTION_KEY_HINT}`,
|
|
101
137
|
});
|
|
102
138
|
}
|
|
103
139
|
}
|
|
@@ -115,6 +151,7 @@ export async function handleImportPdfToZotero(zoteroApi, userId, args) {
|
|
|
115
151
|
}
|
|
116
152
|
catch (err) {
|
|
117
153
|
logToolError("import_pdf_to_zotero", err);
|
|
118
|
-
|
|
154
|
+
const { error, ...described } = describeAnyError(err);
|
|
155
|
+
return formatErrorResponse(`import_pdf_to_zotero failed: ${error}`, { ...described, details: errorMessage(err) });
|
|
119
156
|
}
|
|
120
157
|
}
|
package/build/tools/index.js
CHANGED
|
@@ -13,7 +13,8 @@ import { handleImportPdfToZotero, toolConfig as importPdfToZoteroConfig } from "
|
|
|
13
13
|
import { handleFindAndAttachPdfs, toolConfig as findAndAttachPdfsConfig } from "./find-and-attach-pdfs.js";
|
|
14
14
|
import { handleDeleteCollection, toolConfig as deleteCollectionConfig } from "./delete-collection.js";
|
|
15
15
|
import { handleDeleteItems, toolConfig as deleteItemsConfig } from "./delete-items.js";
|
|
16
|
-
import { logToolError } from "../utils/error-formatter.js";
|
|
16
|
+
import { formatErrorResponse, logToolError } from "../utils/error-formatter.js";
|
|
17
|
+
import { describeError } from "../utils/error-hints.js";
|
|
17
18
|
const tools = [
|
|
18
19
|
{ config: collectionsConfig, handler: handleGetCollections },
|
|
19
20
|
{ config: collectionItemsConfig, handler: handleGetCollectionItems },
|
|
@@ -32,14 +33,21 @@ const tools = [
|
|
|
32
33
|
{ config: deleteItemsConfig, handler: handleDeleteItems },
|
|
33
34
|
];
|
|
34
35
|
const handlers = new Map(tools.map(({ config, handler }) => [config.name, handler]));
|
|
35
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Run a tool handler; Zotero API errors it throws are logged here once for every tool. API, timeout and
|
|
38
|
+
* network errors become a structured error with a hint on what to do next; anything else (a bug) is rethrown.
|
|
39
|
+
*/
|
|
36
40
|
async function runTool(name, handler, zoteroApi, userId, args, unsafeOps) {
|
|
37
41
|
try {
|
|
38
42
|
return await handler(zoteroApi, userId, args, unsafeOps);
|
|
39
43
|
}
|
|
40
44
|
catch (err) {
|
|
41
45
|
logToolError(name, err);
|
|
42
|
-
|
|
46
|
+
const described = describeError(err);
|
|
47
|
+
if (!described)
|
|
48
|
+
throw err;
|
|
49
|
+
const { error, ...details } = described;
|
|
50
|
+
return formatErrorResponse(error, details);
|
|
43
51
|
}
|
|
44
52
|
}
|
|
45
53
|
export async function handleToolCall(name, args, zoteroApi, userId, unsafeOps = "none") {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
3
4
|
import { injectCitations } from "../citation-injector/injector.js";
|
|
4
5
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
5
6
|
export const toolConfig = {
|
|
@@ -7,13 +8,14 @@ export const toolConfig = {
|
|
|
7
8
|
description: `Replace <zcite> placeholder tags in a .docx file with native Zotero field codes that Zotero for Word can recognize and manage. The tool fetches item metadata from Zotero automatically — you only need to provide the .docx file.
|
|
8
9
|
|
|
9
10
|
WORKFLOW — how to create a Word document with live Zotero citations:
|
|
10
|
-
1. Collect item keys: use add_items_by_doi (or search_library for existing items)
|
|
11
|
+
1. Collect item keys: use add_items_by_doi or add_items (or search_library for existing items)
|
|
11
12
|
2. Generate .docx: create a Word document (e.g. with the "docx" npm package) with <zcite keys="ITEMKEY"/> placeholders where citations should appear. Each <zcite> MUST be in its own dedicated TextRun — do NOT mix it with surrounding text.
|
|
12
13
|
3. Call this tool with the .docx file path. It replaces every zcite tag with a Zotero field code and appends a bibliography.
|
|
13
14
|
4. Tell the user to open the file in Word with the Zotero plugin, click Zotero → Add/Edit Bibliography, then Zotero → Refresh.
|
|
14
15
|
|
|
15
16
|
CITATION STYLES — ask the user which style they want before generating:
|
|
16
17
|
- apa (default): author-year — (Smith, 2023)
|
|
18
|
+
- harvard / chicago: author-date — (Smith, 2023) / (Smith 2023)
|
|
17
19
|
- ieee / vancouver: numbered — [1], [2]
|
|
18
20
|
WARNING: for numbered styles every <zcite> MUST include a num="N" attribute with the sequential citation number. Without num, citations render as [?].
|
|
19
21
|
|
|
@@ -22,14 +24,14 @@ ZCITE TAG FORMAT:
|
|
|
22
24
|
Supported attributes (any order):
|
|
23
25
|
- keys (required): item key or comma-separated keys — "ABC12345" or "ABC12345,DEF67890"
|
|
24
26
|
- num: citation number for IEEE/Vancouver — "1" or "1,2" (required for numbered styles)
|
|
25
|
-
- locator: page reference — "pp. 12
|
|
26
|
-
- prefix: text before citation — "see
|
|
27
|
+
- locator: page reference — "12-15" (shown as "pp. 12–15"; other labels explicit, e.g. "chap. 3")
|
|
28
|
+
- prefix: text before citation — "see"
|
|
27
29
|
- suffix: text after citation — ", emphasis added"
|
|
28
30
|
|
|
29
31
|
OUTPUT:
|
|
30
|
-
A new .docx file (original filename with _cited suffix) with Zotero field codes and a ZOTERO_BIBL bibliography at the end.
|
|
32
|
+
A new .docx file (original filename with _cited suffix) with Zotero field codes and a ZOTERO_BIBL bibliography at the end. A cited key that is not in the library, or that is not a Zotero key (8 characters from 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ), is an error and no file is written.
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
WHERE THE FILE LIVES: this tool reads and writes files on the machine where this MCP server runs. If you create the .docx in a sandbox or cloud workspace that the server cannot see (for example code execution in Claude.ai or Claude Desktop, paths like /mnt/user-data/...), do not call this tool: use the zotero-mcp-integrations skill, which injects the citations inside the sandbox with metadata from get_items_details. If that skill is not installed, recommend it to the user: zotero-skill-mcp-integrations.zip from https://github.com/Xevos117/mcp-zotero/releases/latest.`,
|
|
33
35
|
inputSchema: {
|
|
34
36
|
file_path: z
|
|
35
37
|
.string()
|
|
@@ -43,15 +45,40 @@ NOTE: If the inject-citations skill is available, prefer the skill workflow (run
|
|
|
43
45
|
},
|
|
44
46
|
};
|
|
45
47
|
const InjectCitationsSchema = z.object(toolConfig.inputSchema);
|
|
48
|
+
const FILE_NOT_FOUND = "File not found on the machine where this MCP server runs. If the document is in a sandbox or cloud workspace " +
|
|
49
|
+
"(for example code execution in Claude.ai or Claude Desktop), this server cannot read it: use the " +
|
|
50
|
+
"zotero-mcp-integrations skill, which injects the citations inside the sandbox. If the skill is not installed, " +
|
|
51
|
+
"ask the user to add zotero-skill-mcp-integrations.zip from https://github.com/Xevos117/mcp-zotero/releases/latest.";
|
|
46
52
|
export async function handleInjectCitations(zoteroApi, userId, args) {
|
|
47
53
|
const { file_path, style, library_type, library_id } = InjectCitationsSchema.parse(args);
|
|
48
54
|
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
49
55
|
if (!file_path.endsWith(".docx")) {
|
|
50
56
|
return formatErrorResponse("File must be a .docx file", {
|
|
51
57
|
file_path,
|
|
58
|
+
hint: "Only Word .docx files are supported and the name must end in lowercase .docx: convert .doc, .odt or .rtf to .docx first, or create the document as .docx with <zcite> placeholders.",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await injectCitations(file_path, zoteroApi, libraryType, libraryId, style);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
// A path from a sandbox or cloud workspace (Claude.ai, Claude Desktop code execution) does not exist here:
|
|
67
|
+
// say so and point to the skill instead of a bare ENOENT.
|
|
68
|
+
const code = err.code;
|
|
69
|
+
if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
|
|
70
|
+
return formatErrorResponse(`Cannot read the file or write the _cited copy next to it (${code}).`, {
|
|
71
|
+
file_path,
|
|
72
|
+
hint: "Copy the .docx to a folder this server can write to (e.g. the user's Documents) and call again with that path.",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
if (code !== "ENOENT")
|
|
76
|
+
throw err;
|
|
77
|
+
return formatErrorResponse(FILE_NOT_FOUND, {
|
|
78
|
+
file_path,
|
|
79
|
+
...(isAbsolute(file_path) ? {} : { hint: "Pass an absolute path." }),
|
|
52
80
|
});
|
|
53
81
|
}
|
|
54
|
-
const result = await injectCitations(file_path, zoteroApi, libraryType, libraryId, style);
|
|
55
82
|
const responseObj = {
|
|
56
83
|
output_path: result.outputPath,
|
|
57
84
|
citations_found: result.found,
|
|
@@ -6,8 +6,8 @@ export const toolConfig = {
|
|
|
6
6
|
name: "search_library",
|
|
7
7
|
description: `Search your Zotero library or list items sorted by a field.
|
|
8
8
|
|
|
9
|
-
When 'query' is provided,
|
|
10
|
-
When 'query' is omitted, lists items sorted by the chosen field (default: dateAdded, descending)
|
|
9
|
+
When 'query' is provided, matches it against titles, creator names and years (not abstracts, tags or full text).
|
|
10
|
+
When 'query' is omitted, lists items sorted by the chosen field (default: dateAdded, descending).
|
|
11
11
|
|
|
12
12
|
Examples:
|
|
13
13
|
- Search: { "query": "deep learning" }
|
|
@@ -32,9 +32,11 @@ Use the returned item keys with get_items_details, get_item_fulltext, or inject_
|
|
|
32
32
|
.describe("Sort direction (default: desc)"),
|
|
33
33
|
limit: z
|
|
34
34
|
.number()
|
|
35
|
+
.int()
|
|
36
|
+
.min(1)
|
|
35
37
|
.optional()
|
|
36
38
|
.default(25)
|
|
37
|
-
.describe("Maximum number of items to return (default: 25
|
|
39
|
+
.describe("Maximum number of items to return, 1-100 (default: 25; above 100 returns 100)"),
|
|
38
40
|
...libraryArgsSchema,
|
|
39
41
|
},
|
|
40
42
|
};
|
|
@@ -59,11 +61,11 @@ export async function handleSearchLibrary(zoteroApi, userId, args) {
|
|
|
59
61
|
if (query) {
|
|
60
62
|
return formatEmptyResult("No results found", {
|
|
61
63
|
query,
|
|
62
|
-
suggestion: "Try
|
|
64
|
+
suggestion: "Nothing in this library matches the query in title, creator or year. Try fewer or different words (one author surname or a title keyword), or omit query to list recent items. For a group library pass library_type and library_id.",
|
|
63
65
|
});
|
|
64
66
|
}
|
|
65
67
|
return formatEmptyResult("No items found", {
|
|
66
|
-
suggestion: "Add some items
|
|
68
|
+
suggestion: "This library has no items yet. Add some with add_items_by_doi (items with a DOI) or add_items.",
|
|
67
69
|
});
|
|
68
70
|
}
|
|
69
71
|
const formatted = items.map((item) => ({
|
|
@@ -2,8 +2,19 @@ import { asArray } from "./pagination.js";
|
|
|
2
2
|
export function isPdfAttachment(item) {
|
|
3
3
|
return item.itemType === "attachment" && item.contentType === "application/pdf";
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* A link to a PDF on the web (linkMode "linked_url"): Zotero stores no file for it, so it has no
|
|
7
|
+
* full text on the server and cannot be opened or indexed in Zotero desktop.
|
|
8
|
+
*/
|
|
9
|
+
export function isLinkedUrl(item) {
|
|
10
|
+
return item.linkMode === "linked_url";
|
|
11
|
+
}
|
|
12
|
+
/** All PDF attachments (stored files and links) among the children of an item. */
|
|
13
|
+
export async function findPdfAttachments(zoteroApi, library, itemKey) {
|
|
14
|
+
const response = await zoteroApi.library(library.type, library.id).items(itemKey).children().get();
|
|
15
|
+
return asArray(response.getData()).filter(isPdfAttachment);
|
|
16
|
+
}
|
|
5
17
|
/** First PDF attachment among the children of an item, if any. */
|
|
6
18
|
export async function findPdfAttachment(zoteroApi, library, itemKey) {
|
|
7
|
-
|
|
8
|
-
return asArray(response.getData()).find(isPdfAttachment);
|
|
19
|
+
return (await findPdfAttachments(zoteroApi, library, itemKey))[0];
|
|
9
20
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ITEM_TYPE_FIELDS } from "./zotero-item-types.js";
|
|
1
|
+
import { ITEM_TYPE_CREATOR_TYPES, ITEM_TYPE_FIELDS } from "./zotero-item-types.js";
|
|
2
2
|
const CSL_TO_ZOTERO_TYPE = {
|
|
3
3
|
// Standard CSL types
|
|
4
4
|
"article": "journalArticle",
|
|
@@ -112,13 +112,25 @@ const CONTAINER_TITLE_FIELD = {
|
|
|
112
112
|
tvBroadcast: "programTitle",
|
|
113
113
|
podcast: "programTitle",
|
|
114
114
|
};
|
|
115
|
+
/**
|
|
116
|
+
* CSL name variables ⇄ Zotero creator types, as in Zotero's CSL mappings. Authors first: the
|
|
117
|
+
* order of the CSL arrays becomes the order of the Zotero creators.
|
|
118
|
+
*/
|
|
119
|
+
const CSL_NAME_CREATOR_TYPES = [
|
|
120
|
+
["author", "author"],
|
|
121
|
+
["editor", "editor"],
|
|
122
|
+
["container-author", "bookAuthor"],
|
|
123
|
+
["collection-editor", "seriesEditor"],
|
|
124
|
+
["translator", "translator"],
|
|
125
|
+
];
|
|
115
126
|
export function cslToZoteroItem(csl, options) {
|
|
116
127
|
const itemType = (CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle");
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
128
|
+
// Authors always (as before); other roles only where the item type has them, or the API rejects the item.
|
|
129
|
+
// A CSL `literal` (institution, consortium) is Zotero's single-field name
|
|
130
|
+
const validCreatorTypes = ITEM_TYPE_CREATOR_TYPES[itemType];
|
|
131
|
+
const creators = CSL_NAME_CREATOR_TYPES.flatMap(([variable, creatorType]) => creatorType !== "author" && !validCreatorTypes?.has(creatorType)
|
|
132
|
+
? []
|
|
133
|
+
: (csl[variable] ?? []).map((a) => a.literal && !a.family ? { name: a.literal, creatorType } : { firstName: a.given ?? "", lastName: a.family ?? "", creatorType }));
|
|
122
134
|
const dateParts = csl.issued?.["date-parts"]?.[0];
|
|
123
135
|
const date = dateParts ? dateParts.join("-") : csl.issued?.literal ?? csl.issued?.raw ?? "";
|
|
124
136
|
const collections = [];
|
|
@@ -168,13 +180,26 @@ export function cslToZoteroItem(csl, options) {
|
|
|
168
180
|
}
|
|
169
181
|
return result;
|
|
170
182
|
}
|
|
183
|
+
/** Zotero creator → CSL name: a single-field name (institution) is a CSL `literal`. */
|
|
184
|
+
function cslName(c) {
|
|
185
|
+
if (c.name && !c.lastName)
|
|
186
|
+
return { literal: c.name };
|
|
187
|
+
return { family: c.lastName ?? "", given: c.firstName ?? "" };
|
|
188
|
+
}
|
|
189
|
+
/** CSL title of the container, from the item type's own field (bookTitle, proceedingsTitle, ...) or publicationTitle. */
|
|
190
|
+
function containerTitle(item) {
|
|
191
|
+
const field = CONTAINER_TITLE_FIELD[item.itemType];
|
|
192
|
+
const fields = item;
|
|
193
|
+
const value = (field && fields[field]) || item.publicationTitle;
|
|
194
|
+
return typeof value === "string" && value ? value : undefined;
|
|
195
|
+
}
|
|
171
196
|
export function zoteroItemToCsl(item) {
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
197
|
+
const names = {};
|
|
198
|
+
for (const [variable, creatorType] of CSL_NAME_CREATOR_TYPES) {
|
|
199
|
+
const list = (item.creators ?? []).filter((c) => c.creatorType === creatorType).map(cslName);
|
|
200
|
+
if (list.length > 0)
|
|
201
|
+
names[variable] = list;
|
|
202
|
+
}
|
|
178
203
|
let issued;
|
|
179
204
|
if (item.date) {
|
|
180
205
|
const parts = item.date.split("-").map(Number).filter((n) => !isNaN(n));
|
|
@@ -187,10 +212,10 @@ export function zoteroItemToCsl(item) {
|
|
|
187
212
|
return {
|
|
188
213
|
type: ZOTERO_TO_CSL_TYPE[item.itemType ?? ""] ?? "article-journal",
|
|
189
214
|
title: text(item.title),
|
|
190
|
-
|
|
215
|
+
...names,
|
|
191
216
|
issued,
|
|
192
217
|
DOI: text(item.DOI),
|
|
193
|
-
"container-title":
|
|
218
|
+
"container-title": containerTitle(item),
|
|
194
219
|
volume: text(item.volume),
|
|
195
220
|
issue: text(item.issue),
|
|
196
221
|
page: text(item.pages),
|
|
@@ -1,17 +1,34 @@
|
|
|
1
1
|
import { mapWithConcurrency } from "./concurrency.js";
|
|
2
2
|
import { fetchWithRetry } from "./fetch-retry.js";
|
|
3
3
|
import { errorMessage } from "./error-formatter.js";
|
|
4
|
+
import { InvalidJsonResponseError, readJsonObject } from "./json-response.js";
|
|
5
|
+
import { decodeEntitiesDeep } from "./html-entities.js";
|
|
4
6
|
export async function resolveDoi(doi) {
|
|
5
7
|
const response = await fetchWithRetry(`https://doi.org/${encodeURIComponent(doi)}`, {
|
|
6
8
|
headers: {
|
|
7
9
|
Accept: "application/vnd.citationstyles.csl+json",
|
|
8
10
|
},
|
|
9
11
|
redirect: "follow",
|
|
10
|
-
});
|
|
12
|
+
}, { label: "DOI resolution" });
|
|
11
13
|
if (!response.ok) {
|
|
12
|
-
|
|
14
|
+
const why = response.status === 404
|
|
15
|
+
? " (DOI not registered or mistyped)"
|
|
16
|
+
: response.status === 429 || response.status >= 500
|
|
17
|
+
? " (doi.org or the registry is busy or failing: retry later)"
|
|
18
|
+
: "";
|
|
19
|
+
const status = [response.status, response.statusText].filter(Boolean).join(" ");
|
|
20
|
+
throw new Error(`DOI resolution failed for ${doi}: ${status}${why}`);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
// the registries send some strings HTML-escaped ("Computers & Operations Research"): store the characters
|
|
24
|
+
return decodeEntitiesDeep(await readJsonObject(response));
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
// doi.org answers some unresolvable DOIs with an HTML page and status 200
|
|
28
|
+
if (err instanceof InvalidJsonResponseError)
|
|
29
|
+
throw new Error(`DOI not found or not resolvable: ${doi} (${err.message})`);
|
|
30
|
+
throw err;
|
|
13
31
|
}
|
|
14
|
-
return (await response.json());
|
|
15
32
|
}
|
|
16
33
|
export async function resolveDois(dois, concurrency) {
|
|
17
34
|
const settled = await mapWithConcurrency(dois, (doi) => resolveDoi(doi), concurrency);
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { fetchWithTimeout, TIMEOUTS } from "./timeouts.js";
|
|
1
2
|
const DEFAULT_MAX_RETRIES = 3;
|
|
2
3
|
const DEFAULT_BASE_DELAY_MS = 1000;
|
|
3
4
|
const DEFAULT_RETRYABLE_STATUSES = [429, 503];
|
|
@@ -21,7 +22,7 @@ export async function fetchWithRetry(url, init, options) {
|
|
|
21
22
|
const retryableStatuses = options?.retryableStatuses ?? DEFAULT_RETRYABLE_STATUSES;
|
|
22
23
|
let lastResponse;
|
|
23
24
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
24
|
-
const response = await
|
|
25
|
+
const response = await fetchWithTimeout(url, init ?? {}, options?.timeoutMs ?? TIMEOUTS.metadataMs, options?.label ?? "Request");
|
|
25
26
|
lastResponse = response;
|
|
26
27
|
if (!retryableStatuses.includes(response.status) || attempt === maxRetries) {
|
|
27
28
|
return response;
|