@xevos117/mcp-zotero 2.0.0 → 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.
@@ -123,6 +123,12 @@ export async function injectCitations(filePath, zoteroApi, libraryType, userId,
123
123
  }
124
124
  // Collect all unique item keys
125
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
+ }
126
132
  // Fetch CSL data from Zotero
127
133
  const cslData = await fetchCslData(uniqueKeys, zoteroApi, libraryType, userId);
128
134
  // Replace each zcite tag with a field code
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
3
4
  import { resolveDois } from "../utils/doi-resolver.js";
4
5
  import { cslToZoteroItem } from "../utils/csl-to-zotero.js";
@@ -21,8 +22,7 @@ WORKFLOW TIPS:
21
22
  dois: z
22
23
  .array(z.string())
23
24
  .describe('Array of DOI strings (e.g. ["10.1038/s41586-023-06647-8"]). Each DOI will be resolved and added to Zotero.'),
24
- collection_key: z
25
- .string()
25
+ collection_key: zoteroKey()
26
26
  .optional()
27
27
  .describe("Zotero collection key to add items to. Get this from create_collection or get_collections."),
28
28
  tags: z
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
3
4
  import { postInBatches } from "../utils/write-results.js";
4
5
  import { ZOTERO_ITEM_TYPES, ITEM_TYPE_FIELDS, ITEM_TYPE_CREATOR_TYPES, TITLE_FIELD_NAME, } from "../utils/zotero-item-types.js";
@@ -81,8 +82,7 @@ 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
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
3
4
  import { formatWriteErrors } from "../utils/write-results.js";
