dspace-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.
- dspace_mcp/__init__.py +3 -0
- dspace_mcp/client.py +413 -0
- dspace_mcp/config.py +284 -0
- dspace_mcp/pdf.py +134 -0
- dspace_mcp/server.py +275 -0
- dspace_mcp/shaping.py +318 -0
- dspace_mcp/tools.py +515 -0
- dspace_mcp-0.1.0.dist-info/METADATA +157 -0
- dspace_mcp-0.1.0.dist-info/RECORD +12 -0
- dspace_mcp-0.1.0.dist-info/WHEEL +4 -0
- dspace_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- dspace_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
dspace_mcp/__init__.py
ADDED
dspace_mcp/client.py
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Asynchroniczny klient REST API DSpace 7+ — wyłącznie odczyt.
|
|
2
|
+
|
|
3
|
+
Klient robi te rzeczy, których nie chcemy powtarzać w każdym narzędziu:
|
|
4
|
+
sklejanie URL-i, mapowanie błędów HTTP na komunikaty zrozumiałe dla modelu,
|
|
5
|
+
paginację HAL, sondę startową i wykrywanie zdolności instancji.
|
|
6
|
+
|
|
7
|
+
Wysyła **wyłącznie** żądania GET — to gwarancja bezpieczeństwa całego projektu,
|
|
8
|
+
wynikająca z konstrukcji, a nie z dobrej woli modelu.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .shaping import parse_version
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING: # pragma: no cover - tylko dla typów
|
|
23
|
+
from .config import Config
|
|
24
|
+
|
|
25
|
+
USER_AGENT = f"dspace-mcp/{__version__} (+https://github.com/mpasternak/dspace-mcp)"
|
|
26
|
+
|
|
27
|
+
#: Twardy sufit liczby żądań w jednej pętli paginacyjnej. Bezpiecznik na wypadek
|
|
28
|
+
#: instancji, która w nieskończoność podaje `_links.next`.
|
|
29
|
+
MAX_PAGE_REQUESTS = 20
|
|
30
|
+
|
|
31
|
+
_UUID_RE = re.compile(
|
|
32
|
+
r"\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
|
|
33
|
+
r"-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\Z"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DSpaceError(Exception):
|
|
38
|
+
"""Błąd przeznaczony do pokazania modelowi. ``message`` jest po angielsku."""
|
|
39
|
+
|
|
40
|
+
#: Kod HTTP, jeśli błąd powstał z odpowiedzi serwera (używane wewnętrznie
|
|
41
|
+
#: przez sondę, która na 404 ponawia z dopisanym „/server").
|
|
42
|
+
status: int | None = None
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str) -> None:
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.message = message
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_uuid(value: str) -> bool:
|
|
50
|
+
"""Czy ``value`` ma kształt UUID-a (bez wnikania w wersję i wariant)?"""
|
|
51
|
+
return bool(_UUID_RE.match(value)) if isinstance(value, str) else False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require_uuid(value: str, what: str = "") -> str:
|
|
55
|
+
"""Zwróć ``value``, jeśli to UUID; w przeciwnym razie rzuć ``DSpaceError``.
|
|
56
|
+
|
|
57
|
+
Sprawdzamy po swojej stronie, bo DSpace na niepoprawny UUID w ścieżce
|
|
58
|
+
odpowiada **401 „Authentication is required"** (nie 400) — komunikat, po
|
|
59
|
+
którym model zaczyna szukać sposobu na zalogowanie się, zamiast poprawić
|
|
60
|
+
identyfikator. Patrz ``tests/fixtures/dspace10_401_malformed_uuid.json``.
|
|
61
|
+
|
|
62
|
+
``what`` doprecyzowuje, o który identyfikator chodzi (``"scope"``,
|
|
63
|
+
``"item"``…), żeby model wiedział, który argument poprawić.
|
|
64
|
+
"""
|
|
65
|
+
if not is_uuid(value):
|
|
66
|
+
label = f"{what} UUID" if what else "UUID"
|
|
67
|
+
raise DSpaceError(f"'{value}' is not a valid {label}.")
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _href(link: Any) -> str | None:
|
|
72
|
+
"""Wyciągnij ``href`` z relacji HAL, tolerując listę zamiast obiektu.
|
|
73
|
+
|
|
74
|
+
Wartością relacji bywa lista (np. ``workflowGroups`` w kolekcji), więc
|
|
75
|
+
naiwne ``links[rel]["href"]`` wywala się na realnych odpowiedziach.
|
|
76
|
+
"""
|
|
77
|
+
if isinstance(link, list):
|
|
78
|
+
link = link[0] if link else None
|
|
79
|
+
if isinstance(link, dict):
|
|
80
|
+
href = link.get("href")
|
|
81
|
+
if isinstance(href, str) and href:
|
|
82
|
+
return href
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _mb(num_bytes: int) -> str:
|
|
87
|
+
"""Rozmiar w megabajtach do komunikatu (bez zbędnych zer po przecinku)."""
|
|
88
|
+
value = num_bytes / (1024 * 1024)
|
|
89
|
+
return f"{value:.0f}" if value >= 1 else f"{value:.2f}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class DSpaceClient:
|
|
93
|
+
"""Cienka warstwa nad ``httpx.AsyncClient``, mówiąca w języku DSpace'a."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, config: Config, http: httpx.AsyncClient) -> None:
|
|
96
|
+
self.config = config
|
|
97
|
+
self.http = http
|
|
98
|
+
self._api_url = config.api_url
|
|
99
|
+
self._probe: dict | None = None
|
|
100
|
+
self._capabilities: dict | None = None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def api_url(self) -> str:
|
|
104
|
+
"""Aktualny korzeń API — sonda może go skorygować o „/server"."""
|
|
105
|
+
return self._api_url
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def build_http(cls, config: Config) -> httpx.AsyncClient:
|
|
109
|
+
"""Zbuduj klienta HTTP z ustawieniami, które nie są opcjonalne."""
|
|
110
|
+
return httpx.AsyncClient(
|
|
111
|
+
# /api/pid/find odpowiada 302 z nagłówkiem Location, a httpx domyślnie
|
|
112
|
+
# NIE podąża za przekierowaniami — bez tego wyszukanie po handlu (i
|
|
113
|
+
# pobranie /content przekierowanego do S3) zwraca pustkę.
|
|
114
|
+
follow_redirects=True,
|
|
115
|
+
timeout=config.timeout,
|
|
116
|
+
headers={
|
|
117
|
+
# Odpytujemy cudze repozytoria w pętli sterowanej przez model;
|
|
118
|
+
# anonimowy ruch bez identyfikacji bywa powodem banów IP.
|
|
119
|
+
"User-Agent": USER_AGENT,
|
|
120
|
+
"Accept": "application/json",
|
|
121
|
+
# Nagłówka Origin NIE ustawiamy nigdy: z nim DSpace odrzuca nawet
|
|
122
|
+
# zwykłe GET-y błędem 403 (zweryfikowane empirycznie).
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# --- warstwa żądań ------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def _url(self, path: str) -> str:
|
|
129
|
+
return f"{self._api_url}/{path.lstrip('/')}"
|
|
130
|
+
|
|
131
|
+
def _error_for_status(self, status: int, where: str) -> DSpaceError:
|
|
132
|
+
"""Zamień kod HTTP na komunikat, z którym model może coś zrobić.
|
|
133
|
+
|
|
134
|
+
Treści ``message`` z ciała błędu DSpace świadomie nie przekazujemy —
|
|
135
|
+
Spring Boot wpisuje tam bezużyteczne „An exception has occurred".
|
|
136
|
+
"""
|
|
137
|
+
if status == 404:
|
|
138
|
+
message = f"Not found: no such object at {where}. Check the UUID or handle."
|
|
139
|
+
elif status in (401, 403):
|
|
140
|
+
message = (
|
|
141
|
+
"Not publicly available: this server queries DSpace anonymously "
|
|
142
|
+
"and has no access to that object."
|
|
143
|
+
)
|
|
144
|
+
elif status == 422:
|
|
145
|
+
message = (
|
|
146
|
+
"The repository rejected this query (422). It usually means an "
|
|
147
|
+
"unknown search filter; call get_repository_info to see which "
|
|
148
|
+
"filters this instance supports."
|
|
149
|
+
)
|
|
150
|
+
elif status == 501:
|
|
151
|
+
message = "This repository cannot resolve identifiers of that type."
|
|
152
|
+
elif status in (429, 503):
|
|
153
|
+
# Świadomie BEZ automatycznego ponawiania — to my jesteśmy natrętnym
|
|
154
|
+
# klientem, a ponawianie pod limitem tempa kończy się banem.
|
|
155
|
+
message = "The repository is rate-limiting requests. Wait before retrying."
|
|
156
|
+
else:
|
|
157
|
+
message = (
|
|
158
|
+
f"The repository returned an unexpected error (HTTP {status}) "
|
|
159
|
+
f"for {where}."
|
|
160
|
+
)
|
|
161
|
+
error = DSpaceError(message)
|
|
162
|
+
error.status = status
|
|
163
|
+
return error
|
|
164
|
+
|
|
165
|
+
async def _request_json(
|
|
166
|
+
self, url: str, params: dict | None = None, *, where: str
|
|
167
|
+
) -> dict:
|
|
168
|
+
"""Jedno żądanie GET pod bezwzględny URL, z mapowaniem błędów."""
|
|
169
|
+
try:
|
|
170
|
+
response = await self.http.get(url, params=params)
|
|
171
|
+
except httpx.ConnectError as exc:
|
|
172
|
+
raise DSpaceError(
|
|
173
|
+
f"Repository unreachable at {self.config.base_url}."
|
|
174
|
+
) from exc
|
|
175
|
+
except httpx.TimeoutException as exc:
|
|
176
|
+
raise DSpaceError(
|
|
177
|
+
"The repository did not respond in time; try narrowing the query."
|
|
178
|
+
) from exc
|
|
179
|
+
except httpx.HTTPError as exc: # np. błąd pętli przekierowań, TLS
|
|
180
|
+
raise DSpaceError(
|
|
181
|
+
f"Repository unreachable at {self.config.base_url}."
|
|
182
|
+
) from exc
|
|
183
|
+
|
|
184
|
+
if response.status_code >= 400:
|
|
185
|
+
raise self._error_for_status(response.status_code, where)
|
|
186
|
+
|
|
187
|
+
# Błędom „odpowiedź przyszła, ale to nie nasze API" nadajemy status
|
|
188
|
+
# odpowiedzi HTTP (2xx/3xx). Sonda odróżnia po nim sytuację „pod tym
|
|
189
|
+
# adresem siedzi coś innego” (np. interfejs Angulara, który na /api
|
|
190
|
+
# oddaje HTML) od zerwanego połączenia, gdzie status zostaje None.
|
|
191
|
+
try:
|
|
192
|
+
payload = response.json()
|
|
193
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
194
|
+
error = DSpaceError(
|
|
195
|
+
f"The repository returned a response that is not valid JSON "
|
|
196
|
+
f"for {where}."
|
|
197
|
+
)
|
|
198
|
+
error.status = response.status_code
|
|
199
|
+
raise error from exc
|
|
200
|
+
if not isinstance(payload, dict):
|
|
201
|
+
error = DSpaceError(
|
|
202
|
+
f"The repository returned unexpected JSON (not an object) for {where}."
|
|
203
|
+
)
|
|
204
|
+
error.status = response.status_code
|
|
205
|
+
raise error
|
|
206
|
+
return payload
|
|
207
|
+
|
|
208
|
+
async def get(self, path: str, params: dict | None = None) -> dict:
|
|
209
|
+
"""GET pod ścieżkę **względną wobec /api**, np. ``/core/items/{uuid}``."""
|
|
210
|
+
return await self._request_json(self._url(path), params, where=path)
|
|
211
|
+
|
|
212
|
+
# --- sonda startowa -----------------------------------------------------
|
|
213
|
+
|
|
214
|
+
async def probe(self) -> dict:
|
|
215
|
+
"""Odpytaj korzeń API: nazwa, adresy i wersja instancji (z cache'em)."""
|
|
216
|
+
if self._probe is not None:
|
|
217
|
+
return self._probe
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
payload = await self._request_json(self._api_url, where="/")
|
|
221
|
+
except DSpaceError as exc:
|
|
222
|
+
# Najczęstsza pomyłka konfiguracyjna: base_url bez „/server".
|
|
223
|
+
# Ponawiamy, gdy serwer w ogóle odpowiedział, ale nie tym, czego
|
|
224
|
+
# oczekujemy: 404, albo 2xx/3xx z treścią, która nie jest naszym
|
|
225
|
+
# API (na gołym hoście DSpace serwuje interfejs Angulara, który
|
|
226
|
+
# na /api oddaje HTML ze statusem 200/202 — sam 404 by tego nie
|
|
227
|
+
# złapał). Zerwane połączenie ma status None i nie jest ponawiane,
|
|
228
|
+
# bo drugie żądanie pod ten sam host tylko podwoi czas oczekiwania.
|
|
229
|
+
if exc.status is None or (exc.status != 404 and exc.status >= 400):
|
|
230
|
+
raise
|
|
231
|
+
retry_url = f"{self.config.base_url.rstrip('/')}/server/api"
|
|
232
|
+
if retry_url == self._api_url:
|
|
233
|
+
raise
|
|
234
|
+
payload = await self._request_json(retry_url, where="/")
|
|
235
|
+
self._api_url = retry_url
|
|
236
|
+
|
|
237
|
+
version = payload.get("dspaceVersion")
|
|
238
|
+
self._probe = {
|
|
239
|
+
"name": payload.get("dspaceName"),
|
|
240
|
+
"ui_url": payload.get("dspaceUI"),
|
|
241
|
+
"server_url": payload.get("dspaceServer"),
|
|
242
|
+
"version": version,
|
|
243
|
+
"version_tuple": parse_version(version),
|
|
244
|
+
}
|
|
245
|
+
return self._probe
|
|
246
|
+
|
|
247
|
+
# --- paginacja HAL ------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _envelope(payload: dict, key: str) -> tuple[list[dict], dict, dict]:
|
|
251
|
+
"""Rozpakuj kopertę HAL do ``(elementy, page, _links)``.
|
|
252
|
+
|
|
253
|
+
Dwa kształty w praktyce: płaski (``_embedded.communities``) oraz
|
|
254
|
+
zagnieżdżony w /discover/search/objects, gdzie ``page`` i ``_links.next``
|
|
255
|
+
siedzą w ``_embedded.searchResult``, a nie na wierzchu.
|
|
256
|
+
"""
|
|
257
|
+
embedded = payload.get("_embedded")
|
|
258
|
+
if not isinstance(embedded, dict):
|
|
259
|
+
return [], {}, {}
|
|
260
|
+
|
|
261
|
+
items = embedded.get(key)
|
|
262
|
+
if isinstance(items, list):
|
|
263
|
+
page = payload.get("page")
|
|
264
|
+
links = payload.get("_links")
|
|
265
|
+
return (
|
|
266
|
+
list(items),
|
|
267
|
+
page if isinstance(page, dict) else {},
|
|
268
|
+
links if isinstance(links, dict) else {},
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
for value in embedded.values():
|
|
272
|
+
if not isinstance(value, dict):
|
|
273
|
+
continue
|
|
274
|
+
inner = value.get("_embedded")
|
|
275
|
+
if isinstance(inner, dict) and isinstance(inner.get(key), list):
|
|
276
|
+
page = value.get("page")
|
|
277
|
+
links = value.get("_links")
|
|
278
|
+
return (
|
|
279
|
+
list(inner[key]),
|
|
280
|
+
page if isinstance(page, dict) else {},
|
|
281
|
+
links if isinstance(links, dict) else {},
|
|
282
|
+
)
|
|
283
|
+
return [], {}, {}
|
|
284
|
+
|
|
285
|
+
async def get_page(
|
|
286
|
+
self, path: str, params: dict | None = None, *, key: str
|
|
287
|
+
) -> tuple[list[dict], dict]:
|
|
288
|
+
"""Jedna strona: ``(elementy spod _embedded[key], koperta page)``.
|
|
289
|
+
|
|
290
|
+
Kolejność kluczy w ``page`` bywa różna między instancjami, a endpoint
|
|
291
|
+
faset nie podaje ``totalElements`` — czytamy po nazwach, nie po pozycji.
|
|
292
|
+
"""
|
|
293
|
+
payload = await self.get(path, params)
|
|
294
|
+
items, page, _links = self._envelope(payload, key)
|
|
295
|
+
return items, page
|
|
296
|
+
|
|
297
|
+
async def get_all(
|
|
298
|
+
self, path: str, params: dict | None = None, *, key: str, limit: int
|
|
299
|
+
) -> tuple[list[dict], int | None, bool]:
|
|
300
|
+
"""Podążaj za ``_links.next``, zbierając najwyżej ``limit`` elementów.
|
|
301
|
+
|
|
302
|
+
Zwraca ``(elementy, total, truncated)``. ``total`` bierzemy z pierwszej
|
|
303
|
+
odpowiedzi i bywa ``None`` (endpoint faset go nie zwraca). ``truncated``
|
|
304
|
+
oznacza „jest tego więcej, niż pokazujemy".
|
|
305
|
+
"""
|
|
306
|
+
cap = max(0, min(limit, self.config.max_results))
|
|
307
|
+
items: list[dict] = []
|
|
308
|
+
total: int | None = None
|
|
309
|
+
truncated = False
|
|
310
|
+
|
|
311
|
+
url = self._url(path)
|
|
312
|
+
request_params = dict(params) if params else None
|
|
313
|
+
|
|
314
|
+
for attempt in range(MAX_PAGE_REQUESTS):
|
|
315
|
+
payload = await self._request_json(url, request_params, where=path)
|
|
316
|
+
page_items, page, links = self._envelope(payload, key)
|
|
317
|
+
if attempt == 0:
|
|
318
|
+
raw_total = page.get("totalElements")
|
|
319
|
+
total = raw_total if isinstance(raw_total, int) else None
|
|
320
|
+
items.extend(page_items)
|
|
321
|
+
next_href = _href(links.get("next"))
|
|
322
|
+
|
|
323
|
+
if len(items) >= cap:
|
|
324
|
+
truncated = len(items) > cap or bool(next_href)
|
|
325
|
+
items = items[:cap]
|
|
326
|
+
break
|
|
327
|
+
# Pusta strona z linkiem `next` to prosta droga do pętli bez końca.
|
|
328
|
+
if not next_href or not page_items:
|
|
329
|
+
break
|
|
330
|
+
# Href strony następnej niesie już własne query — nie doklejamy params.
|
|
331
|
+
url, request_params = next_href, None
|
|
332
|
+
else:
|
|
333
|
+
truncated = True
|
|
334
|
+
|
|
335
|
+
if total is not None and total > len(items):
|
|
336
|
+
truncated = True
|
|
337
|
+
return items, total, truncated
|
|
338
|
+
|
|
339
|
+
# --- zdolności instancji ------------------------------------------------
|
|
340
|
+
|
|
341
|
+
async def capabilities(self) -> dict:
|
|
342
|
+
"""Filtry i sortowania obsługiwane przez TĘ instancję (leniwie, z cache).
|
|
343
|
+
|
|
344
|
+
Zestaw jest konfigurowalny per-instancja (``discovery.xml``), a użycie
|
|
345
|
+
nieistniejącego filtra kończy się błędem 422 — dlatego pytamy zamiast
|
|
346
|
+
zakładać. Gdy endpoint zawiedzie, zwracamy puste listy: brak wiedzy o
|
|
347
|
+
filtrach nie może wysadzić narzędzia, które akurat ich nie potrzebuje.
|
|
348
|
+
"""
|
|
349
|
+
if self._capabilities is not None:
|
|
350
|
+
return self._capabilities
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
payload = await self.get("/discover/search")
|
|
354
|
+
except DSpaceError:
|
|
355
|
+
capabilities = {"filters": [], "sorts": []}
|
|
356
|
+
else:
|
|
357
|
+
capabilities = {
|
|
358
|
+
"filters": _names(payload.get("filters"), "filter"),
|
|
359
|
+
"sorts": _names(payload.get("sortOptions"), "name"),
|
|
360
|
+
}
|
|
361
|
+
self._capabilities = capabilities
|
|
362
|
+
return capabilities
|
|
363
|
+
|
|
364
|
+
# --- pobieranie plików --------------------------------------------------
|
|
365
|
+
|
|
366
|
+
async def stream_bytes(self, url: str, *, max_bytes: int) -> bytes:
|
|
367
|
+
"""Pobierz zawartość spod BEZWZGLĘDNEGO URL-a, twardo limitując rozmiar.
|
|
368
|
+
|
|
369
|
+
``sizeBytes`` z metadanych bitstreamu bywa nieaktualne, a przy
|
|
370
|
+
``Transfer-Encoding: chunked`` nie ma nawet ``Content-Length`` — więc
|
|
371
|
+
liczymy bajty w locie i przerywamy, gdy przekroczą limit.
|
|
372
|
+
"""
|
|
373
|
+
too_big = DSpaceError(
|
|
374
|
+
f"File is larger than the {_mb(max_bytes)} MB limit; "
|
|
375
|
+
f"give the user this link instead: {url}"
|
|
376
|
+
)
|
|
377
|
+
chunks: list[bytes] = []
|
|
378
|
+
size = 0
|
|
379
|
+
try:
|
|
380
|
+
async with self.http.stream("GET", url) as response:
|
|
381
|
+
if response.status_code >= 400:
|
|
382
|
+
await response.aread()
|
|
383
|
+
raise self._error_for_status(response.status_code, url)
|
|
384
|
+
declared = response.headers.get("content-length")
|
|
385
|
+
if declared and declared.isdigit() and int(declared) > max_bytes:
|
|
386
|
+
raise too_big
|
|
387
|
+
async for chunk in response.aiter_bytes():
|
|
388
|
+
size += len(chunk)
|
|
389
|
+
if size > max_bytes:
|
|
390
|
+
raise too_big
|
|
391
|
+
chunks.append(chunk)
|
|
392
|
+
except httpx.ConnectError as exc:
|
|
393
|
+
raise DSpaceError(
|
|
394
|
+
f"Repository unreachable at {self.config.base_url}."
|
|
395
|
+
) from exc
|
|
396
|
+
except httpx.TimeoutException as exc:
|
|
397
|
+
raise DSpaceError(
|
|
398
|
+
"The repository did not respond in time; try narrowing the query."
|
|
399
|
+
) from exc
|
|
400
|
+
except httpx.HTTPError as exc:
|
|
401
|
+
raise DSpaceError(f"Could not download the file at {url}.") from exc
|
|
402
|
+
return b"".join(chunks)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _names(entries: Any, key: str) -> list[str]:
|
|
406
|
+
"""Wyciągnij listę nazw z listy słowników, ignorując śmieci."""
|
|
407
|
+
if not isinstance(entries, list):
|
|
408
|
+
return []
|
|
409
|
+
names = []
|
|
410
|
+
for entry in entries:
|
|
411
|
+
if isinstance(entry, dict) and isinstance(entry.get(key), str):
|
|
412
|
+
names.append(entry[key])
|
|
413
|
+
return names
|
dspace_mcp/config.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Konfiguracja serwera: dataclass ``Config`` plus odczyt ze środowiska i z CLI.
|
|
2
|
+
|
|
3
|
+
Jedna instancja DSpace na proces (decyzja D2 w specyfikacji), więc konfiguracja
|
|
4
|
+
powstaje raz przy starcie i dalej podróżuje jako niemutowalny obiekt.
|
|
5
|
+
|
|
6
|
+
Wszystkie komunikaty widoczne dla użytkownika są po angielsku - to pakiet
|
|
7
|
+
międzynarodowy.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TypeVar
|
|
17
|
+
|
|
18
|
+
ENV_BASE_URL = "DSPACE_BASE_URL"
|
|
19
|
+
ENV_TIMEOUT = "DSPACE_TIMEOUT"
|
|
20
|
+
ENV_MAX_RESULTS = "DSPACE_MAX_RESULTS"
|
|
21
|
+
ENV_PDF_MAX_MB = "DSPACE_PDF_MAX_MB"
|
|
22
|
+
|
|
23
|
+
DEFAULT_TIMEOUT = 15.0
|
|
24
|
+
DEFAULT_MAX_RESULTS = 50
|
|
25
|
+
DEFAULT_PDF_MAX_MB = 20
|
|
26
|
+
|
|
27
|
+
EXAMPLE_BASE_URL = "https://demo.dspace.org/server"
|
|
28
|
+
|
|
29
|
+
_N = TypeVar("_N", int, float)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Config:
|
|
34
|
+
"""Komplet ustawień serwera.
|
|
35
|
+
|
|
36
|
+
Pola ``username``, ``password`` i ``enable_write`` istnieją od początku, żeby
|
|
37
|
+
format konfiguracji nie musiał się zmieniać, gdyby kiedyś doszedł tryb zapisu
|
|
38
|
+
(decyzja D7). Dzisiaj nie są przez nic czytane.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
base_url: str
|
|
42
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
43
|
+
max_results: int = DEFAULT_MAX_RESULTS
|
|
44
|
+
pdf_max_mb: int = DEFAULT_PDF_MAX_MB
|
|
45
|
+
username: str | None = None
|
|
46
|
+
password: str | None = None
|
|
47
|
+
enable_write: bool = False
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def api_url(self) -> str:
|
|
51
|
+
"""Korzeń REST API - wszystkie endpointy wiszą pod nim."""
|
|
52
|
+
return f"{self.base_url}/api"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def pdf_max_bytes(self) -> int:
|
|
56
|
+
"""Limit z konfiguracji w bajtach, bo strumień liczymy w bajtach."""
|
|
57
|
+
return self.pdf_max_mb * 1024 * 1024
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def normalize_base_url(raw: str) -> str:
|
|
61
|
+
"""Sprowadź podany adres do postaci katalogu serwera, bez końcowego ukośnika.
|
|
62
|
+
|
|
63
|
+
``/server`` nie jest dopisywane automatycznie - robi to dopiero sonda startowa
|
|
64
|
+
w ``client.py``, która potrafi sprawdzić, czy taki adres w ogóle odpowiada.
|
|
65
|
+
"""
|
|
66
|
+
cleaned = raw.strip().rstrip("/")
|
|
67
|
+
|
|
68
|
+
# Ludzie kopiują URL-e z przeglądarki razem z /api; base_url ma wskazywać
|
|
69
|
+
# poziom wyżej, bo klient dokleja /api sam.
|
|
70
|
+
if cleaned.endswith("/api"):
|
|
71
|
+
cleaned = cleaned[: -len("/api")].rstrip("/")
|
|
72
|
+
|
|
73
|
+
if not cleaned:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"Base URL must not be empty. Point it at your DSpace server, "
|
|
76
|
+
f"e.g. {EXAMPLE_BASE_URL}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if "://" not in cleaned:
|
|
80
|
+
cleaned = f"https://{cleaned}"
|
|
81
|
+
|
|
82
|
+
return cleaned
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _coerce_positive(
|
|
86
|
+
raw: str,
|
|
87
|
+
*,
|
|
88
|
+
label: str,
|
|
89
|
+
converter: Callable[[str], _N],
|
|
90
|
+
kind: str,
|
|
91
|
+
) -> _N:
|
|
92
|
+
"""Zamień string na liczbę dodatnią albo powiedz dokładnie, co było nie tak."""
|
|
93
|
+
try:
|
|
94
|
+
value = converter(raw)
|
|
95
|
+
except (TypeError, ValueError) as exc:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Invalid value for {label}: {raw!r} is not a valid {kind}."
|
|
98
|
+
) from exc
|
|
99
|
+
if value <= 0:
|
|
100
|
+
raise ValueError(f"Invalid value for {label}: {value} must be greater than 0.")
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _number_from_env(
|
|
105
|
+
env: Mapping[str, str],
|
|
106
|
+
var: str,
|
|
107
|
+
*,
|
|
108
|
+
converter: Callable[[str], _N],
|
|
109
|
+
kind: str,
|
|
110
|
+
default: _N,
|
|
111
|
+
) -> _N:
|
|
112
|
+
# Zmienna obecna, ale pusta, to zwykle literówka w konfiguracji klienta MCP -
|
|
113
|
+
# lepiej krzyknąć niż po cichu użyć domyślnej wartości.
|
|
114
|
+
raw = env.get(var)
|
|
115
|
+
if raw is None:
|
|
116
|
+
return default
|
|
117
|
+
return _coerce_positive(raw, label=var, converter=converter, kind=kind)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def config_from_env(env: Mapping[str, str] | None = None) -> Config:
|
|
121
|
+
"""Zbuduj ``Config`` ze zmiennych środowiskowych (domyślnie ``os.environ``)."""
|
|
122
|
+
if env is None:
|
|
123
|
+
env = os.environ
|
|
124
|
+
|
|
125
|
+
raw_base_url = env.get(ENV_BASE_URL)
|
|
126
|
+
if raw_base_url is None or not raw_base_url.strip():
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"{ENV_BASE_URL} is not set. Set it to the base URL of your DSpace "
|
|
129
|
+
f"server, e.g. {ENV_BASE_URL}={EXAMPLE_BASE_URL} "
|
|
130
|
+
"(or pass --base-url on the command line)."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return Config(
|
|
134
|
+
base_url=normalize_base_url(raw_base_url),
|
|
135
|
+
timeout=_number_from_env(
|
|
136
|
+
env, ENV_TIMEOUT, converter=float, kind="number", default=DEFAULT_TIMEOUT
|
|
137
|
+
),
|
|
138
|
+
max_results=_number_from_env(
|
|
139
|
+
env,
|
|
140
|
+
ENV_MAX_RESULTS,
|
|
141
|
+
converter=int,
|
|
142
|
+
kind="integer",
|
|
143
|
+
default=DEFAULT_MAX_RESULTS,
|
|
144
|
+
),
|
|
145
|
+
pdf_max_mb=_number_from_env(
|
|
146
|
+
env,
|
|
147
|
+
ENV_PDF_MAX_MB,
|
|
148
|
+
converter=int,
|
|
149
|
+
kind="integer",
|
|
150
|
+
default=DEFAULT_PDF_MAX_MB,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
156
|
+
from dspace_mcp import __version__ # lokalny import: unikamy cyklu przy starcie
|
|
157
|
+
|
|
158
|
+
parser = argparse.ArgumentParser(
|
|
159
|
+
prog="dspace-mcp",
|
|
160
|
+
description="Read-only MCP server for DSpace 7+ repositories.",
|
|
161
|
+
)
|
|
162
|
+
parser.add_argument(
|
|
163
|
+
"--base-url",
|
|
164
|
+
metavar="URL",
|
|
165
|
+
default=None,
|
|
166
|
+
help=(
|
|
167
|
+
f"Base URL of the DSpace server, e.g. {EXAMPLE_BASE_URL}. "
|
|
168
|
+
f"Defaults to ${ENV_BASE_URL}."
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--timeout",
|
|
173
|
+
metavar="SECONDS",
|
|
174
|
+
type=float,
|
|
175
|
+
default=None,
|
|
176
|
+
help=(
|
|
177
|
+
f"HTTP request timeout in seconds (default: {DEFAULT_TIMEOUT:g}, "
|
|
178
|
+
f"or ${ENV_TIMEOUT})."
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
"--max-results",
|
|
183
|
+
metavar="N",
|
|
184
|
+
type=int,
|
|
185
|
+
default=None,
|
|
186
|
+
help=(
|
|
187
|
+
f"Hard cap on how many objects any tool may return "
|
|
188
|
+
f"(default: {DEFAULT_MAX_RESULTS}, or ${ENV_MAX_RESULTS})."
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
parser.add_argument(
|
|
192
|
+
"--pdf-max-mb",
|
|
193
|
+
metavar="MB",
|
|
194
|
+
type=int,
|
|
195
|
+
default=None,
|
|
196
|
+
help=(
|
|
197
|
+
f"Refuse to download bitstreams larger than this for text extraction "
|
|
198
|
+
f"(default: {DEFAULT_PDF_MAX_MB}, or ${ENV_PDF_MAX_MB})."
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--version", action="version", version=f"dspace-mcp {__version__}"
|
|
203
|
+
)
|
|
204
|
+
return parser
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _resolve_number(
|
|
208
|
+
parser: argparse.ArgumentParser,
|
|
209
|
+
cli_value: _N | None,
|
|
210
|
+
flag: str,
|
|
211
|
+
env: Mapping[str, str],
|
|
212
|
+
var: str,
|
|
213
|
+
*,
|
|
214
|
+
converter: Callable[[str], _N],
|
|
215
|
+
kind: str,
|
|
216
|
+
default: _N,
|
|
217
|
+
) -> _N:
|
|
218
|
+
"""Flaga wygrywa ze środowiskiem, a środowisko z wartością domyślną.
|
|
219
|
+
|
|
220
|
+
Gdy flaga jest podana, zmiennej nie czytamy w ogóle - zepsuta zmienna nie może
|
|
221
|
+
blokować wywołania, które i tak jej nie używa.
|
|
222
|
+
"""
|
|
223
|
+
if cli_value is not None:
|
|
224
|
+
if cli_value <= 0:
|
|
225
|
+
parser.error(f"{flag}: {cli_value} must be greater than 0")
|
|
226
|
+
return cli_value
|
|
227
|
+
try:
|
|
228
|
+
return _number_from_env(
|
|
229
|
+
env, var, converter=converter, kind=kind, default=default
|
|
230
|
+
)
|
|
231
|
+
except ValueError as exc:
|
|
232
|
+
parser.error(str(exc))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def parse_args(argv: list[str] | None = None) -> Config:
|
|
236
|
+
"""Zbuduj ``Config`` z argumentów CLI, biorąc domyślne wartości ze środowiska."""
|
|
237
|
+
env: Mapping[str, str] = os.environ
|
|
238
|
+
parser = _build_parser()
|
|
239
|
+
args = parser.parse_args(argv)
|
|
240
|
+
|
|
241
|
+
raw_base_url = args.base_url if args.base_url is not None else env.get(ENV_BASE_URL)
|
|
242
|
+
if raw_base_url is None or not raw_base_url.strip():
|
|
243
|
+
parser.error(
|
|
244
|
+
f"no DSpace base URL given: pass --base-url {EXAMPLE_BASE_URL} "
|
|
245
|
+
f"or set ${ENV_BASE_URL}"
|
|
246
|
+
)
|
|
247
|
+
try:
|
|
248
|
+
base_url = normalize_base_url(raw_base_url)
|
|
249
|
+
except ValueError as exc:
|
|
250
|
+
parser.error(str(exc))
|
|
251
|
+
|
|
252
|
+
return Config(
|
|
253
|
+
base_url=base_url,
|
|
254
|
+
timeout=_resolve_number(
|
|
255
|
+
parser,
|
|
256
|
+
args.timeout,
|
|
257
|
+
"--timeout",
|
|
258
|
+
env,
|
|
259
|
+
ENV_TIMEOUT,
|
|
260
|
+
converter=float,
|
|
261
|
+
kind="number",
|
|
262
|
+
default=DEFAULT_TIMEOUT,
|
|
263
|
+
),
|
|
264
|
+
max_results=_resolve_number(
|
|
265
|
+
parser,
|
|
266
|
+
args.max_results,
|
|
267
|
+
"--max-results",
|
|
268
|
+
env,
|
|
269
|
+
ENV_MAX_RESULTS,
|
|
270
|
+
converter=int,
|
|
271
|
+
kind="integer",
|
|
272
|
+
default=DEFAULT_MAX_RESULTS,
|
|
273
|
+
),
|
|
274
|
+
pdf_max_mb=_resolve_number(
|
|
275
|
+
parser,
|
|
276
|
+
args.pdf_max_mb,
|
|
277
|
+
"--pdf-max-mb",
|
|
278
|
+
env,
|
|
279
|
+
ENV_PDF_MAX_MB,
|
|
280
|
+
converter=int,
|
|
281
|
+
kind="integer",
|
|
282
|
+
default=DEFAULT_PDF_MAX_MB,
|
|
283
|
+
),
|
|
284
|
+
)
|