@xevos117/mcp-zotero 2.0.3 → 2.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ 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
+ import { COLLECTION_KEY_HINT, unsafeOperationsHint, zoteroStatusHint } from "../utils/error-hints.js";
8
8
  export const toolConfig = {
9
9
  name: "delete_collection",
10
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'.",
@@ -27,28 +27,38 @@ export async function handleDeleteCollection(zoteroApi, userId, args, unsafeOps
27
27
  });
28
28
  }
29
29
  try {
30
- const response = await zoteroApi
31
- .library(libraryType, libraryId)
32
- .collections(collection_key)
33
- .get();
34
- const collection = response.getData();
35
- const version = response.getVersion();
36
- if (version === null) {
37
- return formatErrorResponse("Could not determine collection version", {
30
+ for (let attempt = 1;; attempt++) {
31
+ const response = await zoteroApi
32
+ .library(libraryType, libraryId)
33
+ .collections(collection_key)
34
+ .get();
35
+ const collection = response.getData();
36
+ const version = response.getVersion();
37
+ if (version === null) {
38
+ return formatErrorResponse("Could not determine collection version", {
39
+ collection_key,
40
+ hint: "Nothing was deleted. Retry the same call once.",
41
+ });
42
+ }
43
+ try {
44
+ await zoteroApi
45
+ .library(libraryType, libraryId)
46
+ .collections(collection_key)
47
+ .version(version)
48
+ .delete();
49
+ }
50
+ catch (err) {
51
+ // 412: the collection changed between the GET and the DELETE; read it again and retry once
52
+ if (attempt === 1 && isZoteroApiError(err) && err.response.status === 412)
53
+ continue;
54
+ throw err;
55
+ }
56
+ return formatJsonResult({
57
+ deleted: true,
38
58
  collection_key,
39
- hint: "Nothing was deleted. Retry the same call once.",
59
+ name: collection.name || collection_key,
40
60
  });
41
61
  }
42
- await zoteroApi
43
- .library(libraryType, libraryId)
44
- .collections(collection_key)
45
- .version(version)
46
- .delete();
47
- return formatJsonResult({
48
- deleted: true,
49
- collection_key,
50
- name: collection.name || collection_key,
51
- });
52
62
  }
53
63
  catch (err) {
54
64
  if (isZoteroApiError(err)) {
@@ -60,9 +70,12 @@ export async function handleDeleteCollection(zoteroApi, userId, args, unsafeOps
60
70
  });
61
71
  }
62
72
  if (err.response.status === 412) {
63
- return formatErrorResponse("Collection was modified by another client, so it was not deleted. Retry the same call.", {
73
+ const { hint } = zoteroStatusHint(412);
74
+ return formatErrorResponse("Collection was modified by another client (HTTP 412 Precondition Failed), also on a second try with its new version, so it was not deleted.", {
64
75
  collection_key,
65
76
  status: "version_conflict",
77
+ http_status: 412,
78
+ hint,
66
79
  });
67
80
  }
68
81
  }
@@ -5,7 +5,7 @@ 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
+ import { ITEM_KEY_HINT, unsafeOperationsHint, zoteroStatusHint } from "../utils/error-hints.js";
9
9
  export const toolConfig = {
10
10
  name: "delete_items",
11
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'.",
@@ -31,48 +31,64 @@ export async function handleDeleteItems(zoteroApi, userId, args, unsafeOps = "no
31
31
  });
32
32
  }
33
33
  try {
34
- const { items, version: libraryVersion } = await fetchItemsByKeys((params) => zoteroApi.library(libraryType, libraryId).items().get(params), item_keys);
35
- const foundKeys = new Set(items.map((item) => item.key).filter(Boolean));
36
- const notFoundKeys = item_keys.filter((k) => !foundKeys.has(k));
37
- if (foundKeys.size === 0) {
38
- return formatErrorResponse("No items found for the given keys", {
39
- item_keys,
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}`,
42
- });
43
- }
44
- // Use library version from response header (Last-Modified-Version),
45
- // not individual item versions — required for multi-object DELETE
46
- if (libraryVersion === null) {
47
- return formatErrorResponse("Could not determine library version", {
48
- item_keys,
49
- hint: "Nothing was deleted. Retry the same call once.",
50
- });
51
- }
52
- // only keys the caller asked for, whatever the API returned
53
- const keysToDelete = item_keys.filter((k) => foundKeys.has(k));
54
- await zoteroApi
55
- .library(libraryType, libraryId)
56
- .items()
57
- .version(libraryVersion)
58
- .delete(keysToDelete);
59
- const result = {
60
- deleted_keys: keysToDelete,
61
- deleted_count: keysToDelete.length,
62
- };
63
- if (notFoundKeys.length > 0) {
64
- result.not_found = notFoundKeys;
65
- }
66
- return formatJsonResult(result);
67
- }
68
- catch (err) {
69
- if (isZoteroApiError(err)) {
70
- if (err.response.status === 412) {
71
- return formatErrorResponse("Items were modified by another client, so nothing was deleted. Retry the same call.", {
34
+ const notFoundKeys = [];
35
+ let pending = item_keys;
36
+ for (let attempt = 1;; attempt++) {
37
+ const { items, version: libraryVersion } = await fetchItemsByKeys((params) => zoteroApi.library(libraryType, libraryId).items().get(params), pending);
38
+ const foundKeys = new Set(items.map((item) => item.key).filter(Boolean));
39
+ notFoundKeys.push(...pending.filter((k) => !foundKeys.has(k)));
40
+ if (foundKeys.size === 0) {
41
+ return formatErrorResponse("No items found for the given keys", {
42
+ item_keys,
43
+ status: "not_found",
44
+ hint: `Nothing was deleted: none of these keys exists in this library (already deleted, or in another library). ${ITEM_KEY_HINT}`,
45
+ });
46
+ }
47
+ // Use library version from response header (Last-Modified-Version),
48
+ // not individual item versions — required for multi-object DELETE
49
+ if (libraryVersion === null) {
50
+ return formatErrorResponse("Could not determine library version", {
72
51
  item_keys,
73
- status: "version_conflict",
52
+ hint: "Nothing was deleted. Retry the same call once.",
74
53
  });
75
54
  }
55
+ // only keys the caller asked for, whatever the API returned
56
+ const keysToDelete = pending.filter((k) => foundKeys.has(k));
57
+ try {
58
+ await zoteroApi
59
+ .library(libraryType, libraryId)
60
+ .items()
61
+ .version(libraryVersion)
62
+ .delete(keysToDelete);
63
+ }
64
+ catch (err) {
65
+ // 412: the library changed between the GET and the DELETE (another client, a sync). Read the new
66
+ // version and the keys that still exist, and retry once with those.
67
+ if (attempt === 1 && isZoteroApiError(err) && err.response.status === 412) {
68
+ pending = keysToDelete;
69
+ continue;
70
+ }
71
+ throw err;
72
+ }
73
+ const result = {
74
+ deleted_keys: keysToDelete,
75
+ deleted_count: keysToDelete.length,
76
+ };
77
+ if (notFoundKeys.length > 0) {
78
+ result.not_found = notFoundKeys;
79
+ }
80
+ return formatJsonResult(result);
81
+ }
82
+ }
83
+ catch (err) {
84
+ if (isZoteroApiError(err) && err.response.status === 412) {
85
+ const { hint } = zoteroStatusHint(412);
86
+ return formatErrorResponse("Items were modified by another client (HTTP 412 Precondition Failed), also on a second try with the new library version, so nothing was deleted.", {
87
+ item_keys,
88
+ status: "version_conflict",
89
+ http_status: 412,
90
+ hint,
91
+ });
76
92
  }
77
93
  throw err;
78
94
  }
@@ -123,8 +123,33 @@ const CSL_NAME_CREATOR_TYPES = [
123
123
  ["collection-editor", "seriesEditor"],
124
124
  ["translator", "translator"],
125
125
  ];
126
+ /** Zotero field holding the CSL publisher, for the types that call it otherwise (Zotero's base field mappings). */
127
+ const PUBLISHER_FIELD = {
128
+ thesis: "university",
129
+ report: "institution",
130
+ preprint: "repository",
131
+ };
132
+ /** Zotero field holding the CSL number (report number, standard or manuscript number). */
133
+ const NUMBER_FIELD = {
134
+ report: "reportNumber",
135
+ standard: "number",
136
+ manuscript: "number",
137
+ };
138
+ /** arXiv DOIs (DataCite): the item is a preprint in the arXiv repository. */
139
+ const ARXIV_DOI = /^10\.48550\/arxiv\.(.+)$/i;
140
+ /** JATS abstract (Crossref: <jats:p>, <jats:italic>, …) → plain text, one line per paragraph, as Zotero's import does. */
141
+ function plainAbstract(abstract) {
142
+ if (!/<jats:/.test(abstract))
143
+ return abstract;
144
+ return abstract
145
+ .split(/<\/jats:p>/)
146
+ .map((p) => p.replace(/<\/?jats:[^>]*>/g, " ").replace(/\s+/g, " ").trim())
147
+ .filter(Boolean)
148
+ .join("\n");
149
+ }
126
150
  export function cslToZoteroItem(csl, options) {
127
- const itemType = (CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle");
151
+ const arxiv = ARXIV_DOI.exec(csl.DOI ?? "");
152
+ const itemType = (arxiv ? "preprint" : (CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle"));
128
153
  // Authors always (as before); other roles only where the item type has them, or the API rejects the item.
129
154
  // A CSL `literal` (institution, consortium) is Zotero's single-field name
130
155
  const validCreatorTypes = ITEM_TYPE_CREATOR_TYPES[itemType];
@@ -138,25 +163,39 @@ export function cslToZoteroItem(csl, options) {
138
163
  collections.push(options.collectionKey);
139
164
  }
140
165
  const tags = (options?.tags ?? []).map((t) => ({ tag: t }));
166
+ // Zotero joins the subtitle to the title; the article number is the item's number, or its pages when it has none
167
+ const subtitle = Array.isArray(csl.subtitle) ? csl.subtitle[0] : csl.subtitle;
168
+ const title = subtitle ? `${csl.title ?? ""}: ${subtitle}` : (csl.title ?? "");
169
+ const number = csl.number ?? csl["article-number"] ?? "";
170
+ const numberField = NUMBER_FIELD[itemType];
141
171
  // Build candidate fields (CSL → Zotero field names)
142
172
  const candidates = {
143
- title: csl.title ?? "",
173
+ title,
144
174
  date,
145
175
  DOI: csl.DOI ?? "",
146
176
  volume: csl.volume ?? "",
147
177
  issue: csl.issue ?? "",
148
- pages: csl.page ?? "",
178
+ pages: csl.page ?? (numberField ? "" : (csl["article-number"] ?? "")),
149
179
  publisher: csl.publisher ?? "",
150
180
  place: csl["publisher-place"] ?? "",
151
181
  url: csl.URL ?? "",
152
- abstractNote: csl.abstract ?? "",
182
+ abstractNote: csl.abstract ? plainAbstract(csl.abstract) : "",
153
183
  ISBN: Array.isArray(csl.ISBN) ? csl.ISBN[0] : csl.ISBN ?? "",
154
184
  ISSN: Array.isArray(csl.ISSN) ? csl.ISSN[0] : csl.ISSN ?? "",
155
185
  edition: csl.edition ?? "",
156
186
  numPages: csl["number-of-pages"] ?? "",
157
187
  series: csl["collection-title"] ?? "",
158
188
  language: csl.language ?? "",
189
+ journalAbbreviation: csl["container-title-short"] ?? "",
190
+ conferenceName: csl["event-title"] ?? csl.event ?? "",
159
191
  };
192
+ if (numberField)
193
+ candidates[numberField] = number;
194
+ const publisherField = PUBLISHER_FIELD[itemType];
195
+ if (publisherField)
196
+ candidates[publisherField] = arxiv ? "arXiv" : (csl.publisher ?? "");
197
+ if (arxiv)
198
+ candidates.archiveID = `arXiv:${arxiv[1]}`;
160
199
  // Map container-title to the correct field for this item type
161
200
  const containerTitleField = CONTAINER_TITLE_FIELD[itemType];
162
201
  if (containerTitleField) {
@@ -193,6 +232,16 @@ function containerTitle(item) {
193
232
  const value = (field && fields[field]) || item.publicationTitle;
194
233
  return typeof value === "string" && value ? value : undefined;
195
234
  }
235
+ /**
236
+ * CSL date of a Zotero date: "YYYY-MM-DD" (or a prefix of it) as date-parts; a date that does not start that way
237
+ * ("October 2023", "Spring 2023") as `raw`, which citeproc parses itself, instead of being dropped.
238
+ */
239
+ export function cslDate(value) {
240
+ if (!value)
241
+ return undefined;
242
+ const parts = value.split("-").map(Number).filter((n) => !isNaN(n));
243
+ return parts.length > 0 ? { "date-parts": [parts] } : { raw: value };
244
+ }
196
245
  export function zoteroItemToCsl(item) {
197
246
  const names = {};
198
247
  for (const [variable, creatorType] of CSL_NAME_CREATOR_TYPES) {
@@ -200,28 +249,34 @@ export function zoteroItemToCsl(item) {
200
249
  if (list.length > 0)
201
250
  names[variable] = list;
202
251
  }
203
- let issued;
204
- if (item.date) {
205
- const parts = item.date.split("-").map(Number).filter((n) => !isNaN(n));
206
- if (parts.length > 0) {
207
- issued = { "date-parts": [parts] };
208
- }
209
- }
210
252
  // The Zotero API returns every field of the item type, empty ones as "": keep them out of the field code
211
- const text = (v) => (v ? v : undefined);
253
+ const fields = item;
254
+ const text = (...values) => values.find((v) => typeof v === "string" && v !== "") ?? undefined;
255
+ // accessDate is "YYYY-MM-DD hh:mm:ss" or ISO: only the day
256
+ const accessDate = text(fields.accessDate)?.replace(/[T ].*$/, "");
212
257
  return {
213
258
  type: ZOTERO_TO_CSL_TYPE[item.itemType ?? ""] ?? "article-journal",
214
259
  title: text(item.title),
215
260
  ...names,
216
- issued,
261
+ issued: cslDate(item.date),
217
262
  DOI: text(item.DOI),
218
263
  "container-title": containerTitle(item),
219
264
  volume: text(item.volume),
220
265
  issue: text(item.issue),
221
266
  page: text(item.pages),
222
- publisher: text(item.publisher),
267
+ // Zotero's base fields: university (thesis), institution (report), repository (preprint) are the publisher;
268
+ // reportNumber and archiveID the number; thesisType and reportType the genre
269
+ publisher: text(item.publisher, fields.university, fields.institution, fields.repository),
223
270
  "publisher-place": text(item.place),
271
+ number: text(fields.reportNumber, fields.archiveID),
272
+ genre: text(fields.thesisType, fields.reportType),
224
273
  URL: text(item.url),
274
+ accessed: cslDate(accessDate),
275
+ ISBN: text(fields.ISBN),
276
+ ISSN: text(fields.ISSN),
277
+ edition: text(fields.edition),
278
+ language: text(fields.language),
279
+ "number-of-pages": text(fields.numPages),
225
280
  // abstract deliberately omitted: it bloats every field code (same choice as the skill's inject.js)
226
281
  };
227
282
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xevos117/mcp-zotero",
3
3
  "type": "module",
4
- "version": "2.0.3",
4
+ "version": "2.0.4",
5
5
  "mcpName": "io.github.Xevos117/mcp-zotero",
6
6
  "description": "Unofficial MCP server for Zotero integration with citation management",
7
7
  "repository": {