livelib-mcp 0.1.0__tar.gz

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.
Files changed (35) hide show
  1. livelib_mcp-0.1.0/.gitignore +3 -0
  2. livelib_mcp-0.1.0/PKG-INFO +9 -0
  3. livelib_mcp-0.1.0/pyproject.toml +30 -0
  4. livelib_mcp-0.1.0/src/livelib_mcp/__init__.py +3 -0
  5. livelib_mcp-0.1.0/src/livelib_mcp/client.py +441 -0
  6. livelib_mcp-0.1.0/src/livelib_mcp/server.py +124 -0
  7. livelib_mcp-0.1.0/tests/fixtures/contents_1001482955.json +1 -0
  8. livelib_mcp-0.1.0/tests/fixtures/edition_1004124394.json +1 -0
  9. livelib_mcp-0.1.0/tests/fixtures/edition_quotes_page1.json +1 -0
  10. livelib_mcp-0.1.0/tests/fixtures/empty.json +0 -0
  11. livelib_mcp-0.1.0/tests/fixtures/error_401.json +1 -0
  12. livelib_mcp-0.1.0/tests/fixtures/error_404.json +1 -0
  13. livelib_mcp-0.1.0/tests/fixtures/error_422.json +1 -0
  14. livelib_mcp-0.1.0/tests/fixtures/error_422_visibility.json +1 -0
  15. livelib_mcp-0.1.0/tests/fixtures/quote_created.json +1 -0
  16. livelib_mcp-0.1.0/tests/fixtures/quotes_last_page.json +1 -0
  17. livelib_mcp-0.1.0/tests/fixtures/quotes_page1.json +1 -0
  18. livelib_mcp-0.1.0/tests/fixtures/quotes_page2.json +1 -0
  19. livelib_mcp-0.1.0/tests/fixtures/quotes_walk_empty.json +1 -0
  20. livelib_mcp-0.1.0/tests/fixtures/quotes_walk_last.json +1 -0
  21. livelib_mcp-0.1.0/tests/fixtures/quotes_walk_p1.json +1 -0
  22. livelib_mcp-0.1.0/tests/fixtures/quotes_walk_p2.json +1 -0
  23. livelib_mcp-0.1.0/tests/fixtures/review_created.json +1 -0
  24. livelib_mcp-0.1.0/tests/fixtures/review_deleted_200.json +1 -0
  25. livelib_mcp-0.1.0/tests/fixtures/review_updated_200.json +1 -0
  26. livelib_mcp-0.1.0/tests/fixtures/reviews_page1.json +1 -0
  27. livelib_mcp-0.1.0/tests/fixtures/reviews_walk_last.json +1 -0
  28. livelib_mcp-0.1.0/tests/fixtures/search_auzan.json +1 -0
  29. livelib_mcp-0.1.0/tests/fixtures/shelf_readings.json +1 -0
  30. livelib_mcp-0.1.0/tests/fixtures/shelf_readings_dupes.json +1 -0
  31. livelib_mcp-0.1.0/tests/fixtures/shelf_records_reads.json +1 -0
  32. livelib_mcp-0.1.0/tests/test_client.py +919 -0
  33. livelib_mcp-0.1.0/tests/test_regression_guards.py +71 -0
  34. livelib_mcp-0.1.0/tests/test_server.py +426 -0
  35. livelib_mcp-0.1.0/uv.lock +1282 -0
