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 +11 -0
- bpp_mcp/auth.py +110 -0
- bpp_mcp/catalog.py +163 -0
- bpp_mcp/client.py +304 -0
- bpp_mcp/config.py +84 -0
- bpp_mcp/data/autor_djangoql_schema.compact.txt +1163 -0
- bpp_mcp/data/autorzy_djangoql_schema.compact.txt +1163 -0
- bpp_mcp/data/rekord_djangoql_schema.compact.txt +1163 -0
- bpp_mcp/login_state.py +62 -0
- bpp_mcp/oauth_client.py +374 -0
- bpp_mcp/server.py +385 -0
- bpp_mcp/token_store.py +88 -0
- bpp_mcp/tools.py +557 -0
- bpp_mcp-0.1.0.dist-info/METADATA +358 -0
- bpp_mcp-0.1.0.dist-info/RECORD +18 -0
- bpp_mcp-0.1.0.dist-info/WHEEL +4 -0
- bpp_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- bpp_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|