@xevos117/mcp-zotero 1.0.6 → 1.0.8

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
@@ -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 15 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
 
@@ -126,7 +132,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
126
132
  | `get_collections` | List all collections (folders) with keys, names, and parent relationships |
127
133
  | `get_collection_items` | Get items in a specific collection with keys, titles, authors, dates |
128
134
  | `search_library` | Search by query, or list items sorted by field (date, title, etc.) |
129
- | `get_items_details` | Batch metadata retrieval for multiple items in a single call |
135
+ | `get_items_details` | Batch metadata retrieval for multiple items — returns all type-specific fields (bookTitle, proceedingsTitle, university, etc.) |
130
136
  | `get_item_fulltext` | Get full-text content of a PDF attachment via Zotero's fulltext index |
131
137
 
132
138
  ### Adding content
@@ -159,7 +165,7 @@ claude mcp add-json "zotero" '{"command":"npx","args":["tsx","src/server.ts"],"e
159
165
  ```bash
160
166
  npm install
161
167
  npm run build # Compile TypeScript
162
- npm test # Run tests (vitest, 377 tests)
168
+ npm test # Run tests (vitest, 404 tests)
163
169
  npx tsx src/server.ts # Run directly without building
164
170
  ```
165
171
 
@@ -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;
@@ -1,21 +1,40 @@
1
+ import { z } from "zod";
1
2
  import { isZoteroApiError } from "../types/zotero-types.js";
2
3
  import { formatErrorResponse } from "../utils/error-formatter.js";
3
4
  import { logger } from "../utils/logger.js";
4
5
  import { fetchAllPages } from "../utils/pagination.js";
5
6
  export const toolConfig = {
6
7
  name: "get_collections",
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
- inputSchema: {},
8
+ 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. Trashed collections are excluded by default.",
9
+ inputSchema: {
10
+ include_trashed: z
11
+ .boolean()
12
+ .optional()
13
+ .default(false)
14
+ .describe("Include trashed (deleted) collections. Default false."),
15
+ },
9
16
  };
