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,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,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
|