Rozwój¶
Testy są w pełni offline (mock httpx przez respx); domyślne CI nie wykonuje żadnych żywych wywołań.
Dokumentacja¶
Ta dokumentacja to MkDocs + Material. Podgląd lokalny:
Build produkcyjny (jak w CI) — --strict traktuje ostrzeżenia (m.in. martwe
linki) jako błędy:
Po merge do main workflow .github/workflows/docs.yml publikuje stronę na
GitHub Pages.
Wydanie na PyPI¶
Publikacja idzie przez trusted publishing (OIDC) — w repozytorium nie ma i nie może być tokenu API PyPI. Wydanie wyzwala push tagu:
# 0. UPEWNIJ SIĘ, ŻE JESTEŚ NA AKTUALNYM main
git fetch origin && git status -sb # ahead/behind musi być czyste
# 1. podbij wersję w TRZECH miejscach (niżej), zacommituj
# 2. otaguj i wypchnij
git tag vX.Y.Z
git push origin vX.Y.Z
Krok 0 nie jest ozdobnikiem: praca ze starego lokalnego main kończy się
gałęzią, która celuje w nieistniejące API i podbija wersję na numer już zajęty.
git log bez wcześniejszego fetch pokazuje stan sprzed tygodni i wygląda
zupełnie wiarygodnie.
Numer wersji żyje w trzech plikach i wszystkie trzy trzeba podbić razem:
| Plik | Kto to czyta |
|---|---|
pyproject.toml (project.version) |
build, PyPI — i tylko to porównuje z tagiem workflow wydaniowy |
src/bpp_mcp/__init__.py (__version__) |
kod w runtime; trafia do serverInfo w handshake MCP |
manifest.json (version) |
bundle .mcpb dla Claude Desktop |
Gate w CI sprawdza wyłącznie zgodność tagu z pyproject.toml, więc rozjazd
dwóch pozostałych nie przerwie wydania — użytkownik zobaczyłby wtedy
w handshake albo w instalatorze Claude Desktop numer inny niż zainstalowany.
Sprawdź przed tagowaniem:
Workflow .github/workflows/release.yml przepuszcza pełną matrycę testów,
sprawdza, czy tag zgadza się z project.version (rozjazd = przerwane
wydanie, bo numeru raz zajętego na PyPI nie da się odzyskać), buduje sdist +
wheel, weryfikuje je twine check --strict i obecność zbundlowanych schematów
DjangoQL, po czym publikuje z osobnego joba w środowisku pypi.
Hostowanie narzędzi w cudzym procesie¶
Od 0.4.0 ten pakiet da się wpiąć do aplikacji, która sama wystawia endpoint
MCP — w praktyce: do instancji BPP serwującej własne /mcp. Wtedy narzędzia
nie chodzą do API po sieci, tylko wołają aplikację hosta w tym samym procesie,
a użytkownik nie musi nic instalować ani znać adresu swojej uczelni.
Służą do tego cztery szwy. Wszystkie są addytywne — domyślne wartości odtwarzają zachowanie sprzed 0.4.0.
import httpx
from mcp.server.mcpserver import MCPServer
from bpp_mcp import KontekstApp, register_tools
from bpp_mcp.client import BppClient, TrybAuth
class KlientHosta(BppClient):
def _slownik_cache(self): # (3)
return cache_biezacego_zadania.get()
client = KlientHosta(
config,
transport=httpx.ASGITransport(app=aplikacja_hosta), # (1)
tryb_auth=TrybAuth.W_PROCESIE, # (2)
max_retries=0,
)
mcp = MCPServer("bpp", version="…", lifespan=wlasny_lifespan)
register_tools(mcp) # (4)
(1) transport= podstawia warstwę transportową httpx. Z
httpx.ASGITransport żądanie idzie wprost do aplikacji ASGI hosta: bez
gniazda, bez TLS-a, bez adresu, który trzeba znać. Uwaga — ASGITransport
ignoruje timeouty httpx, więc sufit czasu odpowiedzi musi zapewnić host.
Ponawianie też traci sens (nie ma sieci, która by zamigotała): max_retries=0.
Drugie zastrzeżenie: ASGITransport ma domyślnie raise_app_exceptions=True,
a BppClient._request łapie wyłącznie httpx.HTTPError. Wyjątek, który
ucieknie z aplikacji hosta, przejdzie przez klienta surowy — jako
RuntimeError czy DatabaseError, nie jako BppError — i wyląduje
w tracebacku w wyniku narzędzia. Podaj raise_app_exceptions=False, jeśli
wolisz, żeby błąd aplikacji zamienił się w 500, a więc w BppNetworkError
z czytelnym komunikatem.
(2) tryb_auth=TrybAuth.W_PROCESIE — bearer bieżącego żądania albo
anonimowo, nigdy Basic. Tryb ZDALNY (dotąd wybierany przez
transport="http") przy braku bearera rzuca, co dla endpointu z dostępem
publicznym jest złe; tryb LOKALNY sięgnąłby po BPP_BASIC_AUTH, czyli po
wspólne konto omijające scope i revoke tokenu. Polityka jest od 0.4.0 rozłączna
od nazwy transportu: po stronie klienta config.transport nie steruje już
niczym — zarówno politykę auth, jak i treść podpowiedzi po 401 niesie
tryb_auth. (Poza klientem config.transport nadal wybiera tryb serwera
w build_mcp.)
Podawaj człon enuma, nie string. TrybAuth ma mixin str, więc
"w-procesie" wygląda na równoważne — konstruktor koercjonuje je poprawnie
i rzuca ValueError na nieznanej wartości, ale porównania w środku idą przez
is, więc obchodzenie konstruktora skończyłoby się cichym Basikiem.
(3) _slownik_cache() to metoda do nadpisania w podklasie. Domyślnie cache
URL → JSON jest jeden na instancję klienta, co jest poprawne, gdy proces
obsługuje jednego użytkownika. W SDK 2.0 lifespan wchodzi raz, a jego wynik
dzielą wszystkie sesje i żądania — klient jest więc bezwarunkowo współdzielony,
a kluczem cache jest sam URL. Jeden słownik mieszałby wtedy użytkowników i
(w instalacji wielo-tenantowej) uczelnie. Podstaw słownik o właściwym zasięgu,
np. per-żądanie z ContextVar — tak samo, jak SDK 2.0 wymusiło to już na
izolacji bearera.
(4) register_tools(mcp) rejestruje komplet 11 narzędzi i 1 prompt na
instancji MCPServer hosta — bez kopiowania wrapperów. Kontrakt: lifespan
przekazany do MCPServer musi oddawać KontekstApp (albo obiekt o tych samych
atrybutach), bo wrappery sięgają po klienta przez
ctx.request_context.lifespan_context. Host wielo-użytkownikowy ustawia
bearer_provider=None — fallback na token z lokalnego cache ma sens wyłącznie
w stdio, inaczej token jednej osoby trafiłby do żądania innej. Zamknięcie
klienta (await client.aclose()) należy do lifespanu hosta.