10
- export async function handleGetCollections(zoteroApi, userId, _args) {
17
+ const GetCollectionsSchema = z.object(toolConfig.inputSchema);
18
+ export async function handleGetCollections(zoteroApi, userId, args) {
19
+ const { include_trashed } = GetCollectionsSchema.parse(args);
11
20
  try {
12
- const { items: collections } = await fetchAllPages((params) => zoteroApi.library("user", userId).collections().get(params));
13
- if (!Array.isArray(collections) || collections.length === 0) {
21
+ const { items: allCollections } = await fetchAllPages((params) => zoteroApi.library("user", userId).collections().get(params));
22
+ if (!Array.isArray(allCollections) || allCollections.length === 0) {
14
23
  return formatErrorResponse("No collections found", {
15
24
  suggestion: "Create a collection in your Zotero library first",
16
25
  helpUrl: "https://www.zotero.org/support/collections",
17
26
  });
18
27
  }
28
+ // Zotero API returns deleted collections despite not requesting them —
29
+ // filter client-side as a workaround
30
+ const collections = include_trashed
31
+ ? allCollections
32
+ : allCollections.filter((c) => !c.deleted);
33
+ if (collections.length === 0) {
34
+ return formatErrorResponse("No collections found", {
35
+ suggestion: "All collections are in the trash. Use include_trashed=true to see them.",
36
+ });
37
+ }
19
38
  return {
20
39
  content: [
21
40
  { type: "text", text: JSON.stringify(collections, null, 2) },
@@ -5,7 +5,7 @@ import { formatCreators } from "../utils/item-formatter.js";
5
5
  import { logger } from "../utils/logger.js";
6
6
  export const toolConfig = {
7
7
  name: "get_items_details",
8
- 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 calling get_item_details multiple times. Returns title, authors, date, DOI, item type, publication title, and URL for each item. Set include_abstract to include abstracts (excluded by default to keep responses lightweight).",
8
+ 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 calling get_item_details multiple times. 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
9
  inputSchema: {
10
10
  item_keys: z
11
11
  .array(z.string())
@@ -18,6 +18,12 @@ export const toolConfig = {
18
18
  },
19
19
  };
20
20
  const GetItemsDetailsSchema = z.object(toolConfig.inputSchema);
21
+ /** Fields excluded from the response (structural/internal, not bibliographic metadata). */
22
+ const SKIP_FIELDS = new Set([
23
+ "key", "version", "dateAdded", "dateModified",
24
+ "collections", "tags", "relations", "creators",
25
+ "abstractNote",
26
+ ]);
21
27
  export async function handleGetItemsDetails(zoteroApi, userId, args) {
22
28
  const { item_keys, include_abstract } = GetItemsDetailsSchema.parse(args);
23
29
  if (item_keys.length === 0) {
@@ -40,18 +46,26 @@ export async function handleGetItemsDetails(zoteroApi, userId, args) {
40
46
  const key = item.key;
41
47
  if (!key)
42
48
  continue;
43
- result[key] = {
49
+ const entry = {
50
+ itemType: item.itemType || "document",
44
51
  title: item.title || "Untitled",
45
52
  authors: formatCreators(item.creators),
46
- date: item.date || "No date",
47
- doi: item.DOI || null,
48
- itemType: item.itemType || "document",
49
- publicationTitle: item.publicationTitle || null,
50
- url: item.url || null,
51
53
  };
54
+ // Include all non-empty bibliographic fields from the Zotero response
55
+ const raw = item;
56
+ for (const [field, value] of Object.entries(raw)) {
57
+ if (SKIP_FIELDS.has(field))
58
+ continue;
59
+ if (field in entry)
60
+ continue;
61
+ if (value === undefined || value === null || value === "" || value === false)
62
+ continue;
63
+ entry[field] = value;
64
+ }
52
65
  if (include_abstract && item.abstractNote) {
53
- result[key].abstractNote = item.abstractNote;
66
+ entry.abstractNote = item.abstractNote;
54
67
  }
68
+ result[key] = entry;
55
69
  }
56
70
  return {
57
71
  content: [
@@ -1,4 +1,6 @@
1
+ import { ITEM_TYPE_FIELDS } from "./zotero-item-types.js";
1
2
  const CSL_TO_ZOTERO_TYPE = {
3
+ // Standard CSL types
2
4
  "article": "journalArticle",
3
5
  "article-journal": "journalArticle",
4
6
  "article-magazine": "magazineArticle",
@@ -34,6 +36,23 @@ const CSL_TO_ZOTERO_TYPE = {
34
36
  "standard": "standard",
35
37
  "thesis": "thesis",
36
38
  "webpage": "webpage",
39
+ // Non-standard types returned by CrossRef/DataCite DOI content negotiation
40
+ "journal-article": "journalArticle",
41
+ "book-chapter": "bookSection",
42
+ "proceedings-article": "conferencePaper",
43
+ "posted-content": "preprint",
44
+ "dissertation": "thesis",
45
+ "monograph": "book",
46
+ "reference-entry": "encyclopediaArticle",
47
+ "component": "document",
48
+ "peer-review": "journalArticle",
49
+ "edited-book": "book",
50
+ "reference-book": "book",
51
+ "book-series": "book",
52
+ "book-part": "bookSection",
53
+ "proceedings": "book",
54
+ "journal": "journalArticle",
55
+ "report-series": "report",
37
56
  };
38
57
  const ZOTERO_TO_CSL_TYPE = {
39
58
  journalArticle: "article-journal",
@@ -74,7 +93,27 @@ const ZOTERO_TO_CSL_TYPE = {
74
93
  email: "personal_communication",
75
94
  instantMessage: "personal_communication",
76
95
  };
96
+ /**
97
+ * Maps CSL `container-title` to the correct Zotero field name per item type.
98
+ * Types not listed here have no container-title equivalent.
99
+ */
100
+ const CONTAINER_TITLE_FIELD = {
101
+ journalArticle: "publicationTitle",
102
+ magazineArticle: "publicationTitle",
103
+ newspaperArticle: "publicationTitle",
104
+ bookSection: "bookTitle",
105
+ dictionaryEntry: "dictionaryTitle",
106
+ conferencePaper: "proceedingsTitle",
107
+ blogPost: "blogTitle",
108
+ encyclopediaArticle: "encyclopediaTitle",
109
+ forumPost: "forumTitle",
110
+ webpage: "websiteTitle",
111
+ radioBroadcast: "programTitle",
112
+ tvBroadcast: "programTitle",
113
+ podcast: "programTitle",
114
+ };
77
115
  export function cslToZoteroItem(csl, options) {
116
+ const itemType = (CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle");
78
117
  const creators = (csl.author ?? []).map((a) => ({
79
118
  firstName: a.given ?? "",
80
119
  lastName: a.family ?? a.literal ?? "",
@@ -87,13 +126,11 @@ export function cslToZoteroItem(csl, options) {
87
126
  collections.push(options.collectionKey);
88
127
  }
89
128
  const tags = (options?.tags ?? []).map((t) => ({ tag: t }));
90
- return {
91
- itemType: CSL_TO_ZOTERO_TYPE[csl.type] ?? "journalArticle",
129
+ // Build candidate fields (CSL → Zotero field names)
130
+ const candidates = {
92
131
  title: csl.title ?? "",
93
- creators,
94
132
  date,
95
133
  DOI: csl.DOI ?? "",
96
- publicationTitle: csl["container-title"] ?? "",
97
134
  volume: csl.volume ?? "",
98
135
  issue: csl.issue ?? "",
99
136
  pages: csl.page ?? "",
@@ -101,15 +138,35 @@ export function cslToZoteroItem(csl, options) {
101
138
  place: csl["publisher-place"] ?? "",
102
139
  url: csl.URL ?? "",
103
140
  abstractNote: csl.abstract ?? "",
104
- ISBN: csl.ISBN ?? "",
105
- ISSN: csl.ISSN ?? "",
141
+ ISBN: Array.isArray(csl.ISBN) ? csl.ISBN[0] : csl.ISBN ?? "",
142
+ ISSN: Array.isArray(csl.ISSN) ? csl.ISSN[0] : csl.ISSN ?? "",
106
143
  edition: csl.edition ?? "",
107
144
  numPages: csl["number-of-pages"] ?? "",
108
145
  series: csl["collection-title"] ?? "",
109
146
  language: csl.language ?? "",
110
- collections,
111
- tags,
112
147
  };
148
+ // Map container-title to the correct field for this item type
149
+ const containerTitleField = CONTAINER_TITLE_FIELD[itemType];
150
+ if (containerTitleField) {
151
+ candidates[containerTitleField] = csl["container-title"] ?? "";
152
+ }
153
+ // Filter: only include fields valid for this item type
154
+ const validFields = ITEM_TYPE_FIELDS[itemType];
155
+ const result = { itemType, creators, collections, tags };
156
+ if (validFields) {
157
+ for (const [field, value] of Object.entries(candidates)) {
158
+ if (validFields.has(field)) {
159
+ result[field] = value;
160
+ }
161
+ }
162
+ }
163
+ else {
164
+ // Unknown item type — include all fields as fallback
165
+ for (const [field, value] of Object.entries(candidates)) {
166
+ result[field] = value;
167
+ }
168
+ }
169
+ return result;
113
170
  }
114
171
  export function zoteroItemToCsl(item) {
115
172
  const authors = (item.creators ?? [])
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xevos117/mcp-zotero",
3
3
  "type": "module",
4
- "version": "1.0.6",
4
+ "version": "1.0.8",
5
5
  "description": "Unofficial MCP server for Zotero integration with citation management",
6
6
  "repository": {
7
7
  "type": "git",