@xevos117/mcp-zotero 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +133 -4
  2. package/build/citation-injector/citation-formatter.js +66 -7
  3. package/build/citation-injector/document-prefs.js +121 -0
  4. package/build/citation-injector/injector.js +54 -14
  5. package/build/server.js +10 -5
  6. package/build/tools/add-items-by-doi.js +15 -7
  7. package/build/tools/add-items.js +5 -2
  8. package/build/tools/add-linked-url-attachment.js +6 -2
  9. package/build/tools/create-collection.js +6 -2
  10. package/build/tools/delete-collection.js +7 -3
  11. package/build/tools/delete-items.js +7 -3
  12. package/build/tools/find-and-attach-pdfs.js +17 -9
  13. package/build/tools/get-collection-items.js +8 -7
  14. package/build/tools/get-collections.js +2 -2
  15. package/build/tools/get-item-fulltext.js +55 -17
  16. package/build/tools/get-items-details.js +7 -3
  17. package/build/tools/get-user-id.js +1 -1
  18. package/build/tools/import-pdf-to-zotero.js +45 -8
  19. package/build/tools/index.js +11 -3
  20. package/build/tools/inject-citations.js +33 -6
  21. package/build/tools/search-library.js +7 -5
  22. package/build/utils/attachments.js +13 -2
  23. package/build/utils/csl-to-zotero.js +39 -14
  24. package/build/utils/doi-resolver.js +20 -3
  25. package/build/utils/error-hints.js +179 -0
  26. package/build/utils/fetch-retry.js +2 -1
  27. package/build/utils/html-entities.js +55 -0
  28. package/build/utils/item-formatter.js +2 -1
  29. package/build/utils/json-response.js +29 -0
  30. package/build/utils/library-context.js +11 -11
  31. package/build/utils/pdf-uploader.js +41 -16
  32. package/build/utils/timeouts.js +97 -0
  33. package/build/utils/unpaywall.js +35 -6
  34. package/build/utils/zotero-fulltext.js +3 -2
  35. package/build/utils/zotero-keys.js +2 -1
  36. package/package.json +11 -2
@@ -6,12 +6,13 @@ import { cslToZoteroItem } from "../utils/csl-to-zotero.js";
6
6
  import { postInBatches } from "../utils/write-results.js";
7
7
  import { attachOpenAccessPdfs } from "../utils/oa-pdf.js";
8
8
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
9
+ import { UNPAYWALL_EMAIL_HINT, UNPAYWALL_EMAIL_MISSING } from "../utils/unpaywall.js";
9
10
  export const toolConfig = {
10
11
  name: "add_items_by_doi",
11
12
  description: `Add items to your Zotero library by resolving DOIs. Works with ANY item type that has a DOI — journal articles, books, datasets, preprints, conference papers, reports, etc. For each DOI, resolves metadata via content negotiation and creates the item in Zotero with the correct type automatically. Returns a list of successfully added items (with item_key and title) and any failures.
12
13
 
13
14
  WHEN TO USE vs add_items:
14
- - Use add_items_by_doi when the item HAS a DOI — it auto-resolves all metadata and attaches OA PDFs.
15
+ - Use add_items_by_doi when the item HAS a DOI — it auto-resolves all metadata and attaches open-access PDFs (when the server has UNPAYWALL_EMAIL set).
15
16
  - Use add_items when the item does NOT have a DOI, or when you need to override specific metadata fields (add_items_by_doi does not allow metadata overrides).
16
17
  - Mixed batch: if some items have DOIs and others don't, make two separate calls — add_items_by_doi for the DOIs and add_items for the rest.
17
18
 
@@ -20,8 +21,8 @@ WORKFLOW TIPS:
20
21
  - To create a cited Word document, use the returned item_keys as <zcite keys="ITEMKEY"/> placeholders in a .docx, then call inject_citations. See inject_citations description for the full workflow.`,
21
22
  inputSchema: {
22
23
  dois: z
23
- .array(z.string())
24
- .describe('Array of DOI strings (e.g. ["10.1038/s41586-023-06647-8"]). Each DOI will be resolved and added to Zotero.'),
24
+ .array(z.string().trim().min(1, "DOI must not be empty"))
25
+ .describe('Array of bare DOIs (e.g. ["10.1038/s41586-023-06647-8"], without "https://doi.org/"). Each DOI will be resolved and added to Zotero.'),
25
26
  collection_key: zoteroKey()
26
27
  .optional()
27
28
  .describe("Zotero collection key to add items to. Get this from create_collection or get_collections."),
@@ -32,7 +33,7 @@ WORKFLOW TIPS:
32
33
  auto_attach_pdf: z
33
34
  .boolean()
34
35
  .default(true)
35
- .describe("Attach freely available OA PDFs via Unpaywall (default: true). This is lightweight and adds no cost — leave enabled. Only set to false if PDF attachment is causing errors."),
36
+ .describe("Attach open-access PDFs found via Unpaywall (default: true; needs UNPAYWALL_EMAIL in the server settings, uses the user's Zotero file storage). Leave enabled unless the user does not want PDFs or attachment is causing errors."),
36
37
  ...libraryArgsSchema,
37
38
  },
38
39
  };
