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 ADDED
@@ -0,0 +1,3 @@
1
+ """Read-only MCP server for DSpace 7+ repositories."""
2
+
3
+ __version__ = "0.1.0"
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
+ )