livelib-mcp 0.1.0__py3-none-any.whl
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.
livelib_mcp/__init__.py
ADDED
livelib_mcp/client.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""LiveLib trisolaris client: anonymous reads + llsid-cookie writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from collections.abc import Callable, Mapping
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
API_BASE = "https://beta.api.livelib.ru/trisolaris/api"
|
|
16
|
+
USER_AGENT = "livelib-mcp/0.1 (+https://github.com/shared-goals/livelib-mcp)"
|
|
17
|
+
SHELVES = ("reads", "unreads", "wants", "readings")
|
|
18
|
+
VISIBILITIES = ("public", "friends", "private")
|
|
19
|
+
BLOCK_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
20
|
+
BLOCK_ID_LEN = 10
|
|
21
|
+
LLSID_ENV = "LIVELIB_LLSID"
|
|
22
|
+
# Transient HTTP statuses worth another attempt (production behavior inherited
|
|
23
|
+
# from the old text-forge fetcher); everything else fails at once.
|
|
24
|
+
RETRY_STATUSES = (429, 500, 502, 503, 504)
|
|
25
|
+
MAX_ATTEMPTS = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _retry_sleep(attempt: int) -> float:
|
|
29
|
+
"""Backoff before the next attempt: attempt is 0-based, so 2s, then 4s."""
|
|
30
|
+
return 2 * (attempt + 1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _new_block_id() -> str:
|
|
34
|
+
"""Id for a text_json editor block: 10 chars of [A-Za-z0-9], per the API."""
|
|
35
|
+
return "".join(secrets.choice(BLOCK_ID_ALPHABET) for _ in range(BLOCK_ID_LEN))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def client_from_env(env: Mapping[str, str] | None = None, **kwargs) -> TrisolarisClient:
|
|
39
|
+
"""Client from LIVELIB_LLSID; missing/empty env -> anonymous client.
|
|
40
|
+
|
|
41
|
+
`env` keeps callers/tests network-free by letting them hand in the
|
|
42
|
+
environment; **kwargs (a test transport) pass through to the client. The
|
|
43
|
+
server's tools call this with no arguments, i.e. os.environ at call time.
|
|
44
|
+
"""
|
|
45
|
+
env = os.environ if env is None else env
|
|
46
|
+
return TrisolarisClient(llsid=env.get(LLSID_ENV) or None, **kwargs)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LivelibApiError(Exception):
|
|
50
|
+
"""Error envelope from the trisolaris API."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, status, type, detail, error_descriptions=None):
|
|
53
|
+
super().__init__(f"{status} {type}: {detail}")
|
|
54
|
+
self.status = status
|
|
55
|
+
self.type = type
|
|
56
|
+
self.detail = detail
|
|
57
|
+
self.error_descriptions = error_descriptions or []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class LivelibAuthError(LivelibApiError):
|
|
61
|
+
"""401 AuthenticationRequired — the call needs a valid llsid cookie."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _check(r: httpx.Response) -> dict | None:
|
|
65
|
+
"""Raise on any non-2xx or error envelope; 401 -> LivelibAuthError.
|
|
66
|
+
|
|
67
|
+
Returns the parsed body, or None when the response carries no body at all
|
|
68
|
+
(e.g. 204 No Content) — a bodyless 2xx is a success, it just has nothing
|
|
69
|
+
to hand back.
|
|
70
|
+
"""
|
|
71
|
+
status = r.status_code
|
|
72
|
+
body = _body(r)
|
|
73
|
+
if status // 100 != 2 or (body or {}).get("error"):
|
|
74
|
+
err = (body or {}).get("error") or {}
|
|
75
|
+
args = (
|
|
76
|
+
(body or {}).get("status", status),
|
|
77
|
+
err.get("type"),
|
|
78
|
+
err.get("detail"),
|
|
79
|
+
err.get("error_descriptions"),
|
|
80
|
+
)
|
|
81
|
+
raise (LivelibAuthError if status == 401 else LivelibApiError)(*args)
|
|
82
|
+
return body
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _body(r: httpx.Response) -> dict | None:
|
|
86
|
+
"""Parse a JSON body, or None for a bodyless response (e.g. 204 No Content).
|
|
87
|
+
|
|
88
|
+
Content-type is not trusted: an empty body with a JSON content-type header
|
|
89
|
+
still has nothing to parse.
|
|
90
|
+
"""
|
|
91
|
+
raw = r.content
|
|
92
|
+
return json.loads(raw) if raw and raw.strip() else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _default_transport(
|
|
96
|
+
method: str,
|
|
97
|
+
url: str,
|
|
98
|
+
*,
|
|
99
|
+
json_body: dict | None = None,
|
|
100
|
+
headers: dict | None = None,
|
|
101
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
102
|
+
) -> dict | None:
|
|
103
|
+
"""The default transport: one httpx request per attempt, retrying transient
|
|
104
|
+
failures (RETRY_STATUSES / httpx.TransportError) up to MAX_ATTEMPTS total.
|
|
105
|
+
`sleep` is injectable so tests keep the suite network-free and fast.
|
|
106
|
+
Injected transports (any other `transport=`) are NOT retrying — they
|
|
107
|
+
behave exactly as told; the retry belongs to this default only.
|
|
108
|
+
"""
|
|
109
|
+
# trust_env=False is deliberate: LiveLib needs no proxy routing, and an
|
|
110
|
+
# operator's ambient proxy env (esp. a no_proxy list with IDN entries like
|
|
111
|
+
# `*.рф`) can crash httpx IN THE CONSTRUCTOR (InvalidURL: Invalid IDNA
|
|
112
|
+
# hostname) before any request is made. The client must not depend on the
|
|
113
|
+
# quality of the host's proxy bypass lists. If a proxy is ever genuinely
|
|
114
|
+
# needed for LiveLib, add an explicit LIVELIB_PROXY env (YAGNI now).
|
|
115
|
+
for attempt in range(MAX_ATTEMPTS):
|
|
116
|
+
try:
|
|
117
|
+
with httpx.Client(headers={"User-Agent": USER_AGENT}, timeout=25, trust_env=False) as c:
|
|
118
|
+
r = c.request(method, url, json=json_body, headers=headers)
|
|
119
|
+
except httpx.TransportError:
|
|
120
|
+
# the last attempt's network failure propagates to the caller
|
|
121
|
+
if attempt + 1 >= MAX_ATTEMPTS:
|
|
122
|
+
raise
|
|
123
|
+
sleep(_retry_sleep(attempt))
|
|
124
|
+
continue
|
|
125
|
+
if r.status_code in RETRY_STATUSES and attempt + 1 < MAX_ATTEMPTS:
|
|
126
|
+
sleep(_retry_sleep(attempt))
|
|
127
|
+
continue
|
|
128
|
+
# non-retryable status, or the last attempt of a transient one: the
|
|
129
|
+
# envelope check raises it (429/5xx -> LivelibApiError) or returns it
|
|
130
|
+
return _check(r)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _next_offset(payload: dict) -> int | None:
|
|
134
|
+
"""Offset of the next page, from payload.pagination.next_page.
|
|
135
|
+
|
|
136
|
+
The API answers next_page as a path+query (e.g.
|
|
137
|
+
/api/users/1/quotes?limit=2&ordering=created_desc&offset=2); the offset
|
|
138
|
+
query param is authoritative. None when pagination ends.
|
|
139
|
+
"""
|
|
140
|
+
next_page = (payload.get("pagination") or {}).get("next_page")
|
|
141
|
+
if not next_page:
|
|
142
|
+
return None
|
|
143
|
+
offset = urllib.parse.parse_qs(urllib.parse.urlparse(next_page).query).get("offset")
|
|
144
|
+
return int(offset[0]) if offset else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
WORK_URL_RE = re.compile(r"/work/(\d+)")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_shelf_record(mark: dict) -> dict | None:
|
|
151
|
+
"""One shelf mark -> the consumer-shaped record, or None when the mark
|
|
152
|
+
carries no usable art_edition.id. The single place where LiveLib shelf
|
|
153
|
+
semantics are defined: rating is the API's 0-10 value, my_mark the 1-5
|
|
154
|
+
star rendering (rating/2 rounded, None when unrated).
|
|
155
|
+
"""
|
|
156
|
+
ae = mark.get("art_edition") or {}
|
|
157
|
+
edition_id = ae.get("id")
|
|
158
|
+
if edition_id is None:
|
|
159
|
+
return None
|
|
160
|
+
rating = mark.get("rating") or 0
|
|
161
|
+
return {
|
|
162
|
+
"collection_id": mark.get("id"),
|
|
163
|
+
"edition_id": str(edition_id),
|
|
164
|
+
"title": ae.get("title", ""),
|
|
165
|
+
"author": ", ".join(
|
|
166
|
+
a["full_name"] for a in ae.get("authors") or [] if a.get("full_name")
|
|
167
|
+
),
|
|
168
|
+
"url": ae.get("url") or f"/book/{edition_id}",
|
|
169
|
+
"rating": rating,
|
|
170
|
+
"my_mark": round(rating / 2) if rating else None,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TrisolarisClient:
|
|
175
|
+
# Acceptable quote visibilities, as the API documents them. A class
|
|
176
|
+
# attribute so a subclass can widen it (tests use that to observe the API's
|
|
177
|
+
# own 422 for a bad value instead of our pre-request ValueError).
|
|
178
|
+
visibilities = VISIBILITIES
|
|
179
|
+
|
|
180
|
+
def __init__(self, *, llsid: str | None = None, transport=None):
|
|
181
|
+
self.llsid = llsid
|
|
182
|
+
self.transport = transport or _default_transport
|
|
183
|
+
|
|
184
|
+
def _headers(self) -> dict | None:
|
|
185
|
+
"""Auth header, or None so no Cookie key is sent at all when anonymous."""
|
|
186
|
+
return {"Cookie": f"llsid={self.llsid}"} if self.llsid else None
|
|
187
|
+
|
|
188
|
+
def _call(
|
|
189
|
+
self, method: str, path: str, *, json_body: dict | None = None
|
|
190
|
+
) -> dict | None:
|
|
191
|
+
"""One API call; a rejected status is raised by the transport's check."""
|
|
192
|
+
return self.transport(
|
|
193
|
+
method, f"{API_BASE}/{path}", json_body=json_body, headers=self._headers()
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _payload(self, path: str) -> dict:
|
|
197
|
+
return self._payload_of("GET", path)["payload"]
|
|
198
|
+
|
|
199
|
+
def _payload_of(
|
|
200
|
+
self, method: str, path: str, *, json_body: dict | None = None
|
|
201
|
+
) -> dict:
|
|
202
|
+
"""A call that MUST answer with an envelope: body -> payload envelope.
|
|
203
|
+
|
|
204
|
+
A 2xx with no body (204) has nothing to hand back, so it is reported as
|
|
205
|
+
LivelibApiError/EmptyResponse rather than crashing the caller with a
|
|
206
|
+
TypeError on None["payload"]. Any rejected status is already raised by
|
|
207
|
+
the transport's check.
|
|
208
|
+
"""
|
|
209
|
+
body = self._call(method, path, json_body=json_body)
|
|
210
|
+
if body is None:
|
|
211
|
+
# The transport's check already passed, so this is a 2xx that came
|
|
212
|
+
# back with no body; the transport hands back only the parsed body,
|
|
213
|
+
# so the canonical bodyless-success 204 is what we can report.
|
|
214
|
+
raise LivelibApiError(
|
|
215
|
+
204,
|
|
216
|
+
"EmptyResponse",
|
|
217
|
+
f"{method} {path} returned no body — expected an envelope",
|
|
218
|
+
)
|
|
219
|
+
return body
|
|
220
|
+
|
|
221
|
+
def add_quote(self, edition_id: int, text: str, visibility: str = "public") -> dict:
|
|
222
|
+
"""Add a quote to an edition; returns payload.data ({"id": user_quote_id}).
|
|
223
|
+
|
|
224
|
+
`text` goes out byte-exact — no strip, no normalization. The editor block
|
|
225
|
+
id is generated here; the API response carries only the new user-quote id.
|
|
226
|
+
"""
|
|
227
|
+
if visibility not in self.visibilities:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"unknown visibility {visibility!r}; expected one of {VISIBILITIES}"
|
|
230
|
+
)
|
|
231
|
+
body = {
|
|
232
|
+
"text_json": {
|
|
233
|
+
"editor_blocks": [
|
|
234
|
+
{"id": _new_block_id(), "type": "paragraph", "data": {"text": text}}
|
|
235
|
+
]
|
|
236
|
+
},
|
|
237
|
+
"visibility": visibility,
|
|
238
|
+
}
|
|
239
|
+
return self._payload_of(
|
|
240
|
+
"POST", f"art-editions/{edition_id}/quotes", json_body=body
|
|
241
|
+
)["payload"]["data"]
|
|
242
|
+
|
|
243
|
+
def delete_quote(self, user_quote_id: int) -> bool:
|
|
244
|
+
"""Delete one of the caller's own quotes. True on 204; any other status
|
|
245
|
+
(e.g. 404 — already gone) raises, so the caller decides."""
|
|
246
|
+
self._call("DELETE", f"users/me/quotes/{user_quote_id}")
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
def add_review(
|
|
250
|
+
self,
|
|
251
|
+
edition_id: int,
|
|
252
|
+
title: str,
|
|
253
|
+
text: str,
|
|
254
|
+
rating: int = 10,
|
|
255
|
+
*,
|
|
256
|
+
has_spoilers: bool = False,
|
|
257
|
+
) -> dict:
|
|
258
|
+
"""Post a review of an edition; returns payload.data ({"id": review_id}.
|
|
259
|
+
|
|
260
|
+
Contract (sniffed live 2026-10-29): title, text_json, rating, emotions,
|
|
261
|
+
has_spoilers are ALL required at once. **Rating scale is 0-10** (it
|
|
262
|
+
lands on the user's collection mark of the book — `reviewer_art_rating`
|
|
263
|
+
in review records; a review itself never carries a rating; 10 = five
|
|
264
|
+
stars on the site). `text` goes out byte-exact, split into one Editor.js
|
|
265
|
+
paragraph block per blank-line-separated paragraph (each <p> renders
|
|
266
|
+
separately; a single block flattens line breaks).
|
|
267
|
+
"""
|
|
268
|
+
rating = int(rating)
|
|
269
|
+
if not 0 <= rating <= 10:
|
|
270
|
+
raise ValueError(f"rating must be 0..10, got {rating!r}")
|
|
271
|
+
paragraphs = text.split("\n\n") if "\n\n" in text else [text]
|
|
272
|
+
body = {
|
|
273
|
+
"title": title,
|
|
274
|
+
"text_json": {
|
|
275
|
+
"editor_blocks": [
|
|
276
|
+
{"id": _new_block_id(), "type": "paragraph", "data": {"text": p}}
|
|
277
|
+
for p in paragraphs
|
|
278
|
+
]
|
|
279
|
+
},
|
|
280
|
+
"rating": rating,
|
|
281
|
+
"emotions": [],
|
|
282
|
+
"has_spoilers": bool(has_spoilers),
|
|
283
|
+
}
|
|
284
|
+
return self._payload_of(
|
|
285
|
+
"POST", f"art-editions/{edition_id}/reviews", json_body=body
|
|
286
|
+
)["payload"]["data"]
|
|
287
|
+
|
|
288
|
+
def delete_review(self, review_id: int) -> bool:
|
|
289
|
+
"""Delete one of the caller's own reviews. True on 204. The path is the
|
|
290
|
+
ROOT-level /api/reviews/{id} — the /users/me/reviews/... variant is
|
|
291
|
+
404. A review edited in the legacy web editor answers 409
|
|
292
|
+
NotEditableByNewEditor and can only be deleted there."""
|
|
293
|
+
self._call("DELETE", f"reviews/{review_id}")
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
def update_review(
|
|
297
|
+
self,
|
|
298
|
+
review_id: int,
|
|
299
|
+
title: str,
|
|
300
|
+
text: str,
|
|
301
|
+
rating: int = 10,
|
|
302
|
+
*,
|
|
303
|
+
has_spoilers: bool = False,
|
|
304
|
+
) -> bool:
|
|
305
|
+
"""Update one of the caller's own reviews via PATCH; True on 204 (the
|
|
306
|
+
response carries NO body — verified live). Same body rules as
|
|
307
|
+
add_review plus the REQUIRED `visibility` ("public" for our posts). A
|
|
308
|
+
review edited in the legacy web editor answers 409
|
|
309
|
+
NotEditableByNewEditor — edit it there instead.
|
|
310
|
+
"""
|
|
311
|
+
rating = int(rating)
|
|
312
|
+
if not 0 <= rating <= 10:
|
|
313
|
+
raise ValueError(f"rating must be 0..10, got {rating!r}")
|
|
314
|
+
paragraphs = text.split("\n\n") if "\n\n" in text else [text]
|
|
315
|
+
body = {
|
|
316
|
+
"title": title,
|
|
317
|
+
"text_json": {
|
|
318
|
+
"editor_blocks": [
|
|
319
|
+
{"id": _new_block_id(), "type": "paragraph", "data": {"text": p}}
|
|
320
|
+
for p in paragraphs
|
|
321
|
+
]
|
|
322
|
+
},
|
|
323
|
+
"rating": rating,
|
|
324
|
+
"emotions": [],
|
|
325
|
+
"has_spoilers": bool(has_spoilers),
|
|
326
|
+
"visibility": "public",
|
|
327
|
+
}
|
|
328
|
+
self._call("PATCH", f"reviews/{review_id}", json_body=body)
|
|
329
|
+
return True
|
|
330
|
+
|
|
331
|
+
def _walk(self, path: str, *, limit: int, extra_query: str = "") -> list[dict]:
|
|
332
|
+
"""Follow payload.pagination.next_page until absent; concat payload.data."""
|
|
333
|
+
out, offset = [], 0
|
|
334
|
+
while True:
|
|
335
|
+
payload = self._payload(
|
|
336
|
+
f"{path}?limit={limit}&offset={offset}{extra_query}"
|
|
337
|
+
)
|
|
338
|
+
data = payload["data"]
|
|
339
|
+
out.extend(data)
|
|
340
|
+
next_offset = _next_offset(payload)
|
|
341
|
+
if next_offset is None or not data:
|
|
342
|
+
return out
|
|
343
|
+
offset = next_offset
|
|
344
|
+
|
|
345
|
+
def _shelf_page(self, uid: int, shelf: str, offset: int) -> dict:
|
|
346
|
+
return self._payload(f"users/{uid}/arts/{shelf}?limit=100&offset={offset}")
|
|
347
|
+
|
|
348
|
+
def get_edition(self, edition_id: int) -> dict:
|
|
349
|
+
return self._payload(f"art-editions/{edition_id}")["data"]
|
|
350
|
+
|
|
351
|
+
def get_shelf(self, uid: int, shelf: str) -> dict:
|
|
352
|
+
if shelf not in SHELVES:
|
|
353
|
+
raise ValueError(f"unknown shelf {shelf!r}; expected one of {SHELVES}")
|
|
354
|
+
out, offset = [], 0
|
|
355
|
+
while True:
|
|
356
|
+
payload = self._shelf_page(uid, shelf, offset)
|
|
357
|
+
counters_all = (payload.get("counters") or {}).get("all", 0)
|
|
358
|
+
data = payload["data"]
|
|
359
|
+
out.extend(data)
|
|
360
|
+
next_offset = _next_offset(payload)
|
|
361
|
+
if next_offset is None or not data:
|
|
362
|
+
break
|
|
363
|
+
offset = next_offset
|
|
364
|
+
editions, seen = [], set()
|
|
365
|
+
duplicates = 0
|
|
366
|
+
for mark in out:
|
|
367
|
+
edition = mark.get("art_edition") or {}
|
|
368
|
+
eid = edition.get("id")
|
|
369
|
+
if eid in seen:
|
|
370
|
+
duplicates += 1
|
|
371
|
+
continue
|
|
372
|
+
seen.add(eid)
|
|
373
|
+
editions.append(edition)
|
|
374
|
+
return {
|
|
375
|
+
"editions": editions,
|
|
376
|
+
"marks_seen": len(out),
|
|
377
|
+
"duplicate_marks": duplicates,
|
|
378
|
+
"counters_all": counters_all,
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
def get_edition_contents(self, edition_id: int) -> list[tuple[str, str]]:
|
|
382
|
+
"""Contents of a multi-work edition, page order: [(title, work_id)].
|
|
383
|
+
Entries without a /work/<id> url carry no work relation -> skipped, as
|
|
384
|
+
do entries with an empty title. Single page: the endpoint does not
|
|
385
|
+
paginate (verified against tests/fixtures/contents_1001482955.json).
|
|
386
|
+
"""
|
|
387
|
+
contents = []
|
|
388
|
+
for entry in self._payload(f"art-editions/{edition_id}/contents")["data"]:
|
|
389
|
+
match = WORK_URL_RE.search(entry.get("url") or "")
|
|
390
|
+
if match and entry.get("title"):
|
|
391
|
+
contents.append((entry["title"], match.group(1)))
|
|
392
|
+
return contents
|
|
393
|
+
|
|
394
|
+
def shelf_records(self, uid: int, shelf: str) -> list[dict]:
|
|
395
|
+
"""Shelf walk + edition dedupe (first record wins), each mark shaped by
|
|
396
|
+
parse_shelf_record; marks without a usable edition id are skipped.
|
|
397
|
+
"""
|
|
398
|
+
if shelf not in SHELVES:
|
|
399
|
+
raise ValueError(f"unknown shelf {shelf!r}; expected one of {SHELVES}")
|
|
400
|
+
out, offset, seen = [], 0, set()
|
|
401
|
+
while True:
|
|
402
|
+
payload = self._shelf_page(uid, shelf, offset)
|
|
403
|
+
data = payload["data"]
|
|
404
|
+
for mark in data:
|
|
405
|
+
record = parse_shelf_record(mark)
|
|
406
|
+
if record is None or record["edition_id"] in seen:
|
|
407
|
+
continue
|
|
408
|
+
seen.add(record["edition_id"])
|
|
409
|
+
out.append(record)
|
|
410
|
+
next_offset = _next_offset(payload)
|
|
411
|
+
if next_offset is None or not data:
|
|
412
|
+
return out
|
|
413
|
+
offset = next_offset
|
|
414
|
+
|
|
415
|
+
def get_edition_quotes(
|
|
416
|
+
self, edition_id: int, *, limit: int = 50, offset: int = 0
|
|
417
|
+
) -> dict:
|
|
418
|
+
payload = self._payload(
|
|
419
|
+
f"art-editions/{edition_id}/quotes?limit={limit}&offset={offset}"
|
|
420
|
+
)
|
|
421
|
+
return {
|
|
422
|
+
"quotes": payload["data"],
|
|
423
|
+
"counters_all": (payload.get("counters") or {}).get("all", 0),
|
|
424
|
+
"next_offset": _next_offset(payload),
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
def get_quotes(self, uid: int) -> list[dict]:
|
|
428
|
+
return self._walk(
|
|
429
|
+
f"users/{uid}/quotes", limit=50, extra_query="&ordering=created_desc"
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
def get_reviews(self, uid: int) -> list[dict]:
|
|
433
|
+
return self._walk(f"users/{uid}/reviews", limit=50)
|
|
434
|
+
|
|
435
|
+
def search_editions(self, query: str, *, limit: int = 10) -> list[dict]:
|
|
436
|
+
"""Search; returns the art_edition records (mixed-type results carry
|
|
437
|
+
their instance under `instance`, other types are not editions)."""
|
|
438
|
+
data = self._payload(
|
|
439
|
+
f"search?query={urllib.parse.quote(query, safe='')}&limit={limit}"
|
|
440
|
+
)["data"]
|
|
441
|
+
return [r["instance"] for r in data if r.get("type") == "art_editions"]
|
livelib_mcp/server.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""MCP stdio server exposing LiveLib reads and llsid-authenticated quote
|
|
2
|
+
writes as tools.
|
|
3
|
+
|
|
4
|
+
Thin wrapper: each tool is exactly one client call — no data shaping. All tools
|
|
5
|
+
build their client through the module-level `_client` factory, which delegates to
|
|
6
|
+
`client_from_env()` so LIVELIB_LLSID is read at call time. Tests monkeypatch
|
|
7
|
+
`_client` to inject a fixture-backed transport (call-counted, so the pins prove
|
|
8
|
+
the tool really built its client that way).
|
|
9
|
+
|
|
10
|
+
API failures are re-raised as `mcp` ToolError: that is the SDK's "anticipated
|
|
11
|
+
failure" channel, whose message reaches the model. Any other exception is
|
|
12
|
+
treated by the SDK as a crash and reaches the client with its text withheld.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from mcp.server.mcpserver import MCPServer
|
|
17
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
18
|
+
|
|
19
|
+
from .client import LivelibApiError, TrisolarisClient, client_from_env
|
|
20
|
+
|
|
21
|
+
SERVER = MCPServer(
|
|
22
|
+
"livelib",
|
|
23
|
+
instructions="LiveLib book data via the trisolaris API: edition cards, "
|
|
24
|
+
"quotes, reviews, shelves, and search (anonymous), plus adding and deleting "
|
|
25
|
+
"your own quotes (requires the LIVELIB_LLSID env var).",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _client(**kwargs) -> TrisolarisClient:
|
|
30
|
+
"""Fresh stateless client per call; LIVELIB_LLSID is read at call time."""
|
|
31
|
+
return client_from_env(**kwargs)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _call(what, fn):
|
|
35
|
+
"""Run one client call, surfacing API errors to the model as ToolError."""
|
|
36
|
+
try:
|
|
37
|
+
return fn()
|
|
38
|
+
except LivelibApiError as exc:
|
|
39
|
+
raise ToolError(f"{what}: {exc}") from exc
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@SERVER.tool()
|
|
43
|
+
def get_edition(edition_id: int) -> dict:
|
|
44
|
+
"""LiveLib edition card: title, authors, cover, series, publication meta."""
|
|
45
|
+
return _call("get_edition", lambda: _client().get_edition(edition_id))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@SERVER.tool()
|
|
49
|
+
def get_shelf(uid: int, shelf: str) -> dict:
|
|
50
|
+
"""A user's bookshelf (reads/unreads/wants/readings) as edition records,
|
|
51
|
+
with duplicate-mark dedupe and counters."""
|
|
52
|
+
return _call("get_shelf", lambda: _client().get_shelf(uid, shelf))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@SERVER.tool()
|
|
56
|
+
def add_review(edition_id: int, title: str, text: str, rating: int = 10, has_spoilers: bool = False) -> dict:
|
|
57
|
+
"""Post a review of an edition (rating 0-10, 10 = five stars on the site).
|
|
58
|
+
Requires the LIVELIB_LLSID env var; returns the created review id."""
|
|
59
|
+
return _call("add_review", lambda: _client().add_review(
|
|
60
|
+
edition_id, title, text, rating, has_spoilers=has_spoilers))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@SERVER.tool()
|
|
64
|
+
def delete_review(review_id: int) -> bool:
|
|
65
|
+
"""Delete the caller's own review. Requires the LIVELIB_LLSID env var.
|
|
66
|
+
A review edited in the legacy web editor can only be deleted there."""
|
|
67
|
+
return _call("delete_review", lambda: _client().delete_review(review_id))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@SERVER.tool()
|
|
71
|
+
def update_review(review_id: int, title: str, text: str, rating: int = 10, has_spoilers: bool = False) -> bool:
|
|
72
|
+
"""Update the caller's own review (title, text, rating 0-10). Requires the
|
|
73
|
+
LIVELIB_LLSID env var. Fails 409 if the review was edited in the legacy
|
|
74
|
+
web editor — edit it there instead."""
|
|
75
|
+
return _call("update_review", lambda: _client().update_review(
|
|
76
|
+
review_id, title, text, rating, has_spoilers=has_spoilers))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@SERVER.tool()
|
|
80
|
+
def get_edition_quotes(edition_id: int, limit: int = 50, offset: int = 0) -> dict:
|
|
81
|
+
"""One page of quotes marked on an edition, plus total count and next offset."""
|
|
82
|
+
return _call("get_edition_quotes", lambda: _client().get_edition_quotes(edition_id, limit=limit, offset=offset))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@SERVER.tool()
|
|
86
|
+
def get_quotes(uid: int) -> list[dict]:
|
|
87
|
+
"""All quotes a user has marked, newest first (paginated walk)."""
|
|
88
|
+
return _call("get_quotes", lambda: _client().get_quotes(uid))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@SERVER.tool()
|
|
92
|
+
def get_reviews(uid: int) -> list[dict]:
|
|
93
|
+
"""All reviews a user has written (paginated walk)."""
|
|
94
|
+
return _call("get_reviews", lambda: _client().get_reviews(uid))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@SERVER.tool()
|
|
98
|
+
def search_editions(query: str, limit: int = 10) -> list[dict]:
|
|
99
|
+
"""Search LiveLib for book editions matching a query; returns edition records."""
|
|
100
|
+
return _call("search_editions", lambda: _client().search_editions(query, limit=limit))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@SERVER.tool()
|
|
104
|
+
def add_quote(edition_id: int, text: str, visibility: str = "public") -> dict:
|
|
105
|
+
"""Add a quote to a LiveLib edition. Requires the LIVELIB_LLSID env var
|
|
106
|
+
(livelib.ru session cookie value); an anonymous call gets a 401 from the
|
|
107
|
+
API. Returns the created user-quote id as {"id": ...}."""
|
|
108
|
+
return _call("add_quote", lambda: _client().add_quote(edition_id, text, visibility))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@SERVER.tool()
|
|
112
|
+
def delete_quote(user_quote_id: int) -> bool:
|
|
113
|
+
"""Delete a quote you marked on LiveLib. Requires the LIVELIB_LLSID env var
|
|
114
|
+
(livelib.ru session cookie value). True on success; a quote that is not
|
|
115
|
+
found, or is not yours, is reported as an error."""
|
|
116
|
+
return _call("delete_quote", lambda: _client().delete_quote(user_quote_id))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main() -> None:
|
|
120
|
+
SERVER.run()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
main()
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
livelib_mcp/__init__.py,sha256=RbLqcjGpQ_uRsViGVyP0C_gwVnh8_zW_O-m8RB46F60,179
|
|
2
|
+
livelib_mcp/client.py,sha256=CRQaVQzOaSMvBx-S4o2qt5WislaWxg2zJvHak33HkJo,17644
|
|
3
|
+
livelib_mcp/server.py,sha256=O8oziniYkQ7K2i45Old70VgS5b30qhQaC_DimiSEqdc,4939
|
|
4
|
+
livelib_mcp-0.1.0.dist-info/METADATA,sha256=HEGaCNcD8vTS7c7xrkQJ2QCQb-2JBEINQNX1F8gzunw,248
|
|
5
|
+
livelib_mcp-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
livelib_mcp-0.1.0.dist-info/entry_points.txt,sha256=5KKwfSqOHHYkl2UkqWOiVuX_tv80MRiVdZW1TPerVTw,56
|
|
7
|
+
livelib_mcp-0.1.0.dist-info/RECORD,,
|