@xevos117/mcp-zotero 1.0.9 → 2.0.1

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 (34) hide show
  1. package/README.md +64 -3
  2. package/build/citation-injector/injector.js +22 -12
  3. package/build/server.js +14 -6
  4. package/build/tools/add-items-by-doi.js +55 -143
  5. package/build/tools/add-items.js +22 -57
  6. package/build/tools/add-linked-url-attachment.js +27 -46
  7. package/build/tools/create-collection.js +26 -45
  8. package/build/tools/delete-collection.js +14 -26
  9. package/build/tools/delete-items.js +15 -29
  10. package/build/tools/find-and-attach-pdfs.js +57 -148
  11. package/build/tools/get-collection-items.js +15 -25
  12. package/build/tools/get-collections.js +21 -37
  13. package/build/tools/get-item-fulltext.js +16 -28
  14. package/build/tools/get-items-details.js +42 -56
  15. package/build/tools/get-user-id.js +7 -6
  16. package/build/tools/import-pdf-to-zotero.js +22 -38
  17. package/build/tools/index.js +22 -29
  18. package/build/tools/inject-citations.js +15 -34
  19. package/build/tools/search-library.js +27 -43
  20. package/build/utils/attachments.js +9 -0
  21. package/build/utils/concurrency.js +8 -0
  22. package/build/utils/csl-to-zotero.js +12 -5
  23. package/build/utils/doi-resolver.js +2 -1
  24. package/build/utils/error-formatter.js +36 -11
  25. package/build/utils/library-context.js +99 -0
  26. package/build/utils/oa-pdf.js +86 -0
  27. package/build/utils/pagination.js +33 -2
  28. package/build/utils/pdf-text-extractor.js +3 -2
  29. package/build/utils/pdf-uploader.js +48 -20
  30. package/build/utils/unpaywall.js +37 -70
  31. package/build/utils/write-results.js +57 -0
  32. package/build/utils/zotero-fulltext.js +2 -2
  33. package/build/utils/zotero-keys.js +10 -0
  34. package/package.json +15 -13
package/README.md CHANGED
@@ -32,6 +32,8 @@ LLMs without filesystem access — including Claude Desktop, which connects to M
32
32
 
33
33
  ## Setup
34
34
 
35
+ Requires **Node.js 22 or newer**.
36
+
35
37
  1. Get your Zotero credentials:
36
38
 
37
39
  ```bash
@@ -50,6 +52,65 @@ LLMs without filesystem access — including Claude Desktop, which connects to M
50
52
  export UNSAFE_OPERATIONS="none" # Optional: "none" | "items" | "all" (see below)
51
53
  ```
52
54
 
