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,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ DEFAULT_MAX_CHARS = 20_000
6
+ MAX_MAX_CHARS = 200_000
7
+
8
+ def self.clamp_max_chars(max_chars)
9
+ unless max_chars.is_a?(Integer) && max_chars >= 1
10
+ return DEFAULT_MAX_CHARS
11
+ end
12
+
13
+ max_chars.clamp(1, MAX_MAX_CHARS)
14
+ end
15
+
16
+ # Full text lives on the attachment, not its parent, but callers often
17
+ # only have the parent's key. Falling back to the children (PDFs first)
18
+ # means either key works for zotero_get_item_fulltext.
19
+ def self.fulltext_with_fallback(client, item_key)
20
+ { body: client.fulltext(item_key), item_key: item_key }
21
+ # rubocop:disable Naming/RescuedExceptionsVariableName -- names the error re-raised below
22
+ rescue NotFoundError => original
23
+ # rubocop:enable Naming/RescuedExceptionsVariableName
24
+ begin
25
+ kids = client.children(item_key)
26
+ rescue NotFoundError, APIError
27
+ # Not a parent item (Zotero refuses /children on an
28
+ # attachment or a note): the original 404 is the answer.
29
+ raise original
30
+ end
31
+ fulltext_from_children(client, item_key, kids) ||
32
+ (raise NotFoundError,
33
+ "No indexed full text for this item or its attachments.")
34
+ end
35
+
36
+ def self.fulltext_from_children(client, parent_key, children)
37
+ candidate_attachments(children).each do |child|
38
+ key = child["key"]
39
+ begin
40
+ return { body: client.fulltext(key), item_key: key,
41
+ parent_key: parent_key }
42
+ rescue NotFoundError
43
+ next
44
+ end
45
+ end
46
+ nil
47
+ end
48
+
49
+ def self.candidate_attachments(children)
50
+ attachments = children.select do |child|
51
+ child.dig("data", "itemType") == "attachment"
52
+ end
53
+ pdfs, others = attachments.partition do |child|
54
+ child.dig("data", "contentType") == "application/pdf"
55
+ end
56
+ pdfs + others
57
+ end
58
+
59
+ def self.paginate_fulltext(resolved, offset, max_chars)
60
+ content = resolved[:body]["content"].to_s
61
+ start = clamp_start(offset)
62
+ slice = content[start, clamp_max_chars(max_chars)] || ""
63
+ result = fulltext_slice_result(resolved, content, start, slice)
64
+ copy_fulltext_meta(result, resolved[:body])
65
+ end
66
+
67
+ def self.fulltext_slice_result(resolved, content, start, slice)
68
+ result = {
69
+ item_key: resolved[:item_key], content: slice, offset: start,
70
+ returned_chars: slice.length, total_chars: content.length
71
+ }
72
+ result[:parent_key] = resolved[:parent_key] if resolved[:parent_key]
73
+ next_offset = start + slice.length
74
+ result[:next_offset] = next_offset if next_offset < content.length
75
+ result
76
+ end
77
+
78
+ def self.copy_fulltext_meta(result, body)
79
+ %w[indexedPages totalPages indexedChars totalChars].each do |field|
80
+ result[field.to_sym] = body[field] if body.key?(field)
81
+ end
82
+ result
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ # ----------------------------------------------------------------- #
6
+ # Small helpers
7
+ # ----------------------------------------------------------------- #
8
+
9
+ def self.truthy?(value)
10
+ %w[1 true yes on].include?(value.to_s.downcase)
11
+ end
12
+
13
+ def self.presence(value)
14
+ stripped = value.to_s.strip
15
+ stripped.empty? ? nil : stripped
16
+ end
17
+
18
+ def self.clamp_limit(limit)
19
+ return DEFAULT_LIMIT unless limit.is_a?(Integer) && limit.positive?
20
+
21
+ [limit, MAX_LIMIT].min
22
+ end
23
+
24
+ def self.clamp_start(start)
25
+ start.is_a?(Integer) && start.positive? ? start : 0
26
+ end
27
+
28
+ # Zotero keys are short alphanumeric strings. Rejecting anything else
29
+ # keeps bad input out of URL paths and fails with a clear message.
30
+ def self.validate_key!(key)
31
+ return key if KEY_PATTERN.match?(key.to_s)
32
+
33
+ raise Error, "Invalid Zotero key: #{key.inspect}."
34
+ end
35
+
36
+ def self.compact(hash)
37
+ hash.reject { |_key, value| value.nil? || value == "" }
38
+ end
39
+
40
+ def self.batch_error(keys)
41
+ return "Provide at least one item key." if keys.empty?
42
+ return nil if keys.length <= MAX_WRITE_BATCH
43
+
44
+ "At most #{MAX_WRITE_BATCH} items per call."
45
+ end
46
+
47
+ def self.nonempty(value) = value.nil? || value.empty? ? nil : value
48
+ end
49
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ # ----------------------------------------------------------------- #
6
+ # Read-side endpoints
7
+ # ----------------------------------------------------------------- #
8
+
9
+ module ReadAPI
10
+ def search_items(query:, qmode:, item_type:, tag:, top_only:, # rubocop:disable Metrics/ParameterLists -- mirrors the input_schema
11
+ sort:, direction:, limit:, start:)
12
+ path = "#{prefix}/items#{"/top" if top_only}"
13
+ params = Zotero::MCP.compact(
14
+ q: query, qmode: (query ? qmode : nil), itemType: item_type,
15
+ tag: tag, sort: sort, direction: direction,
16
+ limit: Zotero::MCP.clamp_limit(limit),
17
+ start: Zotero::MCP.clamp_start(start)
18
+ )
19
+ get_page(path, params)
20
+ end
21
+
22
+ def item(key)
23
+ get_json("#{prefix}/items/#{Zotero::MCP.validate_key!(key)}")
24
+ end
25
+
26
+ def children(key)
27
+ key = Zotero::MCP.validate_key!(key)
28
+ get_json("#{prefix}/items/#{key}/children")
29
+ end
30
+
31
+ def collections(top_only:, limit:, start:)
32
+ path = "#{prefix}/collections#{"/top" if top_only}"
33
+ get_page(path, limit_params(limit, start))
34
+ end
35
+
36
+ def collection_items(key:, top_only:, item_type:, tag:, limit:, # rubocop:disable Metrics/ParameterLists -- mirrors the input_schema
37
+ start:)
38
+ key = Zotero::MCP.validate_key!(key)
39
+ suffix = top_only ? "/top" : ""
40
+ path = "#{prefix}/collections/#{key}/items#{suffix}"
41
+ params = Zotero::MCP.compact(
42
+ itemType: item_type, tag: tag,
43
+ limit: Zotero::MCP.clamp_limit(limit),
44
+ start: Zotero::MCP.clamp_start(start)
45
+ )
46
+ get_page(path, params)
47
+ end
48
+
49
+ def tags(query:, limit:, start:)
50
+ params = Zotero::MCP.compact(
51
+ q: query, limit: Zotero::MCP.clamp_limit(limit),
52
+ start: Zotero::MCP.clamp_start(start)
53
+ )
54
+ get_page("#{prefix}/tags", params)
55
+ end
56
+
57
+ def fulltext(key)
58
+ key = Zotero::MCP.validate_key!(key)
59
+ get_json("#{prefix}/items/#{key}/fulltext")
60
+ end
61
+
62
+ def item_types = get_json("/itemTypes")
63
+
64
+ def item_template(item_type)
65
+ get_json("/items/new", itemType: item_type)
66
+ end
67
+
68
+ def creator_types(item_type)
69
+ get_json("/itemTypeCreatorTypes", itemType: item_type)
70
+ end
71
+
72
+ def bibliography(item_keys:, collection_key:, style:, mode:,
73
+ start: 0)
74
+ include = mode == "citation" ? "citation" : "bib"
75
+ start = Zotero::MCP.clamp_start(start)
76
+ params = { include: include, style: style, limit: MAX_LIMIT,
77
+ start: start }
78
+ response = bib_response(item_keys, collection_key, params)
79
+ bibliography_result(response, include, start)
80
+ end
81
+
82
+ private
83
+
84
+ def bibliography_result(response, include, start)
85
+ body = parse_json(response.body)
86
+ {
87
+ entries: body.map { |entry| entry[include] }.compact,
88
+ total: response["Total-Results"]&.to_i,
89
+ start: start, returned: body.length
90
+ }
91
+ end
92
+
93
+ # /top so child notes and attachments contributed by a collection
94
+ # produce no bibliography entries of their own.
95
+ def bib_response(item_keys, collection_key, params)
96
+ if collection_key
97
+ key = Zotero::MCP.validate_key!(collection_key)
98
+ path = "#{prefix}/collections/#{key}/items/top"
99
+ else
100
+ keys = item_keys.map { |key| Zotero::MCP.validate_key!(key) }
101
+ path = "#{prefix}/items"
102
+ params = params.merge(itemKey: keys.join(","))
103
+ end
104
+ request(:get, path, params: params)
105
+ end
106
+
107
+ def limit_params(limit, start)
108
+ {
109
+ limit: Zotero::MCP.clamp_limit(limit),
110
+ start: Zotero::MCP.clamp_start(start)
111
+ }
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ # HTTP status-code interpretation for Client: dispatches a response to
6
+ # the matching domain error (or the retryable rate-limit path) and
7
+ # builds the error message text. Included into Client.
8
+ module ResponseHandling
9
+ private
10
+
11
+ def handle(response)
12
+ status = response.code.to_i
13
+ return response if (200..299).cover?(status)
14
+
15
+ raise_for_status(status, response)
16
+ end
17
+
18
+ def raise_for_status(status, response)
19
+ case status
20
+ in 403 then raise NotAuthorisedError, forbidden_text(response)
21
+ in 404 then raise NotFoundError, not_found_text(response)
22
+ in 412 then raise ConflictError, conflict_text(response)
23
+ in 429 then raise_rate_limited(response)
24
+ in 503 then raise_service_unavailable(response)
25
+ else raise APIError, api_error_text(status, response)
26
+ end
27
+ end
28
+
29
+ def forbidden_text(response)
30
+ "#{forbidden_message}#{body_suffix(response)}"
31
+ end
32
+
33
+ def not_found_text(response)
34
+ "Not found (404). Check the key.#{body_suffix(response)}"
35
+ end
36
+
37
+ def conflict_text(response)
38
+ "#{conflict_message}#{body_suffix(response)}"
39
+ end
40
+
41
+ def api_error_text(status, response)
42
+ "Zotero API error #{status}.#{body_suffix(response)}"
43
+ end
44
+
45
+ def raise_rate_limited(response)
46
+ header = response["Retry-After"] || response["Backoff"]
47
+ seconds = Zotero::MCP.presence(header)&.to_i
48
+ raise RateLimitedError.new(
49
+ "Rate limited (429). Retry in #{seconds || "a few"} " \
50
+ "seconds.#{body_suffix(response)}",
51
+ retry_after: seconds
52
+ )
53
+ end
54
+
55
+ def raise_service_unavailable(response)
56
+ header = retry_header(response)
57
+ return unavailable_without_retry(response) unless header
58
+
59
+ raise RateLimitedError.new(service_unavailable_text(header,
60
+ response),
61
+ retry_after: header.to_i)
62
+ end
63
+
64
+ def retry_header(response)
65
+ Zotero::MCP.presence(
66
+ response["Retry-After"] || response["Backoff"]
67
+ )
68
+ end
69
+
70
+ def unavailable_without_retry(response)
71
+ raise APIError, "Zotero API error 503.#{body_suffix(response)}"
72
+ end
73
+
74
+ def service_unavailable_text(header, response)
75
+ "Service unavailable (503). Retry in #{header.to_i} " \
76
+ "seconds.#{body_suffix(response)}"
77
+ end
78
+
79
+ def forbidden_message
80
+ "Permission denied (403). The API key is missing, invalid, " \
81
+ "or lacks the access needed (write keys need write " \
82
+ "permission)."
83
+ end
84
+
85
+ def conflict_message
86
+ "Version conflict (412). The object changed on the server; " \
87
+ "re-fetch it and retry."
88
+ end
89
+
90
+ # Non-2xx responses often carry a plain-text explanation;
91
+ # surfacing it (bounded, so a huge body can't blow up an error
92
+ # message) helps diagnose failures that the generic status
93
+ # message cannot.
94
+ def body_suffix(response)
95
+ body = Zotero::MCP.presence(response.body)
96
+ return "" unless body
97
+
98
+ snippet = body.length > 500 ? "#{body[0, 500]}…" : body
99
+ " Server said: #{snippet}"
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class AddToCollection < ::MCP::Tool
6
+ tool_name "zotero_add_to_collection"
7
+ description <<~DESC
8
+ Add existing items to a collection, leaving other memberships
9
+ intact. Returns which keys were added and any failures.
10
+ DESC
11
+ input_schema(
12
+ properties: {
13
+ collection_key: { type: "string",
14
+ description: "Target collection key." },
15
+ item_keys: { type: "array", items: { type: "string" },
16
+ description: "Keys of items to add." }
17
+ },
18
+ required: %w[collection_key item_keys]
19
+ )
20
+ annotations(title: "Add items to a collection",
21
+ read_only_hint: false, destructive_hint: false,
22
+ idempotent_hint: true, open_world_hint: true)
23
+
24
+ def self.call(collection_key:, item_keys:)
25
+ Zotero::MCP.guard do
26
+ error = Zotero::MCP.batch_error(item_keys)
27
+ next Zotero::MCP.error_text(error) if error
28
+
29
+ Zotero::MCP.text(
30
+ Zotero::MCP.client.add_to_collection(
31
+ collection_key: collection_key, keys: item_keys
32
+ )
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class CreateCollection < ::MCP::Tool
6
+ tool_name "zotero_create_collection"
7
+ description <<~DESC
8
+ Create a new collection, optionally nested under a parent. Returns
9
+ the created collection key.
10
+ DESC
11
+
12
+ parent_collection_key_desc = "Parent collection to nest under " \
13
+ "(optional)."
14
+
15
+ input_schema(
16
+ properties: {
17
+ name: { type: "string",
18
+ description: "Name of the new collection." },
19
+ parent_collection_key: { type: "string",
20
+ description:
21
+ parent_collection_key_desc }
22
+ },
23
+ required: ["name"]
24
+ )
25
+ annotations(title: "Create a collection", read_only_hint: false,
26
+ destructive_hint: false, idempotent_hint: false,
27
+ open_world_hint: true)
28
+
29
+ def self.call(name:, parent_collection_key: nil)
30
+ Zotero::MCP.guard do
31
+ payload = { "name" => name }
32
+ if parent_collection_key
33
+ payload["parentCollection"] = parent_collection_key
34
+ end
35
+ Zotero::MCP.text(
36
+ Zotero::MCP.client.create_collections([payload])
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class CreateItem < ::MCP::Tool
6
+ tool_name "zotero_create_item"
7
+ description <<~DESC
8
+ Create a single item in the library. Builds a valid payload from
9
+ the item type's template, applies the given
10
+ fields/creators/tags/collections, and submits it. Returns the
11
+ created item key(s) or per-item failure details.
12
+ DESC
13
+
14
+ fields_desc = "Field -> value map, e.g. " \
15
+ "{'title': '...', 'date': '2023'}."
16
+ creators_desc = "Creators, e.g. {'creatorType': 'author', " \
17
+ "'firstName': 'Ada', 'lastName': 'Lovelace'}."
18
+
19
+ input_schema(
20
+ properties: {
21
+ item_type: { type: "string",
22
+ description: "Type, e.g. 'journalArticle', " \
23
+ "'book'." },
24
+ fields: { type: "object", description: fields_desc },
25
+ creators: { type: "array", items: { type: "object" },
26
+ description: creators_desc },
27
+ tags: { type: "array", items: { type: "string" },
28
+ description: "Tag strings." },
29
+ collection_keys: { type: "array",
30
+ items: { type: "string" },
31
+ description: "Collections the item " \
32
+ "belongs to." }
33
+ },
34
+ required: ["item_type"]
35
+ )
36
+ annotations(title: "Create a Zotero item", read_only_hint: false,
37
+ destructive_hint: false, idempotent_hint: false,
38
+ open_world_hint: true)
39
+
40
+ def self.call(item_type:, fields: {}, creators: nil, tags: nil,
41
+ collection_keys: nil)
42
+ Zotero::MCP.guard do
43
+ client = Zotero::MCP.client
44
+ template = client.item_template(item_type)
45
+ apply!(template, fields, creators, tags, collection_keys)
46
+ Zotero::MCP.text(client.create_items([template]))
47
+ end
48
+ end
49
+
50
+ def self.apply!(template, fields, creators, tags, collection_keys)
51
+ (fields || {}).each do |name, value|
52
+ template[name.to_s] = value
53
+ end
54
+ template["creators"] = creators unless creators.nil?
55
+ template["tags"] = tags.map { |tag| { "tag" => tag } } if tags
56
+ return if collection_keys.nil?
57
+
58
+ template["collections"] = collection_keys
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class CreateNote < ::MCP::Tool
6
+ tool_name "zotero_create_note"
7
+ description <<~DESC
8
+ Create a standalone or child note. If parent_item_key is set, the
9
+ note is created as a child of that item. Returns the new note key.
10
+ DESC
11
+
12
+ note_html_desc = "Note content as HTML (plain text is fine)."
13
+ parent_item_key_desc = "Make the note a child of this item."
14
+ collection_keys_desc = "Collections for a standalone note."
15
+
16
+ input_schema(
17
+ properties: {
18
+ note_html: { type: "string",
19
+ description: note_html_desc },
20
+ parent_item_key: { type: "string",
21
+ description: parent_item_key_desc },
22
+ tags: { type: "array", items: { type: "string" },
23
+ description: "Tag strings for the note." },
24
+ collection_keys: { type: "array",
25
+ items: { type: "string" },
26
+ description: collection_keys_desc }
27
+ },
28
+ required: ["note_html"]
29
+ )
30
+ annotations(title: "Create a Zotero note", read_only_hint: false,
31
+ destructive_hint: false, idempotent_hint: false,
32
+ open_world_hint: true)
33
+
34
+ def self.call(note_html:, parent_item_key: nil, tags: nil,
35
+ collection_keys: nil)
36
+ Zotero::MCP.guard do
37
+ error = note_conflict_error(parent_item_key,
38
+ collection_keys)
39
+ next Zotero::MCP.error_text(error) if error
40
+
41
+ client = Zotero::MCP.client
42
+ note = build_note(client, note_html, parent_item_key,
43
+ tags, collection_keys)
44
+ Zotero::MCP.text(client.create_items([note]))
45
+ end
46
+ end
47
+
48
+ def self.note_conflict_error(parent_item_key, collection_keys)
49
+ return nil unless parent_item_key &&
50
+ Array(collection_keys).any?
51
+
52
+ "collection_keys apply only to standalone notes; omit " \
53
+ "parent_item_key or collection_keys."
54
+ end
55
+
56
+ def self.build_note(client, note_html, parent_item_key, tags,
57
+ collection_keys)
58
+ note = client.item_template("note")
59
+ note["note"] = note_html
60
+ note["tags"] = tags.map { |tag| { "tag" => tag } } if tags
61
+ note["parentItem"] = parent_item_key if parent_item_key
62
+ if collection_keys && parent_item_key.nil?
63
+ note["collections"] = collection_keys
64
+ end
65
+ note
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class DeleteItem < ::MCP::Tool
6
+ tool_name "zotero_delete_item"
7
+ description <<~DESC
8
+ Delete one or more items (moves them to the Zotero trash, so
9
+ deletions are recoverable). Up to 50 keys per call. Returns the
10
+ deleted keys and any that could not be deleted.
11
+ DESC
12
+ input_schema(
13
+ properties: {
14
+ item_keys: { type: "array", items: { type: "string" },
15
+ description: "Item keys to delete (max 50)." }
16
+ },
17
+ required: ["item_keys"]
18
+ )
19
+ annotations(title: "Delete Zotero items", read_only_hint: false,
20
+ destructive_hint: true, idempotent_hint: false,
21
+ open_world_hint: true)
22
+
23
+ def self.call(item_keys:)
24
+ Zotero::MCP.guard do
25
+ error = Zotero::MCP.batch_error(item_keys)
26
+ next Zotero::MCP.error_text(error) if error
27
+
28
+ Zotero::MCP.text(Zotero::MCP.client.delete_items(item_keys))
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end