@@ -41,12 +42,14 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
41
42
  const { dois, collection_key, tags, auto_attach_pdf, library_type, library_id } = AddItemsByDoiSchema.parse(args);
42
43
  const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
43
44
  if (dois.length === 0) {
44
- return formatErrorResponse("At least one DOI is required");
45
+ return formatErrorResponse('At least one DOI is required: pass dois as an array, e.g. ["10.1038/s41586-023-06647-8"].');
45
46
  }
46
47
  const resolved = await resolveDois(dois);
47
48
  if (resolved.success.length === 0) {
48
49
  return formatErrorResponse("All DOI resolutions failed", {
49
50
  failed: resolved.failed,
51
+ hint: "Nothing was created; failed[].error gives the reason per DOI. Pass bare DOIs (10.xxxx/...) and check them for typos. " +
52
+ "For a DOI that does not resolve, add the item with add_items and its metadata instead. If every DOI failed with a timeout, network or 5xx error, retry later.",
50
53
  });
51
54
  }
52
55
  const zoteroItems = resolved.success.map((r) => cslToZoteroItem(r.data, {
@@ -61,7 +64,10 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
61
64
  ];
62
65
  if (write.created.length === 0) {
63
66
  const errorMessages = write.failed.map(({ index, error }) => `Item ${index}: ${error}`).join("; ");
64
- return formatErrorResponse(`Zotero API write failed: ${errorMessages}`, { failed });
67
+ return formatErrorResponse(`Zotero API write failed: ${errorMessages}`, {
68
+ failed,
69
+ hint: "Nothing was created. Fix what failed[].error reports (an invalid collection_key: check it with get_collections) and retry; for a rate limit or server error, wait a minute and retry once.",
70
+ });
65
71
  }
66
72
  const success = write.created.map(({ index, entity }) => {
67
73
  const { doi, data } = resolved.success[index];
@@ -81,14 +87,16 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
81
87
  : { ...r, pdf_attached: false, error: r.reason });
82
88
  }
83
89
  const quotaHit = pdf_results?.some((r) => r.status === "quota_exceeded");
90
+ const unpaywallOff = pdf_results?.some((r) => r.reason === UNPAYWALL_EMAIL_MISSING);
84
91
  return formatJsonResult({
85
92
  success,
86
93
  failed,
87
94
  ...(pdf_results !== undefined ? { pdf_results } : {}),
88
95
  ...(quotaHit
89
96
  ? {
90
- storage_quota_warning: "Zotero storage quota is full. Some PDF attachments were skipped. All items were created successfully (metadata only). Free up space at https://www.zotero.org/settings/storage or upgrade your plan.",
97
+ storage_quota_warning: "Zotero storage quota is full. Some PDF attachments were skipped. All items were created successfully (metadata only). Tell the user they can free up space or upgrade at https://www.zotero.org/settings/storage, then attach the PDFs with find_and_attach_pdfs.",
91
98
  }
92
99
  : {}),
100
+ ...(unpaywallOff ? { hint: UNPAYWALL_EMAIL_HINT } : {}),
93
101
  });
94
102
  }
@@ -56,7 +56,7 @@ WHEN TO USE:
56
56
  - Mixed batch: if some items have DOIs and others don't, call add_items_by_doi for the DOIs and add_items for the rest (two separate calls)
57
57
  - Prefer add_items_by_doi when DOIs are available AND you don't need to override metadata (it auto-resolves everything and attaches OA PDFs)
58
58
 
59
- BATCH: Pass multiple items in the 'items' array (single API call).
59
+ BATCH: Pass multiple items in the 'items' array (one tool call).
60
60
 
61
61
  COMMON FIELDS (available for most types):
62
62
  title, date, abstractNote, url, DOI, publisher, place, pages, volume, language, extra
@@ -130,7 +130,10 @@ export async function handleAddItems(zoteroApi, userId, args) {
130
130
  }));
