@xevos117/mcp-zotero 2.0.2 → 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 +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 +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 +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 +39 -14
- 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
|
@@ -4,9 +4,10 @@ import { isZoteroApiError } from "../types/zotero-types.js";
|
|
|
4
4
|
import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
|
|
5
5
|
import { canDeleteCollections } from "../utils/unsafe-operations.js";
|
|
6
6
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
7
|
+
import { COLLECTION_KEY_HINT, unsafeOperationsHint } from "../utils/error-hints.js";
|
|
7
8
|
export const toolConfig = {
|
|
8
9
|
name: "delete_collection",
|
|
9
|
-
description: "Delete a collection (folder) from your Zotero library. Items inside the collection are NOT deleted — they remain in your library.
|
|
10
|
+
description: "Delete a collection (folder) from your Zotero library; confirm with the user first. Items inside the collection are NOT deleted — they remain in your library. Disabled unless the server setting UNSAFE_OPERATIONS is 'all'.",
|
|
10
11
|
inputSchema: {
|
|
11
12
|
collection_key: zoteroKey()
|
|
12
13
|
.describe("Zotero collection key to delete. Get this from get_collections."),
|
|
@@ -18,7 +19,8 @@ export async function handleDeleteCollection(zoteroApi, userId, args, unsafeOps
|
|
|
18
19
|
const { collection_key, library_type, library_id } = DeleteCollectionSchema.parse(args);
|
|
19
20
|
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
20
21
|
if (!canDeleteCollections(unsafeOps)) {
|
|
21
|
-
return formatErrorResponse(
|
|
22
|
+
return formatErrorResponse(`Deletion of collections is not allowed: it needs UNSAFE_OPERATIONS 'all' in this server's configuration (it is '${unsafeOps}'). Nothing was deleted.`, {
|
|
23
|
+
hint: unsafeOperationsHint("collections"),
|
|
22
24
|
env_var: "UNSAFE_OPERATIONS",
|
|
23
25
|
current_value: unsafeOps,
|
|
24
26
|
required_values: ["all"],
|
|
@@ -34,6 +36,7 @@ export async function handleDeleteCollection(zoteroApi, userId, args, unsafeOps
|
|
|
34
36
|
if (version === null) {
|
|
35
37
|
return formatErrorResponse("Could not determine collection version", {
|
|
36
38
|
collection_key,
|
|
39
|
+
hint: "Nothing was deleted. Retry the same call once.",
|
|
37
40
|
});
|
|
38
41
|
}
|
|
39
42
|
await zoteroApi
|
|
@@ -53,10 +56,11 @@ export async function handleDeleteCollection(zoteroApi, userId, args, unsafeOps
|
|
|
53
56
|
return formatErrorResponse("Collection not found", {
|
|
54
57
|
collection_key,
|
|
55
58
|
status: "not_found",
|
|
59
|
+
hint: `Nothing was deleted. ${COLLECTION_KEY_HINT}`,
|
|
56
60
|
});
|
|
57
61
|
}
|
|
58
62
|
if (err.response.status === 412) {
|
|
59
|
-
return formatErrorResponse("Collection was modified by another client. Retry the
|
|
63
|
+
return formatErrorResponse("Collection was modified by another client, so it was not deleted. Retry the same call.", {
|
|
60
64
|
collection_key,
|
|
61
65
|
status: "version_conflict",
|
|
62
66
|
});
|
|
@@ -5,9 +5,10 @@ import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.
|
|
|
5
5
|
import { canDeleteItems } from "../utils/unsafe-operations.js";
|
|
6
6
|
import { fetchItemsByKeys } from "../utils/pagination.js";
|
|
7
7
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
8
|
+
import { ITEM_KEY_HINT, unsafeOperationsHint } from "../utils/error-hints.js";
|
|
8
9
|
export const toolConfig = {
|
|
9
10
|
name: "delete_items",
|
|
10
|
-
description: "Delete one or more items from your Zotero library: they are permanently deleted (not moved to the Zotero trash). Accepts up to 50 item keys per call.
|
|
11
|
+
description: "Delete one or more items from your Zotero library: they are permanently deleted (not moved to the Zotero trash), so confirm with the user first. Accepts up to 50 item keys per call. Disabled unless the server setting UNSAFE_OPERATIONS is 'items' or 'all'.",
|
|
11
12
|
inputSchema: {
|
|
12
13
|
item_keys: z
|
|
13
14
|
.array(zoteroKey())
|
|
@@ -22,10 +23,11 @@ export async function handleDeleteItems(zoteroApi, userId, args, unsafeOps = "no
|
|
|
22
23
|
const { item_keys, library_type, library_id } = DeleteItemsSchema.parse(args);
|
|
23
24
|
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
24
25
|
if (!canDeleteItems(unsafeOps)) {
|
|
25
|
-
return formatErrorResponse(
|
|
26
|
+
return formatErrorResponse(`Deletion of items is not allowed: destructive operations are disabled in this server's configuration (UNSAFE_OPERATIONS is '${unsafeOps}'). Nothing was deleted.`, {
|
|
26
27
|
env_var: "UNSAFE_OPERATIONS",
|
|
27
28
|
current_value: unsafeOps,
|
|
28
29
|
required_values: ["items", "all"],
|
|
30
|
+
hint: unsafeOperationsHint("items"),
|
|
29
31
|
});
|
|
30
32
|
}
|
|
31
33
|
try {
|
|
@@ -36,6 +38,7 @@ export async function handleDeleteItems(zoteroApi, userId, args, unsafeOps = "no
|
|
|
36
38
|
return formatErrorResponse("No items found for the given keys", {
|
|
37
39
|
item_keys,
|
|
38
40
|
status: "not_found",
|
|
41
|
+
hint: `Nothing was deleted: none of these keys exists in this library (already deleted, or in another library). ${ITEM_KEY_HINT}`,
|
|
39
42
|
});
|
|
40
43
|
}
|
|
41
44
|
// Use library version from response header (Last-Modified-Version),
|
|
@@ -43,6 +46,7 @@ export async function handleDeleteItems(zoteroApi, userId, args, unsafeOps = "no
|
|
|
43
46
|
if (libraryVersion === null) {
|
|
44
47
|
return formatErrorResponse("Could not determine library version", {
|
|
45
48
|
item_keys,
|
|
49
|
+
hint: "Nothing was deleted. Retry the same call once.",
|
|
46
50
|
});
|
|
47
51
|
}
|
|
48
52
|
// only keys the caller asked for, whatever the API returned
|
|
@@ -64,7 +68,7 @@ export async function handleDeleteItems(zoteroApi, userId, args, unsafeOps = "no
|
|
|
64
68
|
catch (err) {
|
|
65
69
|
if (isZoteroApiError(err)) {
|
|
66
70
|
if (err.response.status === 412) {
|
|
67
|
-
return formatErrorResponse("Items were modified by another client. Retry the
|
|
71
|
+
return formatErrorResponse("Items were modified by another client, so nothing was deleted. Retry the same call.", {
|
|
68
72
|
item_keys,
|
|
69
73
|
status: "version_conflict",
|
|
70
74
|
});
|
|
@@ -4,9 +4,11 @@ import { formatJsonResult, formatErrorResponse, formatEmptyResult, errorMessage,
|
|
|
4
4
|
import { attachOpenAccessPdfs } from "../utils/oa-pdf.js";
|
|
5
5
|
import { fetchAllPages, fetchItemsByKeys } from "../utils/pagination.js";
|
|
6
6
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
7
|
+
import { describeAnyError, MISSING_API_KEY, MISSING_API_KEY_HINT } from "../utils/error-hints.js";
|
|
8
|
+
import { UNPAYWALL_EMAIL_HINT, UNPAYWALL_EMAIL_MISSING } from "../utils/unpaywall.js";
|
|
7
9
|
export const toolConfig = {
|
|
8
10
|
name: "find_and_attach_pdfs",
|
|
9
|
-
description: "For each Zotero item, check Unpaywall for open access PDFs and attach them. Items must have a DOI. Uses the same source as Zotero Desktop's 'Find Available PDFs'.",
|
|
11
|
+
description: "For each Zotero item, check Unpaywall for open access PDFs and attach them. Items must have a DOI; needs UNPAYWALL_EMAIL in the server settings. Uploads use the user's Zotero file storage; dry_run only reports what is available. Uses the same source as Zotero Desktop's 'Find Available PDFs'.",
|
|
10
12
|
inputSchema: {
|
|
11
13
|
item_keys: z
|
|
12
14
|
.array(zoteroKey())
|
|
@@ -32,14 +34,14 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
32
34
|
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
33
35
|
// Validate: exactly one of item_keys or collection_key
|
|
34
36
|
if (item_keys && collection_key) {
|
|
35
|
-
return formatErrorResponse("Provide either item_keys or collection_key, not both");
|
|
37
|
+
return formatErrorResponse("Provide either item_keys or collection_key, not both: call once per selection.");
|
|
36
38
|
}
|
|
37
39
|
if (!item_keys && !collection_key) {
|
|
38
|
-
return formatErrorResponse("Provide either item_keys or collection_key");
|
|
40
|
+
return formatErrorResponse("Provide either item_keys or collection_key: item_keys for specific items (from search_library), collection_key for every item of a collection (from get_collections).");
|
|
39
41
|
}
|
|
40
42
|
const apiKey = process.env.ZOTERO_API_KEY;
|
|
41
43
|
if (!apiKey) {
|
|
42
|
-
return formatErrorResponse(
|
|
44
|
+
return formatErrorResponse(MISSING_API_KEY, { hint: MISSING_API_KEY_HINT });
|
|
43
45
|
}
|
|
44
46
|
try {
|
|
45
47
|
// 1. Resolve item keys
|
|
@@ -55,7 +57,11 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
55
57
|
keys = item_keys;
|
|
56
58
|
}
|
|
57
59
|
if (keys.length === 0) {
|
|
58
|
-
return formatEmptyResult("No items to process"
|
|
60
|
+
return formatEmptyResult("No items to process", {
|
|
61
|
+
suggestion: collection_key
|
|
62
|
+
? "The collection is empty or holds only attachments and notes: check it with get_collection_items."
|
|
63
|
+
: "item_keys is empty: pass at least one item key (from search_library or get_collection_items).",
|
|
64
|
+
});
|
|
59
65
|
}
|
|
60
66
|
// 2. Batch-fetch item metadata to get DOIs
|
|
61
67
|
const { items: metaItems } = await fetchItemsByKeys((params) => zoteroApi.library(libraryType, libraryId).items().get(params), keys);
|
|
@@ -71,9 +77,9 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
71
77
|
for (const key of keys) {
|
|
72
78
|
const item = itemMap.get(key);
|
|
73
79
|
if (!item)
|
|
74
|
-
preResults.set(key, { item_key: key, doi: null, status: "error", reason: "Item not found" });
|
|
80
|
+
preResults.set(key, { item_key: key, doi: null, status: "error", reason: "Item not found in this library" });
|
|
75
81
|
else if (!item.DOI)
|
|
76
|
-
preResults.set(key, { item_key: key, doi: null, status: "error", reason: "No DOI" });
|
|
82
|
+
preResults.set(key, { item_key: key, doi: null, status: "error", reason: "No DOI: Unpaywall needs one. Add the DOI to the item, or attach a PDF from a known URL with import_pdf_to_zotero" });
|
|
77
83
|
else
|
|
78
84
|
targets.push({ itemKey: key, doi: item.DOI });
|
|
79
85
|
}
|
|
@@ -118,14 +124,16 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
|
|
|
118
124
|
dry_run,
|
|
119
125
|
...(quotaExceeded > 0
|
|
120
126
|
? {
|
|
121
|
-
storage_quota_warning: "Zotero storage quota is full. Remaining PDF uploads were skipped.
|
|
127
|
+
storage_quota_warning: "Zotero storage quota is full. Remaining PDF uploads were skipped. Tell the user they can free up space or upgrade at https://www.zotero.org/settings/storage, then run this tool again.",
|
|
122
128
|
}
|
|
123
129
|
: {}),
|
|
130
|
+
...(results.some((r) => r.reason === UNPAYWALL_EMAIL_MISSING) ? { hint: UNPAYWALL_EMAIL_HINT } : {}),
|
|
124
131
|
results,
|
|
125
132
|
});
|
|
126
133
|
}
|
|
127
134
|
catch (err) {
|
|
128
135
|
logToolError("find_and_attach_pdfs", err);
|
|
129
|
-
|
|
136
|
+
const { error, ...described } = describeAnyError(err);
|
|
137
|
+
return formatErrorResponse(`find_and_attach_pdfs failed: ${error}`, { ...described, details: errorMessage(err) });
|
|
130
138
|
}
|
|
131
139
|
}
|
|
@@ -5,15 +5,16 @@ import { formatJsonResult, formatErrorResponse, formatEmptyResult } from "../uti
|
|
|
5
5
|
import { formatCreators, formatTags } from "../utils/item-formatter.js";
|
|
6
6
|
import { fetchAllPages } from "../utils/pagination.js";
|
|
7
7
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
8
|
+
import { COLLECTION_KEY_HINT } from "../utils/error-hints.js";
|
|
8
9
|
export const toolConfig = {
|
|
9
10
|
name: "get_collection_items",
|
|
10
|
-
description: "Get all items in a specific Zotero collection. Returns item keys, titles, authors, and dates. Use the collectionKey from get_collections. Use the returned item keys with get_items_details, get_item_fulltext, or inject_citations.",
|
|
11
|
+
description: "Get all items in a specific Zotero collection (only items directly in it, not those in its subcollections). Returns item keys, titles, authors, and dates. Use the collectionKey from get_collections. Use the returned item keys with get_items_details, get_item_fulltext, or inject_citations.",
|
|
11
12
|
inputSchema: {
|
|
12
|
-
collectionKey: zoteroKey().describe("
|
|
13
|
+
collectionKey: zoteroKey().describe("Collection key from get_collections"),
|
|
13
14
|
excludeAttachments: z
|
|
14
15
|
.boolean()
|
|
15
16
|
.default(true)
|
|
16
|
-
.describe("Exclude attachment and note items (default: true)"),
|
|
17
|
+
.describe("Exclude attachment and note items (default: true). Set false to list them too."),
|
|
17
18
|
...libraryArgsSchema,
|
|
18
19
|
},
|
|
19
20
|
};
|
|
@@ -26,7 +27,7 @@ export async function handleGetCollectionItems(zoteroApi, userId, args) {
|
|
|
26
27
|
if (!allItems || allItems.length === 0) {
|
|
27
28
|
return formatEmptyResult("Collection is empty", {
|
|
28
29
|
collectionKey,
|
|
29
|
-
suggestion: "
|
|
30
|
+
suggestion: "No items directly in this collection. Items of its subcollections are not listed: find them with get_collections (parentCollection = this key). To add items, pass collection_key to add_items_by_doi or add_items.",
|
|
30
31
|
status: "empty",
|
|
31
32
|
});
|
|
32
33
|
}
|
|
@@ -50,7 +51,7 @@ export async function handleGetCollectionItems(zoteroApi, userId, args) {
|
|
|
50
51
|
if (formatted.length === 0) {
|
|
51
52
|
return formatEmptyResult("No valid items found in collection", {
|
|
52
53
|
collectionKey,
|
|
53
|
-
suggestion: "
|
|
54
|
+
suggestion: "The collection contains only attachments or notes, which excludeAttachments=true (the default) hides: call again with excludeAttachments=false to list them.",
|
|
54
55
|
status: "invalid_items",
|
|
55
56
|
});
|
|
56
57
|
}
|
|
@@ -63,9 +64,9 @@ export async function handleGetCollectionItems(zoteroApi, userId, args) {
|
|
|
63
64
|
catch (err) {
|
|
64
65
|
if (isZoteroApiError(err)) {
|
|
65
66
|
if (err.response?.status === 404) {
|
|
66
|
-
return formatErrorResponse("Collection
|
|
67
|
+
return formatErrorResponse("Collection not found: no collection with this key in this library, or the API key cannot access it.", {
|
|
67
68
|
collectionKey,
|
|
68
|
-
suggestion:
|
|
69
|
+
suggestion: COLLECTION_KEY_HINT,
|
|
69
70
|
status: "not_found",
|
|
70
71
|
});
|
|
71
72
|
}
|
|
@@ -21,7 +21,7 @@ export async function handleGetCollections(zoteroApi, userId, args) {
|
|
|
21
21
|
const { items: allCollections } = await fetchAllPages((params) => zoteroApi.library(libraryType, libraryId).collections().get(params));
|
|
22
22
|
if (!Array.isArray(allCollections) || allCollections.length === 0) {
|
|
23
23
|
return formatEmptyResult("No collections found", {
|
|
24
|
-
suggestion: "
|
|
24
|
+
suggestion: "This library has no collections. Items can still be found with search_library; to organize them, create one with create_collection.",
|
|
25
25
|
helpUrl: "https://www.zotero.org/support/collections",
|
|
26
26
|
});
|
|
27
27
|
}
|
|
@@ -32,7 +32,7 @@ export async function handleGetCollections(zoteroApi, userId, args) {
|
|
|
32
32
|
: allCollections.filter((c) => !c.deleted);
|
|
33
33
|
if (collections.length === 0) {
|
|
34
34
|
return formatEmptyResult("No collections found", {
|
|
35
|
-
suggestion: "All collections are in the trash
|
|
35
|
+
suggestion: "All collections of this library are in the Zotero trash: call again with include_trashed=true to see them, or create a new one with create_collection.",
|
|
36
36
|
});
|
|
37
37
|
}
|
|
38
38
|
return formatJsonResult(collections);
|
|
@@ -6,16 +6,19 @@ import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.
|
|
|
6
6
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
7
7
|
import { findPdfAttachments, isLinkedUrl, isPdfAttachment } from "../utils/attachments.js";
|
|
8
8
|
import { fetchWithTimeout, isTimeoutAbort, timeoutError, TIMEOUTS } from "../utils/timeouts.js";
|
|
9
|
+
import { ITEM_KEY_HINT, MISSING_API_KEY, MISSING_API_KEY_HINT, zoteroStatusHint } from "../utils/error-hints.js";
|
|
9
10
|
export const toolConfig = {
|
|
10
11
|
name: "get_item_fulltext",
|
|
11
|
-
description: "Get the
|
|
12
|
+
description: "Get the text of a Zotero item's stored PDF from Zotero's full-text index. Works for PDFs indexed by Zotero desktop and synced, or imported with import_pdf_to_zotero / find_and_attach_pdfs; not for linked URLs or web pages. Use this to read the full content of papers instead of relying on abstracts.",
|
|
12
13
|
inputSchema: {
|
|
13
|
-
item_key: zoteroKey().describe("
|
|
14
|
+
item_key: zoteroKey().describe("Item key (the parent item or its PDF attachment), from search_library or get_collection_items"),
|
|
14
15
|
max_characters: z
|
|
15
16
|
.number()
|
|
17
|
+
.int()
|
|
18
|
+
.min(0)
|
|
16
19
|
.optional()
|
|
17
20
|
.default(50000)
|
|
18
|
-
.describe("Maximum characters to return
|
|
21
|
+
.describe("Maximum characters to return: a positive integer, or 0 for no limit (default: 50000); truncated: true in the result when cut"),
|
|
19
22
|
...libraryArgsSchema,
|
|
20
23
|
},
|
|
21
24
|
};
|
|
@@ -25,7 +28,7 @@ export async function handleGetItemFulltext(zoteroApi, userId, args) {
|
|
|
25
28
|
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
26
29
|
const apiKey = process.env.ZOTERO_API_KEY;
|
|
27
30
|
if (!apiKey) {
|
|
28
|
-
return formatErrorResponse(
|
|
31
|
+
return formatErrorResponse(MISSING_API_KEY, { hint: MISSING_API_KEY_HINT });
|
|
29
32
|
}
|
|
30
33
|
try {
|
|
31
34
|
// Fetch parent item metadata for context
|
|
@@ -37,7 +40,7 @@ export async function handleGetItemFulltext(zoteroApi, userId, args) {
|
|
|
37
40
|
// If the item itself is a PDF attachment, use it directly (a web link has no stored file)
|
|
38
41
|
if (isPdfAttachment(itemData)) {
|
|
39
42
|
if (isLinkedUrl(itemData)) {
|
|
40
|
-
return formatErrorResponse("This attachment is a link to a PDF on the web (linked URL): Zotero stores no file or full text for it. Fetch the URL directly.", { item_key, url: itemData.url || undefined });
|
|
43
|
+
return formatErrorResponse("This attachment is a link to a PDF on the web (linked URL): Zotero stores no file or full text for it. Fetch the URL directly if you have a web tool, or store the PDF with import_pdf_to_zotero and call again.", { item_key, url: itemData.url || undefined });
|
|
41
44
|
}
|
|
42
45
|
return fetchFulltext(item_key, item_key, libraryType, libraryId, apiKey, max_characters);
|
|
43
46
|
}
|
|
@@ -46,24 +49,25 @@ export async function handleGetItemFulltext(zoteroApi, userId, args) {
|
|
|
46
49
|
const pdfAttachment = pdfs.find((p) => !isLinkedUrl(p));
|
|
47
50
|
const linkedPdf = pdfs.find(isLinkedUrl);
|
|
48
51
|
if (!pdfAttachment?.key && linkedPdf) {
|
|
49
|
-
return formatErrorResponse("No stored PDF attachment found. This item only links to a PDF on the web (linked URL attachment), which Zotero does not index — fetch the linked URL directly.", { item_key, attachment_key: linkedPdf.key, url: linkedPdf.url || undefined });
|
|
52
|
+
return formatErrorResponse("No stored PDF attachment found. This item only links to a PDF on the web (linked URL attachment), which Zotero does not index — fetch the linked URL directly if you have a web tool, or store the PDF with import_pdf_to_zotero (parent_item = this item) and call again.", { item_key, attachment_key: linkedPdf.key, url: linkedPdf.url || undefined });
|
|
50
53
|
}
|
|
51
54
|
if (!pdfAttachment?.key) {
|
|
52
55
|
// Provide context-aware error messages
|
|
53
56
|
if (itemData.itemType === "webpage" || itemData.itemType === "blogPost") {
|
|
54
|
-
return formatErrorResponse("This is a web page item
|
|
57
|
+
return formatErrorResponse("This is a web page item: Zotero has no PDF text for it. Fetch its URL directly if you have a web tool; otherwise tell the user.", { item_key, url: itemData.url || undefined });
|
|
55
58
|
}
|
|
56
59
|
if (itemData.url) {
|
|
57
|
-
return formatErrorResponse(
|
|
60
|
+
return formatErrorResponse("No PDF attachment found. This item has a URL — you can fetch its content directly from the web, or attach the PDF " +
|
|
61
|
+
"(find_and_attach_pdfs if the item has a DOI, import_pdf_to_zotero for a PDF URL) and call again.", { item_key, url: itemData.url });
|
|
58
62
|
}
|
|
59
|
-
return formatErrorResponse("No PDF attachment found for this item.", { item_key });
|
|
63
|
+
return formatErrorResponse("No PDF attachment found for this item. Attach one with find_and_attach_pdfs (items with a DOI) or import_pdf_to_zotero (a PDF URL), then call again.", { item_key });
|
|
60
64
|
}
|
|
61
65
|
return fetchFulltext(item_key, pdfAttachment.key, libraryType, libraryId, apiKey, max_characters);
|
|
62
66
|
}
|
|
63
67
|
catch (err) {
|
|
64
68
|
if (isZoteroApiError(err)) {
|
|
65
69
|
if (err.response?.status === 404) {
|
|
66
|
-
return formatErrorResponse("Item not found", { item_key });
|
|
70
|
+
return formatErrorResponse("Item not found in this library", { item_key, hint: ITEM_KEY_HINT });
|
|
67
71
|
}
|
|
68
72
|
}
|
|
69
73
|
throw err;
|
|
@@ -73,10 +77,20 @@ async function fetchFulltext(itemKey, attachmentKey, libraryType, userId, apiKey
|
|
|
73
77
|
const url = `https://api.zotero.org/${libraryType}s/${userId}/items/${attachmentKey}/fulltext`;
|
|
74
78
|
const response = await fetchWithTimeout(url, { headers: { "Zotero-API-Key": apiKey } }, TIMEOUTS.zoteroApiMs, "Zotero fulltext request");
|
|
75
79
|
if (response.status === 404) {
|
|
76
|
-
return formatErrorResponse("Full text not indexed for this item
|
|
80
|
+
return formatErrorResponse("Full text not indexed for this item: Zotero has no text for this PDF yet. Retrying now will not help.", {
|
|
81
|
+
item_key: itemKey,
|
|
82
|
+
attachment_key: attachmentKey,
|
|
83
|
+
hint: "Tell the user to open the PDF in Zotero desktop (or right-click it → Reindex Item) and let Zotero sync, then try again. " +
|
|
84
|
+
"A scanned PDF without a text layer cannot be indexed.",
|
|
85
|
+
});
|
|
77
86
|
}
|
|
78
87
|
if (!response.ok) {
|
|
79
|
-
|
|
88
|
+
const { error, hint } = zoteroStatusHint(response.status);
|
|
89
|
+
return formatErrorResponse(`Zotero fulltext API returned status ${response.status}. ${error}`, {
|
|
90
|
+
item_key: itemKey,
|
|
91
|
+
attachment_key: attachmentKey,
|
|
92
|
+
hint,
|
|
93
|
+
});
|
|
80
94
|
}
|
|
81
95
|
let data;
|
|
82
96
|
try {
|
|
@@ -91,6 +105,7 @@ async function fetchFulltext(itemKey, attachmentKey, libraryType, userId, apiKey
|
|
|
91
105
|
return formatErrorResponse(timeoutError("Zotero fulltext request", url, TIMEOUTS.zoteroApiMs).message, {
|
|
92
106
|
item_key: itemKey,
|
|
93
107
|
attachment_key: attachmentKey,
|
|
108
|
+
hint: "Retry once; large PDFs can be slow. If it keeps timing out, tell the user to check the network connection of the machine running this server.",
|
|
94
109
|
});
|
|
95
110
|
}
|
|
96
111
|
let text = data.content;
|
|
@@ -4,9 +4,10 @@ import { formatJsonResult, formatErrorResponse, formatEmptyResult } from "../uti
|
|
|
4
4
|
import { formatCreators } from "../utils/item-formatter.js";
|
|
5
5
|
import { fetchItemsByKeys } from "../utils/pagination.js";
|
|
6
6
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
7
|
+
import { ITEM_KEY_HINT } from "../utils/error-hints.js";
|
|
7
8
|
export const toolConfig = {
|
|
8
9
|
name: "get_items_details",
|
|
9
|
-
description: "Get metadata for multiple Zotero items in a single call. Accepts an array of item keys and returns a map of key → metadata. Use this instead of one call per item. Returns all type-specific fields (e.g. bookTitle for bookSection, proceedingsTitle for conferencePaper, university for thesis). Set include_abstract to include abstracts (excluded by default to keep responses lightweight).",
|
|
10
|
+
description: "Get metadata for multiple Zotero items in a single call. Accepts an array of item keys and returns a map of key → metadata. Use this instead of one call per item. Returns all type-specific fields (e.g. bookTitle for bookSection, proceedingsTitle for conferencePaper, university for thesis). Set include_abstract to include abstracts (excluded by default to keep responses lightweight). Keys not found in the library are listed in not_found (present only when some are missing).",
|
|
10
11
|
inputSchema: {
|
|
11
12
|
item_keys: z
|
|
12
13
|
.array(zoteroKey())
|
|
@@ -30,12 +31,13 @@ export async function handleGetItemsDetails(zoteroApi, userId, args) {
|
|
|
30
31
|
const { item_keys, include_abstract, library_type, library_id } = GetItemsDetailsSchema.parse(args);
|
|
31
32
|
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
32
33
|
if (item_keys.length === 0) {
|
|
33
|
-
return formatErrorResponse("At least one item key is required");
|
|
34
|
+
return formatErrorResponse("At least one item key is required: pass item_keys from search_library, get_collection_items or add_items_by_doi.");
|
|
34
35
|
}
|
|
35
36
|
const { items } = await fetchItemsByKeys((params) => zoteroApi.library(libraryType, libraryId).items().get(params), item_keys);
|
|
36
37
|
if (items.length === 0) {
|
|
37
38
|
return formatEmptyResult("No items found for the given keys", {
|
|
38
39
|
item_keys,
|
|
40
|
+
suggestion: `None of these keys exists in this library (deleted, or from another library). ${ITEM_KEY_HINT}`,
|
|
39
41
|
});
|
|
40
42
|
}
|
|
41
43
|
const result = {};
|
|
@@ -69,5 +71,7 @@ export async function handleGetItemsDetails(zoteroApi, userId, args) {
|
|
|
69
71
|
}
|
|
70
72
|
result[key] = entry;
|
|
71
73
|
}
|
|
72
|
-
|
|
74
|
+
// Lowercase name: never collides with an item key (8 uppercase characters); the skill's inject.js skips it
|
|
75
|
+
const notFound = [...new Set(item_keys)].filter((k) => !(k in result));
|
|
76
|
+
return formatJsonResult(notFound.length > 0 ? { ...result, not_found: notFound } : result);
|
|
73
77
|
}
|
|
@@ -2,7 +2,7 @@ import { resolveLibrary } from "../utils/library-context.js";
|
|
|
2
2
|
import { formatJsonResult } from "../utils/error-formatter.js";
|
|
3
3
|
export const toolConfig = {
|
|
4
4
|
name: "get_user_id",
|
|
5
|
-
description: "Returns the Zotero library configured in the server environment: user_id, library_type, library_id and library_path (\"users/<id>\" or \"groups/<id>\"). Pass library_path to the
|
|
5
|
+
description: "Returns the Zotero library configured in the server environment: user_id, library_type, library_id and library_path (\"users/<id>\" or \"groups/<id>\"). Pass library_path to the zotero-mcp-integrations skill script (inject.js) to generate Zotero field code URIs. Not needed when using the inject_citations MCP tool, which reads the library internally.",
|
|
6
6
|
inputSchema: {},
|
|
7
7
|
};
|
|
8
8
|
export async function handleGetUserId(_zoteroApi, userId, _args) {
|
|
@@ -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
|
|
@@ -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,48 +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,
|
|
83
107
|
details: result.error.networkDetail, // e.g. the timeout or connection error
|
|
108
|
+
...uploadFailureHint(result.error.status, result.itemKey),
|
|
84
109
|
});
|
|
85
110
|
case "upload_failed":
|
|
86
111
|
return formatErrorResponse("File upload failed", {
|
|
87
112
|
status: result.error.status,
|
|
88
113
|
details: result.error.networkDetail, // e.g. the timeout or connection error
|
|
114
|
+
...uploadFailureHint(result.error.status, result.itemKey),
|
|
89
115
|
});
|
|
90
116
|
case "not_pdf":
|
|
91
117
|
return formatErrorResponse("Downloaded file is not a valid PDF", {
|
|
92
118
|
url,
|
|
93
119
|
details: result.error.message,
|
|
94
|
-
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.",
|
|
95
121
|
});
|
|
96
122
|
case "registration_failed":
|
|
97
123
|
return formatErrorResponse("Upload registration failed", {
|
|
98
124
|
status: result.error.status,
|
|
99
125
|
details: result.error.networkDetail, // e.g. the timeout or connection error
|
|
126
|
+
...uploadFailureHint(result.error.status, result.itemKey),
|
|
100
127
|
});
|
|
101
128
|
case "item_creation_failed":
|
|
102
129
|
return formatErrorResponse("import_pdf_to_zotero failed", {
|
|
103
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}`,
|
|
104
137
|
});
|
|
105
138
|
}
|
|
106
139
|
}
|
|
@@ -118,6 +151,7 @@ export async function handleImportPdfToZotero(zoteroApi, userId, args) {
|
|
|
118
151
|
}
|
|
119
152
|
catch (err) {
|
|
120
153
|
logToolError("import_pdf_to_zotero", err);
|
|
121
|
-
|
|
154
|
+
const { error, ...described } = describeAnyError(err);
|
|
155
|
+
return formatErrorResponse(`import_pdf_to_zotero failed: ${error}`, { ...described, details: errorMessage(err) });
|
|
122
156
|
}
|
|
123
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") {
|