zotero-mcp 1.3.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.
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class GenerateBibliography < ::MCP::Tool
6
+ tool_name "zotero_generate_bibliography"
7
+ description <<~DESC
8
+ Produce formatted references for items using a CSL citation style.
9
+ Give either item_keys or collection_key. Returns JSON with an
10
+ `entries` list of HTML-formatted strings. A collection is formatted
11
+ 100 items at a time: when more remain, the result has
12
+ `truncated: true` and a `next_start` to pass back as `start`.
13
+ DESC
14
+
15
+ item_keys_desc = "Item keys to format (or use collection_key)."
16
+ collection_key_desc = "Format every item in this collection."
17
+ style_desc = "CSL style: 'apa', 'ieee', " \
18
+ "'chicago-note-bibliography', etc. (default 'apa')."
19
+ mode_desc = "'bibliography' (default) or 'citation'."
20
+ start_desc = "Offset into the collection (default 0)."
21
+
22
+ input_schema(
23
+ properties: {
24
+ item_keys: { type: "array", items: { type: "string" },
25
+ description: item_keys_desc },
26
+ collection_key: { type: "string",
27
+ description: collection_key_desc },
28
+ style: { type: "string", description: style_desc },
29
+ mode: { type: "string",
30
+ enum: %w[bibliography citation],
31
+ description: mode_desc },
32
+ start: { type: "integer", description: start_desc }
33
+ },
34
+ required: []
35
+ )
36
+ annotations(title: "Generate a bibliography", read_only_hint: true,
37
+ destructive_hint: false, idempotent_hint: true,
38
+ open_world_hint: true)
39
+
40
+ def self.call(item_keys: nil, collection_key: nil, style: "apa",
41
+ mode: "bibliography", start: 0)
42
+ Zotero::MCP.guard do
43
+ keys = Array(item_keys)
44
+ error = batch_error_for(keys, collection_key)
45
+ next Zotero::MCP.error_text(error) if error
46
+
47
+ result = Zotero::MCP.client.bibliography(
48
+ item_keys: keys, collection_key: collection_key,
49
+ style: style, mode: mode, start: start
50
+ )
51
+ Zotero::MCP.text(bibliography_payload(style, mode, result))
52
+ end
53
+ end
54
+
55
+ def self.batch_error_for(keys, collection_key)
56
+ if keys.empty? && collection_key.nil?
57
+ return "Provide item_keys or collection_key."
58
+ end
59
+ return nil if keys.length <= MAX_WRITE_BATCH
60
+
61
+ "At most #{MAX_WRITE_BATCH} item keys per call."
62
+ end
63
+
64
+ def self.bibliography_payload(style, mode, result)
65
+ payload = { style: style, mode: mode,
66
+ entries: result[:entries] }
67
+ total = result[:total]
68
+ return payload unless total
69
+
70
+ add_pagination!(payload, total, result)
71
+ payload
72
+ end
73
+
74
+ def self.add_pagination!(payload, total, result)
75
+ payload[:total] = total
76
+ next_start = result[:start] + result[:returned]
77
+ return unless next_start < total
78
+
79
+ payload[:truncated] = true
80
+ payload[:next_start] = next_start
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class GetCollectionItems < ::MCP::Tool
6
+ tool_name "zotero_get_collection_items"
7
+ description <<~DESC
8
+ List the items in a specific collection. Same response shape as
9
+ zotero_search_items: a `pagination` block and an `items` list
10
+ (compact summaries by default).
11
+ DESC
12
+
13
+ collection_key_desc = "The collection key to list items from."
14
+ top_only_desc = "Only top-level items (default true)."
15
+ item_type_desc = "Filter by item type, e.g. 'book'."
16
+ tag_desc = "Filter by tag (boolean syntax allowed)."
17
+ start_desc = "Offset for pagination (default 0)."
18
+ response_format_desc = "'summary' (default) or 'json'."
19
+
20
+ input_schema(
21
+ properties: {
22
+ collection_key: { type: "string",
23
+ description: collection_key_desc },
24
+ top_only: { type: "boolean", description: top_only_desc },
25
+ item_type: { type: "string", description: item_type_desc },
26
+ tag: { type: "string", description: tag_desc },
27
+ limit: { type: "integer",
28
+ description: "Max items, 1-100 (default 25)." },
29
+ start: { type: "integer", description: start_desc },
30
+ response_format: { type: "string",
31
+ enum: %w[summary json],
32
+ description: response_format_desc }
33
+ },
34
+ required: ["collection_key"]
35
+ )
36
+ annotations(title: "Get items in a collection",
37
+ read_only_hint: true, destructive_hint: false,
38
+ idempotent_hint: true, open_world_hint: true)
39
+
40
+ def self.call(collection_key:, top_only: true, item_type: nil, # rubocop:disable Metrics/ParameterLists -- mirrors the input_schema
41
+ tag: nil, limit: DEFAULT_LIMIT, start: 0,
42
+ response_format: "summary")
43
+ Zotero::MCP.guard do
44
+ page = Zotero::MCP.client.collection_items(
45
+ key: collection_key, top_only: top_only,
46
+ item_type: item_type, tag: tag, limit: limit,
47
+ start: start
48
+ )
49
+ Zotero::MCP.text(Zotero::MCP.format_page(page,
50
+ response_format))
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class GetItem < ::MCP::Tool
6
+ tool_name "zotero_get_item"
7
+ description <<~DESC
8
+ Fetch the full data for a single item by its key. Returns the
9
+ complete item JSON. When include_children is true, an additional
10
+ `children` array with the item's notes and attachments is added.
11
+ Children in the trash are left out, although the item's
12
+ meta.numChildren counts them.
13
+ DESC
14
+
15
+ item_key_desc = "The 8-character item key, e.g. 'ABCD2345'."
16
+ include_children_desc = "Also fetch child notes/attachments."
17
+
18
+ input_schema(
19
+ properties: {
20
+ item_key: { type: "string", description: item_key_desc },
21
+ include_children: { type: "boolean",
22
+ description: include_children_desc }
23
+ },
24
+ required: ["item_key"]
25
+ )
26
+ annotations(title: "Get a Zotero item", read_only_hint: true,
27
+ destructive_hint: false, idempotent_hint: true,
28
+ open_world_hint: true)
29
+
30
+ def self.call(item_key:, include_children: false)
31
+ Zotero::MCP.guard do
32
+ client = Zotero::MCP.client
33
+ result = { item: client.item(item_key) }
34
+ if include_children
35
+ result[:children] = client.children(item_key)
36
+ end
37
+ Zotero::MCP.text(result)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class GetItemFulltext < ::MCP::Tool
6
+ tool_name "zotero_get_item_fulltext"
7
+ description <<~DESC
8
+ Return the indexed full-text content of an attachment item (e.g. a
9
+ stored PDF). Either the attachment's own key or its parent item's
10
+ key is accepted; given a parent key, the attachment (PDFs first)
11
+ is resolved automatically. The content is paged: use offset and
12
+ max_chars, and next_offset in the result, to read more.
13
+ DESC
14
+
15
+ item_key_desc = "Key of an attachment with indexed text, or of " \
16
+ "its parent item."
17
+ max_chars_desc = "Max characters to return, 1-200000 " \
18
+ "(default 20000)."
19
+
20
+ input_schema(
21
+ properties: {
22
+ item_key: { type: "string", description: item_key_desc },
23
+ offset: { type: "integer",
24
+ description: "Character offset to start from " \
25
+ "(default 0)." },
26
+ max_chars: { type: "integer",
27
+ description: max_chars_desc }
28
+ },
29
+ required: ["item_key"]
30
+ )
31
+ annotations(title: "Get item full text", read_only_hint: true,
32
+ destructive_hint: false, idempotent_hint: true,
33
+ open_world_hint: true)
34
+
35
+ def self.call(item_key:, offset: 0,
36
+ max_chars: Zotero::MCP::DEFAULT_MAX_CHARS)
37
+ Zotero::MCP.guard do
38
+ resolved = Zotero::MCP.fulltext_with_fallback(
39
+ Zotero::MCP.client, item_key
40
+ )
41
+ Zotero::MCP.text(
42
+ Zotero::MCP.paginate_fulltext(resolved, offset,
43
+ max_chars)
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class GetItemTemplate < ::MCP::Tool
6
+ tool_name "zotero_get_item_template"
7
+ description <<~DESC
8
+ Get a blank item template, or list all valid item types. With no
9
+ item_type, returns the list of item types. With an item_type,
10
+ returns a blank editable template plus valid creator types — use
11
+ this before zotero_create_item.
12
+ DESC
13
+
14
+ item_type_desc = "Type to template, e.g. 'journalArticle'. " \
15
+ "Omit to list all item types."
16
+
17
+ input_schema(
18
+ properties: {
19
+ item_type: { type: "string", description: item_type_desc }
20
+ },
21
+ required: []
22
+ )
23
+ annotations(title: "Get item type template", read_only_hint: true,
24
+ destructive_hint: false, idempotent_hint: true,
25
+ open_world_hint: true)
26
+
27
+ def self.call(item_type: nil)
28
+ Zotero::MCP.guard do
29
+ client = Zotero::MCP.client
30
+ next Zotero::MCP.text(itemTypes: client.item_types) unless
31
+ item_type
32
+
33
+ Zotero::MCP.text(item_template_payload(client, item_type))
34
+ end
35
+ end
36
+
37
+ def self.item_template_payload(client, item_type)
38
+ {
39
+ itemType: item_type,
40
+ template: client.item_template(item_type),
41
+ creatorTypes: client.creator_types(item_type)
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class ListCollections < ::MCP::Tool
6
+ tool_name "zotero_list_collections"
7
+ description <<~DESC
8
+ List collections (folders) in the library. Returns a `pagination`
9
+ block plus a `collections` list, each with key, name,
10
+ parentCollection, numItems and numCollections.
11
+ DESC
12
+
13
+ top_only_desc = "Only top-level collections (default false)."
14
+
15
+ input_schema(
16
+ properties: {
17
+ top_only: { type: "boolean", description: top_only_desc },
18
+ limit: { type: "integer",
19
+ description: "Max results, 1-100 (default 25)." },
20
+ start: { type: "integer",
21
+ description: "Offset for pagination (default 0)." }
22
+ },
23
+ required: []
24
+ )
25
+ annotations(title: "List Zotero collections", read_only_hint: true,
26
+ destructive_hint: false, idempotent_hint: true,
27
+ open_world_hint: true)
28
+
29
+ def self.call(top_only: false, limit: DEFAULT_LIMIT, start: 0)
30
+ Zotero::MCP.guard do
31
+ page = Zotero::MCP.client.collections(
32
+ top_only: top_only, limit: limit, start: start
33
+ )
34
+ Zotero::MCP.text(collections_payload(page))
35
+ end
36
+ end
37
+
38
+ def self.collections_payload(page)
39
+ {
40
+ pagination: Zotero::MCP.pagination_of(page),
41
+ collections: page.items.map { |col| collection_of(col) }
42
+ }
43
+ end
44
+
45
+ def self.collection_of(col)
46
+ data = col["data"] || {}
47
+ meta = col["meta"] || {}
48
+ {
49
+ key: col["key"], name: data["name"],
50
+ parentCollection: data["parentCollection"],
51
+ numItems: meta["numItems"],
52
+ numCollections: meta["numCollections"]
53
+ }.compact
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class ListTags < ::MCP::Tool
6
+ tool_name "zotero_list_tags"
7
+ description <<~DESC
8
+ List tags used in the library, optionally filtered by text. Returns
9
+ a `pagination` block and a `tags` list of tag objects.
10
+ DESC
11
+ filter_text_desc = "Only tags matching this text."
12
+
13
+ input_schema(
14
+ properties: {
15
+ filter_text: { type: "string",
16
+ description: filter_text_desc },
17
+ limit: { type: "integer",
18
+ description: "Max tags, 1-100 (default 25)." },
19
+ start: { type: "integer",
20
+ description: "Offset for pagination (default 0)." }
21
+ },
22
+ required: []
23
+ )
24
+ annotations(title: "List Zotero tags", read_only_hint: true,
25
+ destructive_hint: false, idempotent_hint: true,
26
+ open_world_hint: true)
27
+
28
+ def self.call(filter_text: nil, limit: DEFAULT_LIMIT, start: 0)
29
+ Zotero::MCP.guard do
30
+ page = Zotero::MCP.client.tags(
31
+ query: filter_text, limit: limit, start: start
32
+ )
33
+ Zotero::MCP.text(
34
+ pagination: Zotero::MCP.pagination_of(page),
35
+ tags: page.items
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class SearchItems < ::MCP::Tool
6
+ tool_name "zotero_search_items"
7
+ description <<~DESC
8
+ Search or browse items in the Zotero library. Returns a JSON
9
+ object with a `pagination` block (count, start, total, has_more,
10
+ next_start) and an `items` list. In 'summary' format each item has
11
+ key, itemType, title, creators, date, publication, tags and
12
+ numChildren; use zotero_get_item with the key for the full record.
13
+ DESC
14
+
15
+ query_desc = "Free-text search; omit to browse all."
16
+ qmode_desc = "'titleCreatorYear' (default) or 'everything' " \
17
+ "(also searches attachment full text)."
18
+ item_type_desc = "Filter by type, e.g. 'journalArticle', 'book'. " \
19
+ "Boolean syntax like '-attachment' works."
20
+ tag_desc = "Filter by tag; boolean 'a && b' / 'a || b'."
21
+ top_only_desc = "Only top-level items (default true)."
22
+ sort_desc = "Sort field, e.g. 'dateModified', 'title'."
23
+ limit_desc = "Max items, 1-100 (default 25)."
24
+ start_desc = "Offset for pagination (default 0)."
25
+ response_format_desc = "'summary' (default) or 'json' for raw " \
26
+ "data."
27
+
28
+ input_schema(
29
+ properties: {
30
+ query: { type: "string", description: query_desc },
31
+ qmode: { type: "string",
32
+ enum: %w[titleCreatorYear everything],
33
+ description: qmode_desc },
34
+ item_type: { type: "string", description: item_type_desc },
35
+ tag: { type: "string", description: tag_desc },
36
+ top_only: { type: "boolean", description: top_only_desc },
37
+ sort: { type: "string", description: sort_desc },
38
+ direction: { type: "string", enum: %w[asc desc],
39
+ description: "'asc' or 'desc'." },
40
+ limit: { type: "integer", description: limit_desc },
41
+ start: { type: "integer", description: start_desc },
42
+ response_format: { type: "string",
43
+ enum: %w[summary json],
44
+ description: response_format_desc }
45
+ },
46
+ required: []
47
+ )
48
+ annotations(title: "Search Zotero items", read_only_hint: true,
49
+ destructive_hint: false, idempotent_hint: true,
50
+ open_world_hint: true)
51
+
52
+ def self.call(query: nil, qmode: "titleCreatorYear", # rubocop:disable Metrics/ParameterLists -- mirrors the input_schema
53
+ item_type: nil, tag: nil, top_only: true, sort: nil,
54
+ direction: nil, limit: DEFAULT_LIMIT, start: 0,
55
+ response_format: "summary")
56
+ Zotero::MCP.guard do
57
+ page = Zotero::MCP.client.search_items(
58
+ query: query, qmode: qmode, item_type: item_type,
59
+ tag: tag, top_only: top_only, sort: sort,
60
+ direction: direction, limit: limit, start: start
61
+ )
62
+ Zotero::MCP.text(Zotero::MCP.format_page(page,
63
+ response_format))
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class UpdateItem < ::MCP::Tool
6
+ tool_name "zotero_update_item"
7
+ description <<~DESC
8
+ Update fields, tags, or collections of an existing item. Fetches
9
+ the item to obtain its current version for safe concurrent editing,
10
+ applies changes, and saves. Providing tags or collections REPLACES
11
+ the existing ones.
12
+ DESC
13
+
14
+ collection_keys_desc = "Replaces the item's collections."
15
+
16
+ input_schema(
17
+ properties: {
18
+ item_key: { type: "string",
19
+ description: "Key of the item to update." },
20
+ fields: { type: "object",
21
+ description: "Field -> new value map." },
22
+ tags: { type: "array", items: { type: "string" },
23
+ description: "Replaces the item's tags." },
24
+ collection_keys: { type: "array",
25
+ items: { type: "string" },
26
+ description: collection_keys_desc }
27
+ },
28
+ required: ["item_key"]
29
+ )
30
+ annotations(title: "Update a Zotero item", read_only_hint: false,
31
+ destructive_hint: false, idempotent_hint: false,
32
+ open_world_hint: true)
33
+
34
+ def self.call(item_key:, fields: nil, tags: nil,
35
+ collection_keys: nil)
36
+ Zotero::MCP.guard do
37
+ changes = build_changes(fields, tags, collection_keys)
38
+ next update_item_error unless changes.any?
39
+
40
+ Zotero::MCP.text(
41
+ Zotero::MCP.client.update_item(
42
+ key: item_key, changes: changes
43
+ )
44
+ )
45
+ end
46
+ end
47
+
48
+ def self.update_item_error
49
+ Zotero::MCP.error_text(
50
+ "Provide fields, tags, or collection_keys."
51
+ )
52
+ end
53
+
54
+ def self.build_changes(fields, tags, collection_keys)
55
+ changes = {}
56
+ (fields || {}).each { |name, value| changes[name.to_s] = value }
57
+ changes["tags"] = tags.map { |tag| { "tag" => tag } } if tags
58
+ unless collection_keys.nil?
59
+ changes["collections"] =
60
+ collection_keys
61
+ end
62
+ changes
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ VERSION = "1.3.0"
6
+ end
7
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ # ----------------------------------------------------------------- #
6
+ # Write-side endpoints
7
+ # ----------------------------------------------------------------- #
8
+
9
+ module WriteAPI
10
+ def create_items(payload)
11
+ ensure_writable!
12
+ write_json(:post, "#{prefix}/items", payload)
13
+ end
14
+
15
+ def create_collections(payload)
16
+ ensure_writable!
17
+ write_json(:post, "#{prefix}/collections", payload)
18
+ end
19
+
20
+ def update_item(key:, changes:)
21
+ ensure_writable!
22
+ current = item(key)
23
+ patch_item(key, current.fetch("version"), changes)
24
+ { success: true, key: key, message: "Item updated." }
25
+ end
26
+
27
+ def delete_items(keys)
28
+ ensure_writable!
29
+ deleted = []
30
+ failed = []
31
+ keys.each { |key| delete_one(key, deleted, failed) }
32
+ { deleted: deleted, failed: failed }
33
+ end
34
+
35
+ def add_to_collection(collection_key:, keys:)
36
+ ensure_writable!
37
+ collection_key = Zotero::MCP.validate_key!(collection_key)
38
+ added = []
39
+ failed = []
40
+ keys.each { |key| add_one(collection_key, key, added, failed) }
41
+ { collection: collection_key, added: added, failed: failed }
42
+ end
43
+
44
+ private
45
+
46
+ # Moves the item to the trash. A DELETE request would remove it
47
+ # from Zotero for good, past any recovery; the trash is the
48
+ # item's "deleted" property.
49
+ def delete_one(key, deleted, failed)
50
+ current = item(key)
51
+ patch_item(key, current.fetch("version"), { deleted: 1 })
52
+ deleted << key
53
+ rescue Error => e
54
+ failed << { key: key, reason: e.message }
55
+ end
56
+
57
+ def add_one(collection_key, key, added, failed)
58
+ current = item(key)
59
+ merged = (current.dig("data",
60
+ "collections") || []) + [collection_key]
61
+ patch_item(key, current.fetch("version"),
62
+ { collections: merged.uniq })
63
+ added << key
64
+ rescue Error => e
65
+ failed << { key: key, reason: e.message }
66
+ end
67
+
68
+ def patch_item(key, version, changes)
69
+ request(:patch, "#{prefix}/items/#{key}",
70
+ body: JSON.dump(changes),
71
+ headers: version_header(version).merge(json_content))
72
+ end
73
+
74
+ def version_header(version)
75
+ { "If-Unmodified-Since-Version" => version.to_s }
76
+ end
77
+
78
+ def write_json(method, path, payload)
79
+ headers = write_headers(method)
80
+ response = request(method, path, body: JSON.dump(payload),
81
+ headers: headers)
82
+ normalize_write(parse_json(response.body))
83
+ end
84
+
85
+ def write_headers(method)
86
+ return json_content unless method == :post
87
+
88
+ json_content.merge("Zotero-Write-Token" => SecureRandom.hex(16))
89
+ end
90
+
91
+ def normalize_write(body)
92
+ failed = body["failed"].to_h
93
+ result = { success: failed.empty?,
94
+ created_keys: created_keys(body) }
95
+ result[:failed] = failed unless failed.empty?
96
+ unchanged = body["unchanged"]
97
+ result[:unchanged] = unchanged if nonempty_unchanged?(unchanged)
98
+ result
99
+ end
100
+
101
+ def nonempty_unchanged?(unchanged)
102
+ unchanged.respond_to?(:any?) && unchanged.any?
103
+ end
104
+
105
+ def created_keys(body)
106
+ from_map = (body["success"] || {}).values.grep(String)
107
+ successful = (body["successful"] || {}).values
108
+ from_obj = successful.filter_map do |entry|
109
+ entry["key"] if entry.is_a?(Hash)
110
+ end
111
+ (from_map + from_obj).uniq
112
+ end
113
+
114
+ def json_content = { "Content-Type" => "application/json" }
115
+ end
116
+ end
117
+ end