131
131
  const failed = write.failed.map(({ index, error }) => ({ index, title: items[index].title, error }));
132
132
  if (success.length === 0 && failed.length > 0) {
133
- return formatErrorResponse("All items failed to create", { failed });
133
+ return formatErrorResponse("All items failed to create", {
134
+ failed,
135
+ hint: "Nothing was created. failed[].error gives Zotero's reason per item: fix those items (an invalid collection_key: check it with get_collections) and call again; retrying unchanged will fail again.",
136
+ });
134
137
  }
135
138
  return formatJsonResult({
136
139
  success,
@@ -3,9 +3,10 @@ import { zoteroKey } from "../utils/zotero-keys.js";
3
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
4
  import { formatWriteErrors } from "../utils/write-results.js";
5
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
6
+ import { COLLECTION_KEY_HINT, ITEM_KEY_HINT } from "../utils/error-hints.js";
6
7
  export const toolConfig = {
7
8
  name: "add_linked_url_attachment",
8
- description: "Attach a linked URL to an existing Zotero item, or create a standalone linked-URL attachment. Use this to link external PDFs, web pages, or other resources to items already in your library. If parent_item is provided, the attachment is added as a child; otherwise it is standalone.",
9
+ description: "Attach a linked URL to an existing Zotero item, or create a standalone linked-URL attachment. Use this to link external PDFs, web pages, or other resources to items already in your library. If parent_item is provided, the attachment is added as a child; otherwise it is standalone. A linked URL stores no file and no full text (get_item_fulltext cannot read it): to store a PDF in Zotero use import_pdf_to_zotero.",
9
10
  inputSchema: {
10
11
  url: z.string().url().describe("URL of the resource to link"),
11
12
  title: z
@@ -18,7 +19,7 @@ export const toolConfig = {
18
19
  .describe('MIME type of the linked resource (e.g. "application/pdf", "text/html")'),
19
20
  parent_item: zoteroKey()
20
21
  .optional()
21
- .describe("Item key of the parent item. If provided, the attachment becomes a child of that item."),
22
+ .describe("Key of the parent item (a regular item, not an attachment or note), from search_library or get_collection_items. If provided, the attachment becomes a child of that item."),
22
23
  collections: z
23
24
  .array(zoteroKey())
24
25
  .optional()
@@ -59,6 +60,9 @@ export async function handleAddLinkedUrlAttachment(zoteroApi, userId, args) {
59
60
  const errorMsg = formatWriteErrors(response.getErrors());
60
61
  return formatErrorResponse("Failed to create linked URL attachment", {
61
62
  details: errorMsg,
63
+ hint: parent_item
64
+ ? `details has Zotero's reason. parent_item must be an existing regular item, not an attachment or note: ${ITEM_KEY_HINT}`
65
+ : `details has Zotero's reason. Check the collections keys: ${COLLECTION_KEY_HINT}`,
62
66
  });
63
67
  }
64
68
  const created = response.getData();
@@ -3,9 +3,10 @@ import { zoteroKey } from "../utils/zotero-keys.js";
3
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
4
  import { formatWriteErrors } from "../utils/write-results.js";
5
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
6
+ import { COLLECTION_KEY_HINT } from "../utils/error-hints.js";
6
7
  export const toolConfig = {
7
8
  name: "create_collection",
8
- description: "Create a new collection (folder) in your Zotero library. Optionally nest it under a parent collection. Returns the new collection key and name. Use the key with add_items_by_doi to organize imported papers.",
9
+ description: "Create a new collection (folder) in your Zotero library. Optionally nest it under a parent collection. Returns the new collection key and name. Use the key as collection_key in add_items_by_doi or add_items to organize imported papers.",
9
10
  inputSchema: {
10
11
  name: z.string().describe("Name of the new collection"),
11
12
  parent_collection: zoteroKey()
@@ -19,7 +20,7 @@ export async function handleCreateCollection(zoteroApi, userId, args) {
19
20
  const { name, parent_collection, library_type, library_id } = CreateCollectionSchema.parse(args);
20
21
  const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
21
22
  if (!name?.trim()) {
22
- return formatErrorResponse("Collection name is required");
23
+ return formatErrorResponse("Collection name is required: pass a non-empty name.");
23
24
  }
24
25
  const collectionData = { name: name.trim() };
25
26
  if (parent_collection) {
@@ -33,6 +34,9 @@ export async function handleCreateCollection(zoteroApi, userId, args) {
33
34
  const errorMsg = formatWriteErrors(response.getErrors());
34
35
  return formatErrorResponse("Failed to create collection", {
35
36
  details: errorMsg,
37
+ hint: parent_collection
38
+ ? `details has Zotero's reason. Check that parent_collection exists: ${COLLECTION_KEY_HINT}`
39
+ : "details has Zotero's reason: fix the name if it is the cause and retry.",
36
40
  });
37
41
  }
38
42
  const created = response.getData();
@@ -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. Requires UNSAFE_OPERATIONS environment variable set to 'all'.",
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("Deletion of collections is not allowed. Set the UNSAFE_OPERATIONS environment variable to 'all' to enable this operation.", {
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 operation.", {
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. Requires UNSAFE_OPERATIONS environment variable set to 'items' or 'all'.",
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("Deletion of items is not allowed. Set the UNSAFE_OPERATIONS environment variable to 'items' or 'all' to enable this operation.", {
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 operation.", {
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("ZOTERO_API_KEY environment variable is not set");
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. Free up space at https://www.zotero.org/settings/storage or upgrade your plan.",
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
- return formatErrorResponse("find_and_attach_pdfs failed", { details: errorMessage(err) });
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("The collection key/ID"),
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: "Add some items to this collection in Zotero",
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: "Check that items in this collection have the expected metadata",
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 is empty or not accessible", {
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: "Verify the collection exists and try adding some items to it",
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: "Create a collection in your Zotero library first",
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. Use include_trashed=true to see them.",
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);
@@ -1,19 +1,24 @@
1
+ import { InvalidJsonResponseError, readJsonObject } from "../utils/json-response.js";
1
2
  import { z } from "zod";
2
3
  import { zoteroKey } from "../utils/zotero-keys.js";
3
4
  import { isZoteroApiError } from "../types/zotero-types.js";
4
5
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
5
6
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
6
- import { findPdfAttachment, isPdfAttachment } from "../utils/attachments.js";
7
+ import { findPdfAttachments, isLinkedUrl, isPdfAttachment } from "../utils/attachments.js";
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";
7
10
  export const toolConfig = {
8
11
  name: "get_item_fulltext",
9
- description: "Get the full text content of a Zotero item's PDF attachment via Zotero's fulltext index. Zotero desktop automatically indexes PDFs when synced. Use this to read the full content of papers instead of relying on abstracts.",
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.",
10
13
  inputSchema: {
11
- item_key: zoteroKey().describe("The Zotero item key (parent item or attachment key)"),
14
+ item_key: zoteroKey().describe("Item key (the parent item or its PDF attachment), from search_library or get_collection_items"),
12
15
  max_characters: z
13
16
  .number()
17
+ .int()
18
+ .min(0)
14
19
  .optional()
15
20
  .default(50000)
16
- .describe("Maximum characters to return (default: 50000, 0 = no limit)"),
21
+ .describe("Maximum characters to return: a positive integer, or 0 for no limit (default: 50000); truncated: true in the result when cut"),
17
22
  ...libraryArgsSchema,
18
23
  },
19
24
  };
@@ -23,7 +28,7 @@ export async function handleGetItemFulltext(zoteroApi, userId, args) {
23
28
  const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
24
29
  const apiKey = process.env.ZOTERO_API_KEY;
25
30
  if (!apiKey) {
26
- return formatErrorResponse("ZOTERO_API_KEY environment variable is not set");
31
+ return formatErrorResponse(MISSING_API_KEY, { hint: MISSING_API_KEY_HINT });
27
32
  }
28
33
  try {
29
34
  // Fetch parent item metadata for context
@@ -32,27 +37,37 @@ export async function handleGetItemFulltext(zoteroApi, userId, args) {
32
37
  .items(item_key)
33
38
  .get();
34
39
  const itemData = itemResponse.getData();
35
- // If the item itself is a PDF attachment, use it directly
40
+ // If the item itself is a PDF attachment, use it directly (a web link has no stored file)
36
41
  if (isPdfAttachment(itemData)) {
42
+ if (isLinkedUrl(itemData)) {
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 });
44
+ }
37
45
  return fetchFulltext(item_key, item_key, libraryType, libraryId, apiKey, max_characters);
38
46
  }
39
- const pdfAttachment = await findPdfAttachment(zoteroApi, { type: libraryType, id: libraryId }, item_key);
47
+ // Stored PDFs first: linked URLs never have server-side full text, even when listed first
48
+ const pdfs = await findPdfAttachments(zoteroApi, { type: libraryType, id: libraryId }, item_key);
49
+ const pdfAttachment = pdfs.find((p) => !isLinkedUrl(p));
50
+ const linkedPdf = pdfs.find(isLinkedUrl);
51
+ if (!pdfAttachment?.key && linkedPdf) {
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 });
53
+ }
40
54
  if (!pdfAttachment?.key) {
41
55
  // Provide context-aware error messages
42
56
  if (itemData.itemType === "webpage" || itemData.itemType === "blogPost") {
43
- return formatErrorResponse("This is a web page item. Use the item's URL to fetch its content directly.", { item_key, url: itemData.url || undefined });
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 });
44
58
  }
45
59
  if (itemData.url) {
46
- return formatErrorResponse(`No PDF attachment found. This item has a URL — you can fetch its content directly from the web.`, { item_key, url: itemData.url });
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 });
47
62
  }
48
- 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 });
49
64
  }
50
65
  return fetchFulltext(item_key, pdfAttachment.key, libraryType, libraryId, apiKey, max_characters);
51
66
  }
52
67
  catch (err) {
53
68
  if (isZoteroApiError(err)) {
54
69
  if (err.response?.status === 404) {
55
- return formatErrorResponse("Item not found", { item_key });
70
+ return formatErrorResponse("Item not found in this library", { item_key, hint: ITEM_KEY_HINT });
56
71
  }
57
72
  }
58
73
  throw err;
@@ -60,16 +75,39 @@ export async function handleGetItemFulltext(zoteroApi, userId, args) {
60
75
  }
61
76
  async function fetchFulltext(itemKey, attachmentKey, libraryType, userId, apiKey, maxCharacters) {
62
77
  const url = `https://api.zotero.org/${libraryType}s/${userId}/items/${attachmentKey}/fulltext`;
63
- const response = await fetch(url, {
64
- headers: { "Zotero-API-Key": apiKey },
65
- });
78
+ const response = await fetchWithTimeout(url, { headers: { "Zotero-API-Key": apiKey } }, TIMEOUTS.zoteroApiMs, "Zotero fulltext request");
66
79
  if (response.status === 404) {
67
- return formatErrorResponse("Full text not indexed for this item. Open the PDF in Zotero desktop to trigger indexing, then sync.", { item_key: itemKey, attachment_key: attachmentKey });
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
+ });
68
86
  }
69
87
  if (!response.ok) {
70
- return formatErrorResponse(`Zotero fulltext API returned status ${response.status}`, { item_key: itemKey, attachment_key: attachmentKey });
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
+ });
94
+ }
95
+ let data;
96
+ try {
97
+ data = await readJsonObject(response);
98
+ }
99
+ catch (err) {
100
+ if (err instanceof InvalidJsonResponseError) {
101
+ return formatErrorResponse(`Zotero fulltext API: ${err.message}`, { item_key: itemKey, attachment_key: attachmentKey });
102
+ }
103
+ if (!isTimeoutAbort(err))
104
+ throw err;
105
+ return formatErrorResponse(timeoutError("Zotero fulltext request", url, TIMEOUTS.zoteroApiMs).message, {
106
+ item_key: itemKey,
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.",
109
+ });
71
110
  }
72
- const data = (await response.json());
73
111
  let text = data.content;
74
112
  let truncated = false;
75
113
  if (maxCharacters > 0 && text.length > maxCharacters) {
@@ -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
- return formatJsonResult(result);
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 standalone inject-citations skill script (inject.js) to generate Zotero field code URIs. Not needed when using the inject_citations MCP tool, which reads the library internally.",
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) {