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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8f8e280c65713f3051f5c0d200f24dc22b8b4cecaba390696473a99c5d13d68e
4
+ data.tar.gz: b8fc1a864ca6beda8b72f7bbc383dfa772afa09e054544aa1a390d0803f3ae8f
5
+ SHA512:
6
+ metadata.gz: 0af867aa2ceaee0e2322e7ca7dded6da05871e031ba9af64249cf7da007ace79f4174a07e4786ff0d5137811cef651db813015e4415de635ee9953218fb687d0
7
+ data.tar.gz: 563ff449c26f3ba41071010935c7b4c6afbbd011662b5553c3039899c9f882eec7afb1ed189ee09cd60857a7e6d506bd13874c4fe95371399708cc5d83a92f41
data/DESIGN.md ADDED
@@ -0,0 +1,112 @@
1
+ # Design
2
+
3
+ Why the Zotero MCP server is shaped the way it is. How to install and use
4
+ it is in `README.md`.
5
+
6
+ ## Small files, standard library HTTP
7
+
8
+ `lib/zotero/mcp.rb` loads the parts and assembles the server; each part
9
+ is its own file under `lib/zotero/mcp/` (errors, configuration, the
10
+ HTTP client and its response handling, the read and write endpoints,
11
+ formatting, full text), and each tool is a file under
12
+ `lib/zotero/mcp/tools/`. Files stay short enough for the house style's
13
+ size limits, so a change to one tool reads as a change to one file.
14
+ `lib/zotero/mcp/version.rb` holds only the version, so the gemspec can
15
+ read it without loading the `mcp` gem. One launcher, `exe/zotero-mcp`, calls `Zotero::MCP.run`; the
16
+ gem installs it on the `PATH`, and a checkout runs the same file
17
+ through Bundler. The only dependency is the `mcp` gem; HTTP goes
18
+ through `Net::HTTP`. A REST client gem would save little: the Zotero API
19
+ needs only GET, POST, PATCH and DELETE with a few headers.
20
+
21
+ The gem is `zotero-mcp` and its code is `Zotero::MCP`, following the
22
+ RubyGems rule that a dash in a name marks a namespace. The price is
23
+ sharing the top-level `Zotero` module with the unrelated `zotero` gem
24
+ (0.2.1), which defines `Zotero::VERSION`, `Zotero::Api` and
25
+ `Zotero::Entities`. Everything here lives under `Zotero::MCP`, so the
26
+ two can load in one process without overwriting each other's constants.
27
+
28
+ ## Layers
29
+
30
+ `Config` reads the environment once. `Client` is the HTTP core
31
+ (`request`, the status-to-exception mapping, retries). The endpoint
32
+ methods are mixed into it from `ReadAPI` and `WriteAPI`. The `MCP::Tool`
33
+ subclasses are thin: they validate arguments, call one client method
34
+ inside `Zotero::MCP.guard`, and format the result. Because of this split,
35
+ the suite can replace `Client#perform` (the one method that touches the
36
+ network) with a stub, and exercise everything else offline.
37
+
38
+ ## Errors are results, not exceptions
39
+
40
+ Every failure the server can foresee is a subclass of `Zotero::MCP::Error`.
41
+ `guard` turns any exception into a tool result flagged `isError` and
42
+ carrying an `error` message, so the client always gets an answer it can
43
+ act on, and the stdio transport never sees a Ruby exception. Messages say
44
+ what to do next ("re-fetch it and retry"), because their reader is a
45
+ model deciding on its next call. When Zotero explains a refusal in the
46
+ response body, the first 500 characters of that explanation are
47
+ appended. Nothing is ever written to stdout: that stream carries the
48
+ JSON-RPC protocol.
49
+
50
+ ## Writes are version-safe
51
+
52
+ An update, a delete, or adding an item to a collection first fetches the
53
+ item's current version. The write then sends that version back in
54
+ `If-Unmodified-Since-Version`. If someone else changed the item in
55
+ between, Zotero rejects the write with a 412, which reaches the caller as
56
+ a conflict instead of silently overwriting their edit. The cost is one
57
+ extra GET per item.
58
+
59
+ Deletes and collection additions go one item at a time, not as a single
60
+ batch request. That costs one round trip per item, but each key's
61
+ success or failure is reported separately: a batch that fails on one
62
+ item would tell the caller nothing about the others.
63
+
64
+ A delete moves the item to the trash: it sets the item's `deleted`
65
+ property with the same version-checked PATCH. An HTTP DELETE removes the
66
+ item from Zotero for good (it then appears in the library's `/deleted`
67
+ log and nowhere else), so the server never sends one, and
68
+ `zotero_delete_item` stays recoverable from Zotero's trash.
69
+
70
+ ## Bounded retries
71
+
72
+ A 429, or a 503 that says when to come back, is retried at most
73
+ `MAX_RETRIES` times, and only when the server says how long to wait
74
+ (`Retry-After` or `Backoff`) and that wait is at most `MAX_BACKOFF`
75
+ seconds. A longer wait goes back to the caller as an error, because an
76
+ MCP tool call that blocks for minutes looks like a hang. A 503 with no
77
+ such header is an ordinary error.
78
+
79
+ Each create request is one POST carrying a `Zotero-Write-Token`. The
80
+ token is generated once per call and reused on every retry of that call,
81
+ so if the server does act on a request that the client thinks failed,
82
+ the retry cannot create the item a second time.
83
+
84
+ ## Keys are validated before they reach a URL
85
+
86
+ Every item or collection key passes through `validate_key!`, which
87
+ accepts up to 32 alphanumerics. Keys are interpolated into URL paths;
88
+ rejecting anything else keeps a malformed or hostile argument out of
89
+ the path, and fails with a message that names the bad key.
90
+
91
+ ## Summaries by default
92
+
93
+ The list and search tools return compact summaries unless asked for
94
+ `json`: key, type, title, creators (first three and a count), date,
95
+ publication, tags. A full Zotero record is mostly empty fields, and the
96
+ caller pays for every byte in its context window. `zotero_get_item`
97
+ returns the full record for one key.
98
+
99
+ Full text is the extreme case: an indexed book can run to megabytes.
100
+ `zotero_get_item_fulltext` returns at most `max_chars` characters per
101
+ call (20,000 by default) along with the offset to continue from, so the
102
+ caller decides how much to read. It also accepts a parent item's key,
103
+ since that is the key a search returns. When Zotero has no text under
104
+ that key, it tries the item's attachments, PDFs first.
105
+
106
+ ## Local mode is read-only
107
+
108
+ The Zotero desktop app's local API (`ZOTERO_LOCAL=true`) serves reads
109
+ only. The write tools stay registered in that mode, and refuse with a
110
+ `ReadOnlyError` that says to use the Web API. This keeps the tool list
111
+ the same in both modes, so a client's view of the server does not
112
+ depend on an environment variable.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stephane D'Alu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # Zotero MCP Server (Ruby)
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server that lets Claude Code (and any
4
+ other MCP client) read from and write to your [Zotero](https://www.zotero.org)
5
+ library through the [Zotero Web API v3](https://www.zotero.org/support/dev/web_api/v3/start).
6
+ It can search and browse items, read full text, generate formatted
7
+ bibliographies, and create, update, and delete items, notes, and collections.
8
+
9
+ Pure Ruby: the only dependency is the official `mcp` gem. HTTP is handled with
10
+ the standard library.
11
+
12
+ ## Tools
13
+
14
+ | Tool | What it does | Writes? |
15
+ |---|---|---|
16
+ | `zotero_search_items` | Search or browse items (query, type, tag, sort, paging) | no |
17
+ | `zotero_get_item` | Fetch one item's full data, optionally with its children | no |
18
+ | `zotero_list_collections` | List collections (folders) | no |
19
+ | `zotero_get_collection_items` | List items inside a collection | no |
20
+ | `zotero_list_tags` | List tags, optionally filtered | no |
21
+ | `zotero_get_item_fulltext` | Indexed full text of an attachment (e.g. a PDF), or of its parent item — the attachment is resolved automatically (PDFs first); paged with `offset`/`max_chars` | no |
22
+ | `zotero_generate_bibliography` | Formatted references in a CSL style (APA, IEEE, …), for up to 50 items or a whole collection; a collection comes 100 items at a time, continued with `start` | no |
23
+ | `zotero_get_item_template` | List item types, or get a blank template + creator types | no |
24
+ | `zotero_create_item` | Create an item from a type template | **yes** |
25
+ | `zotero_create_note` | Create a standalone or child note | **yes** |
26
+ | `zotero_update_item` | Update fields, tags, or collections of an item | **yes** |
27
+ | `zotero_delete_item` | Delete items (up to 50; goes to trash, recoverable) | **yes** |
28
+ | `zotero_add_to_collection` | Add existing items to a collection | **yes** |
29
+ | `zotero_create_collection` | Create a collection, optionally nested | **yes** |
30
+
31
+ ## Requirements
32
+
33
+ - Ruby 3.3 or newer.
34
+ - [Bundler](https://bundler.io) to install the `mcp` gem.
35
+ - A Zotero account and a library to connect to.
36
+
37
+ ## Setup
38
+
39
+ ### 1. Install
40
+
41
+ Either run it from a checkout:
42
+
43
+ ```bash
44
+ cd zotero-mcp
45
+ bundle install
46
+ ```
47
+
48
+ or build and install the gem, which puts a `zotero-mcp` command on your
49
+ `PATH` and pulls in the `mcp` gem:
50
+
51
+ ```bash
52
+ gem build zotero-mcp.gemspec
53
+ gem install ./zotero-mcp-*.gem
54
+ ```
55
+
56
+ ### 2. Get a Zotero API key and library id
57
+
58
+ 1. Sign in and open <https://www.zotero.org/settings/keys>.
59
+ 2. Create a new key. Grant **read** access, and **write** access too if you
60
+ want the create/update/delete tools to work. For a group library, allow the
61
+ relevant group.
62
+ 3. Copy the key. Your numeric **userID** is shown on that same page.
63
+
64
+ You can also skip the library id — if `ZOTERO_LIBRARY_ID` is unset, the server
65
+ resolves your user id from the key automatically. For a **group** library, set
66
+ `ZOTERO_LIBRARY_TYPE=group` and `ZOTERO_LIBRARY_ID` to the group's id.
67
+
68
+ ### 3. Register with Claude Code
69
+
70
+ Use **absolute paths** (a relative path is the most common reason a server
71
+ fails to connect). The server is started by `exe/zotero-mcp`.
72
+
73
+ From a checkout, run it through Bundler so the `mcp` gem is found (`which
74
+ ruby` gives the Ruby to name):
75
+
76
+ ```bash
77
+ claude mcp add zotero --scope user \
78
+ --env ZOTERO_API_KEY=your-zotero-api-key \
79
+ --env ZOTERO_LIBRARY_ID=1234567 \
80
+ --env BUNDLE_GEMFILE=/absolute/path/to/zotero-mcp/Gemfile \
81
+ -- /absolute/path/to/ruby -rbundler/setup /absolute/path/to/zotero-mcp/exe/zotero-mcp
82
+ ```
83
+
84
+ With the gem installed, register its command instead (absolute path from
85
+ `which zotero-mcp`):
86
+
87
+ ```bash
88
+ claude mcp add zotero --scope user \
89
+ --env ZOTERO_API_KEY=your-zotero-api-key \
90
+ -- /absolute/path/to/zotero-mcp
91
+ ```
92
+
93
+ Then check it connected:
94
+
95
+ ```bash
96
+ claude mcp list
97
+ ```
98
+
99
+ Inside a Claude Code session, `/mcp` shows the server and its tools.
100
+
101
+ Prefer editing config by hand? Copy `mcp.json.example` to `.mcp.json` in your
102
+ project (this shares the server with anyone who has the repo), fill in the
103
+ absolute path and your credentials, and restart Claude Code.
104
+
105
+ ## Environment variables
106
+
107
+ | Variable | Required | Description |
108
+ |---|---|---|
109
+ | `ZOTERO_API_KEY` | for the Web API | Key from the Zotero settings page. |
110
+ | `ZOTERO_LIBRARY_ID` | optional | Numeric user or group id. Auto-resolved from the key when omitted (user libraries only). |
111
+ | `ZOTERO_LIBRARY_TYPE` | optional | `user` (default) or `group`. |
112
+ | `ZOTERO_LOCAL` | optional | `true` reads the local Zotero desktop app instead of the web service. |
113
+
114
+ ## Local (desktop) mode
115
+
116
+ Set `ZOTERO_LOCAL=true` to read from the Zotero desktop app running on the same
117
+ machine (`http://localhost:23119`). First enable it in Zotero under
118
+ **Settings → Advanced → Allow other applications on this computer to
119
+ communicate with Zotero**.
120
+
121
+ The local API is **read-only**: the write tools return a clear error in this
122
+ mode. No API key is needed for local reads.
123
+
124
+ ## A typical workflow
125
+
126
+ To add an item, ask for a template first so fields and creator types are valid:
127
+
128
+ 1. `zotero_get_item_template` with `item_type: "journalArticle"` — see the
129
+ fields and valid creator types.
130
+ 2. `zotero_create_item` with `item_type`, a `fields` map, `creators`, and any
131
+ `tags` — the server fills the template and submits it.
132
+
133
+ Updates are version-safe: `zotero_update_item` fetches the item's current
134
+ version and sends it back, so a concurrent edit fails loudly (a 412 conflict)
135
+ rather than silently clobbering. Passing `tags` or `collection_keys` to update
136
+ **replaces** those lists.
137
+
138
+ ## Tests
139
+
140
+ The suite is plain [Minitest](https://github.com/minitest/minitest) and never
141
+ touches the network: a `StubClient` records outgoing requests
142
+ and returns canned responses, so the client, read/write endpoints, formatting,
143
+ and tool `.call` methods are all exercised offline; `test/server_test.rb` also
144
+ drives the assembled server through JSON-RPC (`initialize`, `tools/list`,
145
+ `tools/call`). `bundle install` brings in the `mcp` gem and the development
146
+ tools (Minitest, Rake, RuboCop, pinned in the `Gemfile`):
147
+
148
+ ```bash
149
+ bundle exec rake test # the whole suite (or: ruby test/all.rb)
150
+ bundle exec ruby test/retry_test.rb # a single file
151
+ bundle exec rubocop # the house style, from .rubocop.yml
152
+ ```
153
+
154
+ CI (`.github/workflows/ci.yml`) runs the suite on Ruby 3.3 and 3.4, plus
155
+ RuboCop and a gem build, on every push to `main` and every pull request.
156
+
157
+ ## Notes and safety
158
+
159
+ - Writes are capped at 50 objects per call, matching the Zotero API.
160
+ - Deletes move items to the Zotero **trash**, so they can be restored.
161
+ - The server logs nothing to stdout, as required for stdio MCP servers (stdout
162
+ carries the JSON-RPC protocol). Errors come back to the client as tool results
163
+ flagged `isError`, carrying Zotero's own explanation when it sends one.
164
+ - Give the API key the narrowest access that fits your use. Use a read-only key
165
+ if you never intend to write.
166
+
167
+ ## Troubleshooting
168
+
169
+ - **Server won't connect / not listed** — use absolute paths for `ruby` and the
170
+ script; confirm `ruby -v` is 3.3+; run the `claude mcp add` command again.
171
+ - **"Permission denied (403)"** — the key is missing, invalid, or lacks the
172
+ needed access; write tools require a write-enabled key.
173
+ - **"Version conflict (412)"** — the item changed on the server; re-fetch it
174
+ with `zotero_get_item` and retry.
175
+ - **Full text is empty** — pass the attachment's key, or its parent item's
176
+ key (the attachment is then resolved automatically, PDFs first), and make
177
+ sure Zotero has indexed that attachment.
178
+
179
+ ## License
180
+
181
+ MIT; see `LICENSE`.
data/exe/zotero-mcp ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Zotero MCP server over stdio; configuration comes from the environment
5
+ # (see README.md).
6
+ require_relative "../lib/zotero/mcp"
7
+
8
+ Zotero::MCP.run
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ # ----------------------------------------------------------------- #
6
+ # HTTP client core
7
+ # ----------------------------------------------------------------- #
8
+
9
+ class Client
10
+ include ReadAPI
11
+ include WriteAPI
12
+ include ResponseHandling
13
+
14
+ def initialize(config)
15
+ @config = config
16
+ end
17
+
18
+ def library_id
19
+ @library_id ||= @config.library_id ||
20
+ (@config.local ? "0" : resolve_library_id)
21
+ end
22
+
23
+ def prefix = "/#{@config.library_type}s/#{library_id}"
24
+
25
+ def request(method, path, params: {}, body: nil, headers: {})
26
+ uri = build_uri(path, params)
27
+ attempts = 0
28
+ begin
29
+ handle(perform(method, uri, body, headers))
30
+ rescue RateLimitedError => e
31
+ attempts += 1
32
+ raise unless retryable?(e, attempts)
33
+
34
+ sleep(e.retry_after)
35
+ retry
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def build_uri(path, params)
42
+ uri = URI.parse("#{@config.base_url}#{path}")
43
+ uri.query = URI.encode_www_form(params) unless params.empty?
44
+ uri
45
+ end
46
+
47
+ def retryable?(error, attempts)
48
+ attempts <= MAX_RETRIES && !error.retry_after.nil? &&
49
+ error.retry_after <= MAX_BACKOFF
50
+ end
51
+
52
+ def perform(method, uri, body, extra_headers)
53
+ http = Net::HTTP.new(uri.host, uri.port)
54
+ http.use_ssl = uri.scheme == "https"
55
+ http.open_timeout = OPEN_TIMEOUT
56
+ http.read_timeout = READ_TIMEOUT
57
+ http.request(build_request(method, uri, body, extra_headers))
58
+ rescue SystemCallError, SocketError, IOError, Net::OpenTimeout,
59
+ Net::ReadTimeout, OpenSSL::SSL::SSLError => e
60
+ raise ConnectionError, connection_message(e)
61
+ end
62
+
63
+ def connection_message(error)
64
+ reason = "#{error.class}: #{error.message}"
65
+ return "Could not reach #{@config.base_url} (#{reason})." unless
66
+ @config.local
67
+
68
+ "Could not reach the local Zotero API (#{reason}). " \
69
+ "Is the Zotero desktop app running?"
70
+ end
71
+
72
+ def build_request(method, uri, body, extra_headers)
73
+ request = REQUEST_CLASSES.fetch(method).new(uri)
74
+ default_headers.merge(extra_headers).each do |name, value|
75
+ request[name] = value
76
+ end
77
+ request.body = body if body
78
+ request
79
+ end
80
+
81
+ def default_headers
82
+ { "Zotero-API-Version" => API_VERSION,
83
+ "Zotero-API-Key" => @config.api_key }.compact
84
+ end
85
+
86
+ def resolve_library_id
87
+ check_library_id_resolvable!
88
+ info = parse_json(request(:get, "/keys/current").body)
89
+ Zotero::MCP.presence(info["userID"]&.to_s) or
90
+ raise ConfigurationError, "Could not resolve library id."
91
+ end
92
+
93
+ def check_library_id_resolvable!
94
+ if @config.library_type == "group"
95
+ raise ConfigurationError,
96
+ "Set ZOTERO_LIBRARY_ID for group libraries."
97
+ end
98
+ return if @config.api_key
99
+
100
+ raise ConfigurationError,
101
+ "Set ZOTERO_API_KEY or ZOTERO_LIBRARY_ID."
102
+ end
103
+
104
+ def parse_json(body)
105
+ JSON.parse(body.to_s)
106
+ rescue JSON::ParserError
107
+ raise APIError,
108
+ "Zotero returned a response that is not valid JSON."
109
+ end
110
+
111
+ def get_json(path, params = {})
112
+ parse_json(request(:get, path, params: params).body)
113
+ end
114
+
115
+ def get_page(path, params)
116
+ response = request(:get, path, params: params)
117
+ Page.new(
118
+ items: parse_json(response.body),
119
+ total: response["Total-Results"]&.to_i,
120
+ start: params[:start] || 0
121
+ )
122
+ end
123
+
124
+ def ensure_writable!
125
+ return if @config.write_allowed?
126
+
127
+ raise ReadOnlyError,
128
+ "The local Zotero API is read-only; use the Web API " \
129
+ "to write."
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ DEFAULT_LIMIT = 25
6
+ MAX_LIMIT = 100
7
+ MAX_WRITE_BATCH = 50
8
+ API_VERSION = "3"
9
+ WEB_BASE = "https://api.zotero.org"
10
+ LOCAL_BASE = "http://localhost:23119/api"
11
+ OPEN_TIMEOUT = 10 # seconds to establish a connection
12
+ READ_TIMEOUT = 60 # seconds to wait for a response
13
+ MAX_RETRIES = 2 # extra attempts after a 429 or a 503
14
+ MAX_BACKOFF = 15 # never sleep longer than this between retries
15
+ KEY_PATTERN = /\A[A-Za-z0-9]{1,32}\z/
16
+
17
+ REQUEST_CLASSES = {
18
+ get: Net::HTTP::Get,
19
+ post: Net::HTTP::Post,
20
+ patch: Net::HTTP::Patch
21
+ }.freeze
22
+
23
+ # ----------------------------------------------------------------- #
24
+ # Configuration
25
+ # ----------------------------------------------------------------- #
26
+
27
+ Config = Data.define(:api_key, :library_id, :library_type, :local) do
28
+ def self.from_env
29
+ type = (ENV["ZOTERO_LIBRARY_TYPE"] || "user").downcase
30
+ validate_type!(type)
31
+ new(api_key: env_presence("ZOTERO_API_KEY"),
32
+ library_id: env_presence("ZOTERO_LIBRARY_ID"),
33
+ library_type: type, local: env_truthy?("ZOTERO_LOCAL"))
34
+ end
35
+
36
+ def self.env_presence(name)
37
+ Zotero::MCP.presence(ENV.fetch(name, nil))
38
+ end
39
+
40
+ def self.env_truthy?(name)
41
+ Zotero::MCP.truthy?(ENV.fetch(name, nil))
42
+ end
43
+
44
+ def self.validate_type!(type)
45
+ return if %w[user group].include?(type)
46
+
47
+ raise ConfigurationError,
48
+ 'ZOTERO_LIBRARY_TYPE must be "user" or "group".'
49
+ end
50
+
51
+ def base_url = local ? LOCAL_BASE : WEB_BASE
52
+
53
+ def write_allowed? = !local
54
+ end
55
+
56
+ # ----------------------------------------------------------------- #
57
+ # Value objects
58
+ # ----------------------------------------------------------------- #
59
+
60
+ Page = Data.define(:items, :total, :start) do
61
+ def more? = total ? (start + items.length) < total : false
62
+
63
+ def next_start = more? ? start + items.length : nil
64
+ end
65
+
66
+ # A minimal stand-in so response handling is uniform and testable.
67
+ FakeResponse = Data.define(:code, :body, :headers) do
68
+ def [](key) = headers[key]
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ class Error < StandardError; end
6
+ class ConfigurationError < Error; end
7
+ class NotAuthorisedError < Error; end
8
+ class NotFoundError < Error; end
9
+ class ConflictError < Error; end
10
+ class ReadOnlyError < Error; end
11
+ class APIError < Error; end
12
+ class ConnectionError < Error; end
13
+
14
+ class RateLimitedError < Error
15
+ attr_reader :retry_after
16
+
17
+ def initialize(message, retry_after: nil)
18
+ super(message)
19
+ @retry_after = retry_after
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module MCP
5
+ # ----------------------------------------------------------------- #
6
+ # Module-level glue: client, formatting, tool responses
7
+ # ----------------------------------------------------------------- #
8
+
9
+ def self.client = @client ||= Client.new(Config.from_env)
10
+
11
+ def self.text(object)
12
+ ::MCP::Tool::Response.new(
13
+ [{ type: "text", text: JSON.pretty_generate(object) }]
14
+ )
15
+ end
16
+
17
+ def self.error_text(message)
18
+ ::MCP::Tool::Response.new(
19
+ [{ type: "text", text: JSON.pretty_generate(error: message) }],
20
+ error: true
21
+ )
22
+ end
23
+
24
+ def self.guard
25
+ yield
26
+ rescue Error => e
27
+ error_text(e.message)
28
+ rescue StandardError => e
29
+ error_text("Unexpected #{e.class}: #{e.message}")
30
+ end
31
+
32
+ def self.format_page(page, response_format)
33
+ items = if response_format == "json"
34
+ page.items
35
+ else
36
+ page.items.map { |item| summarize(item) }
37
+ end
38
+ { pagination: pagination_of(page), items: items }
39
+ end
40
+
41
+ def self.pagination_of(page)
42
+ {
43
+ count: page.items.length, start: page.start, total: page.total,
44
+ has_more: page.more?, next_start: page.next_start
45
+ }.compact
46
+ end
47
+
48
+ def self.summarize(item)
49
+ data = item["data"] || {}
50
+ meta = item["meta"] || {}
51
+ summary_core(item, data, meta).merge(
52
+ summary_extra(data, meta)
53
+ ).compact
54
+ end
55
+
56
+ def self.summary_core(item, data, meta)
57
+ {
58
+ key: item["key"], version: item["version"],
59
+ itemType: data["itemType"], title: title_of(data),
60
+ creators: creators_of(data, meta),
61
+ date: data["date"] || meta["parsedDate"]
62
+ }
63
+ end
64
+
65
+ def self.summary_extra(data, meta)
66
+ {
67
+ publication: publication_of(data), DOI: data["DOI"],
68
+ url: data["url"], tags: tags_of(data),
69
+ collections: nonempty(data["collections"]),
70
+ numChildren: meta["numChildren"]
71
+ }
72
+ end
73
+
74
+ def self.title_of(data)
75
+ data["title"] || data["caseName"] || data["subject"]
76
+ end
77
+
78
+ def self.publication_of(data)
79
+ data["publicationTitle"] || data["bookTitle"] ||
80
+ data["proceedingsTitle"] || data["publisher"]
81
+ end
82
+
83
+ def self.tags_of(data)
84
+ names = (data["tags"] || []).map { |tag| tag["tag"] }
85
+ nonempty(names)
86
+ end
87
+
88
+ def self.creators_of(data, meta)
89
+ names = (data["creators"] || [])
90
+ .map { |creator| creator_name(creator) }
91
+ .reject(&:empty?)
92
+ return meta["creatorSummary"] if names.empty?
93
+
94
+ summarize_names(names)
95
+ end
96
+
97
+ def self.creator_name(creator)
98
+ creator["name"] ||
99
+ [creator["firstName"], creator["lastName"]].compact.join(" ")
100
+ end
101
+
102
+ def self.summarize_names(names)
103
+ return names.join(", ") if names.length <= 3
104
+
105
+ "#{names.first(3).join(", ")} (+#{names.length - 3} more)"
106
+ end
107
+ end
108
+ end