@xevos117/mcp-zotero 2.0.3 → 2.1.0
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 +66 -12
- package/build/citation-injector/citation-formatter.js +3 -0
- package/build/citation-injector/citation-json.js +37 -0
- package/build/citation-injector/citekey-resolver.js +122 -0
- package/build/citation-injector/document-prefs.js +6 -3
- package/build/citation-injector/field-codes.js +2 -8
- package/build/citation-injector/formats/docx.js +83 -0
- package/build/citation-injector/formats/odt.js +149 -0
- package/build/citation-injector/formats/types.js +1 -0
- package/build/citation-injector/injector.js +100 -184
- package/build/citation-injector/model.js +9 -0
- package/build/citation-injector/odt-normalizer.js +169 -0
- package/build/citation-injector/pandoc-body.js +99 -0
- package/build/citation-injector/pipeline.js +189 -0
- package/build/citation-injector/zcite-normalizer.js +27 -11
- package/build/citation-injector/zcite.js +36 -0
- package/build/cited-document/create.js +40 -0
- package/build/cited-document/document.js +83 -0
- package/build/cited-document/markdown.js +104 -0
- package/build/cited-document/model.js +1 -0
- package/build/cited-document/number.js +17 -0
- package/build/cited-document/pandoc-cite.js +74 -0
- package/build/prompts/index.js +127 -0
- package/build/server.js +12 -5
- package/build/tools/add-items.js +9 -41
- package/build/tools/check-retractions.js +131 -0
- package/build/tools/create-cited-document.js +101 -0
- package/build/tools/create-note.js +77 -0
- package/build/tools/delete-collection.js +35 -22
- package/build/tools/delete-items.js +129 -42
- package/build/tools/find-duplicates.js +163 -0
- package/build/tools/get-collections.js +8 -18
- package/build/tools/get-items-details.js +11 -6
- package/build/tools/index.js +10 -0
- package/build/tools/inject-citations.js +29 -7
- package/build/tools/search-library.js +2 -2
- package/build/tools/update-item.js +123 -0
- package/build/utils/atomic-write.js +62 -0
- package/build/utils/crossref-retractions.js +76 -0
- package/build/utils/csl-to-zotero.js +69 -14
- package/build/utils/doi.js +55 -0
- package/build/utils/duplicates.js +64 -0
- package/build/utils/item-fields.js +58 -0
- package/build/utils/item-formatter.js +8 -0
- package/build/utils/unpaywall.js +1 -1
- package/package.json +11 -9
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const STYLES = "apa, chicago, harvard, ieee or vancouver";
|
|
3
|
+
export const PROMPTS = [
|
|
4
|
+
{
|
|
5
|
+
name: "cite_word_document",
|
|
6
|
+
title: "Add Zotero citations to a Word document",
|
|
7
|
+
description: "Turn the <zcite> placeholders in an existing .docx (or .odt) into live Zotero citations and a bibliography.",
|
|
8
|
+
argsSchema: {
|
|
9
|
+
file_path: z
|
|
10
|
+
.string()
|
|
11
|
+
.describe("Absolute path to the .docx/.odt file on the machine running this server"),
|
|
12
|
+
style: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(`Citation style: ${STYLES} (default apa)`),
|
|
16
|
+
},
|
|
17
|
+
template: (a) => [
|
|
18
|
+
`I have a Word document at ${a.file_path} and I want to add Zotero citations to it.`,
|
|
19
|
+
"",
|
|
20
|
+
"Please:",
|
|
21
|
+
"1. Make sure each place a citation should appear contains a <zcite keys=\"ITEMKEY\"/> placeholder in its own text run (keys are Zotero item keys from search_library, add_items or add_items_by_doi). For a numbered style (ieee/vancouver) every <zcite> also needs a num attribute, numbered in order of first appearance.",
|
|
22
|
+
`2. Call inject_citations with file_path "${a.file_path}" and style "${a.style ?? "apa"}". It fetches the metadata from Zotero and writes a new <name>_cited file next to the original.`,
|
|
23
|
+
"3. Tell me to open the result in Word with the Zotero plugin and run Zotero → Refresh, so the citations become editable.",
|
|
24
|
+
].join("\n"),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "write_cited_document",
|
|
28
|
+
title: "Write a cited document from scratch",
|
|
29
|
+
description: "Draft a document on a topic with inline Zotero citations and a bibliography, saved as .docx or .odt.",
|
|
30
|
+
argsSchema: {
|
|
31
|
+
topic: z.string().describe("What the document should be about"),
|
|
32
|
+
output_path: z
|
|
33
|
+
.string()
|
|
34
|
+
.describe("Absolute path where the finished document is written"),
|
|
35
|
+
format: z
|
|
36
|
+
.string()
|
|
37
|
+
.optional()
|
|
38
|
+
.describe("Output format: docx or odt (default docx)"),
|
|
39
|
+
style: z
|
|
40
|
+
.string()
|
|
41
|
+
.optional()
|
|
42
|
+
.describe(`Citation style: ${STYLES} (default apa)`),
|
|
43
|
+
},
|
|
44
|
+
template: (a) => [
|
|
45
|
+
`Write a well-structured document about "${a.topic}" with Zotero citations, and save it to ${a.output_path} as ${a.format ?? "docx"}.`,
|
|
46
|
+
"",
|
|
47
|
+
"Please:",
|
|
48
|
+
"1. Gather the references in Zotero first: add_items_by_doi for items with a DOI, add_items for the rest, or search_library to reuse items already in the library. Keep track of each item key.",
|
|
49
|
+
"2. Write the body as Markdown and cite sources inline with pandoc-style keys: [@ITEMKEY] for one source, [@KEY1; @KEY2] for several, [see @ITEMKEY, p. 12] with a locator, and -@ITEMKEY to suppress the author.",
|
|
50
|
+
`3. Call create_cited_document with that Markdown as the markdown argument, output_path "${a.output_path}", format "${a.format ?? "docx"}" and style "${a.style ?? "apa"}". It resolves the citations against Zotero, numbers them in order of first appearance and appends the bibliography.`,
|
|
51
|
+
"4. Tell me the citations are live Zotero fields: open the result in Word or LibreOffice with the Zotero plugin and run Refresh to edit them.",
|
|
52
|
+
].join("\n"),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: "import_references",
|
|
56
|
+
title: "Import references by DOI",
|
|
57
|
+
description: "Add one or more references to Zotero from their DOIs, attaching open-access PDFs when available.",
|
|
58
|
+
argsSchema: {
|
|
59
|
+
dois: z
|
|
60
|
+
.string()
|
|
61
|
+
.describe("The DOIs to import, separated by commas or newlines"),
|
|
62
|
+
},
|
|
63
|
+
template: (a) => [
|
|
64
|
+
`Import these references into Zotero by DOI: ${a.dois}`,
|
|
65
|
+
"",
|
|
66
|
+
"Please:",
|
|
67
|
+
"1. Call find_duplicates once with all these DOIs in a single dois array (strip any surrounding text, keep only the bare DOIs) to see which are already in the library.",
|
|
68
|
+
"2. Call add_items_by_doi with the DOIs that are not already present. It resolves the metadata automatically and attaches an open-access PDF when one is found.",
|
|
69
|
+
"3. Call check_retractions with item_keys set to the keys of the items just added, to flag anything retracted or under an expression of concern (if the response has truncated: true, call again with start = next_start).",
|
|
70
|
+
"4. For any imported item still without a PDF, try find_and_attach_pdfs.",
|
|
71
|
+
"Report which DOIs were added, which were skipped as already present and which failed, and any retraction warnings; suggest add_items for anything without a usable DOI.",
|
|
72
|
+
].join("\n"),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
name: "note_from_item",
|
|
76
|
+
title: "Summarize an item as a note",
|
|
77
|
+
description: "Read an existing Zotero item and attach a concise summary as a child note.",
|
|
78
|
+
argsSchema: {
|
|
79
|
+
item_key: z.string().describe("The Zotero item key to summarize"),
|
|
80
|
+
},
|
|
81
|
+
template: (a) => [
|
|
82
|
+
`Read the Zotero item ${a.item_key} and attach a short summary note to it.`,
|
|
83
|
+
"",
|
|
84
|
+
"Please:",
|
|
85
|
+
`1. Fetch the item with get_items_details (item_keys ["${a.item_key}"], include_abstract true); if it has a PDF, get_item_fulltext for more detail.`,
|
|
86
|
+
`2. Write a concise plain-text summary (key points, method, findings) and attach it with create_note (parent_item_key "${a.item_key}"). Use blank lines between paragraphs; no HTML or Markdown.`,
|
|
87
|
+
].join("\n"),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: "check_bibliography",
|
|
91
|
+
title: "Check a collection before handing it in",
|
|
92
|
+
description: "Check a Zotero collection for retracted items and duplicates before using it in a document.",
|
|
93
|
+
argsSchema: {
|
|
94
|
+
collection_key: z.string().describe("The Zotero collection key to check (from get_collections)"),
|
|
95
|
+
},
|
|
96
|
+
template: (a) => [
|
|
97
|
+
`Check the Zotero collection ${a.collection_key} before I use it in a bibliography.`,
|
|
98
|
+
"",
|
|
99
|
+
"Please:",
|
|
100
|
+
`1. Call find_duplicates with collection_key "${a.collection_key}" to list duplicate items (same DOI or normalized title). Report each group and suggest which to keep; do not delete anything without asking.`,
|
|
101
|
+
`2. Call check_retractions with collection_key "${a.collection_key}" to flag any retracted items or expressions of concern (if the response has truncated: true, continue with start = next_start).`,
|
|
102
|
+
"3. Summarize what you found and what I should fix before handing the bibliography in.",
|
|
103
|
+
].join("\n"),
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
/** Register every prompt on the server (call once, before connect, with the prompts capability declared). */
|
|
107
|
+
export function registerAllPrompts(server) {
|
|
108
|
+
for (const p of PROMPTS) {
|
|
109
|
+
server.registerPrompt(p.name, { title: p.title, description: p.description, argsSchema: p.argsSchema }, (args) => ({
|
|
110
|
+
messages: [
|
|
111
|
+
{
|
|
112
|
+
role: "user",
|
|
113
|
+
content: { type: "text", text: p.template(args) },
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** The argument names of a prompt, in manifest order. */
|
|
120
|
+
export function promptArgumentNames(p) {
|
|
121
|
+
return Object.keys(p.argsSchema);
|
|
122
|
+
}
|
|
123
|
+
/** The prompt text with every argument left as a ${arguments.x} placeholder, as manifest.json stores it. */
|
|
124
|
+
export function promptManifestText(p) {
|
|
125
|
+
const placeholders = Object.fromEntries(promptArgumentNames(p).map((k) => [k, "${arguments." + k + "}"]));
|
|
126
|
+
return p.template(placeholders);
|
|
127
|
+
}
|
package/build/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { createRequire } from "module";
|
|
7
7
|
import { registerAllTools } from "./tools/index.js";
|
|
8
|
+
import { registerAllPrompts } from "./prompts/index.js";
|
|
8
9
|
import { logger } from "./utils/logger.js";
|
|
9
10
|
import { parseUnsafeOperations } from "./utils/unsafe-operations.js";
|
|
10
11
|
import { getDefaultLibrary } from "./utils/library-context.js";
|
|
@@ -21,22 +22,27 @@ class ZoteroServer {
|
|
|
21
22
|
unsafeOps;
|
|
22
23
|
constructor(options = {}) {
|
|
23
24
|
this.server = new McpServer({ name: "zotero", version: SERVER_VERSION }, {
|
|
24
|
-
capabilities: { tools: {} },
|
|
25
|
+
capabilities: { tools: {}, prompts: {} },
|
|
25
26
|
instructions: [
|
|
26
27
|
"Zotero library management and citation injection for Word documents.",
|
|
27
28
|
"",
|
|
28
29
|
"CITATION WORKFLOW:",
|
|
29
30
|
"1. Collect item keys: use add_items_by_doi for items with DOIs, add_items for items without DOIs (or search_library for existing items)",
|
|
30
|
-
"2. Generate
|
|
31
|
-
"3. Call inject_citations with the
|
|
32
|
-
"4. Tell the user to open it in Word with the Zotero plugin → Zotero → Add/Edit Bibliography, then Zotero → Refresh.",
|
|
31
|
+
"2. Generate the document: create a .docx (Word) or .odt (LibreOffice) with <zcite keys=\"ITEMKEY\"/> placeholders where citations should appear. Each <zcite> must be in its own run.",
|
|
32
|
+
"3. Call inject_citations with the file path — it fetches metadata from Zotero automatically and picks the format from the extension (.docx or .odt; pass format to override). The file must be on the machine where this server runs.",
|
|
33
|
+
"4. Tell the user to open it in Word or LibreOffice with the Zotero plugin → Zotero → Add/Edit Bibliography, then Zotero → Refresh.",
|
|
34
|
+
"",
|
|
35
|
+
"To write a whole document from scratch (not fill placeholders in an existing one), use create_cited_document: give it Markdown with pandoc-style citations ([@KEY], [@K1; @K2], [see @KEY, p. 12], -@KEY) and an output .docx/.odt path. It never overwrites an existing file.",
|
|
33
36
|
"",
|
|
34
37
|
"KEY NOTES:",
|
|
35
38
|
"- add_items_by_doi auto-attaches open-access PDFs found via Unpaywall (needs UNPAYWALL_EMAIL in the server settings; uses the user's Zotero storage). Do not disable auto_attach_pdf unless it causes errors.",
|
|
39
|
+
"- find_duplicates is read-only. Call it with dois before add_items_by_doi to skip DOIs already in the library, and without dois to list duplicate groups (same DOI, or same normalized title and year) in the library or a collection. It never merges or deletes; let the user choose which item to keep.",
|
|
36
40
|
"- For IEEE/Vancouver (numbered) citation styles, every <zcite> MUST include a num attribute. num is the citation number assigned in order of first appearance in the text (first cited = num=\"1\", second = num=\"2\", etc.). For multi-item citations use comma-separated values: keys=\"KEY1,KEY2\" num=\"1,2\". Without num, citations render as [?].",
|
|
37
41
|
"- If the .docx is in a sandbox or cloud workspace this server cannot see (code execution in Claude.ai or Claude Desktop), use the zotero-mcp-integrations skill instead of inject_citations. If it is not installed, recommend zotero-skill-mcp-integrations.zip from https://github.com/Xevos117/mcp-zotero/releases/latest.",
|
|
38
42
|
"- get_user_id is only needed by the skill script (inject.js), not by the inject_citations tool.",
|
|
39
|
-
"-
|
|
43
|
+
"- To edit an existing item, call get_items_details first (it returns each item's version), then update_item with that version; if it returns version_conflict, re-read and retry. Use create_note to attach a plain-text note to an item.",
|
|
44
|
+
"- To check whether items have been retracted, flagged with an expression of concern, or corrected, use check_retractions with item_keys (up to 50) or a collection_key: it looks up each DOI on Crossref (read-only) and returns a status per item. Rerun only the items that come back lookup_failed.",
|
|
45
|
+
"- Deleting is off unless the server setting UNSAFE_OPERATIONS (\"Deletions allowed\" in the Claude Desktop extension) allows it ('items': delete_items; 'all': also delete_collection). delete_items moves items to the Zotero trash by default (recoverable); pass permanent: true to delete for good. delete_collection is always permanent (collections have no trash). Never work around a blocked deletion with other tools.",
|
|
40
46
|
"- Errors carry a hint with the next step. When it says the fix is on the user's side (API key, server settings), tell the user instead of retrying.",
|
|
41
47
|
].join("\n"),
|
|
42
48
|
});
|
|
@@ -64,6 +70,7 @@ class ZoteroServer {
|
|
|
64
70
|
}
|
|
65
71
|
setupHandlers() {
|
|
66
72
|
registerAllTools(this.server, this.zoteroApi, this.userId, this.unsafeOps);
|
|
73
|
+
registerAllPrompts(this.server);
|
|
67
74
|
}
|
|
68
75
|
async start() {
|
|
69
76
|
this.setupHandlers();
|
package/build/tools/add-items.js
CHANGED
|
@@ -2,18 +2,11 @@ import { z } from "zod";
|
|
|
2
2
|
import { zoteroKey } from "../utils/zotero-keys.js";
|
|
3
3
|
import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
|
|
4
4
|
import { postInBatches } from "../utils/write-results.js";
|
|
5
|
-
import { ZOTERO_ITEM_TYPES,
|
|
5
|
+
import { ZOTERO_ITEM_TYPES, TITLE_FIELD_NAME, } from "../utils/zotero-item-types.js";
|
|
6
|
+
import { CreatorSchema, fieldValidationIssues, creatorValidationIssues, toZoteroCreator, } from "../utils/item-fields.js";
|
|
6
7
|
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
firstName: z.string().optional(),
|
|
10
|
-
lastName: z.string().optional(),
|
|
11
|
-
name: z.string().optional(),
|
|
12
|
-
creatorType: z.string().default("author"),
|
|
13
|
-
})
|
|
14
|
-
.refine((data) => data.name != null || (data.firstName != null && data.lastName != null), {
|
|
15
|
-
message: "Provide either 'name' (institutional) or both firstName+lastName",
|
|
16
|
-
});
|
|
8
|
+
/** Keys that are not type-specific fields (handled as dedicated inputs), so excluded from field validation. */
|
|
9
|
+
const NON_FIELD_KEYS = new Set(["itemType", "title", "creators"]);
|
|
17
10
|
const ItemSchema = z
|
|
18
11
|
.object({
|
|
19
12
|
itemType: z.enum(ZOTERO_ITEM_TYPES),
|
|
@@ -23,27 +16,11 @@ const ItemSchema = z
|
|
|
23
16
|
.catchall(z.string())
|
|
24
17
|
.superRefine((data, ctx) => {
|
|
25
18
|
const itemType = data.itemType;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
if (key === "itemType" || key === "title" || key === "creators")
|
|
29
|
-
continue;
|
|
30
|
-
if (!validFields.has(key)) {
|
|
31
|
-
ctx.addIssue({
|
|
32
|
-
code: "custom",
|
|
33
|
-
message: `Field "${key}" is not valid for itemType "${data.itemType}". Valid fields: ${[...validFields].join(", ")}`,
|
|
34
|
-
path: [key],
|
|
35
|
-
});
|
|
36
|
-
}
|
|
19
|
+
for (const issue of fieldValidationIssues(itemType, Object.keys(data), NON_FIELD_KEYS)) {
|
|
20
|
+
ctx.addIssue({ code: "custom", message: issue.message, path: issue.path });
|
|
37
21
|
}
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
if (!validCreatorTypes.has(creator.creatorType)) {
|
|
41
|
-
ctx.addIssue({
|
|
42
|
-
code: "custom",
|
|
43
|
-
message: `Invalid creatorType "${creator.creatorType}" for "${data.itemType}". Valid: ${[...validCreatorTypes].join(", ")}`,
|
|
44
|
-
path: ["creators", i, "creatorType"],
|
|
45
|
-
});
|
|
46
|
-
}
|
|
22
|
+
for (const issue of creatorValidationIssues(itemType, data.creators ?? [])) {
|
|
23
|
+
ctx.addIssue({ code: "custom", message: issue.message, path: issue.path });
|
|
47
24
|
}
|
|
48
25
|
});
|
|
49
26
|
export const toolConfig = {
|
|
@@ -103,16 +80,7 @@ export async function handleAddItems(zoteroApi, userId, args) {
|
|
|
103
80
|
itemType,
|
|
104
81
|
...fields,
|
|
105
82
|
[titleFieldName]: title,
|
|
106
|
-
creators: (creators ?? []).map(
|
|
107
|
-
if (c.name) {
|
|
108
|
-
return { name: c.name, creatorType: c.creatorType };
|
|
109
|
-
}
|
|
110
|
-
return {
|
|
111
|
-
firstName: c.firstName,
|
|
112
|
-
lastName: c.lastName,
|
|
113
|
-
creatorType: c.creatorType,
|
|
114
|
-
};
|
|
115
|
-
}),
|
|
83
|
+
creators: (creators ?? []).map(toZoteroCreator),
|
|
116
84
|
collections: collection_key ? [collection_key] : [],
|
|
117
85
|
tags: (tags ?? []).map((t) => ({ tag: t })),
|
|
118
86
|
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { zoteroKey } from "../utils/zotero-keys.js";
|
|
3
|
+
import { isZoteroApiError } from "../types/zotero-types.js";
|
|
4
|
+
import { formatJsonResult, formatErrorResponse, formatEmptyResult } from "../utils/error-formatter.js";
|
|
5
|
+
import { fetchItemsByKeys, fetchAllPages } from "../utils/pagination.js";
|
|
6
|
+
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
7
|
+
import { mapWithConcurrency } from "../utils/concurrency.js";
|
|
8
|
+
import { itemDoi } from "../utils/doi.js";
|
|
9
|
+
import { lookupRetraction } from "../utils/crossref-retractions.js";
|
|
10
|
+
import { isValidEmail } from "../utils/unpaywall.js";
|
|
11
|
+
import { COLLECTION_KEY_HINT, ITEM_KEY_HINT } from "../utils/error-hints.js";
|
|
12
|
+
const MAX_ITEM_KEYS = 50;
|
|
13
|
+
// Cap the Crossref lookups per call by the number of distinct DOIs; the rest is left for a follow-up call (start)
|
|
14
|
+
const MAX_LOOKUPS = 200;
|
|
15
|
+
export const toolConfig = {
|
|
16
|
+
name: "check_retractions",
|
|
17
|
+
description: "Check whether Zotero items have been retracted, flagged with an expression of concern, or corrected. Pass EITHER item_keys (up to 50) OR collection_key, exactly one. Read-only: it never changes the library. For each item it reads the DOI (the DOI field, or a 'DOI: …' line in Extra) and looks it up on Crossref (Retraction Watch data), then reports a status: retracted, expression_of_concern, corrected, ok, no_doi (no DOI to check), not_in_crossref (the DOI is unknown to Crossref), or lookup_failed (a transient error — rerun just those). The response opens with a summary of the counts and then lists, per item, its key, title, doi, status and any notices (type, date, source, the notice's own DOI). A failed lookup never fails the rest; rerun the lookup_failed items. At most 200 distinct DOIs are looked up per call: if the response has truncated=true, call again with start=next_start to continue (useful for a large collection).",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
item_keys: z
|
|
20
|
+
.array(zoteroKey())
|
|
21
|
+
.max(MAX_ITEM_KEYS, `At most ${MAX_ITEM_KEYS} item keys per call`)
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('Zotero item keys to check (up to 50), e.g. ["EUHUT5K3", "F9UQM7N2"]. Provide this OR collection_key, not both. Get keys from search_library or get_collection_items.'),
|
|
24
|
+
collection_key: zoteroKey()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Check every item directly in this collection (not its subcollections). Provide this OR item_keys, not both. Get the key from get_collections."),
|
|
27
|
+
start: z
|
|
28
|
+
.number()
|
|
29
|
+
.int()
|
|
30
|
+
.min(0)
|
|
31
|
+
.optional()
|
|
32
|
+
.default(0)
|
|
33
|
+
.describe("Item offset to start from (default 0). Only needed to continue a truncated response: pass the next_start it returned."),
|
|
34
|
+
...libraryArgsSchema,
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const CheckRetractionsSchema = z.object(toolConfig.inputSchema);
|
|
38
|
+
export async function handleCheckRetractions(zoteroApi, userId, args) {
|
|
39
|
+
const { item_keys, collection_key, start, library_type, library_id } = CheckRetractionsSchema.parse(args);
|
|
40
|
+
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
41
|
+
const hasKeys = item_keys !== undefined && item_keys.length > 0;
|
|
42
|
+
const hasCollection = collection_key !== undefined;
|
|
43
|
+
if (hasKeys === hasCollection) {
|
|
44
|
+
return formatErrorResponse("Pass exactly one of item_keys or collection_key.", {
|
|
45
|
+
hint: "item_keys (up to 50) checks those items; collection_key checks every item directly in a collection.",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
let items;
|
|
49
|
+
let notFound = [];
|
|
50
|
+
if (hasKeys) {
|
|
51
|
+
const res = await fetchItemsByKeys((params) => zoteroApi.library(libraryType, libraryId).items().get(params), item_keys);
|
|
52
|
+
items = res.items.filter((i) => typeof i.key === "string");
|
|
53
|
+
const found = new Set(items.map((i) => i.key));
|
|
54
|
+
notFound = [...new Set(item_keys)].filter((k) => !found.has(k));
|
|
55
|
+
if (items.length === 0) {
|
|
56
|
+
return formatEmptyResult("No items found for the given keys", { item_keys, suggestion: ITEM_KEY_HINT });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
try {
|
|
61
|
+
const { items: all } = await fetchAllPages((params) => zoteroApi.library(libraryType, libraryId).collections(collection_key).items().get(params));
|
|
62
|
+
items = (all ?? []).filter((i) => i && typeof i.key === "string" && i.itemType !== "attachment" && i.itemType !== "note");
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (isZoteroApiError(err) && err.response?.status === 404) {
|
|
66
|
+
return formatErrorResponse("Collection not found: no collection with this key in this library, or the API key cannot access it.", { collection_key, suggestion: COLLECTION_KEY_HINT });
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
if (items.length === 0) {
|
|
71
|
+
return formatEmptyResult("No items to check in this collection", {
|
|
72
|
+
collection_key,
|
|
73
|
+
suggestion: "The collection is empty or holds only attachments and notes (those are not checked).",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Crossref's polite pool: reuse the server's contact email only if it is a valid address (as unpaywall.ts checks);
|
|
78
|
+
// optional, Crossref works without it.
|
|
79
|
+
const email = process.env.UNPAYWALL_EMAIL?.trim();
|
|
80
|
+
const mailto = email && isValidEmail(email) ? email : undefined;
|
|
81
|
+
const prepared = items.map((item) => ({
|
|
82
|
+
key: item.key,
|
|
83
|
+
title: item.title || "Untitled",
|
|
84
|
+
doi: itemDoi(item) ?? null,
|
|
85
|
+
}));
|
|
86
|
+
// Cap the lookups per call by the number of DISTINCT DOIs (MAX_LOOKUPS): a large collection would otherwise make
|
|
87
|
+
// thousands of Crossref requests and the MCP client could time out and lose everything. `start` is the item
|
|
88
|
+
// offset; when the page fills the budget the rest is left for a follow-up call (truncated + next_start).
|
|
89
|
+
const page = prepared.slice(start);
|
|
90
|
+
const pageItems = [];
|
|
91
|
+
const uniqueDois = new Set();
|
|
92
|
+
let truncated = false;
|
|
93
|
+
let nextStart;
|
|
94
|
+
for (let i = 0; i < page.length; i++) {
|
|
95
|
+
const p = page[i];
|
|
96
|
+
if (p.doi && !uniqueDois.has(p.doi)) {
|
|
97
|
+
if (uniqueDois.size >= MAX_LOOKUPS) {
|
|
98
|
+
truncated = true;
|
|
99
|
+
nextStart = start + i;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
uniqueDois.add(p.doi);
|
|
103
|
+
}
|
|
104
|
+
pageItems.push(p);
|
|
105
|
+
}
|
|
106
|
+
// One lookup per distinct DOI (deduplicated), then remapped to every item that carries it
|
|
107
|
+
const dois = [...uniqueDois];
|
|
108
|
+
const settled = await mapWithConcurrency(dois, (doi) => lookupRetraction(doi, mailto));
|
|
109
|
+
const byDoi = new Map();
|
|
110
|
+
dois.forEach((doi, i) => {
|
|
111
|
+
const r = settled[i];
|
|
112
|
+
byDoi.set(doi, r.status === "fulfilled" ? r.value : { status: "lookup_failed", notices: [] });
|
|
113
|
+
});
|
|
114
|
+
const results = pageItems.map((p) => {
|
|
115
|
+
if (!p.doi)
|
|
116
|
+
return { key: p.key, title: p.title, doi: null, status: "no_doi", notices: [] };
|
|
117
|
+
const l = byDoi.get(p.doi) ?? { status: "lookup_failed", notices: [] };
|
|
118
|
+
return { key: p.key, title: p.title, doi: p.doi, status: l.status, notices: l.notices };
|
|
119
|
+
});
|
|
120
|
+
const summary = { checked: results.length };
|
|
121
|
+
for (const r of results)
|
|
122
|
+
summary[r.status] = (summary[r.status] ?? 0) + 1;
|
|
123
|
+
const flagged = results.filter((r) => r.status === "retracted" || r.status === "expression_of_concern");
|
|
124
|
+
return formatJsonResult({
|
|
125
|
+
summary,
|
|
126
|
+
flagged_count: flagged.length,
|
|
127
|
+
...(truncated ? { truncated: true, next_start: nextStart } : {}),
|
|
128
|
+
...(notFound.length ? { not_found: notFound } : {}),
|
|
129
|
+
items: results,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
|
|
4
|
+
import { createCitedDocument } from "../cited-document/create.js";
|
|
5
|
+
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
6
|
+
export const toolConfig = {
|
|
7
|
+
name: "create_cited_document",
|
|
8
|
+
description: `Create a Word (.docx) or LibreOffice (.odt) document from Markdown with native Zotero citations and a bibliography, ready for the Zotero plugin's Refresh. Unlike inject_citations (which fills <zcite> placeholders in an existing file), this writes a new document from Markdown text.
|
|
9
|
+
|
|
10
|
+
WORKFLOW:
|
|
11
|
+
1. Collect item keys with add_items_by_doi, add_items or search_library.
|
|
12
|
+
2. Write the body as Markdown, with pandoc-style citations where they belong.
|
|
13
|
+
3. Call this tool with the Markdown and an output path. It fetches metadata from Zotero, renders the document and appends the bibliography. It never overwrites an existing file.
|
|
14
|
+
4. Tell the user to open it in Word or LibreOffice with the Zotero plugin → Add/Edit Bibliography, then Refresh.
|
|
15
|
+
|
|
16
|
+
MARKDOWN SUBSET: headings (#…), paragraphs, bold (**…**), italic (*…*), one-level bullet lists (-). Anything else is kept as text, with a warning.
|
|
17
|
+
|
|
18
|
+
CITATIONS (pandoc style): [@KEY] one item; [@KEY1; @KEY2] several; [see @KEY, p. 12] with prefix and locator; -@KEY to suppress the author. KEY is a Zotero item key. For IEEE/Vancouver the numbers follow the order of first citation automatically.
|
|
19
|
+
|
|
20
|
+
OUTPUT: a new .docx or .odt (format from the extension, or the format argument). A cited key that is not in the library, or is not a Zotero key, is an error and nothing is written. If the output file already exists it is an error (never overwritten).`,
|
|
21
|
+
inputSchema: {
|
|
22
|
+
markdown: z.string().describe("The document body as Markdown, with pandoc-style citations like [@ITEMKEY]."),
|
|
23
|
+
output_path: z
|
|
24
|
+
.string()
|
|
25
|
+
.describe("Absolute path of the .docx or .odt file to create. Must not already exist."),
|
|
26
|
+
style: z
|
|
27
|
+
.enum(["apa", "ieee", "vancouver", "harvard", "chicago"])
|
|
28
|
+
.optional()
|
|
29
|
+
.default("apa")
|
|
30
|
+
.describe("Citation style for the visible placeholder text. Zotero reformats on refresh. Default: apa"),
|
|
31
|
+
format: z
|
|
32
|
+
.enum(["docx", "odt"])
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("docx for Word, odt for LibreOffice. Defaults to the output extension; an error if it disagrees."),
|
|
35
|
+
...libraryArgsSchema,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const Schema = z.object(toolConfig.inputSchema);
|
|
39
|
+
export async function handleCreateCitedDocument(zoteroApi, userId, args) {
|
|
40
|
+
const { markdown, output_path, style, format, library_type, library_id } = Schema.parse(args);
|
|
41
|
+
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
42
|
+
if (!isAbsolute(output_path)) {
|
|
43
|
+
return formatErrorResponse("output_path must be an absolute path", {
|
|
44
|
+
output_path,
|
|
45
|
+
hint: "Pass an absolute path (e.g. the user's Documents folder), as the tool description requires.",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const extFormat = output_path.endsWith(".docx") ? "docx" : output_path.endsWith(".odt") ? "odt" : undefined;
|
|
49
|
+
if (!extFormat) {
|
|
50
|
+
return formatErrorResponse("Output must be a .docx (Word) or .odt (LibreOffice) file", {
|
|
51
|
+
output_path,
|
|
52
|
+
hint: "The output name must end in lowercase .docx or .odt.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (format && format !== extFormat) {
|
|
56
|
+
return formatErrorResponse(`format '${format}' does not match the output extension of ${output_path}`, {
|
|
57
|
+
output_path,
|
|
58
|
+
hint: `The file ends in .${extFormat}: pass format "${extFormat}" or omit it.`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
let result;
|
|
62
|
+
try {
|
|
63
|
+
result = await createCitedDocument(markdown, output_path, zoteroApi, libraryType, libraryId, style, format ?? extFormat);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
const code = err.code;
|
|
67
|
+
if (code === "EEXIST") {
|
|
68
|
+
return formatErrorResponse("The output file already exists; it was not overwritten.", {
|
|
69
|
+
output_path,
|
|
70
|
+
hint: "Choose a different output_path, or delete/rename the existing file and call again.",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (code === "EBUSY") {
|
|
74
|
+
return formatErrorResponse("Cannot write the document: the output file is locked (EBUSY).", {
|
|
75
|
+
output_path,
|
|
76
|
+
hint: "Close the file if it is open in Word or LibreOffice and call again.",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
if (code === "ENOENT") {
|
|
80
|
+
return formatErrorResponse("Cannot write the document: the output folder does not exist.", {
|
|
81
|
+
output_path,
|
|
82
|
+
hint: "Create the folder or pass an output_path in an existing folder.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
|
|
86
|
+
return formatErrorResponse(`Cannot write the document (${code}).`, {
|
|
87
|
+
output_path,
|
|
88
|
+
hint: "Pass an output_path in a folder this server can write to.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
const responseObj = {
|
|
94
|
+
output_path: result.outputPath,
|
|
95
|
+
blocks: result.blocks,
|
|
96
|
+
citations: result.citations,
|
|
97
|
+
};
|
|
98
|
+
if (result.warnings.length > 0)
|
|
99
|
+
responseObj.warnings = result.warnings;
|
|
100
|
+
return formatJsonResult(responseObj);
|
|
101
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { zoteroKey } from "../utils/zotero-keys.js";
|
|
3
|
+
import { isZoteroApiError, } from "../types/zotero-types.js";
|
|
4
|
+
import { formatJsonResult, formatErrorResponse } from "../utils/error-formatter.js";
|
|
5
|
+
import { formatWriteErrors } from "../utils/write-results.js";
|
|
6
|
+
import { resolveLibrary, libraryArgsSchema } from "../utils/library-context.js";
|
|
7
|
+
import { escapeXml } from "../citation-injector/xml-utils.js";
|
|
8
|
+
import { ITEM_KEY_HINT } from "../utils/error-hints.js";
|
|
9
|
+
export const toolConfig = {
|
|
10
|
+
name: "create_note",
|
|
11
|
+
description: "Add a child note to an existing Zotero item. Provide the note as PLAIN TEXT (no HTML or Markdown): blank lines become separate paragraphs and single line breaks are preserved. The parent must be a regular item (not a note or attachment). Use this to attach a summary, annotation, or reading note to a reference. For top-level, item-wide editing of fields use update_item; to add references use add_items or add_items_by_doi.",
|
|
12
|
+
inputSchema: {
|
|
13
|
+
parent_item_key: zoteroKey().describe("Key of the regular item the note is attached to (from search_library, get_items_details, get_collection_items or an add_* tool). It must not be a note or an attachment."),
|
|
14
|
+
note: z
|
|
15
|
+
.string()
|
|
16
|
+
.min(1)
|
|
17
|
+
.describe("The note text, PLAIN TEXT only (no HTML/Markdown). Blank lines separate paragraphs; single newlines are kept as line breaks."),
|
|
18
|
+
tags: z
|
|
19
|
+
.array(z.string())
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("Tags to apply to the note"),
|
|
22
|
+
...libraryArgsSchema,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const CreateNoteSchema = z.object(toolConfig.inputSchema);
|
|
26
|
+
/**
|
|
27
|
+
* Plain text → the minimal HTML Zotero stores in a note: one <p> per paragraph (blocks separated by a
|
|
28
|
+
* blank line), single newlines as <br/>. Everything is XML-escaped first, so user text can never inject markup.
|
|
29
|
+
*/
|
|
30
|
+
export function noteTextToHtml(text) {
|
|
31
|
+
return text
|
|
32
|
+
.split(/\n[ \t]*\n/)
|
|
33
|
+
.map((para) => `<p>${escapeXml(para).replace(/\n/g, "<br/>")}</p>`)
|
|
34
|
+
.join("");
|
|
35
|
+
}
|
|
36
|
+
export async function handleCreateNote(zoteroApi, userId, args) {
|
|
37
|
+
const { parent_item_key, note, tags, library_type, library_id } = CreateNoteSchema.parse(args);
|
|
38
|
+
const { type: libraryType, id: libraryId } = resolveLibrary({ library_type, library_id }, userId);
|
|
39
|
+
// Fetch the parent first: a note can only hang off a regular item.
|
|
40
|
+
let parent;
|
|
41
|
+
try {
|
|
42
|
+
const resp = await zoteroApi.library(libraryType, libraryId).items(parent_item_key).get();
|
|
43
|
+
parent = resp.getData();
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
if (isZoteroApiError(err) && err.response.status === 404) {
|
|
47
|
+
return formatErrorResponse(`Parent item ${parent_item_key} not found`, {
|
|
48
|
+
hint: ITEM_KEY_HINT,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
if (parent.itemType === "note" || parent.itemType === "attachment") {
|
|
54
|
+
const article = /^[aeiou]/i.test(parent.itemType) ? "an" : "a";
|
|
55
|
+
return formatErrorResponse(`Parent item ${parent_item_key} is ${article} ${parent.itemType}; a note can only be attached to a regular item`, { hint: ITEM_KEY_HINT });
|
|
56
|
+
}
|
|
57
|
+
const payload = {
|
|
58
|
+
itemType: "note",
|
|
59
|
+
parentItem: parent_item_key,
|
|
60
|
+
note: noteTextToHtml(note),
|
|
61
|
+
tags: (tags ?? []).map((t) => ({ tag: t })),
|
|
62
|
+
collections: [],
|
|
63
|
+
};
|
|
64
|
+
const response = await zoteroApi.library(libraryType, libraryId).items().post([payload]);
|
|
65
|
+
if (!response.isSuccess()) {
|
|
66
|
+
const errorMsg = formatWriteErrors(response.getErrors());
|
|
67
|
+
return formatErrorResponse("Failed to create note", {
|
|
68
|
+
details: errorMsg,
|
|
69
|
+
hint: `details has Zotero's reason. parent_item_key must be an existing regular item: ${ITEM_KEY_HINT}`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const created = response.getData();
|
|
73
|
+
return formatJsonResult({
|
|
74
|
+
item_key: created[0].key,
|
|
75
|
+
parent_item_key,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -4,10 +4,10 @@ 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
|
-
description: "
|
|
10
|
+
description: "Permanently delete a collection (folder) from your Zotero library; confirm with the user first. Unlike delete_items, this cannot be undone: the Zotero Web API has no trash for collections, so a deleted collection is not recoverable. Items inside the collection are NOT deleted — they remain in your library. Disabled unless the server setting UNSAFE_OPERATIONS is 'all'.",
|
|
11
11
|
inputSchema: {
|
|
12
12
|
collection_key: zoteroKey()
|
|
13
13
|
.describe("Zotero collection key to delete. Get this from get_collections."),
|
|
@@ -27,28 +27,38 @@ export async function handleDeleteCollection(zoteroApi, userId, args, unsafeOps
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|