@@ -0,0 +1,3 @@
1
+
2
+ # local editor config — machine-specific venv path
3
+ pyrightconfig.json
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.5
2
+ Name: livelib-mcp
3
+ Version: 0.1.0
4
+ Summary: Anonymous read client for the LiveLib trisolaris API
5
+ Author: Shared Goals contributors
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: httpx>=0.27
9
+ Requires-Dist: mcp>=2
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "livelib-mcp"
7
+ version = "0.1.0"
8
+ description = "Anonymous read client for the LiveLib trisolaris API"
9
+ license = "MIT"
10
+ authors = [{ name = "Shared Goals contributors" }]
11
+ requires-python = ">=3.10"
12
+ # No [socks] extra: the client sets trust_env=False, so it never reads ambient
13
+ # proxy env and could never route through a SOCKS proxy — the extra is dead weight.
14
+ dependencies = ["httpx>=0.27", "mcp>=2"]
15
+
16
+ [dependency-groups]
17
+ dev = [
18
+ "anyio>=4.15.1",
19
+ "pytest",
20
+ "ruff",
21
+ ]
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/livelib_mcp"]
25
+
26
+ [project.scripts]
27
+ livelib-mcp = "livelib_mcp.server:main"
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ from .client import LivelibApiError, LivelibAuthError, TrisolarisClient, client_from_env
2
+
3
+ __all__ = ["LivelibApiError", "LivelibAuthError", "TrisolarisClient", "client_from_env"]
@@ -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"]
@@ -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 @@
1
+ {"status":200,"error":null,"payload":{"data":[{"id":2657866,"title":"О Викторе Юзефовиче Драгунском","url":"/work/1004575603-o-viktore-yuzefoviche-dragunskom-alla-dragunskaya","type":"статья","authors":[{"id":506646,"full_name":"Алла Драгунская","url":"/author/506646-alla-dragunskaya"}],"translators":"","comments":"","pages":""},{"id":2657995,"title":"Про папу моего","url":"/work/1004575609-pro-papu-moego-kseniya-dragunskaya","type":"статья","authors":[{"id":231638,"full_name":"Ксения Драгунская","url":"/author/231638-kseniya-dragunskaya"}],"translators":null,"comments":"","pages":""},{"id":2658136,"title":"ДЕНИСКИНЫ РАССКАЗЫ","url":null,"type":null,"authors":[],"translators":"","comments":"","pages":""},{"id":2658262,"title":"«Он живой и светится…»","url":"/work/1000952528-on-zhivoj-i-svetitsya-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658268,"title":"Надо иметь чувство юмора","url":"/work/1000967704-nado-imet-chuvstvo-yumora-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658274,"title":"Слава Ивана Козловского","url":"/work/1000967703-slava-ivana-kozlovskogo-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658277,"title":"Красный шарик в синем небе","url":"/work/1000952529-krasnyj-sharik-v-sinem-nebe-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658283,"title":"Кот в сапогах","url":"/work/1000967705-kot-v-sapogah-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658289,"title":"Сражение у Чистой речки","url":"/work/1006228168-srazhenie-u-chistoj-rechki-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658295,"title":"Друг детства","url":"/work/1001855378-drug-detstva-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658301,"title":"Ничего изменить нельзя","url":"/work/1000971057-nichego-izmenit-nelzya-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658307,"title":"Заколдованная буква","url":"/work/1000971068-zakoldovannaya-bukva-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658313,"title":"Синий кинжал","url":"/work/1000940089-sinij-kinzhal-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658319,"title":"Мотогонки по отвесной стене","url":"/work/1000971061-motogonki-po-otvesnoj-stene-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658328,"title":"Третье место в стиле баттерфляй","url":"/work/1000971062-trete-mesto-v-stile-batterflyaj-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658442,"title":"Сверху вниз, наискосок!","url":"/work/1001287006-sverhu-vniz-naiskosok-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658448,"title":"Не пиф, не паф!","url":"/work/1000971064-ne-pif-ne-paf-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658451,"title":"Англичанин Павля","url":"/work/1001862740-anglichanin-pavlya-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658454,"title":"Смерть шпиона Гадюкина","url":"/work/1000972678-smert-shpiona-gadyukina-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658460,"title":"Старый Мореход","url":"/work/1000972682-staryj-morehod-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658466,"title":"Запах неба и махорочки","url":"/work/1000972683-zapah-neba-i-mahorochki-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658472,"title":"Двадцать лет под кроватью","url":"/work/1001847635-dvadtsat-let-pod-krovatyu-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658475,"title":"Девочка на шаре","url":"/work/1000967701-devochka-na-share-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658478,"title":"Расскажите мне про Сингапур","url":"/work/1001090805-rasskazhite-mne-pro-singapur-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658484,"title":"Что я люблю...","url":"/work/1001009428-chto-ya-lyublyu-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658490,"title":"...И чего не люблю!","url":"/work/1001090802-i-chego-ne-lyublyu-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658496,"title":"Что любит Мишка","url":"/work/1001009996-chto-lyubit-mishka-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658502,"title":"Тайное становится явным","url":"/work/1002176298-tajnoe-stanovitsya-yavnym-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658505,"title":"Профессор кислых щей","url":"/work/1001090804-professor-kislyh-schej-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658511,"title":"Главные реки","url":"/work/1001447051-glavnye-reki-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658517,"title":"Зелёнчатые леопарды","url":"/work/1000967699-zeljonchatye-leopardy-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658523,"title":"Удивительный день","url":"/work/1001851197-udivitelnyj-den-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658529,"title":"И мы!","url":"/work/1002012696-i-my-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658535,"title":"Шляпа гроссмейстера","url":"/work/1000940090-shlyapa-grossmejstera-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658538,"title":"Ровно 25 кило","url":"/work/1001447020-rovno-25-kilo-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658544,"title":"Здоровая мысль","url":"/work/1001552358-zdorovaya-mysl-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""},{"id":2658550,"title":"Похититель собак","url":"/work/1001287004-pohititel-sobak-viktor-dragunskij","type":"рассказ","authors":[],"translators":"","comments":"","pages":""}]}}
@@ -0,0 +1 @@
1
+ {"status":200,"error":null,"payload":{"data":{"id":1004124394,"title":"Экономика всего. Как институты определяют нашу жизнь","cover_url":"https://s1.livelib.ru/boocover/1004124394/120x180/fa5d/Aleksandr_Auzan__Ekonomika_vsego._Kak_instituty_opredelyayut_nashu_zhizn.jpg","art_id":690710,"authors":[{"id":120503,"full_name":"Александр Аузан","url":"/author/120503-aleksandr-auzan"}],"stats":{"rating":"8.448","marks_count":656,"reviews_count":30,"quotes_count":67,"selections_count":130},"primary_user_collection":null,"art_type":"paper","url":"/book/1004124394-ekonomika-vsego-kak-instituty-opredelyayut-nashu-zhizn-aleksandr-auzan","isbn":"978-5-00100-684-8","publication_year":2017,"publishers":[{"title":"Манн","url":"/publisher/11864-mif"},{"title":"Иванов и Фербер","url":"/publisher/11864-mif"}],"art_text_url":"/book/235749/readpart-ekonomika-vsego-kak-instituty-opredelyayut-nashu-zhizn-aleksandr-auzan","badge":null,"is_adult":false,"min_age":12,"is_favorite":false,"is_rightholder_blocked":false,"is_lgbt_blocked":false,"is_rkn_blocked":false}}}