@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
|
-
|
|
|
21
|
-
|
|
|
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
|
-
|
|
23
|
+
LLMs with filesystem access can use all tools directly, including `inject_citations` which reads and writes `.docx` files on disk.
|
|
24
24
|
|
|
25
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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:
|
|
13
|
-
if (!Array.isArray(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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 ?? [])
|