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