bpp-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.
bpp_mcp/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Serwer MCP dla API BPP (Bibliografia Publikacji Pracowników).
2
+
3
+ Wystawia read-only API BPP (`/api/v1/`) jako kuratorowane narzędzia MCP:
4
+ wyszukiwanie publikacji i autorów, pobieranie rozwiniętych rekordów
5
+ (z autorami/źródłem/streszczeniami zamiast hyperlinków), harvest list
6
+ publikacji oraz małe słowniki referencyjne.
7
+ """
8
+
9
+ __version__ = "0.1.0"
10
+
11
+ __all__ = ["__version__"]
bpp_mcp/auth.py ADDED
@@ -0,0 +1,110 @@
1
+ """Warstwa OAuth Resource Server: weryfikacja opaque tokenu BPP przez
2
+ ``whoami`` oraz przekazanie tokenu BIEŻĄCEGO requestu do BppClient.
3
+
4
+ Token BPP jest OPAQUE (nie JWT) — weryfikacja tylko zdalnie przez
5
+ ``GET /api/v1/whoami/``: 200 → ważny, 401/403 → nieważny (``None`` →
6
+ transportowy 401), inne/sieć/nie-JSON → :class:`WhoamiUnavailable` (BPP
7
+ niedostępne; materializuje się jako HTTP 500 — klient NIE robi re-auth).
8
+
9
+ UWAGA (K1): do passthrough NIE używamy ``get_access_token()`` — w stateful
10
+ streamable HTTP zwraca on token z chwili ``initialize`` (stale). Token
11
+ bieżącego requestu bierzemy z ``ctx.request_context.request`` (patrz
12
+ ``server._client``) i mostkujemy przez ContextVar poniżej.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import time
18
+ from contextvars import ContextVar
19
+
20
+ import httpx
21
+ from mcp.server.auth.provider import AccessToken
22
+
23
+ _current_bearer: ContextVar[str | None] = ContextVar(
24
+ "bpp_mcp_current_bearer", default=None
25
+ )
26
+
27
+
28
+ class WhoamiUnavailable(Exception):
29
+ """BPP niedostępne przy weryfikacji tokenu (nie-200/401/403, sieć, nie-JSON).
30
+
31
+ Różne od ``None`` (token nieważny → re-auth): token mógł być ważny, więc
32
+ nie wymuszamy re-autoryzacji — request kończy się błędem serwera (5xx).
33
+ """
34
+
35
+
36
+ def set_current_bearer(token: str | None) -> None:
37
+ """Zapisz token bieżącego requestu w kontekście (dla BppClient)."""
38
+ _current_bearer.set(token)
39
+
40
+
41
+ def current_bearer() -> str | None:
42
+ """Zwróć token bieżącego requestu z kontekstu (``None`` poza http)."""
43
+ return _current_bearer.get()
44
+
45
+
46
+ def bearer_from_request(request) -> str | None:
47
+ """Wyłuskaj surowy token z nagłówka ``Authorization: Bearer`` (lub None)."""
48
+ if request is None:
49
+ return None
50
+ authz = request.headers.get("authorization", "")
51
+ if authz.lower().startswith("bearer "):
52
+ return authz.split(" ", 1)[1].strip()
53
+ return None
54
+
55
+
56
+ class WhoamiTokenVerifier:
57
+ """``TokenVerifier`` (protokół SDK) oparty o ``whoami`` BPP, z positive-cache.
58
+
59
+ Cache (``ttl`` s) redukuje +1 request/wywołanie i wygładza blipy; ma eviction
60
+ (usuwa wygasłe) i twardy cap ``max_entries`` (drop najstarszego), by proces
61
+ nie akumulował zrewokowanych tokenów w nieskończoność.
62
+ """
63
+
64
+ def __init__(
65
+ self, base_url: str, *, ttl: float = 30.0, max_entries: int = 256
66
+ ) -> None:
67
+ self._base_url = base_url.rstrip("/")
68
+ self._ttl = ttl
69
+ self._max_entries = max_entries
70
+ self._cache: dict[str, tuple[AccessToken, float]] = {}
71
+
72
+ async def verify_token(self, token: str) -> AccessToken | None:
73
+ now = time.monotonic()
74
+ cached = self._cache.get(token)
75
+ if cached is not None and cached[1] > now:
76
+ return cached[0]
77
+ url = f"{self._base_url}/api/v1/whoami/"
78
+ headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
79
+ try:
80
+ async with httpx.AsyncClient(
81
+ timeout=httpx.Timeout(10.0, connect=5.0), follow_redirects=True
82
+ ) as client:
83
+ resp = await client.get(url, headers=headers)
84
+ except httpx.HTTPError as exc:
85
+ raise WhoamiUnavailable(f"whoami niedostępne: {exc}") from exc
86
+ if resp.status_code in (401, 403):
87
+ return None
88
+ if resp.status_code != 200:
89
+ raise WhoamiUnavailable(f"whoami zwróciło {resp.status_code}")
90
+ try:
91
+ data = resp.json()
92
+ except ValueError as exc:
93
+ raise WhoamiUnavailable(f"whoami zwróciło nie-JSON: {exc}") from exc
94
+ access = AccessToken(
95
+ token=token,
96
+ client_id="bpp-mcp",
97
+ scopes=["read"],
98
+ subject=str(data["id"]) if data.get("id") is not None else None,
99
+ claims=data,
100
+ )
101
+ self._store(token, access, now)
102
+ return access
103
+
104
+ def _store(self, token: str, access: AccessToken, now: float) -> None:
105
+ if len(self._cache) >= self._max_entries:
106
+ for k in [k for k, (_, exp) in self._cache.items() if exp <= now]:
107
+ del self._cache[k]
108
+ while len(self._cache) >= self._max_entries:
109
+ self._cache.pop(next(iter(self._cache)))
110
+ self._cache[token] = (access, now + self._ttl)
bpp_mcp/catalog.py ADDED
@@ -0,0 +1,163 @@
1
+ """Kuratorowana wiedza o kształcie API BPP dzielona przez narzędzia MCP.
2
+
3
+ Zawiera:
4
+
5
+ * :data:`CATALOG` — mapa ``typ → {endpoint, relacje do rozwinięcia}`` dla
6
+ pięciu typów rekordów obecnych w :class:`Rekord` (ciągłe, zwarte, patent,
7
+ praca doktorska, praca habilitacyjna). Steruje głębokością rozwijania
8
+ hyperlinków w :func:`bpp_mcp.tools.pobierz_rekord`,
9
+ * :data:`SLOWNIKI` — biała lista MAŁYCH tabel referencyjnych dostępnych przez
10
+ :func:`bpp_mcp.tools.slownik`,
11
+ * :data:`SLOWNIKI_WOLUMENOWE` — jawnie odrzucane „słowniki" o dużej liczności
12
+ (konferencja/wydawca/nagroda), które nie należą do :func:`slownik`,
13
+ * helpery normalizacji identyfikatorów rekordów.
14
+
15
+ Świadomie utrzymywane ręcznie (bez generatora z YAML — YAGNI). Weryfikowane
16
+ z serializerami ``src/api_v1/serializers/`` instancji BPP.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class TypRekordu:
26
+ """Opis jednego typu publikacji i jego relacji do rozwinięcia."""
27
+
28
+ endpoint: str
29
+ # Pole listy przez-autorskiej (through) z płaskim ``zapisany_jako``.
30
+ # ``None`` gdy typ nie ma tabeli through (prace dr/hab mają pojedynczy
31
+ # ``autor`` jako bezpośredni URL — patrz ``relacje_pojedyncze``).
32
+ autorzy: str | None = None
33
+ # Relacje 1:1 — pojedynczy URL do rozwinięcia w obiekt.
34
+ relacje_pojedyncze: tuple[str, ...] = ()
35
+ # Relacje 1:N — lista URL-i do rozwinięcia w listę obiektów.
36
+ relacje_wielokrotne: tuple[str, ...] = ()
37
+ # Białe listy filtrów opcjonalnych (poza uniwersalnym zakresem roku
38
+ # i ``zmienione_po``) faktycznie honorowanych przez django-filter danego
39
+ # endpointu. django-filter CICHO ignoruje nieznane parametry, więc
40
+ # doklejenie np. ``charakter_formalny`` do typu, który go nie filtruje,
41
+ # dałoby fałszywie „przefiltrowany" wynik. Sterują walidacją w
42
+ # :func:`bpp_mcp.tools.lista_publikacji`.
43
+ filtry_dodatkowe: frozenset[str] = frozenset()
44
+
45
+
46
+ CATALOG: dict[str, TypRekordu] = {
47
+ # Wydawnictwo ciągłe: autorzy (through), źródło, streszczenia, zewn. bazy.
48
+ "wydawnictwo_ciagle": TypRekordu(
49
+ endpoint="wydawnictwo_ciagle",
50
+ autorzy="autorzy_set",
51
+ relacje_pojedyncze=("zrodlo",),
52
+ relacje_wielokrotne=("streszczenia", "zewnetrzna_baza_danych"),
53
+ filtry_dodatkowe=frozenset({"charakter_formalny"}),
54
+ ),
55
+ # Wydawnictwo zwarte: NIE ma zrodlo ani zewnetrzna_baza_danych; ma serię.
56
+ # ``wydawnictwo_nadrzedne`` świadomie NIE rozwijane (ryzyko rekurencji) —
57
+ # pozostaje jako goły URL w wyniku.
58
+ "wydawnictwo_zwarte": TypRekordu(
59
+ endpoint="wydawnictwo_zwarte",
60
+ autorzy="autorzy_set",
61
+ relacje_pojedyncze=("seria_wydawnicza",),
62
+ relacje_wielokrotne=("streszczenia",),
63
+ filtry_dodatkowe=frozenset({"charakter_formalny"}),
64
+ ),
65
+ # Patent: tylko autorzy (through). rodzaj_prawa/zasieg są inline (string).
66
+ "patent": TypRekordu(
67
+ endpoint="patent",
68
+ autorzy="autorzy_set",
69
+ ),
70
+ # Prace dr/hab: pojedynczy bezpośredni autor (+ promotor/jednostka/wydawca),
71
+ # bez tabeli through.
72
+ "praca_doktorska": TypRekordu(
73
+ endpoint="praca_doktorska",
74
+ autorzy=None,
75
+ relacje_pojedyncze=("autor", "promotor", "jednostka", "wydawca"),
76
+ ),
77
+ "praca_habilitacyjna": TypRekordu(
78
+ endpoint="praca_habilitacyjna",
79
+ autorzy=None,
80
+ relacje_pojedyncze=("autor", "jednostka", "wydawca"),
81
+ ),
82
+ }
83
+
84
+ TYPY_REKORDOW: tuple[str, ...] = tuple(CATALOG.keys())
85
+
86
+
87
+ # Biała lista małych tabel referencyjnych (jedno żądanie ?limit=500).
88
+ SLOWNIKI: tuple[str, ...] = (
89
+ "charakter_formalny",
90
+ "typ_kbn",
91
+ "jezyk",
92
+ "dyscyplina_naukowa",
93
+ "rodzaj_zrodla",
94
+ "poziom_wydawcy",
95
+ "funkcja_autora",
96
+ "tytul",
97
+ "czas_udostepnienia_openaccess",
98
+ )
99
+
100
+ # Dane wolumenowe udające słowniki — jawnie odrzucane z czytelnym błędem.
101
+ SLOWNIKI_WOLUMENOWE: tuple[str, ...] = (
102
+ "konferencja",
103
+ "wydawca",
104
+ "nagroda",
105
+ "seria_wydawnicza",
106
+ "zrodlo",
107
+ "autor",
108
+ "jednostka",
109
+ )
110
+
111
+
112
+ # Biała lista prefiksów endpointów, których odpowiedzi wolno cache'ować w
113
+ # procesie (:meth:`bpp_mcp.client.BppClient.get_json`). To WYŁĄCZNIE stabilne,
114
+ # powtarzalnie odpytywane tabele referencyjne / encje słownikowe (jednostka,
115
+ # źródło, wydawca, słowniki). Świadomie POZA whitelistą: rekordy publikacji,
116
+ # streszczenia, tabele through-autorów oraz raporty ``recent_*`` — to dane
117
+ # zmienne albo jednorazowe, których cache'owanie tylko puchłoby i groziło
118
+ # staleness. Brak prefiksu na liście ⇒ odpowiedź NIE ląduje w cache, nawet
119
+ # przy ``use_cache=True``.
120
+ PREFIKSY_CACHOWALNE: frozenset[str] = frozenset(SLOWNIKI) | frozenset(
121
+ {
122
+ "jednostka",
123
+ "zrodlo",
124
+ "wydawca",
125
+ "seria_wydawnicza",
126
+ "konferencja",
127
+ "nagroda",
128
+ }
129
+ )
130
+
131
+
132
+ def rozbij_rekord_url(url: str | None) -> tuple[str | None, str | None]:
133
+ """Z ``rekord_url`` (``/szukaj/``) wyłuskaj ``(typ, pk)``.
134
+
135
+ Mapę ct→typ budujemy dynamicznie z samego URL-a (segment endpointu),
136
+ NIE z numerycznych ID ContentType (per-instancja). Przykład::
137
+
138
+ ".../api/v1/wydawnictwo_ciagle/123/" -> ("wydawnictwo_ciagle", "123")
139
+ """
140
+ if not url:
141
+ return (None, None)
142
+ segmenty = [s for s in url.split("/") if s]
143
+ for i, seg in enumerate(segmenty):
144
+ if seg in CATALOG and i + 1 < len(segmenty):
145
+ return (seg, segmenty[i + 1])
146
+ return (None, None)
147
+
148
+
149
+ def rozbij_tuple_id(surowy: object) -> tuple[int | None, int | None]:
150
+ """Sparsuj identyfikator ``Rekord`` w formacie ``"(6, 123)"`` z ``recent_*``.
151
+
152
+ Zwraca ``(content_type_id, pk)`` lub ``(None, None)`` przy złym wejściu.
153
+ """
154
+ if not isinstance(surowy, str):
155
+ return (None, None)
156
+ wnetrze = surowy.strip().strip("()")
157
+ czesci = wnetrze.split(",")
158
+ if len(czesci) != 2:
159
+ return (None, None)
160
+ try:
161
+ return (int(czesci[0]), int(czesci[1]))
162
+ except ValueError:
163
+ return (None, None)
bpp_mcp/client.py ADDED
@@ -0,0 +1,304 @@
1
+ """Asynchroniczny klient HTTP dla API BPP.
2
+
3
+ Cechy (wg specu Fazy 2):
4
+
5
+ * jeden współdzielony ``httpx.AsyncClient`` (zakładany w lifespanie serwera),
6
+ * ``timeout = Timeout(10.0, connect=5.0)``,
7
+ * nagłówek ``Accept: application/json`` (NIE ``?format=json``),
8
+ * retry×2 z narastającym backoffem na błędy sieciowe / 5xx (GET jest
9
+ idempotentny),
10
+ * semafor współbieżności (domyślnie 8) — ogranicza równoległe rozwijanie
11
+ hyperlinków w :func:`bpp_mcp.tools.pobierz_rekord`,
12
+ * procesowy cache ``URL → JSON`` OGRANICZONY do białej listy prefiksów
13
+ słownikowo-referencyjnych (:data:`bpp_mcp.catalog.PREFIKSY_CACHOWALNE` —
14
+ jednostka/źródło/wydawca/słowniki). Rekordy publikacji, streszczenia,
15
+ through-autorzy i raporty ``recent_*`` NIE są cache'owane (dane zmienne /
16
+ jednorazowe → cache tylko by puchł i groził staleness),
17
+ * auto-follow paginacji ``LimitOffset`` porcjami po ``PAGE_LIMIT`` do zadanego
18
+ ``limit`` (z twardym sufitem liczby stron — bezpiecznik przed zapętleniem),
19
+ * mapowanie 404 na czytelny :class:`BppNotFound` zamiast tracebacku; przy 4xx
20
+ do komunikatu dołączamy fragment ciała odpowiedzi (wskazówka walidacji DRF).
21
+
22
+ Żadnego bare ``except`` — łapiemy wąskie typy httpx i re-raise'ujemy sensowny
23
+ błąd domenowy.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ from typing import Any
30
+
31
+ import httpx
32
+
33
+ from .auth import current_bearer
34
+ from .catalog import PREFIKSY_CACHOWALNE
35
+
36
+ # Rozmiar pojedynczej strony przy auto-follow paginacji. Stronicujemy porcjami
37
+ # zamiast żądać całego ``limit`` jednym requestem — chroni instancję BPP przed
38
+ # skrajnie dużym ``?limit=`` (BPP nie deklaruje ``max_limit`` po stronie DRF).
39
+ PAGE_LIMIT = 50
40
+
41
+
42
+ class BppError(Exception):
43
+ """Bazowy, czytelny błąd domenowy zwracany narzędziom MCP.
44
+
45
+ Gdy błąd pochodzi z odpowiedzi HTTP (4xx/5xx), niesie ``status_code`` oraz
46
+ (dla 4xx z ciałem JSON) zdeserializowany ``payload`` — narzędzia mapują je
47
+ na sensowne komunikaty (np. 400 DjangoQL → pozycja błędu, 503 → „zawęź").
48
+ """
49
+
50
+ def __init__(
51
+ self, *args: object, status_code: int | None = None, payload: Any = None
52
+ ) -> None:
53
+ super().__init__(*args)
54
+ self.status_code = status_code
55
+ self.payload = payload
56
+
57
+
58
+ class BppNotFound(BppError):
59
+ """Zasób zwrócił 404 (rekord ukryty, niewidoczny lub nieistniejący)."""
60
+
61
+
62
+ class BppNetworkError(BppError):
63
+ """Trwały błąd sieci / 5xx po wyczerpaniu prób."""
64
+
65
+
66
+ class BppClient:
67
+ """Cienka, kuratorowana warstwa nad ``httpx.AsyncClient``."""
68
+
69
+ def __init__(
70
+ self,
71
+ config,
72
+ *,
73
+ concurrency: int = 8,
74
+ max_retries: int = 2,
75
+ backoff_base: float = 0.5,
76
+ ) -> None:
77
+ self._api_root = config.api_root
78
+ self._auth_tuple = config.auth_tuple
79
+ self._transport = config.transport
80
+ self._client = httpx.AsyncClient(
81
+ timeout=httpx.Timeout(10.0, connect=5.0),
82
+ headers={"Accept": "application/json"},
83
+ follow_redirects=True,
84
+ )
85
+ self._sem = asyncio.Semaphore(concurrency)
86
+ self._cache: dict[str, Any] = {}
87
+ self._max_retries = max_retries
88
+ self._backoff_base = backoff_base
89
+
90
+ async def aclose(self) -> None:
91
+ await self._client.aclose()
92
+
93
+ @property
94
+ def transport(self) -> str:
95
+ """Tryb transportu (``stdio``/``http``) — steruje hybrydową
96
+ podpowiedzią logowania w narzędziach zapytań DjangoQL."""
97
+ return self._transport
98
+
99
+ async def __aenter__(self) -> BppClient:
100
+ return self
101
+
102
+ async def __aexit__(self, *exc_info: object) -> None:
103
+ await self.aclose()
104
+
105
+ def _full_url(self, url: str, params: dict | None = None) -> httpx.URL:
106
+ """Zbuduj pełny URL: bezwzględny przyjmujemy wprost, względny
107
+ doklejamy do korzenia API. ``params`` mergujemy w query string."""
108
+ if url.startswith("http://") or url.startswith("https://"):
109
+ full = httpx.URL(url)
110
+ else:
111
+ full = httpx.URL(f"{self._api_root}/{url.lstrip('/')}")
112
+ if params:
113
+ czyste = {k: v for k, v in params.items() if v is not None}
114
+ if czyste:
115
+ full = full.copy_merge_params(czyste)
116
+ return full
117
+
118
+ def _auth_kwargs(self) -> dict[str, Any]:
119
+ """Per-request auth. Bearer (bieżący request) wygrywa zawsze. W trybie
120
+ http brak bearera = błąd (żadnego cichego fallbacku na konto serwisowe).
121
+ W stdio: Basic (gdy skonfigurowany) albo anonimowo."""
122
+ bearer = current_bearer()
123
+ if bearer:
124
+ return {"headers": {"Authorization": f"Bearer {bearer}"}}
125
+ if self._transport == "http":
126
+ raise BppError(
127
+ "Brak tokenu OAuth w kontekście żądania (tryb http) — nie "
128
+ "forwarduję anonimowo ani przez konto serwisowe."
129
+ )
130
+ if self._auth_tuple:
131
+ return {"auth": self._auth_tuple}
132
+ return {}
133
+
134
+ async def _request(self, full: httpx.URL, *, retry_5xx: bool = True) -> Any:
135
+ """Wykonaj GET z retry×N i backoffem. Zwraca zdeserializowany JSON.
136
+
137
+ ``retry_5xx=False`` wyłącza ponawianie na 5xx (błąd sieci nadal jest
138
+ ponawiany) — używane przez zapytania DjangoQL, bo 503 = deterministyczny
139
+ ``statement_timeout``, więc ponawianie tylko 3× re-uruchamiałoby ten sam
140
+ wolny SQL.
141
+ """
142
+ auth_kwargs = self._auth_kwargs()
143
+ ostatni: Exception | None = None
144
+ for proba in range(self._max_retries + 1):
145
+ async with self._sem:
146
+ try:
147
+ resp = await self._client.get(full, **auth_kwargs)
148
+ except httpx.HTTPError as exc:
149
+ # Błąd transportu (connect/read/timeout) — kwalifikuje do retry.
150
+ ostatni = exc
151
+ else:
152
+ if resp.status_code == 404:
153
+ raise BppNotFound(
154
+ f"Zasób nie istnieje lub jest niewidoczny: {full}"
155
+ )
156
+ if resp.status_code >= 500:
157
+ blad_5xx = BppNetworkError(
158
+ f"Serwer BPP zwrócił {resp.status_code} dla {full}",
159
+ status_code=resp.status_code,
160
+ )
161
+ # ``retry_5xx=False`` → od razu podnosimy (bez ponawiania
162
+ # deterministycznego 503 statement_timeout).
163
+ if not retry_5xx:
164
+ raise blad_5xx
165
+ ostatni = blad_5xx
166
+ else:
167
+ try:
168
+ resp.raise_for_status()
169
+ except httpx.HTTPStatusError as exc:
170
+ # 4xx: dołącz fragment ciała odpowiedzi — DRF zwraca
171
+ # tam komunikat walidacji (np. że charakter_formalny
172
+ # oczekuje PK ze slownik(...)), co jest wskazówką dla
173
+ # LLM-a. Bez tego zostawał sam suchy kod stanu.
174
+ tresc = " ".join(resp.text.split())[:300]
175
+ dodatek = f" — {tresc}" if tresc else ""
176
+ try:
177
+ # Ciało 4xx bywa JSON-em (DjangoQL 400:
178
+ # {error,line,column,mark}) — zachowaj strukturę.
179
+ payload = resp.json()
180
+ except ValueError:
181
+ payload = None
182
+ raise BppError(
183
+ f"Błąd HTTP {resp.status_code} dla {full}{dodatek}",
184
+ status_code=resp.status_code,
185
+ payload=payload,
186
+ ) from exc
187
+ return resp.json()
188
+ if proba < self._max_retries:
189
+ await asyncio.sleep(self._backoff_base * (proba + 1))
190
+ raise BppNetworkError(
191
+ f"Nie udało się pobrać {full} po {self._max_retries + 1} próbach: {ostatni}"
192
+ ) from ostatni
193
+
194
+ @staticmethod
195
+ def _prefiks_cachowalny(full: httpx.URL) -> bool:
196
+ """Czy pierwszy segment ścieżki po ``/api/v1/`` jest na białej liście
197
+ prefiksów wolno-cache'owalnych (:data:`PREFIKSY_CACHOWALNE`)."""
198
+ segmenty = [s for s in full.path.split("/") if s]
199
+ if "v1" in segmenty:
200
+ idx = segmenty.index("v1")
201
+ prefiks = segmenty[idx + 1] if idx + 1 < len(segmenty) else None
202
+ else:
203
+ prefiks = segmenty[0] if segmenty else None
204
+ return prefiks in PREFIKSY_CACHOWALNE
205
+
206
+ async def get_json(
207
+ self,
208
+ url: str,
209
+ params: dict | None = None,
210
+ *,
211
+ use_cache: bool = True,
212
+ retry_5xx: bool = True,
213
+ ) -> Any:
214
+ """Pobierz i zdeserializuj JSON.
215
+
216
+ Cache procesowy po pełnym URL-u działa TYLKO gdy ``use_cache`` jest
217
+ prawdą ORAZ prefiks endpointu jest na białej liście
218
+ :data:`PREFIKSY_CACHOWALNE` (słowniki, jednostki, źródła, wydawcy).
219
+ Rekordy publikacji, streszczenia i through-autorzy nie trafiają do
220
+ cache nawet przy ``use_cache=True`` — cache nie rośnie w nieskończoność.
221
+ """
222
+ full = self._full_url(url, params)
223
+ klucz = str(full)
224
+ cachowalne = use_cache and self._prefiks_cachowalny(full)
225
+ if cachowalne and klucz in self._cache:
226
+ return self._cache[klucz]
227
+ dane = await self._request(full, retry_5xx=retry_5xx)
228
+ if cachowalne:
229
+ self._cache[klucz] = dane
230
+ return dane
231
+
232
+ async def get_paginated(
233
+ self,
234
+ path: str,
235
+ params: dict | None = None,
236
+ limit: int = 25,
237
+ *,
238
+ page_limit: int = PAGE_LIMIT,
239
+ retry_5xx: bool = True,
240
+ ) -> tuple[list[Any], int, bool]:
241
+ """Auto-follow paginacji ``LimitOffset`` do zebrania ``limit`` pozycji.
242
+
243
+ Stronicuje porcjami po ``min(page_limit, limit)`` (NIE żąda całego
244
+ ``limit`` jednym requestem — chroni instancję BPP), podążając za
245
+ ``next`` aż do wyczerpania stron (``next == null``) lub osiągnięcia
246
+ ``limit``. Strony list nie są cache'owane (zmienne, jednorazowe).
247
+
248
+ Zwraca krotkę ``(zebrane, laczna_liczba, niepelne)``:
249
+
250
+ * ``laczna_liczba`` — serwerowy ``count`` z pierwszej strony
251
+ (rzeczywista liczba trafień po stronie BPP, ≠ ``len(zebrane)`` przy
252
+ obcięciu do ``limit``),
253
+ * ``niepelne`` — ``True`` gdy pętlę przerwał BEZPIECZNIK (sufit liczby
254
+ stron / powtórzony ``next``) mimo że ``next`` był wciąż niepusty, więc
255
+ pobranie mogło NIE objąć wszystkiego, co było w zasięgu ``limit``.
256
+ ``False`` przy naturalnym końcu (``next == null``) lub dobiciu limitu.
257
+
258
+ Bezpieczniki przed zapętleniem zbugowanego serwera:
259
+
260
+ * twardy sufit liczby stron (``limit // per_page + 2``),
261
+ * przerwanie, gdy ``next`` wskazuje na dokładnie ten sam URL co przed
262
+ chwilą, albo gdy strona nie wniosła żadnych nowych pozycji.
263
+ """
264
+ per_page = max(1, min(page_limit, limit))
265
+ maks_stron = limit // per_page + 2
266
+ zebrane: list[Any] = []
267
+ laczna: int | None = None
268
+ query = dict(params or {})
269
+ query["limit"] = per_page
270
+ url: str | None = path
271
+ pierwsza = True
272
+ poprzedni_url: str | None = None
273
+ strony = 0
274
+ niepelne = False
275
+ while url is not None and len(zebrane) < limit:
276
+ if strony >= maks_stron or url == poprzedni_url:
277
+ # Przerwanie przez bezpiecznik (NIE naturalne next=null ani
278
+ # dobicie limitu): ``next`` wciąż wskazuje kolejną stronę, ale
279
+ # zatrzymujemy się, by nie zapętlić się na zbugowanym serwerze.
280
+ # Sygnalizujemy, że pobranie może być niepełne.
281
+ niepelne = True
282
+ break
283
+ poprzedni_url = url
284
+ if pierwsza:
285
+ dane = await self.get_json(
286
+ url, params=query, use_cache=False, retry_5xx=retry_5xx
287
+ )
288
+ pierwsza = False
289
+ else:
290
+ dane = await self.get_json(url, use_cache=False, retry_5xx=retry_5xx)
291
+ strony += 1
292
+ if not isinstance(dane, dict):
293
+ break
294
+ if laczna is None and isinstance(dane.get("count"), int):
295
+ laczna = dane["count"]
296
+ nowe = dane.get("results", [])
297
+ if not nowe:
298
+ break
299
+ zebrane.extend(nowe)
300
+ url = dane.get("next")
301
+ zebrane = zebrane[:limit]
302
+ if laczna is None:
303
+ laczna = len(zebrane)
304
+ return zebrane, laczna, niepelne
bpp_mcp/config.py ADDED
@@ -0,0 +1,84 @@
1
+ """Konfiguracja serwera MCP: bazowy URL instancji BPP oraz opcjonalny
2
+ BasicAuth (używany wyłącznie dla raportów slotów — poza rdzeniem v1).
3
+
4
+ Wielo-instancyjność: ta sama binarka obsługuje dowolne wdrożenie BPP
5
+ (umlub oraz inne), różnicowane przez zmienną środowiskową ``BPP_BASE_URL``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass
12
+
13
+ _BRAK_HOSTA = (
14
+ "Nie ustawiono BPP_BASE_URL — nie wiadomo, z którą instancją BPP rozmawiać.\n"
15
+ "Wskaż ją jawnie, np.:\n"
16
+ " BPP_BASE_URL=https://bpp.twoja-uczelnia.pl bpp-mcp\n"
17
+ "W konfiguracji klienta MCP ustaw tę zmienną w sekcji `env`."
18
+ )
19
+
20
+
21
+ class BrakKonfiguracji(RuntimeError):
22
+ """Brakuje obowiązkowego ustawienia — serwer nie ma prawa zgadywać."""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Config:
27
+ """Niezmienny zestaw ustawień połączenia z instancją BPP."""
28
+
29
+ base_url: str
30
+ basic_auth: str | None = None
31
+ transport: str = "stdio"
32
+ http_host: str = "127.0.0.1"
33
+ http_port: int = 8000
34
+ resource_url: str | None = None
35
+
36
+ @classmethod
37
+ def from_env(cls) -> Config:
38
+ """Zbuduj konfigurację ze zmiennych środowiskowych.
39
+
40
+ - ``BPP_BASE_URL`` — bazowy URL instancji (WYMAGANY, bez domyślnego),
41
+ - ``BPP_BASIC_AUTH`` — opcjonalny ``user:pass`` (raporty slotów),
42
+ - ``BPP_MCP_TRANSPORT`` — ``stdio`` (dom.) | ``http`` (OAuth),
43
+ - ``BPP_MCP_HTTP_HOST`` / ``BPP_MCP_HTTP_PORT`` — bind serwera HTTP,
44
+ - ``BPP_MCP_RESOURCE_URL`` — nadpisanie pola ``resource`` w PRM.
45
+
46
+ ``BPP_BASE_URL`` nie ma wartości domyślnej celowo. Każde wdrożenie BPP
47
+ to inna uczelnia i inna bibliografia, więc zaszyty host oznaczałby, że
48
+ użytkownik bez tej zmiennej dostaje cudze dane wyglądające na własne —
49
+ błąd cichy i trudny do zauważenia. Lepiej nie wystartować.
50
+
51
+ :raises BrakKonfiguracji: gdy ``BPP_BASE_URL`` jest pusty lub nieustawiony.
52
+ """
53
+ base = (os.environ.get("BPP_BASE_URL") or "").strip()
54
+ if not base:
55
+ raise BrakKonfiguracji(_BRAK_HOSTA)
56
+ auth = os.environ.get("BPP_BASIC_AUTH") or None
57
+ transport = os.environ.get("BPP_MCP_TRANSPORT", "stdio").lower()
58
+ return cls(
59
+ base_url=base,
60
+ basic_auth=auth,
61
+ transport="http" if transport == "http" else "stdio",
62
+ http_host=os.environ.get("BPP_MCP_HTTP_HOST", "127.0.0.1"),
63
+ http_port=int(os.environ.get("BPP_MCP_HTTP_PORT", "8000")),
64
+ resource_url=os.environ.get("BPP_MCP_RESOURCE_URL") or None,
65
+ )
66
+
67
+ @property
68
+ def api_root(self) -> str:
69
+ """Korzeń API v1, bez końcowego ukośnika."""
70
+ return f"{self.base_url.rstrip('/')}/api/v1"
71
+
72
+ @property
73
+ def effective_resource_url(self) -> str:
74
+ """URL zasobu (pole ``resource`` w protected-resource-metadata).
75
+ Domyślnie kanoniczny URI serwera streamable: host:port + ``/mcp``."""
76
+ return self.resource_url or f"http://{self.http_host}:{self.http_port}/mcp"
77
+
78
+ @property
79
+ def auth_tuple(self) -> tuple[str, str] | None:
80
+ """Rozbij ``user:pass`` na krotkę dla httpx (lub ``None``)."""
81
+ if not self.basic_auth:
82
+ return None
83
+ user, _, password = self.basic_auth.partition(":")
84
+ return (user, password)