@xevos117/mcp-zotero 1.0.5 → 1.0.7

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 CHANGED
@@ -5,7 +5,7 @@
5
5
  A Model Context Protocol server for Zotero integration. It gives any LLM full access to your Zotero library: search, organize, add papers by DOI, import PDFs, read full-text content, and inject live citations into Word documents.
6
6
 
7
7
  > Originally based on [mcp-zotero](https://github.com/kaliaboi/mcp-zotero) by Abhishek Kalia.
8
- > This project has since been extensively rewritten with a new architecture, 13 tools (up from 5), citation injection, PDF management, and Claude skill support.
8
+ > This project has since been extensively rewritten with a new architecture, 15 tools (up from 5), citation injection, PDF management, and Claude skill support.
9
9
 
10
10
  ## How it works
11
11
 
@@ -17,12 +17,18 @@ For advanced use cases (PDF upload policy, citation style guidance, source trans
17
17
 
18
18
  | Scenario | MCP server | Skill needed? |
19
19
  |---|---|---|
20
- | Local LLM (Claude Code, LM Studio, etc.) | All 13 tools | No |
21
- | Remote/sandboxed LLM (Claude.ai Projects) | API tools (search, add, metadata) | Yes, for citation injection |
20
+ | LLM with filesystem access (Claude Code, LM Studio, etc.) | All 15 tools | No |
21
+ | LLM without filesystem access (Claude.ai Projects, Claude Desktop) | API tools (search, add, metadata) | Yes, for citation injection |
22
22
 
23
- Local LLMs with filesystem access can use all tools directly, including `inject_citations` which reads and writes `.docx` files on disk.
23
+ LLMs with filesystem access can use all tools directly, including `inject_citations` which reads and writes `.docx` files on disk.
24
24
 
25
- Remote LLMs without filesystem access can use the included **Claude skill** (`skills/zotero-skill-mcp-integrations/`), which runs citation injection entirely inside the sandbox. MCP tools handle all Zotero API operations; the skill handles document assembly.
25
+ LLMs without filesystem access — including Claude Desktop, which connects to MCP but cannot generate files locally — can use the included **Claude skill** (`skills/zotero-skill-mcp-integrations/`), which runs citation injection entirely inside a sandbox. MCP tools handle all Zotero API operations; the skill handles document assembly.
26
+
27
+ ### Claude Skill Setup (for Claude.ai Projects and Claude Desktop)
28
+
29
+ 1. Download the skill `.zip` from the latest [GitHub Release](https://github.com/Xevos117/mcp-zotero/releases)
30
+ 2. Extract it and upload the folder to your Claude.ai Project as a skill
31
+ 3. The skill enables citation injection directly inside the sandbox, without requiring local filesystem access
26
32
 
27
33
  ## Setup
28
34
 
@@ -41,6 +47,7 @@ Remote LLMs without filesystem access can use the included **Claude skill** (`sk
41
47
  export ZOTERO_API_KEY="your-api-key"
42
48
  export ZOTERO_USER_ID="user-id-from-curl"
43
49
  export UNPAYWALL_EMAIL="your@email.edu" # Optional: enables OA PDF lookup via Unpaywall
50
+ export UNSAFE_OPERATIONS="none" # Optional: "none" | "items" | "all" (see below)
44
51
  ```
45
52
 
46
53
  ## Environment Variables
@@ -50,6 +57,45 @@ Remote LLMs without filesystem access can use the included **Claude skill** (`sk
50
57
  | `ZOTERO_API_KEY` | Yes | API key for Zotero Web API v3. Create one at [zotero.org/settings/keys](https://www.zotero.org/settings/keys) with library read/write and file access permissions. |
51
58
  | `ZOTERO_USER_ID` | Yes | Your Zotero numeric user ID. Retrieve it with `curl -H "Zotero-API-Key: KEY" https://api.zotero.org/keys/current`. |
52
59
  | `UNPAYWALL_EMAIL` | No | Email for Unpaywall API requests ([rate-limit policy](https://unpaywall.org/products/api)). Enables OA PDF lookup in `add_items_by_doi` and `find_and_attach_pdfs`. If not set, OA PDF features are silently skipped. |
60
+ | `UNSAFE_OPERATIONS` | No | Controls destructive operations (deletion). See [Unsafe Operations](#unsafe-operations) below. Default: `none` (all deletions blocked). |
61
+
62
+ ### Unsafe Operations
63
+
64
+ By default, the MCP server **does not allow any deletion**. This is a safety measure to prevent an LLM from accidentally deleting items or collections from your library.
65
+
66
+ To enable deletion, set the `UNSAFE_OPERATIONS` environment variable to one of the following values:
67
+
68
+ | Value | `delete_items` | `delete_collection` | Use case |
69
+ |---|---|---|---|
70
+ | `none` (default) | Blocked | Blocked | Safe mode — no deletions possible |
71
+ | `items` | **Allowed** | Blocked | Allow deleting items but protect collection structure |
72
+ | `all` | **Allowed** | **Allowed** | Full access — items and collections can be deleted |
73
+
74
+ **Important notes:**
75
+
76
+ - If `UNSAFE_OPERATIONS` is not set, empty, or set to an unrecognized value, it defaults to `none`.
77
+ - 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).
79
+ - `delete_collection` removes the collection (folder) only — items inside it are **not** deleted and remain in your library.
80
+ - The `all` value includes both item and collection deletion because managing collections inherently requires item-level access.
81
+
82
+ **Configuration example:**
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "zotero": {
88
+ "command": "npx",
89
+ "args": ["-y", "@xevos117/mcp-zotero"],
90
+ "env": {
91
+ "ZOTERO_API_KEY": "YOUR_API_KEY",
92
+ "ZOTERO_USER_ID": "YOUR_USER_ID",
93
+ "UNSAFE_OPERATIONS": "items"
94
+ }
95
+ }
96
+ }
97
+ }
98
+ ```
53
99
 
54
100
  ## Integration with Claude Desktop
55
101
 
@@ -100,6 +146,13 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
100
146
  | `find_and_attach_pdfs` | Batch OA PDF lookup and auto-attach via Unpaywall (by item keys or collection) |
101
147
  | `add_linked_url_attachment` | Attach a URL to an existing item or create a standalone link |
102
148
 
149
+ ### Deleting content
150
+
151
+ | Tool | Description |
152
+ |---|---|
153
+ | `delete_items` | Delete up to 50 items per call (moves to Zotero trash). Requires `UNSAFE_OPERATIONS=items` or `all` |
154
+ | `delete_collection` | Delete a collection (folder). Items inside are kept. Requires `UNSAFE_OPERATIONS=all` |
155
+
103
156
  ### Citation & documents
104
157
 
105
158
  | Tool | Description |
@@ -112,7 +165,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
112
165
  ```bash
113
166
  npm install
114
167
  npm run build # Compile TypeScript
115
- npm test # Run tests (vitest, 328 tests)
168
+ npm test # Run tests (vitest, 382 tests)
116
169
  npx tsx src/server.ts # Run directly without building
117
170
  ```
118
171
 
package/build/server.js CHANGED
@@ -5,6 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
5
5
  import { createRequire } from "module";
6
6
  import { registerAllTools } from "./tools/index.js";
7
7
  import { logger } from "./utils/logger.js";
8
+ import { parseUnsafeOperations } from "./utils/unsafe-operations.js";
8
9
  // Re-export for backward compatibility with existing tests
9
10
  export { formatErrorResponse } from "./utils/error-formatter.js";
10
11
  export { handleToolCall } from "./tools/index.js";
@@ -12,6 +13,7 @@ class ZoteroServer {
12
13
  server;
13
14
  zoteroApi;
14
15
  userId;
16
+ unsafeOps;
15
17
  constructor(options = {}) {
16
18
  this.server = new McpServer({ name: "zotero", version: "1.0.0" }, {
17
19
  capabilities: { tools: {} },
@@ -30,6 +32,7 @@ class ZoteroServer {
30
32
  "- get_user_id is only needed by the standalone skill script (inject.js), not by the inject_citations tool.",
31
33
  ].join("\n"),
32
34
  });
35
+ this.unsafeOps = parseUnsafeOperations(process.env.UNSAFE_OPERATIONS);
33
36
  if (options.zoteroApi) {
34
37
  this.userId = options.userId || "";
35
38
  this.zoteroApi = options.zoteroApi;
@@ -46,7 +49,7 @@ class ZoteroServer {
46
49
  }
47
50
  }
48
51
  setupHandlers() {
49
- registerAllTools(this.server, this.zoteroApi, this.userId);
52
+ registerAllTools(this.server, this.zoteroApi, this.userId, this.unsafeOps);
50
53
  }
51
54
  async start() {
52
55
  this.setupHandlers();
@@ -105,12 +105,31 @@ export async function handleAddItemsByDoi(zoteroApi, userId, args) {
105
105
  .library("user", userId)
106
106
  .items()
107
107
  .post(zoteroItems);
108
+ if (!response.isSuccess()) {
109
+ const errors = response.getErrors();
110
+ const errorMessages = Object.entries(errors)
111
+ .map(([idx, msg]) => `Item ${idx}: ${typeof msg === "object" ? JSON.stringify(msg) : msg}`)
112
+ .join(", ");
113
+ return formatErrorResponse(`Zotero API write failed: ${errorMessages}`);
114
+ }
108
115
  const createdItems = response.getData();
109
- const success = resolved.success.map((r, i) => ({
110
- doi: r.doi,
111
- item_key: createdItems[i]?.key ?? "unknown",
112
- title: createdItems[i]?.title ?? r.data.title ?? "Untitled",
113
- }));
116
+ if (!createdItems || !Array.isArray(createdItems) || createdItems.length === 0) {
117
+ return formatErrorResponse("Zotero API returned empty response - items may not have been created");
118
+ }
119
+ const success = [];
120
+ for (let i = 0; i < resolved.success.length; i++) {
121
+ const r = resolved.success[i];
122
+ const entity = response.getEntityByIndex(i);
123
+ const itemKey = entity?.key;
124
+ if (!itemKey) {
125
+ return formatErrorResponse(`Failed to get item key for DOI ${r.doi} at index ${i}. API response may be invalid.`);
126
+ }
127
+ success.push({
128
+ doi: r.doi,
129
+ item_key: itemKey,
130
+ title: entity?.title ?? r.data.title ?? "Untitled",
131
+ });
132
+ }
114
133
  let pdf_results;
115
134
  if (auto_attach_pdf) {
116
135
  const apiKey = process.env.ZOTERO_API_KEY;
@@ -0,0 +1,79 @@
1
+ import { z } from "zod";
2
+ import { isZoteroApiError } from "../types/zotero-types.js";
3
+ import { formatErrorResponse } from "../utils/error-formatter.js";
4
+ import { canDeleteCollections } from "../utils/unsafe-operations.js";
5
+ import { logger } from "../utils/logger.js";
6
+ export const toolConfig = {
7
+ name: "delete_collection",
8
+ 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'.",
9
+ inputSchema: {
10
+ collection_key: z
11
+ .string()
12
+ .min(1)
13
+ .describe("Zotero collection key to delete. Get this from get_collections."),
14
+ },
15
+ };
16
+ const DeleteCollectionSchema = z.object(toolConfig.inputSchema);
17
+ export async function handleDeleteCollection(zoteroApi, userId, args, unsafeOps = "none") {
18
+ const { collection_key } = DeleteCollectionSchema.parse(args);
19
+ if (!canDeleteCollections(unsafeOps)) {
20
+ return formatErrorResponse("Deletion of collections is not allowed. Set the UNSAFE_OPERATIONS environment variable to 'all' to enable this operation.", {
21
+ env_var: "UNSAFE_OPERATIONS",
22
+ current_value: unsafeOps,
23
+ required_values: ["all"],
24
+ });
25
+ }
26
+ try {
27
+ const response = await zoteroApi
28
+ .library("user", userId)
29
+ .collections(collection_key)
30
+ .get();
31
+ const collection = response.getData();
32
+ const version = response.getVersion();
33
+ if (version === null) {
34
+ return formatErrorResponse("Could not determine collection version", {
35
+ collection_key,
36
+ });
37
+ }
38
+ await zoteroApi
39
+ .library("user", userId)
40
+ .collections(collection_key)
41
+ .version(version)
42
+ .delete();
43
+ return {
44
+ content: [
45
+ {
46
+ type: "text",
47
+ text: JSON.stringify({
48
+ deleted: true,
49
+ collection_key,
50
+ name: collection.name || collection_key,
51
+ }, null, 2),
52
+ },
53
+ ],
54
+ };
55
+ }
56
+ catch (err) {
57
+ if (isZoteroApiError(err)) {
58
+ if (err.response.status === 404) {
59
+ return formatErrorResponse("Collection not found", {
60
+ collection_key,
61
+ status: "not_found",
62
+ });
63
+ }
64
+ if (err.response.status === 412) {
65
+ return formatErrorResponse("Collection was modified by another client. Retry the operation.", {
66
+ collection_key,
67
+ status: "version_conflict",
68
+ });
69
+ }
70
+ logger.error("Tool execution failed", {
71
+ tool: "delete_collection",
72
+ status: err.response.status,
73
+ errorMessage: err.message,
74
+ url: err.response.url,
75
+ });
76
+ }
77
+ throw err;
78
+ }
79
+ }
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+ import { isZoteroApiError } from "../types/zotero-types.js";
3
+ import { formatErrorResponse } from "../utils/error-formatter.js";
4
+ import { canDeleteItems } from "../utils/unsafe-operations.js";
5
+ import { logger } from "../utils/logger.js";
6
+ export const toolConfig = {
7
+ name: "delete_items",
8
+ description: "Delete one or more items from your Zotero library permanently (moves to trash). Accepts up to 50 item keys per call. Requires UNSAFE_OPERATIONS environment variable set to 'items' or 'all'.",
9
+ inputSchema: {
10
+ item_keys: z
11
+ .array(z.string())
12
+ .min(1)
13
+ .max(50)
14
+ .describe('Array of Zotero item keys to delete (e.g. ["EUHUT5K3", "F9UQM7N2"]). Max 50 per call.'),
15
+ },
16
+ };
17
+ const DeleteItemsSchema = z.object(toolConfig.inputSchema);
18
+ export async function handleDeleteItems(zoteroApi, userId, args, unsafeOps = "none") {
19
+ const { item_keys } = DeleteItemsSchema.parse(args);
20
+ if (!canDeleteItems(unsafeOps)) {
21
+ return formatErrorResponse("Deletion of items is not allowed. Set the UNSAFE_OPERATIONS environment variable to 'items' or 'all' to enable this operation.", {
22
+ env_var: "UNSAFE_OPERATIONS",
23
+ current_value: unsafeOps,
24
+ required_values: ["items", "all"],
25
+ });
26
+ }
27
+ try {
28
+ const response = await zoteroApi
29
+ .library("user", userId)
30
+ .items()
31
+ .get({ itemKey: item_keys.join(",") });
32
+ const items = response.getData();
33
+ const itemList = Array.isArray(items) ? items : [items];
34
+ const foundKeys = new Set(itemList.map((item) => item.key).filter(Boolean));
35
+ const notFoundKeys = item_keys.filter((k) => !foundKeys.has(k));
36
+ if (foundKeys.size === 0) {
37
+ return formatErrorResponse("No items found for the given keys", {
38
+ item_keys,
39
+ status: "not_found",
40
+ });
41
+ }
42
+ // Use library version from response header (Last-Modified-Version),
43
+ // not individual item versions — required for multi-object DELETE
44
+ const libraryVersion = response.getVersion();
45
+ if (libraryVersion === null) {
46
+ return formatErrorResponse("Could not determine library version", {
47
+ item_keys,
48
+ });
49
+ }
50
+ const keysToDelete = [...foundKeys];
51
+ await zoteroApi
52
+ .library("user", userId)
53
+ .items()
54
+ .version(libraryVersion)
55
+ .delete(keysToDelete);
56
+ const result = {
57
+ deleted_keys: keysToDelete,
58
+ deleted_count: keysToDelete.length,
59
+ };
60
+ if (notFoundKeys.length > 0) {
61
+ result.not_found = notFoundKeys;
62
+ }
63
+ return {
64
+ content: [
65
+ {
66
+ type: "text",
67
+ text: JSON.stringify(result, null, 2),
68
+ },
69
+ ],
70
+ };
71
+ }
72
+ catch (err) {
73
+ if (isZoteroApiError(err)) {
74
+ if (err.response.status === 412) {
75
+ return formatErrorResponse("Items were modified by another client. Retry the operation.", {
76
+ item_keys,
77
+ status: "version_conflict",
78
+ });
79
+ }
80
+ logger.error("Tool execution failed", {
81
+ tool: "delete_items",
82
+ status: err.response.status,
83
+ errorMessage: err.message,
84
+ url: err.response.url,
85
+ });
86
+ }
87
+ throw err;
88
+ }
89
+ }
@@ -5,6 +5,7 @@ import { logger } from "../utils/logger.js";
5
5
  import { lookupOaPdfWithFallbacks } from "../utils/unpaywall.js";
6
6
  import { downloadAndUploadPdf } from "../utils/pdf-uploader.js";
7
7
  import { mapWithConcurrency } from "../utils/concurrency.js";
8
+ import { fetchAllPages } from "../utils/pagination.js";
8
9
  export const toolConfig = {
9
10
  name: "find_and_attach_pdfs",
10
11
  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'.",
@@ -45,14 +46,8 @@ export async function handleFindAndAttachPdfs(zoteroApi, userId, args) {
45
46
  // 1. Resolve item keys
46
47
  let keys;
47
48
  if (collection_key) {
48
- const collResponse = await zoteroApi
49
- .library("user", userId)
50
- .collections(collection_key)
51
- .items()
52
- .get();
53
- const collItems = collResponse.getData();
54
- const itemsArray = Array.isArray(collItems) ? collItems : [collItems];
55
- keys = itemsArray
49
+ const { items: collItems } = await fetchAllPages((params) => zoteroApi.library("user", userId).collections(collection_key).items().get(params));
50
+ keys = collItems
56
51
  .filter((item) => item.itemType !== "attachment" && item.itemType !== "note")
57
52
  .map((item) => item.key)
58
53
  .filter(Boolean);
@@ -3,6 +3,7 @@ import { isZoteroApiError } from "../types/zotero-types.js";
3
3
  import { formatErrorResponse } from "../utils/error-formatter.js";
4
4
  import { formatCreators, formatTags } from "../utils/item-formatter.js";
5
5
  import { logger } from "../utils/logger.js";
6
+ import { fetchAllPages } from "../utils/pagination.js";
6
7
  export const toolConfig = {
7
8
  name: "get_collection_items",
8
9
  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.",
@@ -18,19 +19,15 @@ const CollectionItemsSchema = z.object(toolConfig.inputSchema);
18
19
  export async function handleGetCollectionItems(zoteroApi, userId, args) {
19
20
  const { collectionKey, excludeAttachments } = CollectionItemsSchema.parse(args);
20
21
  try {
21
- const response = await zoteroApi
22
- .library("user", userId)
23
- .collections(collectionKey)
24
- .items()
25
- .get();
26
- let items = response.getData();
27
- if (!items || !Array.isArray(items) || items.length === 0) {
22
+ const { items: allItems, totalResults } = await fetchAllPages((params) => zoteroApi.library("user", userId).collections(collectionKey).items().get(params));
23
+ if (!allItems || allItems.length === 0) {
28
24
  return formatErrorResponse("Collection is empty", {
29
25
  collectionKey,
30
26
  suggestion: "Add some items to this collection in Zotero",
31
27
  status: "empty",
32
28
  });
33
29
  }
30
+ let items = allItems;
34
31
  if (excludeAttachments) {
35
32
  items = items.filter((item) => item.itemType !== "attachment" && item.itemType !== "note");
36
33
  }
@@ -58,7 +55,11 @@ export async function handleGetCollectionItems(zoteroApi, userId, args) {
58
55
  content: [
59
56
  {
60
57
  type: "text",
61
- text: JSON.stringify(formatted, null, 2),
58
+ text: JSON.stringify({
59
+ total_items: totalResults,
60
+ returned_items: formatted.length,
61
+ items: formatted,
62
+ }, null, 2),
62
63
  },
63
64
  ],
64
65
  };
@@ -1,6 +1,7 @@
1
1
  import { isZoteroApiError } from "../types/zotero-types.js";
2
2
  import { formatErrorResponse } from "../utils/error-formatter.js";
3
3
  import { logger } from "../utils/logger.js";
4
+ import { fetchAllPages } from "../utils/pagination.js";
4
5
  export const toolConfig = {
5
6
  name: "get_collections",
6
7
  description: "List all collections (folders) in your Zotero library. Returns collection keys, names, and parent relationships. Use collection keys with get_collection_items or as parent_collection in create_collection.",
@@ -8,11 +9,7 @@ export const toolConfig = {
8
9
  };
9
10
  export async function handleGetCollections(zoteroApi, userId, _args) {
10
11
  try {
11
- const response = await zoteroApi
12
- .library("user", userId)
13
- .collections()
14
- .get();
15
- const collections = response.getData();
12
+ const { items: collections } = await fetchAllPages((params) => zoteroApi.library("user", userId).collections().get(params));
16
13
  if (!Array.isArray(collections) || collections.length === 0) {
17
14
  return formatErrorResponse("No collections found", {
18
15
  suggestion: "Create a collection in your Zotero library first",
@@ -11,6 +11,8 @@ import { handleAddLinkedUrlAttachment, toolConfig as addLinkedUrlAttachmentConfi
11
11
  import { handleAddItems, toolConfig as addItemsConfig } from "./add-items.js";
12
12
  import { handleImportPdfToZotero, toolConfig as importPdfToZoteroConfig } from "./import-pdf-to-zotero.js";
13
13
  import { handleFindAndAttachPdfs, toolConfig as findAndAttachPdfsConfig } from "./find-and-attach-pdfs.js";
14
+ import { handleDeleteCollection, toolConfig as deleteCollectionConfig } from "./delete-collection.js";
15
+ import { handleDeleteItems, toolConfig as deleteItemsConfig } from "./delete-items.js";
14
16
  const handlers = {
15
17
  get_collections: handleGetCollections,
16
18
  get_collection_items: handleGetCollectionItems,
@@ -25,13 +27,15 @@ const handlers = {
25
27
  add_items: handleAddItems,
26
28
  import_pdf_to_zotero: handleImportPdfToZotero,
27
29
  find_and_attach_pdfs: handleFindAndAttachPdfs,
30
+ delete_collection: handleDeleteCollection,
31
+ delete_items: handleDeleteItems,
28
32
  };
29
- export async function handleToolCall(name, args, zoteroApi, userId) {
33
+ export async function handleToolCall(name, args, zoteroApi, userId, unsafeOps = "none") {
30
34
  const handler = handlers[name];
31
35
  if (!handler) {
32
36
  throw new Error(`Unknown tool: ${name}`);
33
37
  }
34
- return handler(zoteroApi, userId, args);
38
+ return handler(zoteroApi, userId, args, unsafeOps);
35
39
  }
36
40
  const toolConfigs = [
37
41
  { config: collectionsConfig, handler: handleGetCollections },
@@ -47,14 +51,16 @@ const toolConfigs = [
47
51
  { config: addItemsConfig, handler: handleAddItems },
48
52
  { config: importPdfToZoteroConfig, handler: handleImportPdfToZotero },
49
53
  { config: findAndAttachPdfsConfig, handler: handleFindAndAttachPdfs },
54
+ { config: deleteCollectionConfig, handler: handleDeleteCollection },
55
+ { config: deleteItemsConfig, handler: handleDeleteItems },
50
56
  ];
51
- export function registerAllTools(server, zoteroApi, userId) {
57
+ export function registerAllTools(server, zoteroApi, userId, unsafeOps = "none") {
52
58
  for (const { config, handler } of toolConfigs) {
53
59
  server.registerTool(config.name, {
54
60
  description: config.description,
55
61
  inputSchema: config.inputSchema,
56
62
  }, async (args) => {
57
- return handler(zoteroApi, userId, args);
63
+ return handler(zoteroApi, userId, args, unsafeOps);
58
64
  });
59
65
  }
60
66
  }
@@ -101,8 +101,8 @@ export function cslToZoteroItem(csl, options) {
101
101
  place: csl["publisher-place"] ?? "",
102
102
  url: csl.URL ?? "",
103
103
  abstractNote: csl.abstract ?? "",
104
- ISBN: csl.ISBN ?? "",
105
- ISSN: csl.ISSN ?? "",
104
+ ISBN: Array.isArray(csl.ISBN) ? csl.ISBN[0] : csl.ISBN ?? "",
105
+ ISSN: Array.isArray(csl.ISSN) ? csl.ISSN[0] : csl.ISSN ?? "",
106
106
  edition: csl.edition ?? "",
107
107
  numPages: csl["number-of-pages"] ?? "",
108
108
  series: csl["collection-title"] ?? "",
@@ -0,0 +1,22 @@
1
+ const PAGE_LIMIT = 100;
2
+ export async function fetchAllPages(buildRequest) {
3
+ const firstResponse = await buildRequest({ limit: PAGE_LIMIT, start: 0 });
4
+ const firstData = firstResponse.getData();
5
+ const firstItems = Array.isArray(firstData) ? firstData : firstData ? [firstData] : [];
6
+ const totalResults = firstResponse.getTotalResults();
7
+ if (totalResults === null || firstItems.length >= totalResults) {
8
+ return { items: firstItems, totalResults };
9
+ }
10
+ const allItems = [...firstItems];
11
+ let start = firstItems.length;
12
+ while (start < totalResults) {
13
+ const response = await buildRequest({ limit: PAGE_LIMIT, start });
14
+ const data = response.getData();
15
+ const pageItems = Array.isArray(data) ? data : data ? [data] : [];
16
+ if (pageItems.length === 0)
17
+ break;
18
+ allItems.push(...pageItems);
19
+ start += pageItems.length;
20
+ }
21
+ return { items: allItems, totalResults };
22
+ }
@@ -0,0 +1,14 @@
1
+ const VALID_MODES = new Set(["none", "items", "all"]);
2
+ export function parseUnsafeOperations(value) {
3
+ const normalized = (value ?? "").trim().toLowerCase();
4
+ if (VALID_MODES.has(normalized)) {
5
+ return normalized;
6
+ }
7
+ return "none";
8
+ }
9
+ export function canDeleteCollections(mode) {
10
+ return mode === "all";
11
+ }
12
+ export function canDeleteItems(mode) {
13
+ return mode === "items" || mode === "all";
14
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xevos117/mcp-zotero",
3
3
  "type": "module",
4
- "version": "1.0.5",
4
+ "version": "1.0.7",
5
5
  "description": "Unofficial MCP server for Zotero integration with citation management",
6
6
  "repository": {
7
7
  "type": "git",