55
+ ### Targeting a group library
56
+
57
+ By default the server targets your personal Zotero library (`/users/<ZOTERO_USER_ID>/...`).
58
+ To target a group library instead, set two additional environment variables:
59
+
60
+ | Variable | Values | Required? | Default |
61
+ |---|---|---|---|
62
+ | `ZOTERO_LIBRARY_TYPE` | `user` \| `group` | No | `user` |
63
+ | `ZOTERO_LIBRARY_ID` | numeric library ID | Yes when type=`group`; otherwise falls back to `ZOTERO_USER_ID` | — |
64
+
65
+ Example for a group library:
66
+
67
+ ```
68
+ ZOTERO_API_KEY=...
69
+ ZOTERO_USER_ID=12345 # optional for groups: used by get_user_id and per-call library_type="user"
70
+ ZOTERO_LIBRARY_TYPE=group
71
+ ZOTERO_LIBRARY_ID=6178978 # the group ID
72
+ ```
73
+
74
+ The API key in `ZOTERO_API_KEY` must have access to the target group library — generate or scope keys at https://www.zotero.org/settings/keys.
75
+
76
+ The server refuses to start if `ZOTERO_LIBRARY_TYPE=group` is set without `ZOTERO_LIBRARY_ID`, or if a library ID is not numeric.
77
+
78
+
79
+ ### Per-call library override
80
+
81
+ Every tool accepts two optional args that override the env defaults for that single call:
82
+
83
+ - `library_type` — `"user"` or `"group"`
84
+ - `library_id` — numeric library ID
85
+
86
+ Resolution order: per-call arg > env var > implicit default (`user`, `ZOTERO_USER_ID`). `library_id` must be numeric. Overriding `library_type` to `group` on a server configured for a user library requires `library_id`; overriding to `user` on a group server without `library_id` uses `ZOTERO_USER_ID`.
87
+
88
+ Note that per-call overrides let the LLM reach any library the API key can access. Scope the key to the libraries you want exposed; deletions remain gated by `UNSAFE_OPERATIONS`.
89
+
90
+ This lets a single MCP instance target multiple libraries (e.g., a staging group and a final-output group) without restarting:
91
+
92
+ ```jsonc
93
+ // First call targets the staging group
94
+ {
95
+ "tool": "add_items",
96
+ "args": {
97
+ "items": [...],
98
+ "library_type": "group",
99
+ "library_id": "5597114"
100
+ }
101
+ }
102
+
103
+ // Second call targets DART-output (different group)
104
+ {
105
+ "tool": "add_items",
106
+ "args": {
107
+ "items": [...],
108
+ "library_type": "group",
109
+ "library_id": "6178978"
110
+ }
111
+ }
112
+ ```
113
+
53
114
  ## Environment Variables
54
115
 
55
116
  | Variable | Required | Description |
@@ -75,7 +136,7 @@ To enable deletion, set the `UNSAFE_OPERATIONS` environment variable to one of t
75
136
 
76
137
  - If `UNSAFE_OPERATIONS` is not set, empty, or set to an unrecognized value, it defaults to `none`.
77
138
  - The value is **case-insensitive** (e.g. `ALL`, `Items`, `NONE` all work).
78
- - `delete_items` moves items to the Zotero trash (recoverable from the Zotero desktop client).
139
+ - `delete_items` deletes items **permanently**: the Zotero Web API multi-item DELETE does not move them to the Zotero trash, so they cannot be restored from the desktop client.
79
140
  - `delete_collection` removes the collection (folder) only — items inside it are **not** deleted and remain in your library.
80
141
  - The `all` value includes both item and collection deletion because managing collections inherently requires item-level access.
81
142
 
@@ -132,7 +193,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
132
193
  | `get_collections` | List all collections (folders) with keys, names, and parent relationships |
133
194
  | `get_collection_items` | Get items in a specific collection with keys, titles, authors, dates |
134
195
  | `search_library` | Search by query, or list items sorted by field (date, title, etc.) |
135
- | `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, etc.) |
196
+ | `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, etc.), the `authors` string and the structured `creators` |
136
197
  | `get_item_fulltext` | Get full-text content of a PDF attachment via Zotero's fulltext index |
137
198
 
138
199
  ### Adding content
@@ -150,7 +211,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
150
211
 
151
212
  | Tool | Description |
152
213
  |---|---|
153
- | `delete_items` | Delete up to 50 items per call (moves to Zotero trash). Requires `UNSAFE_OPERATIONS=items` or `all` |
214
+ | `delete_items` | Permanently delete up to 50 items per call (not moved to the Zotero trash). Requires `UNSAFE_OPERATIONS=items` or `all` |
154
215
  | `delete_collection` | Delete a collection (folder). Items inside are kept. Requires `UNSAFE_OPERATIONS=all` |
155
216
 
156
217
  ### Citation & documents
@@ -1,5 +1,6 @@
1
1
  import JSZip from "jszip";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
+ import { format, parse } from "node:path";
3
4
  import { generateZoteroFieldCode, generateBibliographyFieldCode } from "./field-codes.js";
4
5
  import { formatCitationText } from "./citation-formatter.js";
5
6
  import { regexEscape, unescapeXml } from "./xml-utils.js";