4
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
@@ -15,12 +16,11 @@ export const toolConfig = {
15
16
  .string()
16
17
  .optional()
17
18
  .describe('MIME type of the linked resource (e.g. "application/pdf", "text/html")'),
18
- parent_item: z
19
- .string()
19
+ parent_item: zoteroKey()
20
20
  .optional()
21
21
  .describe("Item key of the parent item. If provided, the attachment becomes a child of that item."),
22
22
  collections: z
23
- .array(z.string())
23
+ .array(zoteroKey())
24
24
  .optional()
25
25
  .describe("Collection keys to add the attachment to (only used for standalone attachments, ignored when parent_item is set)"),
26
26
  tags: z
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
3
4
  import { formatWriteErrors } from "../utils/write-results.js";
4
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
@@ -7,8 +8,7 @@ export const toolConfig = {
7
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.",
8
9
  inputSchema: {
9
10
  name: z.string().describe("Name of the new collection"),
10
- parent_collection: z
11
- .string()
11
+ parent_collection: zoteroKey()
12
12
  .optional()
13
13
  .describe("Zotero collection key of the parent collection. Get this from get_collections."),
14
14
  ...libraryArgsSchema,
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { isZoteroApiError } from "../types/zotero-types.js";
3
4
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
5
  import { canDeleteCollections } from "../utils/unsafe-operations.js";
@@ -7,9 +8,7 @@ export const toolConfig = {
7
8
  name: "delete_collection",
8
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'.",
9
10
  inputSchema: {
10
- collection_key: z
11
- .string()
12
- .min(1)
11
+ collection_key: zoteroKey()
13
12
  .describe("Zotero collection key to delete. Get this from get_collections."),
14
13
  ...libraryArgsSchema,
15
14
  },
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { isZoteroApiError } from "../types/zotero-types.js";
3
4
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
5
  import { canDeleteItems } from "../utils/unsafe-operations.js";
@@ -9,7 +10,7 @@ export const toolConfig = {
9
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'.",
10
11
  inputSchema: {
11
12
  item_keys: z
12
- .array(z.string())
13
+ .array(zoteroKey())
13
14
  .min(1)
14
15
  .max(50)
15
16
  .describe('Array of Zotero item keys to delete (e.g. ["EUHUT5K3", "F9UQM7N2"]). Max 50 per call.'),
@@ -44,7 +45,8 @@ export async function handleDeleteItems(zoteroApi, userId, args, unsafeOps = "no
44
45
  item_keys,
45
46
  });
46
47
  }
47
- const keysToDelete = [...foundKeys];
48
+ // only keys the caller asked for, whatever the API returned
49
+ const keysToDelete = item_keys.filter((k) => foundKeys.has(k));
48
50
  await zoteroApi
49
51
  .library(libraryType, libraryId)
50
52
  .items()
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse, formatEmptyResult, errorMessage, logToolError } from "../utils/error-formatter.js";
3
4
  import { attachOpenAccessPdfs } from "../utils/oa-pdf.js";
4
5
  import { fetchAllPages, fetchItemsByKeys } from "../utils/pagination.js";
@@ -8,11 +9,10 @@ export const toolConfig = {
8
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'.",
9
10
  inputSchema: {
10
11
  item_keys: z
11
- .array(z.string())
12
+ .array(zoteroKey())
12
13
  .optional()
13
14
  .describe("Array of Zotero item keys to process (mutually exclusive with collection_key)"),
14
- collection_key: z
15
- .string()
15
+ collection_key: zoteroKey()
16
16
  .optional()
17
17
  .describe("Process all items in this collection (mutually exclusive with item_keys)"),
18
18
  skip_if_attachment_exists: z
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { isZoteroApiError } from "../types/zotero-types.js";
3
4
  import { formatJsonResult, formatErrorResponse, formatEmptyResult } from "../utils/error-formatter.js";
4
5
  import { formatCreators, formatTags } from "../utils/item-formatter.js";
@@ -8,7 +9,7 @@ export const toolConfig = {
8
9
  name: "get_collection_items",
9
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.",
10
11
  inputSchema: {
11
- collectionKey: z.string().describe("The collection key/ID"),
12
+ collectionKey: zoteroKey().describe("The collection key/ID"),
12
13
  excludeAttachments: z
13
14
  .boolean()
14
15
  .default(true)
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { isZoteroApiError } from "../types/zotero-types.js";
3
4
  import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
4
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
@@ -7,7 +8,7 @@ export const toolConfig = {
7
8
  name: "get_item_fulltext",
8
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.",
9
10
  inputSchema: {
10
- item_key: z.string().describe("The Zotero item key (parent item or attachment key)"),
11
+ item_key: zoteroKey().describe("The Zotero item key (parent item or attachment key)"),
11
12
  max_characters: z
12
13
  .number()
13
14
  .optional()
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse, formatEmptyResult } from "../utils/error-formatter.js";
3
4
  import { formatCreators } from "../utils/item-formatter.js";
4
5
  import { fetchItemsByKeys } from "../utils/pagination.js";
@@ -8,7 +9,7 @@ export const toolConfig = {
8
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).",
9
10
  inputSchema: {
10
11
  item_keys: z
11
- .array(z.string())
12
+ .array(zoteroKey())
12
13
  .describe('Array of Zotero item keys (e.g. ["EUHUT5K3", "F9UQM7N2"]). Get these from search_library, add_items_by_doi, or get_collection_items.'),
13
14
  include_abstract: z
14
15
  .boolean()
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { zoteroKey } from "../utils/zotero-keys.js";
2
3
  import { formatJsonResult, formatErrorResponse, errorMessage, logToolError } from "../utils/error-formatter.js";
3
4
  import { downloadAndUploadPdf } from "../utils/pdf-uploader.js";
4
5
  import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
@@ -20,12 +21,11 @@ export const toolConfig = {
20
21
  .optional()
21
22
  .default("application/pdf")
22
23
  .describe('MIME type of the file (default: "application/pdf")'),
23
- parent_item: z
24
- .string()
24
+ parent_item: zoteroKey()
25
25
  .optional()
26
26
  .describe("Item key of the parent item. If provided, the attachment becomes a child of that item."),
27
27
  collections: z
28
- .array(z.string())
28
+ .array(zoteroKey())
29
29
  .optional()
30
30
  .describe("Collection keys (only used for standalone attachments, ignored when parent_item is set)"),
31
31
  tags: z
@@ -1,3 +1,4 @@
1
+ import { ZOTERO_KEY } from "./zotero-keys.js";
1
2
  const PAGE_LIMIT = 100;
2
3
  /** getData() returns an object for single-item requests and an array otherwise. */
3
4
  export function asArray(data) {
@@ -30,15 +31,22 @@ export const MAX_KEYS_PER_REQUEST = 50;
30
31
  * Fetch items by key in chunks of MAX_KEYS_PER_REQUEST, each with an explicit `limit`
31
32
  * (the API default page size is 25, so a bare `itemKey` GET silently drops the rest).
32
33
  * Returns the items found and the library version of the last response.
34
+ *
35
+ * When no key in an `itemKey` filter is well-formed, the Web API ignores the filter and returns
36
+ * the whole library. Tools reject malformed keys up front (zoteroKey schema); as a second line of
37
+ * defense malformed keys are never sent here (no request at all if none is valid) and only items
38
+ * whose key was requested are returned.
33
39
  */
34
40
  export async function fetchItemsByKeys(buildRequest, keys) {
35
41
  const items = [];
36
42
  let version = null;
37
- for (let start = 0; start < keys.length; start += MAX_KEYS_PER_REQUEST) {
38
- const chunk = keys.slice(start, start + MAX_KEYS_PER_REQUEST);
43
+ const valid = [...new Set(keys.filter((k) => ZOTERO_KEY.test(k)))];
44
+ const requested = new Set(valid);
45
+ for (let start = 0; start < valid.length; start += MAX_KEYS_PER_REQUEST) {
46
+ const chunk = valid.slice(start, start + MAX_KEYS_PER_REQUEST);
39
47
  const response = await buildRequest({ itemKey: chunk.join(","), limit: chunk.length });
40
48
  const data = response.getData();
41
- items.push(...asArray(data));
49
+ items.push(...asArray(data).filter((item) => typeof item.key === "string" && requested.has(item.key)));
42
50
  version = response.getVersion();
43
51
  }
44
52
  return { items, version };
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ /** Zotero object keys (items, collections): 8 characters from this alphabet, never 0, 1, O or lowercase. */
3
+ export const ZOTERO_KEY = /^[23456789ABCDEFGHIJKLMNPQRSTUVWXYZ]{8}$/;
4
+ export const INVALID_KEY_MESSAGE = "Invalid Zotero key: expected 8 characters from 23456789ABCDEFGHIJKLMNPQRSTUVWXYZ";
5
+ /**
6
+ * Schema for every key argument of every tool. Keys end up in API URL paths (zotero-api-client does not
7
+ * encode path segments, and URL parsing resolves "..") and in itemKey filters (ignored by the API when
8
+ * no key is well-formed): a malformed key must be rejected before any request.
9
+ */
10
+ export const zoteroKey = () => z.string().regex(ZOTERO_KEY, INVALID_KEY_MESSAGE);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xevos117/mcp-zotero",
3
3
  "type": "module",
4
- "version": "2.0.0",
4
+ "version": "2.0.1",
5
5
  "description": "Unofficial MCP server for Zotero integration with citation management",
6
6
  "repository": {
7
7
  "type": "git",