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.
- checksums.yaml +7 -0
- data/DESIGN.md +112 -0
- data/LICENSE +21 -0
- data/README.md +181 -0
- data/exe/zotero-mcp +8 -0
- data/lib/zotero/mcp/client.rb +133 -0
- data/lib/zotero/mcp/config.rb +71 -0
- data/lib/zotero/mcp/errors.rb +23 -0
- data/lib/zotero/mcp/formatting.rb +108 -0
- data/lib/zotero/mcp/fulltext.rb +85 -0
- data/lib/zotero/mcp/helpers.rb +49 -0
- data/lib/zotero/mcp/read_api.rb +115 -0
- data/lib/zotero/mcp/response_handling.rb +103 -0
- data/lib/zotero/mcp/tools/add_to_collection.rb +38 -0
- data/lib/zotero/mcp/tools/create_collection.rb +42 -0
- data/lib/zotero/mcp/tools/create_item.rb +62 -0
- data/lib/zotero/mcp/tools/create_note.rb +69 -0
- data/lib/zotero/mcp/tools/delete_item.rb +33 -0
- data/lib/zotero/mcp/tools/generate_bibliography.rb +84 -0
- data/lib/zotero/mcp/tools/get_collection_items.rb +55 -0
- data/lib/zotero/mcp/tools/get_item.rb +42 -0
- data/lib/zotero/mcp/tools/get_item_fulltext.rb +49 -0
- data/lib/zotero/mcp/tools/get_item_template.rb +46 -0
- data/lib/zotero/mcp/tools/list_collections.rb +57 -0
- data/lib/zotero/mcp/tools/list_tags.rb +41 -0
- data/lib/zotero/mcp/tools/search_items.rb +68 -0
- data/lib/zotero/mcp/tools/update_item.rb +66 -0
- data/lib/zotero/mcp/version.rb +7 -0
- data/lib/zotero/mcp/write_api.rb +117 -0
- data/lib/zotero/mcp.rb +67 -0
- metadata +88 -0
|
@@ -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
|