@@ -20,11 +21,13 @@ function parseZciteMatches(documentXml) {
20
21
  // and values like "pp. 12 &amp; 15" need a second unescape
21
22
  attrs[attrMatch[1]] = unescapeXml(attrMatch[2]);
22
23
  }
23
- if (!attrs["keys"])
24
+ // Tolerate "KEY1, KEY2": a stray space would otherwise become part of the key (404 on fetch)
25
+ const keys = (attrs["keys"] ?? "").split(",").map((k) => k.trim()).filter(Boolean);
26
+ if (keys.length === 0)
24
27
  continue;
25
28
  matches.push({
26
29
  fullMatch,
27
- keys: attrs["keys"].split(","),
30
+ keys,
28
31
  locator: attrs["locator"] || undefined,
29
32
  prefix: attrs["prefix"] || undefined,
30
33
  suffix: attrs["suffix"] || undefined,
@@ -33,11 +36,11 @@ function parseZciteMatches(documentXml) {
33
36
  }
34
37
  return matches;
35
38
  }
36
- async function fetchCslData(keys, zoteroApi, userId) {
39
+ async function fetchCslData(keys, zoteroApi, libraryType, userId) {
37
40
  const cslData = new Map();
38
41
  for (const key of keys) {
39
42
  const response = await zoteroApi
40
- .library("user", userId)
43
+ .library(libraryType, userId)
41
44
  .items(key)
42
45
  .get();
43
46
  const zoteroItem = response.getData();
@@ -45,13 +48,13 @@ async function fetchCslData(keys, zoteroApi, userId) {
45
48
  }
46
49
  return cslData;
47
50
  }
48
- function buildCitationItems(match, cslData, userId) {
51
+ function buildCitationItems(match, cslData, libraryType, userId) {
49
52
  return match.keys.map((key, idx) => {
50
53
  const itemData = cslData.get(key) ?? { type: "article-journal" };
51
54
  const item = {
52
55
  id: idx,
53
- uris: [`http://zotero.org/users/${userId}/items/${key}`],
54
- uri: [`http://zotero.org/users/${userId}/items/${key}`],
56
+ uris: [`http://zotero.org/${libraryType}s/${userId}/items/${key}`],
57
+ uri: [`http://zotero.org/${libraryType}s/${userId}/items/${key}`],
55
58
  itemData,
56
59
  };
57
60
  if (match.locator)
@@ -92,7 +95,10 @@ function replaceZciteInXml(xml, escapedZciteTag, fieldCodeXml) {
92
95
  }
93
96
  return xml;
94
97
  }
95
- export async function injectCitations(filePath, zoteroApi, userId, style) {
98
+ export async function injectCitations(filePath, zoteroApi, libraryType, userId, style) {
99
+ // Derive "<name>_cited<ext>" from the basename only: a directory containing ".docx" stays untouched
100
+ const { dir, name, ext } = parse(filePath);
101
+ const outputPath = format({ dir, name: `${name}_cited`, ext });
96
102
  const fileBuffer = await readFile(filePath);
97
103
  const zip = await JSZip.loadAsync(fileBuffer);
98
104
  const documentEntry = zip.file("word/document.xml");
@@ -103,7 +109,6 @@ export async function injectCitations(filePath, zoteroApi, userId, style) {
103
109
  documentXml = normalizeZciteTags(documentXml);
104
110
  const matches = parseZciteMatches(documentXml);
105
111
  if (matches.length === 0) {
106
- const outputPath = filePath.replace(".docx", "_cited.docx");
107
112
  const buffer = await zip.generateAsync({ type: "nodebuffer" });
108
113
  await writeFile(outputPath, buffer);
109
114
  return { outputPath, found: 0, injected: 0, warnings: [] };
@@ -118,12 +123,18 @@ export async function injectCitations(filePath, zoteroApi, userId, style) {
118
123
  }
119
124
  // Collect all unique item keys
120
125
  const uniqueKeys = new Set(matches.flatMap((m) => m.keys));
126
+ // Keys from the document go into API URL paths (one GET per key): only letters and digits, so a crafted
127
+ // <zcite> cannot point the request at another endpoint ("../", "?", "&"); unknown keys still give a 404
128
+ const invalid = [...uniqueKeys].filter((k) => !/^[A-Za-z0-9]{1,32}$/.test(k));
129
+ if (invalid.length > 0) {
130
+ throw new Error(`Invalid Zotero item key(s) in <zcite>: ${invalid.join(", ")} (letters and digits only)`);
131
+ }
121
132
  // Fetch CSL data from Zotero
122
- const cslData = await fetchCslData(uniqueKeys, zoteroApi, userId);
133
+ const cslData = await fetchCslData(uniqueKeys, zoteroApi, libraryType, userId);
123
134
  // Replace each zcite tag with a field code
124
135
  let injected = 0;
125
136
  for (const match of matches) {
126
- const citationItems = buildCitationItems(match, cslData, userId);
137
+ const citationItems = buildCitationItems(match, cslData, libraryType, userId);
127
138
  const itemDataList = match.keys.map((k) => cslData.get(k) ?? { type: "article-journal" });
128
139
  const formattedText = formatCitationText(itemDataList, style, match.num);
129
140
  const fieldCodeXml = generateZoteroFieldCode(citationItems, formattedText);
@@ -138,7 +149,6 @@ export async function injectCitations(filePath, zoteroApi, userId, style) {
138
149
  documentXml = documentXml.replace("</w:body>", `${biblXml}</w:body>`);
139
150
  // Save
140
151
  zip.file("word/document.xml", documentXml);
141
- const outputPath = filePath.replace(".docx", "_cited.docx");
142
152
  const buffer = await zip.generateAsync({ type: "nodebuffer" });
143
153
  await writeFile(outputPath, buffer);
144
154
  return { outputPath, found: matches.length, injected, warnings };
package/build/server.js CHANGED
@@ -7,6 +7,9 @@ import { createRequire } from "module";
7
7
  import { registerAllTools } from "./tools/index.js";
8
8
  import { logger } from "./utils/logger.js";
9
9
  import { parseUnsafeOperations } from "./utils/unsafe-operations.js";
10
+ import { getDefaultLibrary } from "./utils/library-context.js";
11
+ // Report the package version in the MCP handshake (package.json sits one level above both src/ and build/).
12
+ const { version: SERVER_VERSION } = createRequire(import.meta.url)("../package.json");
10
13
  // Re-export for backward compatibility with existing tests
11
14
  export { formatErrorResponse } from "./utils/error-formatter.js";
12
15
  export { handleToolCall } from "./tools/index.js";
@@ -16,7 +19,7 @@ class ZoteroServer {
16
19
  userId;
17
20
  unsafeOps;
18
21
  constructor(options = {}) {
19
- this.server = new McpServer({ name: "zotero", version: "1.0.0" }, {
22
+ this.server = new McpServer({ name: "zotero", version: SERVER_VERSION }, {
20
23
  capabilities: { tools: {} },
21
24
  instructions: [
22
25
  "Zotero library management and citation injection for Word documents.",
@@ -25,7 +28,7 @@ class ZoteroServer {
25
28
  "1. Collect item keys: use add_items_by_doi for items with DOIs, add_items for items without DOIs (or search_library for existing items)",
26
29
  "2. Generate .docx: create a Word document with <zcite keys=\"ITEMKEY\"/> placeholders where citations should appear. Each <zcite> must be in its own TextRun.",
27
30
  "3. Call inject_citations with the .docx file path — it fetches metadata from Zotero automatically.",
28
- "4. Tell the user to open in Word with Zotero plugin → click Zotero → Refresh.",
31
+ "4. Tell the user to open it in Word with the Zotero plugin → Zotero → Add/Edit Bibliography, then Zotero → Refresh.",
29
32
  "",
30
33
  "KEY NOTES:",
31
34
  "- add_items_by_doi auto-attaches OA PDFs via Unpaywall at no cost. Do not disable auto_attach_pdf unless it causes errors.",
@@ -39,13 +42,18 @@ class ZoteroServer {
39
42
  this.zoteroApi = options.zoteroApi;
40
43
  }
41
44
  else {
42
- this.userId = process.env.ZOTERO_USER_ID || "";
43
45
  const apiKey = process.env.ZOTERO_API_KEY || "";
44
- if (!apiKey || !this.userId) {
45
- throw new Error("Missing ZOTERO_API_KEY or ZOTERO_USER_ID environment variables");
46
+ if (!apiKey) {
47
+ throw new Error("Missing ZOTERO_API_KEY environment variable");
46
48
  }
49
+ // Validate the library configuration eagerly so a misconfigured server fails
50
+ // fast at startup rather than on the first tool call. ZOTERO_LIBRARY_ID takes
51
+ // precedence (group libraries); ZOTERO_USER_ID is the user-library fallback.
52
+ this.userId = getDefaultLibrary().id;
53
+ // zotero-api-client >= 0.51 ships a single CJS entry (lib/main.cjs) that relies on the
54
+ // global fetch; the old lib/main-node.cjs (cross-fetch polyfill) no longer exists.
47
55
  const require = createRequire(import.meta.url);
48
- const zoteroApiFactory = require("zotero-api-client/lib/main-node.cjs").default;
56
+ const { default: zoteroApiFactory } = require("zotero-api-client");
49
57
  this.zoteroApi = zoteroApiFactory(apiKey);
50
58
  }
51
59
  }
@@ -1,12 +1,11 @@
1
1
  import { z } from "zod";
2
- import { isZoteroApiError } from "../types/zotero-types.js";
3
- import { formatErrorResponse } from "../utils/error-formatter.js";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
3
+ import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
4
  import { resolveDois } from "../utils/doi-resolver.js";
5
5
  import { cslToZoteroItem } from "../utils/csl-to-zotero.js";
6
- import { logger } from "../utils/logger.js";
7
- import { lookupOaPdf } from "../utils/unpaywall.js";
8
- import { downloadAndUploadPdf } from "../utils/pdf-uploader.js";
9
- import { mapWithConcurrency, createCancellationToken } from "../utils/concurrency.js";
6
+ import { postInBatches } from "../utils/write-results.js";
7
+ import { attachOpenAccessPdfs } from "../utils/oa-pdf.js";
8
+ import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
10
9
  export const toolConfig = {
11
10
  name: "add_items_by_doi",
12
11
  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.
@@ -23,8 +22,7 @@ WORKFLOW TIPS:
23
22
  dois: z
24
23
  .array(z.string())
25
24
  .describe('Array of DOI strings (e.g. ["10.1038/s41586-023-06647-8"]). Each DOI will be resolved and added to Zotero.'),
26
- collection_key: z
27
- .string()
25
+ collection_key: zoteroKey()
28
26
  .optional()
29
27
  .describe("Zotero collection key to add items to. Get this from create_collection or get_collections."),
30
28
  tags: z
@@ -35,148 +33,62 @@ WORKFLOW TIPS:
35
33
  .boolean()
36
34
  .default(true)
37
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
+ ...libraryArgsSchema,
38
37
  },
39
38
  };
40
39
  const AddItemsByDoiSchema = z.object(toolConfig.inputSchema);
41
- async function attachPdfsToItems(items, zoteroApi, userId, apiKey) {
42
- const itemsWithDoi = items.filter((item) => item.doi);
43
- if (itemsWithDoi.length === 0)
44
- return [];
45
- // Probe Unpaywall config with the first DOI — if email is bad, skip entirely
46
- const probe = await lookupOaPdf(itemsWithDoi[0].doi);
47
- if (probe.warning) {
48
- return [{
49
- item_key: itemsWithDoi[0].item_key,
50
- doi: itemsWithDoi[0].doi,
51
- pdf_attached: false,
52
- source: null,
53
- error: probe.warning,
54
- }];
55
- }
56
- // Email is valid — process all items in parallel (reuse probe for first)
57
- const cancelToken = createCancellationToken();
58
- const settled = await mapWithConcurrency(itemsWithDoi, async (item, i) => {
59
- const oaResult = i === 0 ? probe : await lookupOaPdf(item.doi);
60
- if (oaResult.found && oaResult.pdf_url) {
61
- const uploadResult = await downloadAndUploadPdf(zoteroApi, userId, apiKey, {
62
- url: oaResult.pdf_url,
63
- parentItem: item.item_key,
64
- });
65
- if (!uploadResult.success && uploadResult.error.code === "storage_quota_exceeded") {
66
- cancelToken.cancelled = true;
67
- }
68
- return {
69
- item_key: item.item_key,
70
- doi: item.doi,
71
- pdf_attached: uploadResult.success,
72
- source: oaResult.source,
73
- error: uploadResult.success ? undefined : uploadResult.error.message,
74
- };
75
- }
76
- return {
77
- item_key: item.item_key,
78
- doi: item.doi,
79
- pdf_attached: false,
80
- source: null,
81
- oa_status: oaResult.oa_status ?? undefined,
82
- landing_url: oaResult.landing_url ?? undefined,
83
- error: oaResult.landing_url
84
- ? "Open access copy exists at a repository but no direct PDF link is available. The user can download it manually from the landing page and use import_pdf_to_zotero to attach it."
85
- : "No open access PDF found",
86
- };
87
- }, undefined, cancelToken);
88
- return settled
89
- .filter((r) => r.status === "fulfilled")
90
- .map((r) => r.value);
91
- }
92
40
  export async function handleAddItemsByDoi(zoteroApi, userId, args) {
93
- const { dois, collection_key, tags, auto_attach_pdf } = AddItemsByDoiSchema.parse(args);
41
+ const { dois, collection_key, tags, auto_attach_pdf, library_type, library_id } = AddItemsByDoiSchema.parse(args);
42
+ const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
94
43
  if (dois.length === 0) {
95
44
  return formatErrorResponse("At least one DOI is required");
96
45
  }
97
- try {
98
- const resolved = await resolveDois(dois);
99
- if (resolved.success.length === 0) {
100
- return formatErrorResponse("All DOI resolutions failed", {
101
- failed: resolved.failed,
102
- });
103
- }
104
- const zoteroItems = resolved.success.map((r) => cslToZoteroItem(r.data, {
105
- collectionKey: collection_key,
106
- tags,
107
- }));
108
- const response = await zoteroApi
109
- .library("user", userId)
110
- .items()
111
- .post(zoteroItems);
112
- if (!response.isSuccess()) {
113
- const errors = response.getErrors();
114
- const errorMessages = Object.entries(errors)
115
- .map(([idx, msg]) => `Item ${idx}: ${typeof msg === "object" ? JSON.stringify(msg) : msg}`)
116
- .join(", ");
117
- return formatErrorResponse(`Zotero API write failed: ${errorMessages}`);
118
- }
119
- const createdItems = response.getData();
120
- if (!createdItems || !Array.isArray(createdItems) || createdItems.length === 0) {
121
- return formatErrorResponse("Zotero API returned empty response - items may not have been created");
122
- }
123
- const success = [];
124
- for (let i = 0; i < resolved.success.length; i++) {
125
- const r = resolved.success[i];
126
- const entity = response.getEntityByIndex(i);
127
- const itemKey = entity?.key;
128
- if (!itemKey) {
129
- return formatErrorResponse(`Failed to get item key for DOI ${r.doi} at index ${i}. API response may be invalid.`);
130
- }
131
- success.push({
132
- doi: r.doi,
133
- item_key: itemKey,
134
- title: entity?.title ?? r.data.title ?? "Untitled",
135
- });
136
- }
137
- let pdf_results;
138
- let pdf_attach_error;
139
- if (auto_attach_pdf) {
140
- const apiKey = process.env.ZOTERO_API_KEY;
141
- if (apiKey) {
142
- try {
143
- pdf_results = await attachPdfsToItems(success, zoteroApi, userId, apiKey);
144
- }
145
- catch (err) {
146
- pdf_attach_error = `PDF attachment failed: ${err instanceof Error ? err.message : String(err)}. Items were created successfully.`;
147
- logger.error("PDF attach phase failed", { error: pdf_attach_error });
148
- }
149
- }
150
- }
151
- const quotaHit = pdf_results?.some((r) => r.error?.includes("storage quota"));
152
- return {
153
- content: [
154
- {
155
- type: "text",
156
- text: JSON.stringify({
157
- success,
158
- failed: resolved.failed,
159
- ...(pdf_results !== undefined ? { pdf_results } : {}),
160
- ...(pdf_attach_error !== undefined ? { pdf_attach_error } : {}),
161
- ...(quotaHit
162
- ? {
163
- 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.",
164
- }
165
- : {}),
166
- }, null, 2),
167
- },
168
- ],
169
- };
46
+ const resolved = await resolveDois(dois);
47
+ if (resolved.success.length === 0) {
48
+ return formatErrorResponse("All DOI resolutions failed", {
49
+ failed: resolved.failed,
50
+ });
51
+ }
52
+ const zoteroItems = resolved.success.map((r) => cslToZoteroItem(r.data, {
53
+ collectionKey: collection_key,
54
+ tags,
55
+ }));
56
+ const write = await postInBatches((batch) => zoteroApi.library(libraryType, libraryId).items().post(batch), zoteroItems);
57
+ // Report resolution and write failures together, so a partial write never hides created items
58
+ const failed = [
59
+ ...resolved.failed,
60
+ ...write.failed.map(({ index, error }) => ({ doi: resolved.success[index].doi, error })),
61
+ ];
62
+ if (write.created.length === 0) {
63
+ const errorMessages = write.failed.map(({ index, error }) => `Item ${index}: ${error}`).join("; ");
64
+ return formatErrorResponse(`Zotero API write failed: ${errorMessages}`, { failed });
170
65
  }
171
- catch (err) {
172
- if (isZoteroApiError(err)) {
173
- logger.error("Tool execution failed", {
174
- tool: "add_items_by_doi",
175
- status: err.response?.status,
176
- errorMessage: err.message,
177
- url: err.response?.url,
178
- });
179
- }
180
- throw err;
66
+ const success = write.created.map(({ index, entity }) => {
67
+ const { doi, data } = resolved.success[index];
68
+ return { doi, item_key: entity.key, title: entity.title ?? data.title ?? "Untitled" };
69
+ });
70
+ let pdf_results;
71
+ const apiKey = process.env.ZOTERO_API_KEY;
72
+ if (auto_attach_pdf && apiKey) {
73
+ const targets = success.filter((s) => s.doi).map((s) => ({ itemKey: s.item_key, doi: s.doi }));
74
+ const results = await attachOpenAccessPdfs(targets, {
75
+ zoteroApi,
76
+ library: { type: libraryType, id: libraryId },
77
+ apiKey,
78
+ });
79
+ pdf_results = results.map((r) => r.status === "attached"
80
+ ? { ...r, pdf_attached: true, pdf_url: r.url_used }
81
+ : { ...r, pdf_attached: false, error: r.reason });
181
82
  }
83
+ const quotaHit = pdf_results?.some((r) => r.status === "quota_exceeded");
84
+ return formatJsonResult({
85
+ success,
86
+ failed,
87
+ ...(pdf_results !== undefined ? { pdf_results } : {}),
88
+ ...(quotaHit
89
+ ? {
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.",
91
+ }
92
+ : {}),
93
+ });
182
94
  }
@@ -1,8 +1,9 @@
1
1
  import { z } from "zod";
2
- import { isZoteroApiError } from "../types/zotero-types.js";
3
- import { formatErrorResponse } from "../utils/error-formatter.js";
4
- import { logger } from "../utils/logger.js";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
3
+ import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
+ import { postInBatches } from "../utils/write-results.js";
5
5
  import { ZOTERO_ITEM_TYPES, ITEM_TYPE_FIELDS, ITEM_TYPE_CREATOR_TYPES, TITLE_FIELD_NAME, } from "../utils/zotero-item-types.js";
6
+ import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
6
7
  const CreatorSchema = z
7
8
  .object({
8
9
  firstName: z.string().optional(),
@@ -81,19 +82,20 @@ Invalid fields or creatorTypes for a given type are rejected with helpful error
81
82
  .array(ItemSchema)
82
83
  .min(1)
83
84
  .describe("Array of items to add. Each item must have itemType and title. Additional fields depend on the item type."),
84
- collection_key: z
85
- .string()
85
+ collection_key: zoteroKey()
86
86
  .optional()
87
87
  .describe("Zotero collection key to add all items to. Get this from create_collection or get_collections."),
88
88
  tags: z
89
89
  .array(z.string())
90
90
  .optional()
91
91
  .describe("Tags to apply to all items"),
92
+ ...libraryArgsSchema,
92
93
  },
93
94
  };
94
95
  const AddItemsSchema = z.object(toolConfig.inputSchema);
95
96
  export async function handleAddItems(zoteroApi, userId, args) {
96
- const { items, collection_key, tags } = AddItemsSchema.parse(args);
97
+ const { items, collection_key, tags, library_type, library_id } = AddItemsSchema.parse(args);
98
+ const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
97
99
  const payloads = items.map((item) => {
98
100
  const { itemType, title, creators, ...fields } = item;
99
101
  const titleFieldName = TITLE_FIELD_NAME[itemType] ?? "title";
@@ -119,56 +121,19 @@ export async function handleAddItems(zoteroApi, userId, args) {
119
121
  }
120
122
  return payload;
121
123
  });
122
- try {
123
- const response = await zoteroApi
124
- .library("user", userId)
125
- .items()
126
- .post(payloads);
127
- const errors = response.getErrors();
128
- const errorIndices = new Set(Object.keys(errors));
129
- const success = [];
130
- const failed = [];
131
- for (const [idx, msg] of Object.entries(errors)) {
132
- const i = Number(idx);
133
- failed.push({ index: i, title: items[i].title, error: msg });
134
- }
135
- const created = response.getData();
136
- let successIdx = 0;
137
- for (let i = 0; i < items.length; i++) {
138
- if (!errorIndices.has(String(i))) {
139
- const entity = created[successIdx++];
140
- success.push({
141
- index: i,
142
- item_key: entity?.key ?? "unknown",
143
- title: entity?.title ?? items[i].title,
144
- item_type: items[i].itemType,
145
- });
146
- }
147
- }
148
- if (success.length === 0 && failed.length > 0) {
149
- return formatErrorResponse("All items failed to create", { failed });
150
- }
151
- return {
152
- content: [
153
- {
154
- type: "text",
155
- text: JSON.stringify({
156
- success,
157
- ...(failed.length > 0 ? { failed } : {}),
158
- }, null, 2),
159
- },
160
- ],
161
- };
162
- }
163
- catch (err) {
164
- if (isZoteroApiError(err)) {
165
- logger.error("Tool execution failed", {
166
- tool: "add_items",
167
- status: err.response?.status,
168
- errorMessage: err.message,
169
- url: err.response?.url,
170
- });
171
- }
172
- throw err;
124
+ const write = await postInBatches((batch) => zoteroApi.library(libraryType, libraryId).items().post(batch), payloads);
125
+ const success = write.created.map(({ index, entity }) => ({
126
+ index,
127
+ item_key: entity.key,
128
+ title: entity.title ?? items[index].title,
129
+ item_type: items[index].itemType,
130
+ }));
131
+ const failed = write.failed.map(({ index, error }) => ({ index, title: items[index].title, error }));
132
+ if (success.length === 0 && failed.length > 0) {
133
+ return formatErrorResponse("All items failed to create", { failed });
173
134
  }
135
+ return formatJsonResult({
136
+ success,
137
+ ...(failed.length > 0 ? { failed } : {}),
138
+ });
174
139
  }